如何用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
相关产品推荐
相关产品推荐

