html JavaScript:可靠地提取视频帧

u0njafvf  于 2022-12-09  发布在  Java
关注(0)|答案(3)|浏览(195)

我正在开发一个客户端项目,它允许用户提供一个视频文件,并对其进行基本操作。我试图可靠地从视频中提取帧。目前,我有一个<video>,我将选定的视频加载到其中,然后按如下方式提取每一帧:
1.寻找开头
1.暂停视频
1.将<video>绘制为<canvas>
1.使用.toDataUrl()从画布捕获帧
1.向前搜索1/30秒(1帧)。
1.冲洗并重复
这是一个相当低效的过程,更确切地说,是不可靠的,因为我经常得到卡住的帧。这似乎是因为它没有更新实际的<video>元素之前,绘制到画布。
我不想为了拆分帧而将原始视频上传到服务器,然后再将它们下载回客户端。
任何更好的方法来做这件事的建议都是非常感谢的。唯一的警告是,我需要它与任何格式的浏览器支持(解码在JS不是一个伟大的选择)。

1l5u6lss

1l5u6lss1#

[2021 update]: Since this question (and answer) has first been posted, things have evolved in this area, and it is finally time to make an update; the method that was exposed here went out-of-date, but luckily a few new or incoming APIs can help us better in extracting video frames:

The most promising and powerful one, but still under development, with a lot of restrictions: WebCodecs

This new API unleashes access to the media decoders and encoders, enabling us to access raw data from video frames (YUV planes), which may be a lot more useful for many applications than rendered frames; and for the ones who need rendered frames, the VideoFrame interface that this API exposes can be drawn directly to a element or converted to an ImageBitmap, avoiding the slow route of the MediaElement.
However there is a catch, apart from its current low support, this API needs that the input has been demuxed already.
There are some demuxers online, for instance for MP4 videos GPAC's mp4box.js will help a lot.
A full example can be found on the proposal's repo .
The key part consists of

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

Note that we can even grab the frames of a MediaStream, thanks to MediaCapture Transform 's MediaStreamTrackProcessor. This means that we should be able to combine HTMLMediaElement.captureStream() and this API in order to get our VideoFrames, without the need for a demuxer. However this is true only for a few codecs, and it means that we will extract frames at reading speed...
Anyway, here is an example working on latest Chromium based browsers, with chrome://flags/#enable-experimental-web-platform-features switched on:

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();
  const [track] = video.captureStream().getVideoTracks();
  video.onended = (evt) => track.stop();
  return track;
}
video,canvas {
  max-width: 100%
}
<button>start</button>
<select disabled>
</select>
<canvas></canvas>

The easiest to use, but still with relatively poor browser support, and subject to the browser dropping frames: HTMLVideoElement.requestVideoFrameCallback

This method allows us to schedule a callback to whenever a new frame will be painted on the HTMLVideoElement.
It is higher level than WebCodecs, and thus may have more latency, and moreover, with it we can only extract frames at reading speed.

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;
      }
    };
    // the last call to rVFC may happen before .ended is set but never resolve
    video.onended = (evt) => 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>

For your Firefox users, Mozilla's non-standard HTMLMediaElement.seekToNextFrame()

As its name implies, this will make your element seek to the next frame.
Combining this with the seeked event, we can build a loop that will grab every frame of our source, faster than reading speed (yeah!).
But this method is proprietary, available only in Gecko based browsers, not on any standard tracks, and probably gonna be removed in the future when they'll implement the methods exposed above.
But for the time being, it is the best option for Firefox users:

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>

The least reliable, that did stop working over time: HTMLVideoElement.ontimeupdate

The strategy pause - draw - play - wait for timeupdate used to be (in 2015) a quite reliable way to know when a new frame got painted to the element, but since then, browsers have put serious limitations on this event which was firing at great rate and now there isn't much information we can grab from it...
I am not sure I can still advocate for its use, I didn't check how Safari (which is currently the only one without a solution) handles this event (their handling of medias is very weird for me), and there is a good chance that a simple setTimeout(fn, 1000 / 30) loop is actually more reliable in most of the cases.

cpjpxq1n

cpjpxq1n2#

下面是根据这个问题调整的工作函数:

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+ MB的javascript文件(因为它是实际ffmpeg库的emscripten端口,这显然是巨大的)。

brccelvz

brccelvz3#

2023回答:

如果你想可靠地提取所有帧(即没有“寻找”和丢失帧),并且尽可能快地提取(即不受播放速度或其他因素的限制),那么你可能需要使用WebCodecs API。在编写it's supported in Chrome and Edge时,其他浏览器将很快跟进--希望到2023年底会有广泛的支持。
我为此建立了一个简单的库,但是 * 它目前只支持mp4文件 *。

<canvas id="canvasEl"></canvas>
<script type="module">
  import getVideoFrames from "https://deno.land/x/get_video_frames@v0.0.8/mod.js"

  let ctx = canvasEl.getContext("2d");

  // `getVideoFrames` requires a video URL as input.
  // If you have a file/blob instead of a videoUrl, turn it into a URL like this:
  let videoUrl = URL.createObjectURL(fileOrBlob);

  await getVideoFrames({
    videoUrl,
    onFrame(frame) {  // `frame` is a VideoFrame object: https://developer.mozilla.org/en-US/docs/Web/API/VideoFrame
      ctx.drawImage(frame, 0, 0, canvasEl.width, canvasEl.height);
      frame.close();
    },
    onConfig(config) {
      canvasEl.width = config.codedWidth;
      canvasEl.height = config.codedHeight;
    },
  });
  
  URL.revokeObjectURL(fileOrBlob); // revoke URL to prevent memory leak
</script>

*演示https://jsbin.com/mugoguxiha/edit?html,output
*Githubhttps://github.com/josephrocca/getVideoFrames.js

(Note@Kaiido的回答很好,其中提到了WebCodecs API,但不幸的是,单凭这个API并不能解决问题--上面的示例使用mp4box.js来处理WebCodecs不能处理的内容。也许WebCodecs最终会支持容器方面的内容,而这个答案将变得几乎无关紧要,但在那之前,我希望这是有用的。)

相关问题