如何实现一个网页版的剪映(三)使用fabric.js绘制时间轴
如何实现一个网页版的剪映(四)使用插件化思维创建pixi绘制画布(转场/滤镜)
在# 如何实现一个网页版的剪映(一)简介讲过webav是如何seek某一帧的,我们来回顾一下
源码入口是一个tick函数
/**
* 获取素材指定时刻的图像帧、音频数据
* @param time 微秒
*/
async tick(time: number): Promise<{
video?: VideoFrame;
audio: Float32Array[];
state: 'success' | 'done';
}> {
if (time >= this.#meta.duration) {
return await this.tickInterceptor(time, {
audio: (await this.#audioFrameFinder?.find(time)) ?? [],
state: 'done',
});
}
const [audio, video] = await Promise.all([
this.#audioFrameFinder?.find(time) ?? [],
this.#videoFrameFinder?.find(time).then(this.#vfRotater),
]);
if (video == null) {
return await this.tickInterceptor(time, {
audio,
state: 'success',
});
}
return await this.tickInterceptor(time, {
video,
audio,
state: 'success',
});
}
WebAV 是如何seek的
核心类在 VideoFrameFinder
什么时候会 Reset(等价于 Seek 重建解码状态)
在 find 里,满足任一条件就会 #reset(time) :
find = async (time: number): Promise<VideoFrame | null> => {
if (
this.#dec == null ||
this.#dec.state === 'closed' ||
time <= this.#ts ||
time - this.#ts > 3e6
) {
this.#reset(time);
}
this.#curAborter.abort = true;
this.#ts = time;
this.#curAborter = { abort: false, st: performance.now() };
const vf = await this.#parseFrame(time, this.#dec, this.#curAborter);
this.#sleepCnt = 0;
return vf;
};
- 解码器不存在/已关闭
- time <= 上一次的 time (倒退 seek)
- time - 上一次的 time > 3e6 (跨度超过 3 秒,认为是 seek)
Reset 做了几件关键事 #reset :
- 清空缓存的 VideoFrame 队列(并 close)
- 关闭并重建 VideoDecoder (必要时会降级软件解码)
- 最重要: 把解码游标 #videoDecCusorIdx 移到“目标时间点之前最近的 IDR 帧”
- 逻辑是扫描 samples:不断更新 keyIdx (遇到 s.is_idr ),当第一次看到 s.cts >= time 时,把 cursor 设为 keyIdx
这一步决定了: 视频定位不是直接“找 time 对应的那一帧 sample”就解,而是必须从关键帧开始解码 GOP,才能得到后续帧。
parseFrame:从“解码输出队列”里挑出覆盖 time 的那一帧
#parseFrame = async (
time: number,
dec: VideoDecoder | null,
aborter: { abort: boolean; st: number },
): Promise<VideoFrame | null> => {
if (dec == null || dec.state === 'closed' || aborter.abort) return null;
if (this.#videoFrames.length > 0) {
const vf = this.#videoFrames[0];
if (time < vf.timestamp) return null;
// 弹出第一帧
this.#videoFrames.shift();
// 第一帧过期,找下一帧
if (time > vf.timestamp + (vf.duration ?? 0)) {
vf.close();
return await this.#parseFrame(time, dec, aborter);
}
if (!this.#predecodeErr && this.#videoFrames.length < 10) {
// 预解码 避免等待
this.#startDecode(dec).catch((err) => {
this.#predecodeErr = true;
this.#reset(time);
throw err;
});
}
// 符合期望
return vf;
}
// 缺少帧数据
if (
this.#decoding ||
(this.#outputFrameCnt < this.#inputChunkCnt && dec.decodeQueueSize > 0)
) {
if (performance.now() - aborter.st > 6e3) {
throw Error(
`MP4Clip.tick video timeout, ${JSON.stringify(this.#getState())}`,
);
}
// 解码中,等待,然后重试
this.#sleepCnt += 1;
await sleep(15);
} else if (this.#videoDecCusorIdx >= this.samples.length) {
// decode completed
return null;
} else {
try {
await this.#startDecode(dec);
} catch (err) {
this.#reset(time);
throw err;
}
}
return await this.#parseFrame(time, dec, aborter);
};
#parseFrame(time, dec, aborter)的第一优先级是消费 #videoFrames 缓存队列:
- 若队列非空,取队头
vf = videoFrames[0] time < vf.timestamp:说明 当前缓存最早帧都比目标 time 晚 ,直接返回 null- 否则 shift 弹出这一帧,然后判断是否“过期”:
time > vf.timestamp + (vf.duration ?? 0):目标 time 已经超过这帧覆盖区间,close 掉继续递归找下一帧- 否则:这帧覆盖了 time ,直接返回它
因此,“寻找帧”的判定标准就是:vf.timestamp <= time <= vf.timestamp + vf.duration
如果缓存里没有帧,就推进解码(按 GOP 批量 decode)
当 #videoFrames 为空时, #parseFrame 会进入“要么等解码完成,要么启动新解码”的状态机:
- 如果正在解码 / 或者 decodeQueue 里还有待输出:睡眠 15ms 再重试,并带 6s 超时保护
- 如果 cursor 已经到 sample 末尾:返回 null
- 否则调用 #startDecode(dec) 推进一段 GOP 解码
startDecode:如何切出一个 GOP、读数据、喂给 VideoDecoder(最关键的代码)
#startDecode = async (dec: VideoDecoder) => {
if (this.#decoding || dec.decodeQueueSize > 600) return;
// 启动解码任务,然后重试
let endIdx = this.#videoDecCusorIdx + 1;
if (endIdx > this.samples.length) return;
this.#decoding = true;
// 该 GoP 时间区间有时间匹配,且未被删除的帧
let hasValidFrame = false;
for (; endIdx < this.samples.length; endIdx++) {
const s = this.samples[endIdx];
if (!hasValidFrame && !s.deleted) {
hasValidFrame = true;
}
// 找一个 GoP,所以是下一个 IDR 帧结束
if (s.is_idr) break;
}
if (hasValidFrame) {
const samples = this.samples.slice(this.#videoDecCusorIdx, endIdx);
if (samples[0]?.is_idr !== true) {
Log.warn('First sample not idr frame');
} else {
const readStarTime = performance.now();
const chunks = await videosamples2Chunks(samples, this.localFileReader);
const readCost = performance.now() - readStarTime;
if (readCost > 1000) {
const first = samples[0];
const last = samples.at(-1)!;
const rangSize = last.offset + last.size - first.offset;
Log.warn(
`Read video samples time cost: ${Math.round(readCost)}ms, file chunk size: ${rangSize}`,
);
}
// Wait for the previous asynchronous operation to complete, at which point the task may have already been terminated
if (dec.state === 'closed') return;
this.#lastVfDur = chunks[0]?.duration ?? 0;
decodeGoP(dec, chunks, {
onDecodingError: (err) => {
if (this.#downgradeSoftDecode) {
throw err;
} else if (this.#outputFrameCnt === 0) {
this.#downgradeSoftDecode = true;
Log.warn('Downgrade to software decode');
this.#reset();
}
},
});
this.#inputChunkCnt += chunks.length;
}
}
this.#videoDecCusorIdx = endIdx;
this.#decoding = false;
};
首先需要先知道的几个个概念
- 关键帧(sync frame) :能独立解码的帧,H.264/265 里通常是 IDR。代码里用 s.is_idr 作为 GOP 的分界。(更详细的解释看# 如何实现一个网页版的剪映(一)简介)
- GOP :从一个 IDR 到下一个 IDR 之前的那一段帧序列。解码时通常要从 GOP 开头开始喂数据,才能正确得到中间的 delta 帧。
- cursor(游标) : #videoDecCusorIdx 表示“下一次准备从 samples 的哪个下标开始喂给解码器”。
先判断“要不要启动一次解码”
if (this.#decoding || dec.decodeQueueSize > 600) return;
- #decoding :防止重复并发启动(一次还没结束又启动一次)。
- decodeQueueSize > 600 :解码器内部积压太多了就先别喂,避免爆内存/卡死。
确定这次要处理的范围:从 cursor 往后找到一个 GOP 的“结束位置”
let endIdx = this.#videoDecCusorIdx + 1;
...
for (; endIdx < this.samples.length; endIdx++) {
const s = this.samples[endIdx];
...
if (s.is_idr) break;
}
endIdx 往后走,直到遇到下一个关键帧,这就相当于找到了 [start, endIdx) 这一段 GOP (从当前关键帧开始,到下一个关键帧之前结束)
喂给 VideoDecoder 解码(异步产出 VideoFrame)
this.#lastVfDur = chunks[0]?.duration ?? 0;
decodeGoP(dec, chunks, { onDecodingError ... });
this.#inputChunkCnt += chunks.length;
function decodeGoP(
dec: VideoDecoder,
chunks: EncodedVideoChunk[],
opts: {
onDecodingError?: (err: Error) => void;
},
) {
if (dec.state !== 'configured') return;
for (let i = 0; i < chunks.length; i++) dec.decode(chunks[i]);
// todo:flush 之后下一帧必须是 IDR 帧,是否可以根据情况再决定调用 flush?
// windows 某些设备 flush 可能不会被 resolved,所以不能 await flush
dec.flush().catch((err) => {
if (!(err instanceof Error)) throw err;
if (
err.message.includes('Decoding error') &&
opts.onDecodingError != null
) {
opts.onDecodingError(err);
return;
}
// reset 中断解码器,预期会抛出 AbortedError
if (!err.message.includes('Aborted due to close')) {
throw err;
}
});
}
decodeGoP 做的事很直接:
- 循环 dec.decode(chunk) 把 chunk 都送进去
- 调 dec.flush() (但不 await)让解码器尽快把队列处理完
重要: startDecode 本身 不会在这里等到 VideoFrame 真正出来 。帧是通过 new VideoDecoder({ output(vf) { ... } }) 的 output 回调异步推入 #videoFrames 缓存的。
最后推进 cursor,并解除 “正在解码” 状态
this.#videoDecCusorIdx = endIdx;
this.#decoding = false;
这一步非常关键:它决定下一次 startDecode 会从哪里继续喂下一段 GOP。
MediaBunny 是如何seek的
// 一次取单个时间点
const sample = await videoSink.getSample(1.25);
// 一次取多个时间点
for await (const sample of videoSink.samplesAtTimestamps([0.5, 1.0, 1.5])) {
console.log(sample); // MediaSample 或 null
}
MediaBunny 的 seek,和webav一样,本质是三步:
- 定位目标帧(target packet)
- 找到对应关键帧(key packet)
- 从关键帧开始解码到目标帧(按批次)
getSample底层会调mediaSamplesAtTimestamps这个函数,其中getKeyPacket就是获取关键帧的函数
mediaSamplesAtTimestamps部分代码如下
for await (const timestamp of timestampIterator) {
// getPacket(timestamp) 取“表示这个时间点内容”的目标包。
const targetPacket = await packetSink.getPacket(timestamp);
// getKeyPacket(timestamp, { verifyKeyPackets: true })会定位该时间点可用的关键包,
// 并校验关键包标记,避免容器元数据不准导致错误解码。
const keyPacket =
targetPacket &&
(await packetSink.getKeyPacket(timestamp, {
verifyKeyPackets: true,
}));
if (!keyPacket) {
if (maxSequenceNumber !== -1) {
await decodePackets();
await flushDecoder();
}
pushToQueue(null);
lastPacket = null;
continue;
}
// 如果关键帧变了,或者请求时间戳发生“倒退”,说明不能继续复用当前这批解码状态,
// 需要先把上一批收尾并清空解码器状态,再开启新批次。
if (
lastPacket &&
(keyPacket.sequenceNumber !== lastKeyPacket!.sequenceNumber ||
targetPacket.timestamp < lastPacket.timestamp)
) {
await decodePackets();
await flushDecoder(); // 这里始终 flush,一些解码器在这种切换场景下兼容性更好。
}
// 记录这个时间戳最终实际要匹配的样本起始时间。
timestampsOfInterest.push(targetPacket.timestamp);
// 批次终点取多个目标包中的最大序号,这样相邻请求可以复用同一轮解码。
maxSequenceNumber = Math.max(
targetPacket.sequenceNumber,
maxSequenceNumber,
);
lastPacket = targetPacket;
lastKeyPacket = keyPacket;
}
从关键帧解码到目标帧(核心)
// 下一批需要解码到的结束序号(包含)。
// 每一批都从 `lastKeyPacket` 开始,一直解码到这个序号为止。
let maxSequenceNumber = -1;
const decodePackets = async () => {
// 从当前关键帧开始解码,确保解码器拥有正确的参考状态。
let currentPacket = lastKeyPacket;
decoder.decode(currentPacket);
while (currentPacket.sequenceNumber < maxSequenceNumber) {
// `computeMaxQueueSize()` 根据当前已解码样本数动态决定允许的总排队量,避免占用过多内存。
const maxQueueSize = computeMaxQueueSize(sampleQueue.length);
while (
sampleQueue.length + decoder.getDecodeQueueSize() > maxQueueSize &&
!terminated
) {
// 队列太满时暂停继续喂包,等消费者取走一些样本后再继续。
({ promise: queueDequeue, resolve: onQueueDequeue } =
promiseWithResolvers());
await queueDequeue;
}
if (terminated) {
break;
}
// `getNextPacket()` 按编码顺序拿到当前包之后的下一个包。
const nextPacket = await packetSink.getNextPacket(currentPacket);
decoder.decode(nextPacket);
currentPacket = nextPacket;
}
maxSequenceNumber = -1;
};
getKeyPacket是怎么获取关键帧的
入口函数如下
async getKeyPacket(
timestamp: number,
options: PacketRetrievalOptions = {},
): Promise<EncodedPacket | null> {
validateTimestamp(timestamp);
validatePacketRetrievalOptions(options);
if (this._track.input._disposed) {
throw new InputDisposedError();
}
if (!options.verifyKeyPackets) {
return this._track._backing.getKeyPacket(timestamp, options);
}
const packet = await this._track._backing.getKeyPacket(timestamp, options);
if (!packet || packet.type === "delta") {
return packet;
}
const determinedType = await this._track.determinePacketType(packet);
if (determinedType === "delta") {
// Try returning the previous key packet (in hopes that it's actually a key packet)
return this.getKeyPacket(
packet.timestamp - 1 / this._track.timeResolution,
options,
);
}
return packet;
}
this._track._backing.getKeyPacket是内部的函数,如果不做验证这帧是不是关键帧,就直接返回
但是会有这种情况:有些文件写错了 keyframe 标记,会返回“看起来是 key、实际是 delta”的包,导致解码器报错
接着,就会进行校验
- 先向下拿候选 key packet;
- 如果没拿到,或底层直接说它是 delta,就返回
- 如果底层说它是 key,那就“扒开码流看一眼”它到底是不是 key,会解析码流(比如 H.264 看有没有 IDR NAL)来判断
this._track._backing.getKeyPacket如下
/**
* 按时间戳取“关键帧”数据包。
*
* 取包有两条路:
* 1) 文件自带“索引表”(普通 MP4/MOV 常见):先用索引表找到这个时间附近的 sample,再往前退到最近的关键帧。
* 2) 没有索引表、而是“分段存储”(fMP4 常见):就去各个分段里找“时间 <= 目标时间”的最后一个关键帧。
*/
async getKeyPacket(timestamp: number, options: PacketRetrievalOptions) {
// 外部传进来的 timestamp 是“秒”,内部查找用的是 track 自己的时间单位(timescale)
const timestampInTimescale = this.mapTimestampIntoTimescale(timestamp);
// 先尝试走“索引表”这条更快的路:从索引表定位到 sample,再找到对应关键帧
const sampleTable = this.internalTrack.demuxer.getSampleTableForTrack(
this.internalTrack,
);
const sampleIndex = getSampleIndexForTimestamp(
sampleTable,
timestampInTimescale,
);
const keyFrameSampleIndex =
sampleIndex === -1
? -1
: getRelevantKeyframeIndexForSample(sampleTable, sampleIndex);
const regularPacket = await this.fetchPacketForSampleIndex(
keyFrameSampleIndex,
options,
);
// 只要索引表里有内容,或者这个文件不是分段格式,就用上面这条“索引表”结果
if (
!sampleTableIsEmpty(sampleTable) ||
!this.internalTrack.demuxer.isFragmented
) {
return regularPacket;
}
// 索引表为空 + 分段格式:改为到分段里去找关键帧
return this.performFragmentedLookup(
null,
(fragment) => {
const trackData = fragment.trackData.get(this.internalTrack.id);
if (!trackData) {
return { sampleIndex: -1, correctSampleFound: false };
}
// 在这个分段里,从后往前找:
// 最后一个 “是关键帧 && 时间戳 <= 目标时间” 的 sample
const index = findLastIndex(trackData.presentationTimestamps, (x) => {
const sample = trackData.samples[x.sampleIndex]!;
return (
sample.isKeyFrame && x.presentationTimestamp <= timestampInTimescale
);
});
const sampleIndex =
index !== -1
? trackData.presentationTimestamps[index]!.sampleIndex
: -1;
// 如果目标时间戳就在这个分段的时间范围内,说明已经找到“正确分段”了,不用继续翻别的分段
const correctSampleFound =
index !== -1 && timestampInTimescale < trackData.endTimestamp;
return { sampleIndex, correctSampleFound };
},
timestampInTimescale,
timestampInTimescale,
options,
);
}
讲讲关键的函数:
fetchPacketForSampleIndex按 sample 索引读取文件字节 + 组装 packet 元信息(也就是生成一个EncodedPacket对象,这是mediabunny封装的EncodedVideoChunk)
const packet = new EncodedPacket(
data,
sampleInfo.isKeyFrame ? 'key' : 'delta',
timestamp,
duration,
sampleIndex,
sampleInfo.sampleSize,
);
getSampleTableForTrack作用是: 为某个 MP4 track 构建并缓存 sample table(把 MP4 的 stbl(stts/ctts/stsz/stco/co64/stsc/stss 等)解析成内部可用的索引结构),把后续随机访问(按时间取包/找关键帧/取下一帧)需要的索引数据准备好return this.performFragmentedLookup当常规 moov 里的 sample table 为空(或拿不到样本),但文件是 fragmented 的时候,就去扫描后续的 moof fragment,在其中找到时间戳 ≤ 目标时间的最近关键帧并返回对应的 packet
fragmented 指的是“分片/分段的 MP4”(常见叫 fMP4,fragmented MP4),也就是:媒体数据不是一次性在 moov 里用完整的 sample table 描述完,而是被切成很多段,每段用一个 moof (Movie Fragment box)+ 对应的 mdat 来描述/承载。