产品发布视频
制作一段约 40 秒的视频,其中动态图形承载叙事,而每个“产品”像素都由真实应用实际渲染。
这个技能的存在,是因为人们一直在做一个错误的选择:
- 纯动态图形电影看起来专业,但证明不了任何东西——没有真实产品。
- 原始屏幕录制能证明一切,但看起来业余——没有动态设计。
答案两者都不是:Remotion 是主时间线,产品镜头是通过在无头浏览器中驱动实际应用捕获的真实帧。
在项目旁边构建一个小型 Remotion 项目:共享捕获工具包 + 原语,每个应用的影片一个目录(品牌、镜头、捕获脚本、时间线)。参考 references/toolkit.md 获取推荐的布局。
不可妥协的要点
在编写任何代码之前阅读这些。每一条都花费了真实的调试时间,而且每一条都会静默失败——你会得到看起来合理但实际错误的输出。
捕获
| 陷阱 | 发生了什么 | 修复方法 |
|---|---|---|
setViewport({deviceScaleFactor: 2}) | page.screencast() 测量其 ffmpeg 滤镜链时使用系统 DPR,因此它构建了一个 1× 的链并丢弃了视网膜细节。没有错误。 | 在浏览器层面强制设置:--force-device-scale-factor=2 + 视口中的 deviceScaleFactor: 0(0 = “系统默认”)。 |
| 无头模式不渲染光标 | page.mouse.move() 会发送页面能看到的 CDP 事件,但没有任何东西被绘制出来。 | 通过 evaluateOnNewDocument 注入一个 DOM 光标。将其放置在页面中,而不是在 Remotion 中合成——这样每个 Remotion 变换(帧内嵌、缩放推近)都会像真实录制一样精确缩放它。使用精灵需要反转每个变换,而 6 像素的偏移就会被视为损坏。 |
| 无头模式没有 GPU | 任何 WebGL/three.js 内容都会录制为空白或黑色面板。 | --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader。通过像素回读验证,而不仅仅是“上下文是否存在”。 |
| SwiftShader 也合成页面 | 一个软件 GL 驱动程序合成 2560x1440 会将整个标签页限制在大约 8fps,因此每个动画镜头都会录制为幻灯片。它看起来像是一个缓慢的产品,而不是损坏的捕获,所以没有人怀疑这些标志。 | 添加 --disable-gpu-compositing。Skia 的 CPU 光栅化器在处理纯 2D 内容时快得多,WebGL 仍然可以工作,并且 A/B 静态帧是相同的。在同一页面上测量:8.2fps → 60.1fps。 |
| 通过肉眼判断流畅度 | 重复帧在联系表中看起来没问题,逐帧查看也没问题。只有在播放时才会暴露,但那时已经渲染完成了。 | 计算不同的帧:ffmpeg -i take.mp4 -vf mpdecimate,showinfo -f null - 2>&1 | grep -c pts_time。远低于文件帧计数意味着卡顿。 |
page.mouse.click(x, y, {clickCount: 2}) | 发送一次按下,携带 clickCount 元数据。Chrome 从未合成 dblclick。间隔 60ms 的两次离散点击也不行。 | 如果应用需要真正的 dblclick,先点击以移动可见光标,然后在帧内以元素自身的坐标分发事件。 |
点击进入 iframe 后使用 page.keyboard.type() | 按键会发送到父页面聚焦的任何元素。如果父页面将按键转发给应用(箭头键导航、快捷键),你的打字会静默地驱动 UI 而不是输入文本。 | 在帧内输入:frame.type(selector, text)。 |
使用 evaluateOnNewDocument 操作 DOM | 在 document-start 运行,此时 document.head 和 document.documentElement 都可能为 null。未受保护的 appendChild 会抛出异常并静默中止该脚本之后的所有内容。 | 添加保护,并延迟到 DOMContentLoaded。 |
| 录制导航过程 | 将有用的秒数埋藏在文件深处,并膨胀了中间片。 | 将每个镜头拆分为 setup(不录制)和 action(录制)。 |
| 信任标记时间戳 | 屏幕录制是可变帧率;编码后的时间线相对于挂钟时间会发生压缩,因此 22 秒处的标记在文件中并不在 22 秒处。 | 使用标记来大致找到查看位置,然后通过提取帧并查看来选择 startAt。 |
| 远程/热链接资源 | 第三方 CDN 偶尔会拒绝捕获浏览器的请求;图像网格会绘制为空透明度棋盘格,看起来像损坏的产品。 | 等待图像加载并警告,然后构图以避免它们。不要使用带有空白艺术的镜头。 |
在应用 shell 中使用 window.scrollTo | 许多 shell 会滚动内部元素(<main class="overflow-y-auto">),而不是窗口。滚动窗口是静默的无操作,镜头永远不会移动。 | 找到真正的滚动容器并设置其 scrollTop。 |
| 猜测点击坐标 | 应用通常只接受特定节点上的交互——例如,一个只接受叶子元素(childElementCount === 0)的内联编辑器,而标题将其文本包裹在 span 中。点击视觉上明显的目标不会产生任何效果。 | 阅读应用自身的源代码以了解规则,然后向页面询问其有效目标的位置(frame.evaluate)并通过 iframe 的测量框将帧坐标映射到页面坐标。 |
渲染
| 陷阱 | 发生了什么 | 修复方法 |
|---|---|---|
| 跳过中间片转码 | Remotion 任意寻找;使用标准 GOP,每次寻找到解码都需要从之前的 I 帧开始。一个 3 分钟的渲染会变成 40 分钟。 | 转码为全帧内:-c:v libx264 -crf 14 -g 1 -keyint_min 1 -sc_threshold 0 -pix_fmt yuv420p,加上显式的 bt709 标签,这样 Chrome 就不会改变品牌颜色。 |
page.screencast({format:'mp4'}) | 输出 VP9 封装在碎片化的 MP4 中——支持性差。 | 使用 format: 'webm',然后转码。 |
使用带节拍网格的 <TransitionSeries> | 转场重叠,因此总持续时间 = 序列总和 − 转场总和。你的帧网格会静默漂移。 | 对于帧精确的剪辑,使用显式的 <Sequence from/durationInFrames> 并在本地处理溶解。断言链的总和等于总时间。 |
<Sequence> 长于其源素材 | 冻结在最后一个解码帧上——看起来像卡顿,而不是错误。 | 将每个持续时间绑定到真实镜头的长度。在注释中记录源持续时间。 |
在重叠序列上使用 premountFor | 一个预安装的序列会在当前序列之上提前渲染——你会得到幽灵般的双重曝光文本。 | 不要预安装共享屏幕空间的序列;只在硬切之间预安装。 |
| 在合成空间定位覆盖层 | 如果镜头组件在内部裁剪和缩放版,那么在合成坐标中放置的蒙版或高亮将无法与帧中的任何内容对齐——边缘会穿过内容。 | 将覆盖层放在与版相同的变换内部,或者原生地处理效果而不是在素材之上叠加。 |
在合成中使用 Math.random() / Date.now() | 每帧重新滚动;文本闪烁。Remotion 独立渲染每一帧。 | 使所有随机性成为索引的纯函数(基于 sin 的哈希)。 |
Remotion 内部的 CSS 动画 / 过渡 / Tailwind animate-* | 渲染冻结或出现乱码——Remotion 通过 useCurrentFrame() 驱动时间,而不是挂钟时间。 | 使用 useCurrentFrame() 重建运动。这也意味着你通常不能复用营销网站的动画组件。 |
| 通过偏移到影片中来构建循环 | 两个接缝错误:如果范围在溶解处打开,则会出现黑色闪烁;在包装处会出现硬跳切。 | 为循环提供自己的合成,并将其尾部交叉淡入到其自身头部的 <Freeze frame={0}> 中。选择一个连续运动的镜头;任何静止的镜头都接近静态,不利于自动播放。 |
流程
1. 在设计影片之前验证产品事实
最重要的一步。 阅读影片打算声称的每个功能的代码。营销页面展示的是愿景;不要假设产品能实现其登陆页面动画所暗示的功能。
这已经发生过一次:一部影片的计划核心镜头取自营销网站上的一个动画组件,显示一个文档在四种不同的宽高比之间变形。该产品硬编码了单一的宽高比和固定的导出阶段——该组件是一个想法的图示,而不是一个功能。基于它构建核心镜头会在首页上放置一个不支持的声明。
具体检查:
- 哪些内容实际渲染?旧记录可能无法被当前引擎重新渲染。
- 哪些路由需要认证?哪些在没有前置状态的情况下会重定向?
- 每个交互功能实际需要什么才能触发?
2. 用尽可能小的测试降低管道风险
按顺序,在构建任何上层之前:
- 证明捕获几何(加了角标的页面 → 文件是否真的是 2×?)。
- 证明认证(浏览器能否访问已登录的路由?)。
- 如果涉及任何 3D 内容,证明 WebGL 正常工作。
- 端到端证明一个交互。
3. 侦察,不要猜测
对每个候选界面进行截图,并在花费时间拍摄视频之前向用户展示联系表。内容选择主导制作价值:平庸的演示内容会使一部漂亮的影片成为平庸产品的广告。
4. 有目的地选择演示内容
优先选择产品自身的品牌。展示知名第三方更有说服力,但会将他人的商标放在首页上,而且将一个第三方换成另一个并不能改变这一点。自我参照内容也能形成闭环:销售工具的制品是由该工具制作的。
如果内容还不存在,创建它是一种设计工作,而不是捕获工作——它占据了影片后半部分的每一帧。有目的地起草,制作几个候选方案,让用户选择。
5. 按网格剪辑
120 BPM ⇒ 1 小节 = 60 帧 = 2 秒(30fps 下)。让每个剪辑都落在 30 的倍数上,每个幕的边界落在 60 上。这无需任何成本,而且剪辑版本会自然产生。
6. 通过查看来验证
每次渲染后,提取帧并实际查看它们。构建一个带有标签的联系表。到目前为止发现的每个真实缺陷——空白标志、显示错误页面的镜头、在浅色面板上难以辨认的标题、因测量错误的裁剪而产生的暗带、穿过标签的蒙版接缝——都是通过查看帧发现的,而不是通过任何测试通过。
静音观看,因为这就是它自动播放的方式。
编辑规则
这些是区分一部有效影片和一部好影片的关键。
屏幕上的每个声称都必须得到旁边镜头的支持。 有一个剪辑在“分享链接”的文本下展示了没有任何分享操作的镜头。要么展示它,要么在卡片上明确说明它——不要暗示。
注意那些在上下文中诚实但在视频规模上却不是的声称。 一个标注为*“示意”*的小字数字,一旦变成营销影片中一个持久的英雄帧,就变成了一个硬性断言。将这些排除在持久帧之外。
引用产品的运动,不要重新发明它。 采用应用自身的缓动曲线和入场词汇。所有进入或落脚的元素使用同一种曲线。不要有弹跳效果——过冲看起来像犹豫。
两种底色,永远不要中灰色。 深色底色用于陈述,浅色底色用于解释。从产品自身的令牌中选择它们。
一种强调色,谨慎使用——光标、承载句子的那个词、状态的改变。不是装饰。
帧内不要有任何不是为观众准备的东西。 真实的电子邮件地址、组织名称、操作员与应用内助手的对话、开发者模式徽章。优先使用应用自身的机制(用于折叠侧边栏或关闭横幅的 cookie)而不是 CSS 技巧——不要在镜头中出现闪烁。
“核心镜头”获得最多时间。 选择那个展示了竞争对手结构上无法做到的事情的时刻,然后:锁定相机,不要剪辑,不要在它上面覆盖文字,让音乐淡出。静止告诉眼睛是主体在移动,而不是相机。将解释性的文字放在之后——反应之前的文字会告诉人们该想什么。
可读性优于覆盖面。 一个跨越四个屏幕的镜头可能使它们都无法阅读。停留在一个上面通常更好。
当 UI 无法执行时,构建节拍而不是拍摄它。 一个静态屏幕无法同时介绍三件事。如果节拍需要产品无法实现的顺序,使用产品的真实文案和令牌在 Remotion 中原生合成它——并且保持其视觉上与镜头不同,使其看起来像一个说明,而不是一个假的截图。
如果没有画外音,卡片承载 100% 的含义。 预留阅读时间。
与用户讨论,不要独自决定
- 口号和屏幕上的文案。 提供按角度分组的几个选项,附带推荐和理由。注意,影片需要演示提示,而网站标题需要持久的定位陈述——它们不必是相同的句子。
- 要展示哪些内容——展示联系表。
- 音乐。 你不能获得曲目许可。设计影片使其静音工作,并提供一个提示表(BPM,任何跌落发生的位置),以便后续提供的曲目可以落入网格。当曲目到达时,在放置之前检查其形状:测量随时间的响度,而不是假设它有开场或跌落来对齐。
- Remotion 许可。 对个人、非营利组织和员工不超过 3 人的公司免费;超过则需要商业许可。在发布前提示这一点。
命令(形式)
捕获从不启动服务器,也从不猜测端口——认证 URL 通常是固定的。
# 终端 1 — 产品已经在运行
pnpm --filter <app> dev # 或等效命令
# 终端 2 — 影片项目
pnpm install
pnpm check-webgl # 如果涉及任何 3D
pnpm capture:<app> # 或单个镜头:pnpm capture:<app> <shot>
pnpm mezzanine:<app> # 必需 — 参见陷阱
pnpm studio
pnpm render:<app> # 根据需要输出 mp4 / webm / loop / poster
已登录界面的认证:优先借用实时会话 cookie,而不是注册一个一次性用户(空账户在每次拍摄前强制进行缓慢的内容生成)。
发布到页面
两种形式,由用户决定:
- 短循环,点击播放完整影片。 最轻量——几百 KB 自动播放,完整影片仅在点击时加载。
…
[为翻译而截断]