You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用JavaScript基于视频或trace文件生成带计时器的胶片条

服务端JS实现性能对比胶片条方案

全程不需要浏览器环境,所有逻辑在Node.js服务端即可完成,最终输出效果和示例一致。

核心依赖

全部为可直接通过npm安装的服务端包,无客户端依赖:

  • fluent-ffmpeg:调用ffmpeg处理mp4视频抽帧,服务端提前安装ffmpeg二进制即可
  • sharp:高性能服务端图像处理库,负责帧缩放、加文字计时器、多图拼接,比canvas方案性能高3~5倍
  • (可选)@tracerbench/trace-event:如果需要事后解析Chrome trace文件拿性能时间点可以装,要是能改puppeteer审计脚本可以不用

实现步骤

1. 对齐双版本时间基准

胶片条的计时器0点必须是页面导航开始时间,不能直接用视频开头当0点——puppeteer录屏一般会比导航触发早几百毫秒,不对齐会导致时间标记完全错位。

  • 拿到两个版本的时间基准:
    • 最优方案:直接改你现有的puppeteer审计脚本,录屏启动时记一下录屏开始的时间戳,导航触发时记navigationStart时间戳,再把FCP、LCP、load等关键性能里程碑的时间戳一起存在JSON文件里,后续直接读就行,不用解析trace
    • 事后解析方案:读取两份trace的JSON内容,匹配到navigationStart事件和各性能指标事件的时间戳,再通过mp4元信息拿到视频首帧时间戳,计算出导航开始点在视频内的毫秒偏移量
  • 最终要拿到四个核心值:优化前视频的导航偏移量、优化后视频的导航偏移量、两个版本各自的关键性能里程碑时间点(相对导航开始的毫秒数)

2. 等间隔抽帧

胶片条不需要逐帧抽取,按100ms/帧或者200ms/帧的间隔抽即可,兼顾流畅度和最终文件大小:

  • 两个视频都从之前算好的导航偏移位置开始抽帧,抽帧帧率设为1000/抽帧间隔,比如100ms一帧就设10fps
  • 抽出来的所有帧统一缩放到相同尺寸,比如宽度固定400px,高度按视频原始比例等比缩放,保证优化前后的帧大小完全一致
  • 抽帧直到页面load事件触发后多抽2秒即可停止,不用抽取全片内容

3. 帧内容加工

用sharp逐帧处理:

  • 在每帧顶部拼接40px高的黑色不透明条,作为计时器和标记的展示区
  • 黑条左侧写入当前帧的相对时间,格式为X.Xs,比如0ms就是0.0s,1200ms就是1.2s
  • 如果当前帧的时间刚好落在某个性能里程碑时间的±50ms范围内,就在黑条右侧用彩色文字标记对应的指标名,比如FCP、LCP

4. 拼接成最终胶片图

  • 先做横向拼接:把同一时间点的优化前帧、优化后帧左右拼在一起,顶部可以加个通栏标注「优化前」「优化后」
  • 再做纵向拼接:把所有时间点的横向拼接组,按时间顺序从上到下依次拼接,就得到完整的对比胶片条
  • 最终导出为PNG/JPG格式存储即可

核心代码示例

const ffmpeg = require('fluent-ffmpeg');
const sharp = require('sharp');
const fs = require('fs/promises');
const path = require('path');

// 从指定偏移位置按间隔抽帧
async function extractFrames(videoPath, startOffsetMs, frameGapMs = 100, tempDir) {
  await fs.mkdir(tempDir, { recursive: true });
  return new Promise((resolve, reject) => {
    ffmpeg(videoPath)
      .setStartTime(startOffsetMs / 1000) // ffmpeg 接收秒为单位的偏移
      .fps(1000 / frameGapMs)
      .on('end', async () => {
        const files = (await fs.readdir(tempDir)).filter(f => f.endsWith('.png')).sort();
        resolve(files.map((f, idx) => ({
          path: path.join(tempDir, f),
          timeMs: idx * frameGapMs
        })));
      })
      .on('error', reject)
      .save(path.join(tempDir, 'frame-%04d.png'));
  });
}

// 给单帧加计时器和性能标记
async function processSingleFrame(framePath, timeMs, markText = '', barHeight = 40) {
  const img = sharp(framePath);
  const { width } = await img.metadata();
  const timeText = (timeMs / 1000).toFixed(1) + 's';
  return img
    .extend({ top: barHeight, background: '#121212' })
    .composite([
      {
        input: Buffer.from(`
          <svg width="${width}" height="${barHeight}">
            <text x="20" y="28" fill="white" font-size="22" font-family="system-ui,sans-serif">${timeText}</text>
            <text x="${width - 80}" y="28" fill="#22c55e" font-size="22" font-family="system-ui,sans-serif">${markText}</text>
          </svg>
        `),
        top: 0,
        left: 0
      }
    ])
    .png()
    .toBuffer();
}

// 拼接两列帧为最终胶片
async function buildFilmstrip(beforeFrames, afterFrames, frameWidth = 400, outputPath) {
  // 按最长的帧序列对齐,短的一侧补最后一帧
  const maxLen = Math.max(beforeFrames.length, afterFrames.length);
  const compositeLayers = [];
  let currentY = 50; // 顶部留50px放标题
  const titleBar = Buffer.from(`
    <svg width="${frameWidth * 2}" height="50">
      <text x="${frameWidth/2 - 40}" y="32" fill="black" font-size="24" font-weight="bold" font-family="system-ui,sans-serif">优化前</text>
      <text x="${frameWidth + frameWidth/2 - 40}" y="32" fill="black" font-size="24" font-weight="bold" font-family="system-ui,sans-serif">优化后</text>
    </svg>
  `);
  compositeLayers.push({ input: titleBar, top: 0, left: 0 });

  for (let i = 0; i < maxLen; i++) {
    const before = beforeFrames[i] || beforeFrames.at(-1);
    const after = afterFrames[i] || afterFrames.at(-1);
    compositeLayers.push({ input: before, top: currentY, left: 0 });
    compositeLayers.push({ input: after, top: currentY, left: frameWidth });
    const { height } = await sharp(before).metadata();
    currentY += height;
  }

  await sharp({
    create: {
      width: frameWidth * 2,
      height: currentY,
      channels: 3,
      background: 'white'
    }
  })
  .composite(compositeLayers)
  .png()
  .toFile(outputPath);
}

注意事项

部署时提前在服务端装好ffmpeg即可,sharp安装时会自动下载对应系统的预编译二进制,不需要额外安装图形相关的系统依赖,常规Linux服务器、Windows、macOS环境都能直接跑。单组视频生成胶片条的耗时一般在2s以内,性能足够支撑批量审计场景。

内容的提问来源于stack exchange,提问作者Chris

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.27 12:06:19