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而不是像代码中那样的位图图像。