MediaRecorder 忽略 VideoFrame.timestamp

2024-03-27

我想生成一个视频。我在用MediaRecorder记录由MediaStreamTrackGenerator.

生成每一帧需要一些时间,比如说 1 秒,我想在以下位置生成视频10 fps.

因此,当我创建框架时,我使用timestamp and duration指示帧的实时时间。

const ms = 1_000_000; // 1µs
const fps = 10;
const frame = new VideoFrame(await createImageBitmap(canvas), {
  timestamp: (ms * 1) / fps,
  duration: ms / fps,
});

不幸的是,如果生成每一帧需要 1 秒,尽管表明timestamp and duration,视频以 1 帧/秒播放,而不是 10 fps。

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

Bonus:在VLC中下载生成的视频,视频没有时长。这个可以设置吗?


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

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

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

Foreword

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

情况

首先我废弃了这张图网络编解码器 https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API / 可插入流 API https://developer.mozilla.org/en-US/docs/Web/API/Insertable_Streams_for_MediaStreamTrack_API为了更好地理解一切是如何联系在一起的:

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

最常见的用例/流程是您有一个媒体流 https://developer.mozilla.org/en-US/docs/Web/API/MediaStream, 比如一个摄像机馈送 https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia or an 现有视频(在画布上播放) https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/captureStream,然后你会“闯入”不同的媒体流轨道 https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack- 通常是音频和视频轨道,尽管 API 实际上也支持字幕、图像和共享屏幕轨道。

所以你打破了媒体流 https://developer.mozilla.org/en-US/docs/Web/API/MediaStream into a 媒体流轨道 https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack“视频”类型,然后您将其提供给媒体流轨道处理器 https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrackProcessor实际上将视频轨道分解为单独的视频帧 https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame。然后,您可以进行逐帧操作,完成后,您应该流式传输这些内容视频帧 https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame into 媒体流轨道生成器 https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrackGenerator,这又将那些视频帧 https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame into a 媒体流轨道 https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack,您又可以将其填充到媒体流 https://developer.mozilla.org/en-US/docs/Web/API/MediaStream制作一种“完整媒体对象”又名。包含视频和音频轨道的东西。

有趣的是,我无法让 MediaStream 在<video>直接元素,但我认为如果我们想完成OP想要的东西,这是一个硬性要求。

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

据我所知,这里适用于视频的所有内容也适用于音频。所以实际上存在一种叫做音频帧 https://i.stack.imgur.com/qlLWY.png例如,尽管在我写这篇文章时文档页面丢失了。

编码和解码

此外,关于VideoFrames和AudioFrames,还有API支持encoding https://w3c.github.io/webcodecs/#videoencoder-interface and decoding https://w3c.github.io/webcodecs/#videodecoder-interface其中,我实际上尝试过,希望用以下方法编码 VideoFrameVP8 https://en.wikipedia.org/wiki/VP8会以某种方式“烘烤”那个duration https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/duration and 时间戳 https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/timestamp进入它,因为至少 VideoFrame 的持续时间似乎没有做任何事情。

这是我尝试使用它时的编码/解码代码。请注意,整个编码和解码业务+编解码器是一个深渊。我不知道我是怎么找到的this https://github.com/yellowdoge/mediacapture-record-implementation-status/blob/master/chromium.md例如,但它确实告诉我 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;
}

框架计算

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

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

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

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

fps 为 30 时,帧时间 (ft) 为1 / 30秒,又名。每帧在屏幕上显示的时间。我们为 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>

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 });
        ...
    }
}

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

我最新的尝试和 ReadableStream 测试

我已经重构了很多次,但没有运气,但这是我当前的解决方案,我尝试使用 ReadableStream 将生成的 VideoFrames 传递给 MediaStreamTrackGenerator (跳过录制步骤),从中生成 MediaStream 并尝试将结果提供给srcObject of a <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))
};

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

更多信息关于媒体源 https://developer.mozilla.org/en-US/docs/Web/API/MediaSource and 源缓冲区 https://developer.mozilla.org/en-US/docs/Web/API/SourceBuffer。这也是我试图利用的MediaRecorder.start() https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/start功能与timeslice参数结合MediaRecorder.requestFrame() https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/requestData尝试逐帧记录:

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();
}

结论/后记

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

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

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

我遇到的一件非常有趣的事情是媒体流轨道生成器 https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrackGenerator/MediaStreamTrackGenerator其实是旧闻了。 w3 文档只真正讨论了视频轨道生成器 https://w3c.github.io/mediacapture-transform/#video-track-generator还有一个有趣的采取 https://w3c.github.io/mediacapture-transform/#backwards-compatibility关于如何从现有 MediaStreamTrackGenerator 基本构建 VideoTrackGenerator。这部分还要特别注意:

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

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

进一步阅读

  • w3.org 视频帧 https://www.w3.org/TR/webcodecs/#videoframe-interface and duration https://www.w3.org/TR/webcodecs/#dom-videoframe-duration

Edit 1

忘了说我用过离屏画布 https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas它是上下文,而不是普通的画布。由于我们在这里也讨论了性能,所以我想我应该尝试看看 OffscreenCanvas 是如何工作的。

我还使用了第二个构造函数视频帧 https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame/VideoFrame#parameters,也就是说,我给了它一个数组缓冲区 https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer而不是像代码中那样的位图图像。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

MediaRecorder 忽略 VideoFrame.timestamp 的相关文章