如何实现一个网页版的剪映(五)如何跳转到视频某一帧

如何实现一个网页版的剪映(一)简介

如何实现一个网页版的剪映(二)深入webcodecs

如何实现一个网页版的剪映(三)使用fabric.js绘制时间轴

如何实现一个网页版的剪映(四)使用插件化思维创建pixi绘制画布(转场/滤镜)

如何实现一个网页版的剪映(五)如何跳转到视频某一帧


# 如何实现一个网页版的剪映(一)简介讲过webav是如何seek某一帧的,我们来回顾一下

源码入口是一个tick函数

tsx 复制代码
/**
 * 获取素材指定时刻的图像帧、音频数据
 * @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) :

tsx 复制代码
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 的那一帧

tsx 复制代码
#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(最关键的代码)

tsx 复制代码
#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 的哪个下标开始喂给解码器"。

先判断"要不要启动一次解码"

tsx 复制代码
if (this.#decoding || dec.decodeQueueSize > 600) return;
  • #decoding :防止重复并发启动(一次还没结束又启动一次)。
  • decodeQueueSize > 600 :解码器内部积压太多了就先别喂,避免爆内存/卡死。

确定这次要处理的范围:从 cursor 往后找到一个 GOP 的"结束位置"

tsx 复制代码
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)

tsx 复制代码
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,并解除 "正在解码" 状态

tsx 复制代码
this.#videoDecCusorIdx = endIdx;
this.#decoding = false;

这一步非常关键:它决定下一次 startDecode 会从哪里继续喂下一段 GOP。

MediaBunny 是如何seek的

tsx 复制代码
// 一次取单个时间点
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一样,本质是三步:

  1. 定位目标帧(target packet)
  2. 找到对应关键帧(key packet)
  3. 从关键帧开始解码到目标帧(按批次)

getSample底层会调mediaSamplesAtTimestamps这个函数,其中getKeyPacket就是获取关键帧的函数

mediaSamplesAtTimestamps部分代码如下

tsx 复制代码
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;
}

从关键帧解码到目标帧(核心)

tsx 复制代码
// 下一批需要解码到的结束序号(包含)。
// 每一批都从 `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是怎么获取关键帧的

入口函数如下

tsx 复制代码
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"的包,导致解码器报错

接着,就会进行校验

  1. 先向下拿候选 key packet;
  2. 如果没拿到,或底层直接说它是 delta,就返回
  3. 如果底层说它是 key,那就"扒开码流看一眼"它到底是不是 key,会解析码流(比如 H.264 看有没有 IDR NAL)来判断

this._track._backing.getKeyPacket如下

tsx 复制代码
/**
 * 按时间戳取"关键帧"数据包。
 *
 * 取包有两条路:
 * 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)
tsx 复制代码
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 来描述/承载。

相关推荐
南方的耳朵1 小时前
VXLAN-EVPN 多租户私有网络测试文档
后端
BING_Algorithm1 小时前
Java开发常用网络协议解析
后端·网络协议
林恒smileZAZ1 小时前
CSS 滚动驱动动画(scroll-timeline):无 JS 实现滚动特效
前端·javascript·css
俺不会敲代码啊啊啊1 小时前
el-table实现行拖拽(包含展开项)
前端·vue.js·typescript
LIO1 小时前
React Router 极简指南(v6+)
前端·react.js
LucianaiB1 小时前
【腾讯位置服务开发者征文大赛】基于飞书 CLI + 腾讯位置的科研与产业地理情报可视化 Skill
后端
明月_清风1 小时前
从 AST 视角看透前端工程化:一条编译管线如何串联起所有工具
前端
架构源启1 小时前
2026 进阶篇:Spring Boot响应式编程 + Spring AI 1.1.4 流式实战 + Vue前端完整实现(避坑指南)
java·前端·vue.js·人工智能·spring boot·spring·ai编程
MacroZheng1 小时前
面试官:“你连Claude Code都没用过吗?”,我怼回去:“就没用过又怎么了?”
人工智能·后端·claude