纯前端视频剪辑

前言

目前前端视频剪辑的实现方案主要是两种

1. 主流的方案 前端编辑+后端生成视频

前端提供ui界面,可以调整各种参数配置,例如视频大小,视频位置,图片大小位置等根据需要添加相应的配置信息,然后将这些信息提供给后端服务器生成视频最后再返回给前端。需要后端配合,不算纯前端剪辑。

2. ffmpeg + WebAssembly

这算是一种相对比较纯前端的方案

前端通过wasm使用ffmpeg操作视频,可以查看 ffmpeg.wasm 文档学习使用

优点:

  1. 可以提供更多专业性的操作。
  2. 可以兼容更多的视频类型

缺点:

  1. 我觉得最大的缺点就是慢当然可能会有一些很快并且方便的操作,像采用copy模式直接截取视频,这种直接对内存的复制速度还是相当快的,但是如果对于某些帧更加精细的操作,比如查找帧这种情况确实是很慢的例如想要获取某一帧
js 复制代码
console.time('get frame')
await ffmpeg.exec(["-i","input.mp4", "-vf", `select='eq(n,800)'`, "-vframes", "1", , "output.png"])
console.timeEnd('get frame')

我们对命令进行优化,通过帧率可以计算出一个更好的ss时间

js 复制代码
console.time('get frame')
await ffmpeg.exec(["-ss", "00:00:32","-i","input.mp4", "-vf", `select='eq(n,0)'`, "-vframes", "1", , "output.png"])
console.timeEnd('get frame')

这或许是巨大的提升,可以看出来有20+倍提升,但是,我们知道一个视频的帧数是极大的,如果用这种方式,其实获取所有帧的时间远远大于视频时间,甚至我如果用requestAnimationFrame,去抓取视频图片,这效率其实有没有可能会比ffmpeg更好吧。

  1. 内存占用问题

如果对于很大的视频,如果我们想要解析视频,我觉得确实是需要加载到内存中的。不过由于ffmpeg.wasm对于前端开发者来说是一个巨大的黑盒 ,想要优化其中的内存问题并不容易,我觉得更多的可能需要开发者自己去优化ffmpeg的实现方案,自己生成wasm。(例如,上文所说的获取帧,为什么最后要存在output.png中,能不能自己去实现,让这个promise可以直接返回一个图片的unit8array,不做过多的资源占用,以及减少存储和读取所带来的时间消耗?

其实这个对于纯前端剪辑来说,确实是一个巨大的瓶颈,我们能做的只有尽可能优化代码,及时的释放空间去减少带来的内存占用

  1. 资源大小问题

其实我觉得这个不太算问题,主要是wasm文件体积较大。这个其实可以通过service worker或者indexdb等方案缓存起来。

mp4box+WebCodecs API+mp4-muxer+opfs

mp4box

解析MP4文件的第三方npm库 mp4box - npm 或者风痕大大的@webav/mp4box.js - npm对mp4box进行了ts类型标注,在该方案实现上也看了不少风痕大大的博客Blog List | 风痕 · 術&思以及他的webav开源项目。

WebCodecs API

为 web 开发者提供了对视频流的单个帧和音频数据块的底层访问能力。这对于那些需要完全控制媒体处理方式的 web 应用程序非常有用WebCodecs API - Web API | MDN。 这个

mp4-muxer

mp4-muxer - npm用于视频合成

opfs

源私有文件系统 (OPFS)是作为文件系统 API 的一部分提供的一个存储端点。它是页面所属的源专用的,并且不像常规文件系统那样对用户可见。它提供了对一种特殊类型文件的访问能力,这种文件经过高度性能优化,并提供对其内容的原地写入访问特性。

优点:

  1. 相比ffmpeg更快。
  2. 相比ffmpeg有更好的内存控制。

缺点:

  1. 目前仅支持解析mp4文件
  2. 部分帧会解析失败

创建muxer

用来合成MP4文件

js 复制代码
    const muxer = new Muxer({
        target: fileSystemWritableFileStreamTarget,
        video: {
            codec: 'avc',
            width: clipStore.width,
            height: clipStore.height,
            frameRate: clipStore.frameRate
        },
        audio: {
            codec: 'aac',
            numberOfChannels: audioBuffer?.numberOfChannels ?? 1,
            sampleRate: audioBuffer?.sampleRate ?? 48000
        },
        fastStart: false
    })

视频处理

创建一个encoder,用来编码视频

js 复制代码
    const videoEncoder = new VideoEncoder({
        output: async (chunk, meta) => {
            muxer.addVideoChunk(chunk, meta)
            muxNumber++;
            if (muxNumber === framesCount) {
                muxer.finalize();
                await writableStream.close()
                console.log('over');
            }
        },
        error: e => console.error(e)
    });
    videoEncoder.configure({
        codec: 'avc1.4D0032',
        width: clipStore.width,
        height: clipStore.height,
        hardwareAcceleration: 'prefer-software'
    });

mp4 文件经过 mp4box解析以及onReady获取信息构建decoder

js 复制代码
// 分块加载mp4文件
    const chunkSize = 1024 * 1024 * 10;
    let start = 0;
    const resolveVideo = async () => {
        const end = start + chunkSize;
        const chunk = video.source.file.slice(start, end);
        const buffer = await chunk.arrayBuffer()
        //@ts-ignore
        buffer.fileStart = start
        decoderFile.appendBuffer(buffer as MP4ArrayBuffer)
        start = end;
        if (start < video.source.file.size) {
            await resolveVideo()
        }
    };
    await resolveVideo()
 
// 构建decoder
const init: VideoDecoderInit = {
    output: (v) => {
        // do something 其实就是将帧信息以及编辑信息重组生成新的图片frame
        ...
        // 生成新帧 frame传递给encoder编码
        const frame = new VideoFrame(ibmp, { timestamp: cts * count })
        videoEncoder.encode(frame, { keyFrame: count % (clipStore.frameRate * 10) === 0 })
        frame.close()
    },
    error: (e) => {
        console.log(e);
    }
}
videoDecoder = new VideoDecoder(init)
 decoderFile.onReady = async (info) => {
     const videoTrack = info.videoTracks[0];
     const video_track = decoderFile.getTrackById(videoTrack.id);
     console.log(info, video_track);
     // 获取帧率
     const frameRate = Math.ceil(1000 / ((info.duration / info.timescale) * 1000 / videoTrack.nb_samples))
      let description
      for (const entry of video_track.mdia.minf.stbl.stsd.entries) {
          // @ts-ignore
          const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C;
          if (box) {
              const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
              box.write(stream);
              description = new Uint8Array(stream.buffer.slice(8)); // Remove the box header.
          }
      }
      config = {
          codec: videoTrack.codec.startsWith("vp08") ? "vp8" : videoTrack.codec,
          codedWidth: info.videoTracks[0].track_width,
          codedHeight: info.videoTracks[0].track_height,
          description,
      }
      videoDecoder.configure(config)
 }
js 复制代码
 // 解析sample 
const sample = file.getTrackSample(tid, j)
const type = sample.is_sync ? "key" : "delta";
needAddMux = false
// sample转换成EncodedVideoChunk,进而可以被VideoDecoder进行解码
const chunk = new EncodedVideoChunk({
    type: type,
    timestamp: sample.cts,
    duration: sample.duration,
    data: sample.data
});
decoder.decode(chunk)

音频处理

其实对前端来说通过canvas或者OffscreenCanvas 处理图像生成帧还是比较简单的,对于音频就感觉比较抽象了,这边也是通过crunker库crunker - npm进行一些音频的操作,(这里有优化空间)

将音频数据转化成AudioBuffer

js 复制代码
// 目前没看到什么比较好的处理mp4音频的方案,或许可以用ffmpeg提取音频?
// 提取MP4音频
const audioContext = new AudioContext()
const buffer = await video.source.file.arrayBuffer()
const info = await audioContext.decodeAudioData(buffer)
// 提取MP3音频
crunker.fetchAudio('...')

AudioBuffer转化成AudioData

js 复制代码
// 注意双声道处理
const audioData = new AudioData({
    // 当前音频片段的时间偏移
    timestamp: 0,
    // 双声道
    numberOfChannels: audioBuffer.numberOfChannels,
    // 帧数,就是多少个数据点,因为双声道,前一半左声道后一半右声道,所以帧数需要除以 2
    numberOfFrames: length / audioBuffer.numberOfChannels,
    // 48KHz 采样率
    sampleRate: audioBuffer.sampleRate,
    // 通常 32位 左右声道并排的意思,更多 format 看 AudioData 文档
    format: 'f32-planar',
    data: combinedData,
});

创建AudioEncoder并且解析AudioData

js 复制代码
const encoder = new AudioEncoder({
    output: (chunk) => {
        muxer.addAudioChunk(chunk)
    },
    error: console.error,
});

encoder.configure({
    // AAC 编码格式
    codec: 'mp4a.40.2',
    sampleRate: audioBuffer.sampleRate,
    numberOfChannels: audioBuffer.numberOfChannels,
});

// // 编码原始数据对应的 AudioData
encoder.encode(audioData);
// 这里要用flush不然解析不全
await encoder.flush()

完整流程图

存在问题

主要问题,对于不同帧率的视频混剪,可能会存在音画不同步问题,这需要一定抽帧和补帧的操作。

目前demo的git地址

只是个玩具,跑通了流程,存在许多功能需要补足 github.com/ChenNianYa/...

相关推荐
音视频牛哥6 小时前
[2015~2024]SmartMediaKit音视频直播技术演进之路
音视频开发·视频编码·直播
音视频牛哥2 天前
Windows平台Unity3D下RTMP播放器低延迟设计探讨
音视频开发·视频编码·直播
音视频牛哥3 天前
Windows平台Unity3D下如何低延迟低资源占用播放RTMP或RTSP流?
音视频开发·视频编码·直播
音视频牛哥3 天前
Android平台GB28181设备接入模块动态文字图片水印技术探究
音视频开发·视频编码·直播
声知视界4 天前
音视频基础能力之 Android 音频篇 (三):高性能音频采集
android·音视频开发
音视频牛哥7 天前
RTSP摄像头8K超高清使用场景探究和播放器要求
音视频开发·视频编码·直播
音视频牛哥7 天前
RTMP如何实现毫秒级延迟体验?
音视频开发·视频编码·直播
哔哩哔哩技术10 天前
WASM 助力 WebCodecs:填补解封装能力的空白
音视频开发
JustinNeil10 天前
萤石云太贵,老板想自建流媒体服务器,于是让我...
java·后端·音视频开发