【问题标题】:Record at constant fps with CanvasCaptureMediaStream even on slow computers即使在速度较慢的计算机上,也可以使用 CanvasCaptureMediaStream 以恒定 fps 录制
【发布时间】:2020-03-13 09:24:45
【问题描述】:

我想以特定帧速率从 HTML <canvas> 元素录制视频。

我将 CanvasCaptureMediaStream 与canvas.captureStream(fps) 一起使用,并且还可以通过const track = stream.getVideoTracks()[0] 访问视频轨道,因此我创建track.requestFrame() 以通过MediaRecorder 将其写入输出视频缓冲区。

我想一次精确地捕捉一帧,然后更改画布内容。更改画布内容可能需要一些时间(因为需要加载图像等)。所以我无法实时捕捉画布。 画布上的一些变化会在 500 毫秒内实时发生,因此这也需要调整为一次渲染一帧。

【问题讨论】:

  • MediaRecorder 用于录制 live 流。你确定你不能准备你的画布动画,让它运行@30FPS吗?在录制之前准备好所有资产,预先生成 long 以绘制帧,因此您只需要对其进行 drawImage 等。MediaRecorder 可以暂停和恢复,但不能恢复 MediaStream,并且由于 MediaRecorder 从 MediaStream 获取 fps,我不是确保你能得到任何可靠的东西......(虽然当我有更多时间时,我可能会尝试一些事情)。
  • 感谢您的回答!我认为 MediaRecorder 可以用于逐帧录制。这就是为什么流可以逐帧控制并以“0”的fps初始化的原因。我试图让它与你在这里关于使用音频上下文进行定时循环的答案一起工作。但我实际上不需要时间循环。我可以以固定的帧速率在画布上移动我的项目,并且只记录每一帧,最高每秒 60 帧,具体取决于我想要达到的帧速率。我的错误在哪里? ;)

标签: html5-canvas


【解决方案1】:

MediaRecorder API 旨在录制直播-流,做编辑不是它的设计目的,老实说它做得不太好......

MediaRecorder 本身没有帧率的概念,这通常由 MediaStreamTrack 定义。但是,CanvasCaptureStreamTrack 并没有真正明确它的帧速率是多少。
我们可以将参数传递给HTMLCanvas.captureStream(),但这只是告诉我们每秒想要的最大帧数,它并不是真正的 fps 参数。
此外,即使我们停止在画布上绘图,录制器仍会继续实时延长录制视频的持续时间(我认为技术上只录制单个 long 帧,但在这种情况下)。

所以...我们将不得不破解...

我们可以用 MediaRecorder 做的一件事是 pause()resume() 它。
听起来很容易在执行长绘图操作之前暂停并在完成后立即恢复? 是的……也不是那么容易……
再一次,帧率由 MediaStreamTrack 决定,但这个 MediaStreamTrack 不能暂停。
嗯,实际上有一种方法可以暂停一种特殊的 MediaStreamTrack,幸运的是我说的是 CanvasCaptureMediaStreamTracks。
当我们使用参数0 调用我们的捕获流时,我们基本上可以手动控制何时将新帧添加到流中。
所以在这里我们可以将 MediaRecorder 和 MediaStreamTrack 同步到我们想要的任何帧速率。

基本的工作流程是

await the_long_drawing_task;
resumeTheRecorder();
writeTheFrameToStream(); // track.requestFrame();
await wait( time_per_frame );
pauseTheRecorder();

这样做,记录器仅在我们决定的每帧时间唤醒,并且在此期间将单个帧传递给 MediaStream,有效地模拟了 MediaRecorder 所关注的恒定 FPS 绘图。

但与往常一样,在这个仍处于试验阶段的黑客攻击会带来很多浏览器的怪异之处,以下演示实际上仅适用于当前的 Chrome...

无论出于何种原因,Firefox 生成的文件的帧数总是比请求的帧数多一倍,而且它偶尔还会在第一帧前面添加一个长...

还要注意,Chrome has a bug 将在绘图时更新画布流,即使我们使用 0frameRequestRate 启动此流。因此,这意味着如果您在一切准备就绪之前就开始绘图,或者如果在画布上绘图本身需要很长时间,那么我们的记录器将记录我们没有要求的半熟帧。
为了解决这个错误,我们需要使用第二个画布,仅用于流式传输。我们将在该画布上做的就是绘制源画布,这将始终是一个足够快的操作。不要面对那个错误。

class FrameByFrameCanvasRecorder {
  constructor(source_canvas, FPS = 30) {
  
    this.FPS = FPS;
    this.source = source_canvas;
    const canvas = this.canvas = source_canvas.cloneNode();
    const ctx = this.drawingContext = canvas.getContext('2d');

    // we need to draw something on our canvas
    ctx.drawImage(source_canvas, 0, 0);
    const stream = this.stream = canvas.captureStream(0);
    const track = this.track = stream.getVideoTracks()[0];
    // Firefox still uses a non-standard CanvasCaptureMediaStream
    // instead of CanvasCaptureMediaStreamTrack
    if (!track.requestFrame) {
      track.requestFrame = () => stream.requestFrame();
    }
    // prepare our MediaRecorder
    const rec = this.recorder = new MediaRecorder(stream);
    const chunks = this.chunks = [];
    rec.ondataavailable = (evt) => chunks.push(evt.data);
    rec.start();
    // we need to be in 'paused' state
    waitForEvent(rec, 'start')
      .then((evt) => rec.pause());
    // expose a Promise for when it's done
    this._init = waitForEvent(rec, 'pause');

  }
  async recordFrame() {

    await this._init; // we have to wait for the recorder to be paused
    const rec = this.recorder;
    const canvas = this.canvas;
    const source = this.source;
    const ctx = this.drawingContext;
    if (canvas.width !== source.width ||
      canvas.height !== source.height) {
      canvas.width = source.width;
      canvas.height = source.height;
    }

    // start our timer now so whatever happens between is not taken in account
    const timer = wait(1000 / this.FPS);

    // wake up the recorder
    rec.resume();
    await waitForEvent(rec, 'resume');

    // draw the current state of source on our internal canvas (triggers requestFrame in Chrome)
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.drawImage(source, 0, 0);
    // force write the frame
    this.track.requestFrame();

    // wait until our frame-time elapsed
    await timer;

    // sleep recorder
    rec.pause();
    await waitForEvent(rec, 'pause');

  }
  async export () {

    this.recorder.stop();
    this.stream.getTracks().forEach((track) => track.stop());
    await waitForEvent(this.recorder, "stop");
    return new Blob(this.chunks);

  }
}

///////////////////
// how to use:
(async() => {
  const FPS = 30;
  const duration = 5; // seconds

  let x = 0;
  let frame = 0;
  const ctx = canvas.getContext('2d');
  ctx.textAlign = 'right';
  draw(); // we must have drawn on our canvas context before creating the recorder

  const recorder = new FrameByFrameCanvasRecorder(canvas, FPS);

  // draw one frame at a time
  while (frame++ < FPS * duration) {
    await longDraw(); // do the long drawing
    await recorder.recordFrame(); // record at constant FPS
  }
  // now all the frames have been drawn
  const recorded = await recorder.export(); // we can get our final video file
  vid.src = URL.createObjectURL(recorded);
  vid.onloadedmetadata = (evt) => vid.currentTime = 1e100; // workaround https://crbug.com/642012
  download(vid.src, 'movie.webm');

  // Fake long drawing operations that make real-time recording impossible
  function longDraw() {
    x = (x + 1) % canvas.width;
    draw(); // this triggers a bug in Chrome
    return wait(Math.random() * 300)
      .then(draw);
  }

  function draw() {
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = 'black';
    ctx.fillRect(x, 0, 50, 50);
    ctx.fillText(frame + " / " + FPS * duration, 290, 140);
  };
})().catch(console.error);
<canvas id="canvas"></canvas>
<video id="vid" controls></video>

<script>
  // Some helpers
  
  // Promise based timer
  function wait(ms) {
    return new Promise(res => setTimeout(res, ms));
  }
  // implements a sub-optimal monkey-patch for requestPostAnimationFrame
  // see https://stackoverflow.com/a/57549862/3702797 for details
  if (!window.requestPostAnimationFrame) {
    window.requestPostAnimationFrame = function monkey(fn) {
      const channel = new MessageChannel();
      channel.port2.onmessage = evt => fn(evt.data);
      requestAnimationFrame((t) => channel.port1.postMessage(t));
    };
  }
  // Promisifies EventTarget.addEventListener
  function waitForEvent(target, type) {
    return new Promise((res) => target.addEventListener(type, res, {
      once: true
    }));
  }
  // creates a downloadable anchor from url
  function download(url, filename = "file.ext") {
    a = document.createElement('a');
    a.textContent = a.download = filename;
    a.href = url;
    document.body.append(a);
    return a;
  }
</script>

【讨论】:

  • 非常感谢您的代码和想法!我认为您可以稍微改进您的功能“等待”,因为 setTimeout 的处理速度不能快于浏览器刷新率(即 60 fps)。相反,您可以这样做: let time = new Date().getTime();而(新日期()。getTime()-时间
  • @TomLecoz setTimeout 绝对可以比刷新率更快地触发。对于活动窗口中的非嵌套超时,Chrome 中的最小值为 1 毫秒,Firefox 中的最小值为 0 毫秒。如果您想要一个更好的计时器,但在模糊选项卡中效果更好,您可以查看以前的修订版。
  • 我不确定...如果您尝试此代码: let time = new Date().getTime() ; setTimeout( ()=> { console.log(new Date().getTime() - time ); } , 1 ) ,即使您将 1 设置为 settimeout 的参数,您也会得到 17 毫秒。无论如何,我尝试了我的解决方案,但没有成功,因为 recorder.pause 需要在 recorder.resume 之后至少调用一帧才能获得预期的行为
  • @TomLecoz 当然你在页面加载和 FF 中尝试过?在 FF 的页面加载中还有更复杂的规则重新分级 setTimeout,但否则,不,您应该从代码中记录 ~1:jsfiddle.net/p6hyf3dq
【解决方案2】:

我问了一个与此相关的similar question。与此同时,我提出了一个与 Kaiido 重叠的解决方案,我认为值得一读。

我添加了两个技巧:

  • 我推迟了下一次渲染(参见代码),这解决了 Firefox 生成两倍帧数的问题
  • 我存储了一个累积的计时错误以纠正 setTimeout 的不准确性。我个人用它来调整我的渲染进度,例如,如果突然出现延迟,则跳过帧,并使视频的持续时间接近目标持续时间。但仅仅平滑 setTimeout 是不够的。
const recordFrames = (onstop, canvas, fps=30) => {
    const chunks = [];

    // get Firefox to initialise the canvas
    canvas.getContext('2d').fillRect(0, 0, 0, 0);

    const stream = canvas.captureStream();
    const recorder = new MediaRecorder(stream);

    recorder.addEventListener('dataavailable', ({data}) => chunks.push(data));
    recorder.addEventListener('stop', () => onstop(new Blob(chunks)));

    const frameDuration = 1000 / fps;
    
    const frame = (next, start) => {
        recorder.pause();
        api.error += Date.now() - start - frameDuration;
        setTimeout(next, 0); // helps Firefox record the right frame duration
    };

    const api = {
        error: 0,
        init() { 
            recorder.start(); 
            recorder.pause();
        },
        step(next) {
            recorder.resume();
            setTimeout(frame, frameDuration, next, Date.now());
        }, 
        stop: () => recorder.stop()
    };

    return api;
}

如何使用

const fps = 30;
const duration = 5000;

const animation = Something;

const videoOutput = blob => {
    const video = document.createElement('video');
    video.src = URL.createObjectURL(blob);
    document.body.appendChild(video);
}

const recording = recordFrames(videoOutput, canvas, fps);

const startRecording = () => {
   recording.init();
   animation.play();
};

// I am assuming you can call these from your library

const onAnimationRender = nextFrame => recording.step(nextFrame);
const onAnimationEnd = () => recording.step(recording.stop);

let now = 0;
const progression = () => {
    now = now + 1 + recorder.error * fps / 1000;
    recorder.error = 0;
    return now * 1000 / fps / duration
}

我发现这个解决方案在 Chrome 和 Firefox 中都能达到 30fps。我没有遇到 Kaiido 提到的 Chrome 错误,因此没有实施任何处理它们的方法。

【讨论】:

  • 有趣!它适用于 4K 吗?我用自己的实现做了一些 4k 测试,结果一点都不好
  • 我的笔记本电脑不适合播放 4k 视频,所以我不知道是因为录制还是因为播放而断断续续。我确实尝试使用 html2canvas 录制 2560 x 1440 DOM 动画,我对结果很满意。每个视频帧的平均误差为 0.09 毫秒,峰值为 3.66 毫秒(在 Firefox 中),输出视频的持续时间是 DOM 动画的 1.006 倍。然而,我确实觉得有必要调整 MediaRecorder 的 videoBitsPerSecond 选项,因为默认值太低,而且我发现选择正确的设置是一个反复试验的过程。
  • 请参阅this issue 了解为什么即使以正确的速度触发暂停和恢复也不一定会产生具有可靠帧速率的视频。虽然它是满足基本需求的一个不错的简单解决方案,但我认为如果您需要专业外观,最好渲染静止图像并将它们发送到本地服务器。
  • 我现在才看到这个答案,它工作得很好,而且比我的实现简单得多(在 Firefox 中甚至比我的更好)。如果它们没有像我想的那样有帮助,我也许也应该摆脱这些 waitForEvents 。对于 Chrome 错误,这里有一个重现:jsfiddle.net/g4dbxkou 输出中应该没有红色。使用第二个画布可以解决这个问题:jsfiddle.net/g4dbxkou/1
  • 我尝试从一个通过视频元素在后台播放的视频中添加一个绘制方法,但它没有明显的原因就失败了。知道为什么吗?
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2011-06-08
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-11-13
相关资源
最近更新 更多