MediaRecorder 忽略 VideoFrame.timestamp

Ami*_*mit 5 javascript mediarecorder webcodecs

我想生成一个视频。我正在用来MediaRecorder录制由 生成的曲目MediaStreamTrackGenerator

\n

生成每一帧需要一些时间,比如说 1 秒,我想以10fps 生成视频。

\n

因此,当我创建帧时,我使用timestampduration来指示帧的实时时间。

\n
const ms = 1_000_000; // 1\xc2\xb5s\nconst fps = 10;\nconst frame = new VideoFrame(await createImageBitmap(canvas), {\n  timestamp: (ms * 1) / fps,\n  duration: ms / fps,\n});\n
Run Code Online (Sandbox Code Playgroud)\n

不幸的是,如果生成每一帧需要 1 秒,尽管指示timestampduration,视频仍以 1 帧/秒播放,而不是 10 fps。

\n

如何以所需的帧速率对视频帧进行编码?

\n

奖金:在 VLC 中下载生成的视频,视频没有持续时间。这个可以设置吗?

\n
\n

用于复制的 CodePen:https://codepen.io/AmitMY/pen/OJxgPoG \n(此示例适用于 Chrome。如果您使用 Safari,请更改video/webmvideo/mp4。)

\n

我尝试过但对我来说不是一个好的解决方案:

\n
    \n
  1. 将所有帧存储在某个缓存中,然后以所需的速度回放并记录回放。它不可靠、不一致且占用大量内存。
  2. \n
\n

Piw*_*oli 11

前言

所以...我已经调查了两天了,结果一团糟。我没有完整的答案,但这是我迄今为止尝试过并弄清楚的。

情况

首先,我放弃了这张Web 编解码器/可插入流 API的图表,以更好地理解所有内容如何链接在一起:

API图

MediaStream、StreamTrack、VideoFrame、TrackProcessor、TrackGenerator,...

最常见的用例/流程是您有一个MediaStream,例如摄像机源现有视频(在画布上播放),然后您可以将其“分解”成不同的MediaStreamTracks - 通常是音频和视频轨道该 API 实际上还支持字幕、图像和共享屏幕轨道。

因此,您将MediaStream分解为“视频”类型的MediaStreamTrack,然后将其提供给MediaStreamTrackProcessor以实际将视频轨道分解为单独的VideoFrame。然后,您可以进行逐帧操作,完成后,您应该将这些VideoFrame 流式传输到MediaStreamTrackGenerator中,后者又将这些VideoFrame转换为MediaStreamTrack,然后您可以将其填充到MediaStream中以制作又名“完整媒体对象”。包含视频和音频轨道的东西。

有趣的是,我无法让 MediaStream<video>直接在元素上播放,但我认为如果我们想实现 OP 想要的目标,这是一个硬性要求。

就目前情况而言,即使我们已经准备好所有 VideoFrame 并转换为 MediaStream,出于某种原因,我们仍然必须将其记录两次以创建一个<video>接受的正确 Blob - 将此步骤视为“专业视频编辑软件的“渲染”步骤,唯一的区别是我们已经有了最终帧,那么为什么我们不能直接用这些帧创建视频呢?

据我所知,这里适用于视频的所有内容也适用于音频。因此,实际上存在名为AudioFrame的东西,尽管在我撰写本文时缺少文档页面。

编码和解码

此外,关于 VideoFrames 和 AudioFrames,还有对它们的编码解码的 API 支持,我实际上尝试过,希望用VP8编码 VideoFrame 能够以某种方式将持续时间时间戳“烘焙”到其中,至少是 VideoFrame 的持续时间似乎什么也没做。

这是我尝试使用它时的编码/解码代码。请注意,整个编码和解码业务+编解码器是一个深渊。例如,我不知道我是如何找到这个的,但它确实告诉我 Chromium 不支持 Windows 上的硬件加速 VP8(不感谢编解码器错误消息,它只是喋喋不休地谈论“无法使用封闭编解码器”):

const createFrames = async (ctx, fps, streamWriter, width, height) => {
    const getRandomRgb = () => {
        var num = Math.round(0xffffff * Math.random());
        var r = num >> 16;
        var g = num >> 8 & 255;
        var b = num & 255;
        return 'rgb(' + r + ', ' + g + ', ' + b + ')';
    }

    const encodedChunks = [];
    const videoFrames = [];

    const encoderOutput = (encodedChunk) => {
        encodedChunks.push(encodedChunk);
    }

    const encoderError = (err) => {
        //console.error(err);
    }

    const encoder = new VideoEncoder({
        output: encoderOutput,
        error: encoderError
    })

    encoder.configure({
        //codec: "avc1.64001E",
        //avc:{format:"annexb"},
        codec: "vp8",
        hardwareAcceleration: "prefer-software", // VP8 with hardware acceleration not supported
        width: width,
        height: height,
        displayWidth: width,
        displayHeight: height,
        bitrate: 3_000_000,
        framerate: fps,
        bitrateMode: "constant",
        latencyMode: "quality"
    });

    const ft = 1 / fps;
    const micro = 1_000_000;
    const ft_us = Math.floor(ft * micro);

    for(let i = 0; i < 10; i++) {
        console.log(`Writing frames ${i * fps}-${(i + 1) * fps}`);
        ctx.fillStyle = getRandomRgb();
        ctx.fillRect(0,0, width, height);

        ctx.fillStyle = "white";
        ctx.textAlign = "center";
        ctx.font = "80px Arial";
        ctx.fillText(`${i}`, width / 2, height / 2);

        for(let j = 0; j < fps; j++) {
            //console.log(`Writing frame ${i}.${j}`);
            const offset = i > 0 ? 1 : 0;
            const timestamp = i * ft_us * fps + j * ft_us;
            const duration = ft_us;

            var frameData = ctx.getImageData(0, 0, width, height);

            var buffer = frameData.data.buffer;

            const frame = new VideoFrame(buffer, 
            { 
                format: "RGBA",
                codedWidth: width,
                codedHeight: height,
                colorSpace: {
                    primaries: "bt709",
                    transfer: "bt709",
                    matrix: "bt709",
                    fullRange: true
                },
                timestamp: timestamp,
                duration: ft_us
            });
            
            encoder.encode(frame, { keyFrame: false });
            videoFrames.push(frame);
        }  
    }

    //return videoFrames;
    
    await encoder.flush();
    //return encodedChunks;

    const decodedChunks = [];
    
    const decoder = new VideoDecoder({
        output: (frame) => {
            decodedChunks.push(frame);
        },
        error: (e) => {
            console.log(e.message);
        }
    });

    decoder.configure({
        codec: 'vp8',
        codedWidth: width,
        codedHeight: height
    });

    encodedChunks.forEach((chunk) => {
        decoder.decode(chunk);
    });

    await decoder.flush();

    return decodedChunks;
}
Run Code Online (Sandbox Code Playgroud)

框架计算

关于你的框架计算,我做了一些不同的事情。考虑以下图像和代码:

框架

const fps = 30;
const ft = 1 / fps;
const micro = 1_000_000;
const ft_us = Math.floor(ft * micro);
Run Code Online (Sandbox Code Playgroud)

忽略创建 1 帧需要多长时间的事实(因为如果我们可以设置帧持续时间,它在这里应该是无关紧要的),这就是我的想法。

我们希望以每秒 30 帧(fps)的速度播放视频。我们生成10 个彩色矩形,每个矩形要在屏幕上显示 1 秒,从而产生10 秒的视频长度。这意味着,为了实际以 30fps 播放视频,我们需要为每个矩形生成 30 帧。如果我们可以设置帧持续时间,技术上我们只能有 10 帧,每帧持续时间为 1 秒,但 fps 实际上是每秒 1 帧。不过我们的速度是 30fps。

30 的 fps 为我们提供了1 / 30秒的帧时间 (ft)。每帧在屏幕上显示的时间。我们为 1 个矩形生成 30 个帧 ->30 * (1 / 30) = 1 second签出。这里的另一件事是 VideoFrame 持续时间和时间戳不接受秒或毫秒,而是接受微秒,因此我们需要将帧时间 (ft) 转换为以微秒为单位的帧时间 (ft_us),这只是(1 / 30) * 1 000 000 = ~33 333us.

计算每一帧的最终持续时间和时间戳有点棘手,因为我们现在循环两次,每个矩形循环一次,矩形的每一帧以 30fps 循环一次。

j一帧矩形的时间戳i是(英文):

<i> * <frametime in us> * <fps> + <j> * <frametime in us> (+ <offset 0 or 1>
Run Code Online (Sandbox Code Playgroud)

where<i> * <frametime in us> * <fps>可以获取每个前一个矩形需要多少微秒,以及<j> * <frametime in us>当前矩形的每个前一帧需要多少微秒。当我们制作第一个矩形的第一帧时,我们还提供可选的 0 偏移量,否则提供 1 的偏移量,以便避免重叠。

const fps = 30;
const ft = 1 / fps;
const micro = 1_000_000;
const ft_us = Math.floor(ft * micro);

// For each colored rectangle
for(let i = 0; i < 10; i++) {
    // For each frame of colored rectangle at 30fps
    for(let j = 0; j < fps; j++) {
        const offset = i > 0 ? 1 : 0;
        const timestamp = i * ft_us * fps + j * ft_us /* + offset */;
        const duration = ft_us * 10;

        new VideoFrame({ duration, timestamp });
        ...
    }
}
Run Code Online (Sandbox Code Playgroud)

这应该为我们提供10 * 30 = 300以 30 fps 播放时长为 10 秒的视频的总帧数。

我最新的尝试和 ReadableStream 测试

我已经重构了很多次,但没有运气,但这是我当前的解决方案,我尝试使用 ReadableStream 将生成的 VideoFrames 传递给 MediaStreamTrackGenerator (跳过录制步骤),从中生成 MediaStream 并尝试将结果提供srcObject给一个<video>元素:

const streamTrackGenerator = new MediaStreamTrackGenerator({ kind: 'video' });
const streamWriter = streamTrackGenerator.writable;
const chunks = await createFrames(ctx, fps, streamWriter, width, height); // array of VideoFrames
let idx = 0;

await streamWriter.ready;

const frameStream = new ReadableStream({
    start(controller) {
        controller.enqueue(chunks[idx]);
        idx++;
    },

    pull(controller) {
        if(idx >= chunks.length) {
            controller.close();
        }
        else {
            controller.enqueue(chunks[idx]);
            idx++;
        }
    },

    cancel(reason) {
        console.log("Cancelled", reason);
    }

});

await frameStream.pipeThrough(new TransformStream({ 
    transform (chunk, controller) {
        console.log(chunk); // debugging
        controller.enqueue(chunk) // passthrough
    }
 })).pipeTo(streamWriter);

const mediaStreamTrack = streamTrackGenerator.clone();

const mediaStream = new MediaStream([mediaStreamTrack]);

const video = document.createElement('video');
video.style.width = `${width}px`;
video.style.height = `${height}px`;
document.body.appendChild(video);
video.srcObject  = mediaStream;
video.setAttribute('controls', 'true')

video.onloadedmetadata = function(e) {
    video.play().catch(e => alert(e.message))
};
Run Code Online (Sandbox Code Playgroud)

尝试使用 VP8 编码+解码并尝试通过 SourceBuffers 将 VideoFrames 提供给 MediaSource

有关MediaSourceSourceBuffers的更多信息。这也是我尝试利用带有参数的MediaRecorder.start()函数与MediaRecorder.requestFrame()timeslice结合来尝试逐帧记录:

const init = async () => {
    const width = 256;
    const height = 256;
    const fps = 30;
    

    const createFrames = async (ctx, fps, streamWriter, width, height) => {
        const getRandomRgb = () => {
            var num = Math.round(0xffffff * Math.random());
            var r = num >> 16;
            var g = num >> 8 & 255;
            var b = num & 255;
            return 'rgb(' + r + ', ' + g + ', ' + b + ')';
        }

        const encodedChunks = [];
        const videoFrames = [];

        const encoderOutput = (encodedChunk) => {
            encodedChunks.push(encodedChunk);
        }

        const encoderError = (err) => {
            //console.error(err);
        }

        const encoder = new VideoEncoder({
            output: encoderOutput,
            error: encoderError
        })

        encoder.configure({
            //codec: "avc1.64001E",
            //avc:{format:"annexb"},
            codec: "vp8",
            hardwareAcceleration: "prefer-software",
            width: width,
            height: height,
            displayWidth: width,
            displayHeight: height,
            bitrate: 3_000_000,
            framerate: fps,
            bitrateMode: "constant",
            latencyMode: "quality"
        });

        const ft = 1 / fps;
        const micro = 1_000_000;
        const ft_us = Math.floor(ft * micro);

        for(let i = 0; i < 10; i++) {
            console.log(`Writing frames ${i * fps}-${(i + 1) * fps}`);
            ctx.fillStyle = getRandomRgb();
            ctx.fillRect(0,0, width, height);

            ctx.fillStyle = "white";
            ctx.textAlign = "center";
            ctx.font = "80px Arial";
            ctx.fillText(`${i}`, width / 2, height / 2);

            for(let j = 0; j < fps; j++) {
                //console.log(`Writing frame ${i}.${j}`);
                const offset = i > 0 ? 1 : 0;
                const timestamp = i * ft_us * fps + j * ft_us;
                const duration = ft_us;

                var frameData = ctx.getImageData(0, 0, width, height);

                var buffer = frameData.data.buffer;

                const frame = new VideoFrame(buffer, 
                { 
                    format: "RGBA",
                    codedWidth: width,
                    codedHeight: height,
                    colorSpace: {
                        primaries: "bt709",
                        transfer: "bt709",
                        matrix: "bt709",
                        fullRange: true
                    },
                    timestamp: timestamp,
                    duration: ft_us
                });
                
                encoder.encode(frame, { keyFrame: false });
                videoFrames.push(frame);
            }  
        }

        //return videoFrames;
        
        await encoder.flush();
        //return encodedChunks;

        const decodedChunks = [];
        
        const decoder = new VideoDecoder({
            output: (frame) => {
                decodedChunks.push(frame);
            },
            error: (e) => {
                console.log(e.message);
            }
        });

        decoder.configure({
            codec: 'vp8',
            codedWidth: width,
            codedHeight: height
        });

        encodedChunks.forEach((chunk) => {
            decoder.decode(chunk);
        });

        await decoder.flush();

        return decodedChunks;
    }

    const canvas = new OffscreenCanvas(256, 256);
    const ctx = canvas.getContext("2d");

    const recordedChunks = [];
    const streamTrackGenerator = new MediaStreamTrackGenerator({ kind: 'video' });
    const streamWriter = streamTrackGenerator.writable.getWriter();
    const mediaStream = new MediaStream();
    mediaStream.addTrack(streamTrackGenerator);

    const mediaRecorder = new MediaRecorder(mediaStream, {
        mimeType: "video/webm", 
        videoBitsPerSecond: 3_000_000
    });

    mediaRecorder.addEventListener('dataavailable', (event) => {
        recordedChunks.push(event.data);
        console.log(event)
    });

    mediaRecorder.addEventListener('stop', (event) => {
        console.log("stopped?")
        console.log('Frames written');
        console.log('Stopping MediaRecorder');
        console.log('Closing StreamWriter');

        const blob = new Blob(recordedChunks, {type: mediaRecorder.mimeType});
        const url = URL.createObjectURL(blob);

        const video = document.createElement('video');
        video.src = url;
        document.body.appendChild(video);
        video.setAttribute('controls', 'true')
        video.play().catch(e => alert(e.message))
    });

    
    console.log('StreamWrite ready');
    console.log('Starting mediarecorder');

    console.log('Creating frames');
    const chunks = await createFrames(ctx, fps, streamWriter, width, height);

    mediaRecorder.start(33333);

    for(const key in chunks) {
        await streamWriter.ready;
        const chunk = chunks[key];
        //await new Promise(resolve => setTimeout(resolve, 1))
        await streamWriter.write(chunk);
        mediaRecorder.requestData();
    }
    
    //await streamWriter.ready;
    //streamWriter.close();
    //mediaRecorder.stop();

    /*const mediaSource = new MediaSource();
    
    const video = document.createElement('video');
    document.body.appendChild(video);
    video.setAttribute('controls', 'true')

    const url = URL.createObjectURL(mediaSource);
    video.src = url;

    mediaSource.addEventListener('sourceopen', function() {
        var mediaSource = this;
        const sourceBuffer = mediaSource.addSourceBuffer('video/webm; codecs="vp8"');

    let allocationSize = 0;
    chunks.forEach((c) => { allocationSize += c.byteLength});

    var buf = new ArrayBuffer(allocationSize);
    
    chunks.forEach((chunk) => {
        chunk.copyTo(buf);
    });

    sourceBuffer.addEventListener('updateend', function() {
        //mediaSource.endOfStream();
        video.play();
    });

    sourceBuffer.appendBuffer(buf);
    });*/

    //video.play().catch(e => alert(e.message))

    /*mediaStream.getTracks()[0].stop();

    const blob = new Blob(chunks, { type: "video/webm" });
    const url = URL.createObjectURL(blob);

    const video = document.createElement('video');
    video.srcObject = url;
    document.body.appendChild(video);
    video.setAttribute('controls', 'true')
    video.play().catch(e => alert(e.message))*/

    //mediaRecorder.stop();
}
Run Code Online (Sandbox Code Playgroud)

结论/后记

在我尝试了所有这些之后,我在将帧转换为轨道和轨道转换为流等方面遇到了最多的问题。有太多(糟糕的文档)从一件事转换为另一件事,其中一半是通过流完成的,这也缺乏很多的文档。如果不使用 NPM 包,甚至似乎没有任何有意义的方法来创建自定义 ReadableStreams 和 WritableStreams。

我从来没有让 VideoFrameduration工作过。最让我惊讶的是,除了调整老套的await new Promise(resolve => setTimeout(resolve, 1000))时间之外,在视频或帧长度方面基本上没有其他重要的事情,但即使如此,录制也确实不一致。如果录音过程中出现任何延迟,都会在录音中显示出来;我的录音中,一些矩形显示了半秒,其他矩形显示了 2 秒。有趣的是,如果我删除任意 setTimeout,整个记录过程有时会完全中断。一个在没有超时的情况下就会中断的程序可以与await new Promise(resolve => setTimeout(resolve, 1)). 这通常表明这与 JS 事件循环有关,因为 0ms 计时的 setTimeouts 告诉 JS“等待下一轮事件循环”。

我仍然会在这方面继续努力,但我怀疑我是否会取得进一步的进展。我希望在不使用 MediaRecorder 的情况下实现此功能,并利用流来解决资源问题。

我遇到的一件非常有趣的事情是MediaStreamTrackGenerator实际上是旧闻。w3 文档仅真正讨论了VideoTrackGenerator ,并且对如何从现有 MediaStreamTrackGenerator 基本构建 VideoTrackGenerator有一个有趣的看法。这部分还要特别注意:

匀场视频轨道生成器

有趣的是,这告诉我们MediaStreamTrackGenerator.clone() === MediaStreamTrack我尝试使用但没有成功。

无论如何,我希望这能给你一些新的想法或澄清一些事情。也许你会发现一些我没有发现的东西。祝您玩得开心,如果您有疑问或想出办法,请告诉我们!

进一步阅读

编辑1

忘记提及我使用了OffscreenCanvas及其上下文,而不是普通的 Canvas。由于我们在这里也讨论了性能,所以我想我应该尝试看看 OffscreenCanvas 是如何工作的。

我还使用了VideoFrame的第二个构造函数,也就是说,我给它一个ArrayBuffer而不是像代码中那样的位图图像。

  • 感谢您提供令人惊讶的详细答案。这确实是一个问题,我本以为随着 WebCodecs 的发布,这会很容易......我也没有找到解决方案,该解决方案不受延迟或插入帧的实际时间的影响 (2认同)