JavaScript:可靠地提取视频帧

IT技术 javascript html video canvas
2021-02-22 09:57:58

我正在开发一个客户端项目,该项目允许用户提供视频文件并对其应用基本操作。我正在尝试可靠地从视频中提取帧。目前<video>,我正在将选定的视频加载到其中,然后按如下方式拉出每一帧:

  1. 寻找起点
  2. 暂停视频
  3. <video>到一个<canvas>
  4. 从画布捕获帧 .toDataUrl()
  5. 向前搜索 1 / 30 秒(1 帧)。
  6. 冲洗并重复

这是一个相当低效的过程,更具体地说,事实证明这是不可靠的,因为我经常卡住帧。这似乎是因为它<video>在绘制到画布之前没有更新实际元素。

我宁愿不必为了分割帧而将原始视频上传到服务器,然后将它们下载回客户端。

任何关于更好的方法的建议都非常感谢。唯一的警告是我需要它与浏览器支持的任何格式一起使用(在 JS 中解码不是一个很好的选择)。

2个回答

[2021更新]:自从这个问题(和答案)第一次发布以来,这方面的事情已经发生了变化,终于到了更新的时候了;这里公开的方法已经过时了,但幸运的是,一些新的或传入的 API 可以帮助我们更好地提取视频帧:

最有前途和最强大的一个,但仍在开发中,有很多限制:WebCodecs

这个新的 API 释放了对媒体解码器和编码器的访问,使我们能够访问来自视频帧(YUV 平面)的原始数据,这对于许多应用程序来说可能比渲染帧有用得多;而对于需要渲染帧的人,这个API暴露VideoFrame接口可以直接绘制到<canvas>元素上,也可以转成ImageBitmap,避免了MediaElement的慢速路由。
然而,有一个问题,除了它目前的低支持之外,这个 API 需要输入已经被解复用。
网上有一些解复用器,例如对于 MP4 视频,GPAC 的 mp4box.js很有帮助

完整的示例可以在提案的 repo 中找到

关键部分包括

const decoder = new VideoDecoder({
  output: onFrame, // the callback to handle all the VideoFrame objects
  error: e => console.error(e),
});
decoder.configure(config); // depends on the input file, your demuxer should provide it
demuxer.start((chunk) => { // depends on the demuxer, but you need it to return chunks of video data
  decoder.decode(chunk); // will trigger our onFrame callback  
})

请注意,由于MediaCapture TransformMediaStreamTrackProcessor,我们甚至可以抓取 MediaStream 的帧这意味着我们应该能够结合HTMLMediaElement.captureStream()和这个 API 来获得我们的 VideoFrames,而不需要一个分离器。然而,这仅适用于少数编解码器,这意味着我们将以阅读速度提取帧......
无论如何,这是一个在最新的基于 Chromium 的浏览器上工作的示例,并chrome://flags/#enable-experimental-web-platform-features打开:

const frames = [];
const button = document.querySelector("button");
const select = document.querySelector("select");
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

button.onclick = async(evt) => {
  if (window.MediaStreamTrackProcessor) {
    let stopped = false;
    const track = await getVideoTrack();
    const processor = new MediaStreamTrackProcessor(track);
    const reader = processor.readable.getReader();
    readChunk();

    function readChunk() {
      reader.read().then(async({ done, value }) => {
        if (value) {
          const bitmap = await createImageBitmap(value);
          const index = frames.length;
          frames.push(bitmap);
          select.append(new Option("Frame #" + (index + 1), index));
          value.close();
        }
        if (!done && !stopped) {
          readChunk();
        } else {
          select.disabled = false;
        }
      });
    }
    button.onclick = (evt) => stopped = true;
    button.textContent = "stop";
  } else {
    console.error("your browser doesn't support this API yet");
  }
};

select.onchange = (evt) => {
  const frame = frames[select.value];
  canvas.width = frame.width;
  canvas.height = frame.height;
  ctx.drawImage(frame, 0, 0);
};

async function getVideoTrack() {
  const video = document.createElement("video");
  video.crossOrigin = "anonymous";
  video.src = "https://upload.wikimedia.org/wikipedia/commons/a/a4/BBH_gravitational_lensing_of_gw150914.webm";
  document.body.append(video);
  await video.play();
  return video.captureStream().getVideoTracks()[0];
}
video,canvas {
  max-width: 100%
}
<button>start</button>
<select disabled>
</select>
<canvas></canvas>

最容易使用,但浏览器支持相对较差,并且受浏览器丢帧的影响:HTMLVideoElement.requestVideoFrameCallback

这个方法允许我们在 HTMLVideoElement 上绘制新帧时安排回调。
它比WebCodecs级别更高,因此可能会有更多的延迟,而且我们只能以读取速度提取帧。

const frames = [];
const button = document.querySelector("button");
const select = document.querySelector("select");
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

button.onclick = async(evt) => {
  if (HTMLVideoElement.prototype.requestVideoFrameCallback) {
    let stopped = false;
    const video = await getVideoElement();
    const drawingLoop = async(timestamp, frame) => {
      const bitmap = await createImageBitmap(video);
      const index = frames.length;
      frames.push(bitmap);
      select.append(new Option("Frame #" + (index + 1), index));

      if (!video.ended && !stopped) {
        video.requestVideoFrameCallback(drawingLoop);
      } else {
        select.disabled = false;
      }
    };
    video.requestVideoFrameCallback(drawingLoop);
    button.onclick = (evt) => stopped = true;
    button.textContent = "stop";
  } else {
    console.error("your browser doesn't support this API yet");
  }
};

select.onchange = (evt) => {
  const frame = frames[select.value];
  canvas.width = frame.width;
  canvas.height = frame.height;
  ctx.drawImage(frame, 0, 0);
};

async function getVideoElement() {
  const video = document.createElement("video");
  video.crossOrigin = "anonymous";
  video.src = "https://upload.wikimedia.org/wikipedia/commons/a/a4/BBH_gravitational_lensing_of_gw150914.webm";
  document.body.append(video);
  await video.play();
  return video;
}
video,canvas {
  max-width: 100%
}
<button>start</button>
<select disabled>
</select>
<canvas></canvas>

对于 Firefox 用户,Mozilla 的非标准HTMLMediaElement.seekToNextFrame()

顾名思义,这将使您的 <video> 元素寻找下一帧。
与此相结合seeked事件,我们可以建立一个循环,会抢我们的源代码的每一帧,比读取速度更快(是的!)。
但是这种方法是专有的,仅在基于 Gecko 的浏览器中可用,不能在任何标准轨道上使用,并且可能会在将来实现上面公开的方法时被删除。
但就目前而言,它是 Firefox 用户的最佳选择:

const frames = [];
const button = document.querySelector("button");
const select = document.querySelector("select");
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");

button.onclick = async(evt) => {
  if (HTMLMediaElement.prototype.seekToNextFrame) {
    let stopped = false;
    const video = await getVideoElement();
    const requestNextFrame = (callback) => {
      video.addEventListener("seeked", () => callback(video.currentTime), {
        once: true
      });
      video.seekToNextFrame();
    };
    const drawingLoop = async(timestamp, frame) => {
      if(video.ended) {
        select.disabled = false;
        return; // FF apparently doesn't like to create ImageBitmaps
                // from ended videos...
      }
      const bitmap = await createImageBitmap(video);
      const index = frames.length;
      frames.push(bitmap);
      select.append(new Option("Frame #" + (index + 1), index));

      if (!video.ended && !stopped) {
        requestNextFrame(drawingLoop);
      } else {
        select.disabled = false;
      }
    };
    requestNextFrame(drawingLoop);
    button.onclick = (evt) => stopped = true;
    button.textContent = "stop";
  } else {
    console.error("your browser doesn't support this API yet");
  }
};

select.onchange = (evt) => {
  const frame = frames[select.value];
  canvas.width = frame.width;
  canvas.height = frame.height;
  ctx.drawImage(frame, 0, 0);
};

async function getVideoElement() {
  const video = document.createElement("video");
  video.crossOrigin = "anonymous";
  video.src = "https://upload.wikimedia.org/wikipedia/commons/a/a4/BBH_gravitational_lensing_of_gw150914.webm";
  document.body.append(video);
  await video.play();
  return video;
}
video,canvas {
  max-width: 100%
}
<button>start</button>
<select disabled>
</select>
<canvas></canvas>

最不可靠的,随着时间的推移确实停止工作:HTMLVideoElement.ontimeupdate

策略暂停 - 绘制 - 播放 - 等待时间更新曾经是(在 2015 年)一种非常可靠的方式来了解新框架何时被绘制到元素上,但从那时起,浏览器对这个正在触发的事件施加了严重的限制很棒的价格,现在我们可以从中获取的信息不多......

我不确定我是否仍然可以提倡使用它,我没有检查 Safari(这是目前唯一没有解决方案的)如何处理这个事件(他们对媒体的处理对我来说非常奇怪),并且有一个很好的setTimeout(fn, 1000 / 30)在大多数情况下,一个简单的循环实际上更可靠。

我的假设不是每 30 秒会有一个新帧,但如果我每 1/30 秒获得 1 帧,我最终会得到 30 FPS。我犯的错误是假设我得到的帧实际上是正确的当前帧。我很感激你的帮助。timeupdate 事件非常有帮助。蒂:)
2021-04-25 09:57:58
@Kaiido 好的,那我明白了。不,我找不到一个好的词来形容它,所以“玩转速度”只是我的重新措辞。
2021-04-28 09:57:58
@DineshBolkensteyn,您是否阅读了此答案的标题和链接的答案?不,此方法不能可靠地提取文件中ae 的所有视频帧,仅提取浏览器绘制的视频帧,这不考虑视频的帧率。
2021-04-30 09:57:58
timeupdate 每秒触发大约 5 次,绝对不是获取所有帧的好方法。
2021-04-30 09:57:58
这种方法并不完全可靠:它曾经返回 4172 帧,然后在第二次运行时返回 4573,根据 ffmpeg,视频实际上只有 250 帧。10 秒 @ 25 fps:w3schools.com/html/mov_bbb.mp4
2021-05-13 09:57:58

这是从这个问题调整的工作功能

async function extractFramesFromVideo(videoUrl, fps=25) {
  return new Promise(async (resolve) => {

    // fully download it first (no buffering):
    let videoBlob = await fetch(videoUrl).then(r => r.blob());
    let videoObjectUrl = URL.createObjectURL(videoBlob);
    let video = document.createElement("video");

    let seekResolve;
    video.addEventListener('seeked', async function() {
      if(seekResolve) seekResolve();
    });

    video.src = videoObjectUrl;

    // workaround chromium metadata bug (https://stackoverflow.com/q/38062864/993683)
    while((video.duration === Infinity || isNaN(video.duration)) && video.readyState < 2) {
      await new Promise(r => setTimeout(r, 1000));
      video.currentTime = 10000000*Math.random();
    }
    let duration = video.duration;

    let canvas = document.createElement('canvas');
    let context = canvas.getContext('2d');
    let [w, h] = [video.videoWidth, video.videoHeight]
    canvas.width =  w;
    canvas.height = h;

    let frames = [];
    let interval = 1 / fps;
    let currentTime = 0;

    while(currentTime < duration) {
      video.currentTime = currentTime;
      await new Promise(r => seekResolve=r);

      context.drawImage(video, 0, 0, w, h);
      let base64ImageData = canvas.toDataURL();
      frames.push(base64ImageData);

      currentTime += interval;
    }
    resolve(frames);
  });
});

}

用法:

let frames = await extractFramesFromVideo("https://example.com/video.webm");

请注意,目前没有简单的方法来确定视频的实际/自然帧速率,除非您可能使用ffmpeg.js,但这是一个 10+ 兆字节的 javascript 文件(因为它是实际 ffmpeg 库的 emscripten 端口,这显然是巨大的)。

@ParthibanRajendran 您可以使用现有的画布并在开始绘制之前清除它。如果它满足您的需求,这实际上可能对性能更好!
2021-04-18 09:57:58
有趣的是,我在您回答之后就遇到了这个问题。我将给出一个类似(但不那么详细)的答案,指出使用ontimeupdate和建议使用设置的解决方案的问题video.currentTime优秀作品!
2021-04-23 09:57:58
我们每次都必须创建新的画布吗?既然它就像一个临时变量,我们可以只使用一个吗?哪个在性能方面更好?
2021-04-27 09:57:58
我们可以使用它提取多少帧?
2021-05-02 09:57:58
Uncaught SyntaxError: await is only valid in async function? 指的是let frames = await...
2021-05-03 09:57:58