解说动画
视频是 src/timeline.ts 中一个有序的场景列表。有三种类型:
intro:钩子卡片。第一行是钩子,以大字体显示,其单词在朗读时依次点亮(它同时也是自己的字幕)。anim:来自组件库的一个动画组件,与场景中的旁白行同步。outro:结尾卡片,包含 AGILabs 徽标、javieraguilar.ai以及来自SETTINGS.articleTitle的文章标题。
编写时间轴
import type { Cuts } from './types.ts';
import { L, anim, intro, outro, say, videos } from './narrate.ts';
const build = (): Cuts => ({
main: [
intro('hook', [say(0.4, 'EN hook line.', 'ES hook line.')]),
anim(
'result',
'BarCompare',
{ bars: [{ label: L('With the reason', 'Con el porqué'), value: 14, display: '14/20', highlight: true },
{ label: L('Without it', 'Sin él'), value: 4, display: '4/20' }], max: 20 },
[say(0.3, 'First line.', 'Primera línea.'), say(0, 'Second line.', 'Segunda línea.')],
{ min: 4 },
),
outro('outro', [say(0.4, 'More at javieraguilar.ai.', 'Más en javieraguilar.ai.')]),
],
});
export const VIDEOS = videos(build);
export const TIMELINE = VIDEOS.en.main;
say(at, en, es, spoken?):一行旁白。at是该行可以开始的最早场景秒数;各行从不重叠(有 0.35 秒的间隔),场景在其最后一行结束后的 0.8 秒或min处结束。spoken({ en?, es? })是 TTS 在朗读内容与字幕文本不同时读取的内容(参见narration)。- 节拍。 场景中每一行的开始时间就是一个节拍。组件在节拍 i 上播放步骤 i(柱状图 i 增长,节点 i 出现,卡片 i 弹出),因此请为每个想要揭示的步骤编写一行。在 TTS 之前,节拍来自长度估计;在
npm run tts之后,它们来自真实音频,因此在叙述后应重新检查静态帧。 L(en, es):一个双语言屏幕标签。纯字符串用于两种语言。- 场景 ID 必须唯一:行键(
<scene>-01,……)用于命名音频文件。 anim上的opts.chapter存储在场景上,但尚未被任何组件绘制。- 每次更改后运行
npm run lint:检查长度是否在targetSeconds的 ±20% 范围内,字幕提示是否超过 0.8 秒或 17 字符/秒,以及组件名称是否缺失在组件库中。
组件目录
所有组件从场景接收 t、beats、dur、lang 和 vertical;下面的 props 来自时间轴。屏幕上显示的数字使用文章的确切形式:每当文章写有 "14/20"、"0.0018" 或 "83%" 时,优先使用 display / 字符串 value 而非计算文本。
| 组件 | 用途 | 属性 |
|---|---|---|
Counter | 一个标题数字,从下往上计数,标签在其下方 | value: number、label: Label、prefix?、suffix?、decimals?(0-8)、beat?(计数开始的节拍索引,默认为 0) |
BarCompare | 2-5 个水平柱状图,每拍增长一个;高亮的柱状图使用强调渐变 | bars: { label: Label; value: number; display?: string; highlight?: boolean; at?: number }[]、max?(刻度顶部,默认为最大值)、unit?(当没有 display 时附加) |
FlowDiagram | 按顺序出现的方框和箭头;broken 边为虚线且弱化,带有一个间隙 | nodes: { id; label: Label; at?: number }[]、edges: { from; to; label?: Label; broken?: boolean }[]、`layout: 'vertical' |
KeyQuote | 关键句子,逐词显示,强调的词语使用强调颜色并带有下划线 | text: Label、emphasis?: string[](单词或短语,忽略大小写和标点进行匹配)、beat? |
StatGrid | 2-4 个数字卡片,每拍弹出一个 | stats: { value: string; label: Label; at?: number }[] |
at 在柱状图、节点或卡片上将其固定到场景的某一秒,而不是其自己的节拍。没有节拍或 at 的步骤在上一个步骤之后 0.6 秒跟随。每个组件都经过适配,使其在场景结束时完成:最后一帧始终显示完成状态。
要查看整个库:运行 npm run studio 并打开 Gallery-vertical,或渲染其静态帧。
添加自定义组件
当脚本标记一个场景为“自定义”时:
- 先有状态,并附带测试。 编写
src/anim/<Name>.state.ts,包含 props 类型和一个纯函数frameState(props, t, beats, dur),该函数返回纯数字和字符串。那里不要有 Remotion 或 React 的导入。基于src/anim/steps.ts(sceneTime、stepTimes、fit、progress、beatAt、easeOutCubic)构建它,使其是彻底的:在任何t上都有有限值,进度字段在 [0, 1] 内,在其第一个节拍之前不可见,在t = dur时处于结束状态。 - 在
test/anim.test.ts的CASES中添加一个案例(并测试其自身行为),然后运行npm test。它在t < 0、每个节拍、dur和超过dur的情况下检查状态,包括缺失、延迟和非常短的节拍。 - 绘制它 在
src/anim/<Name>.tsx中:调用frameState,在<Stage vertical={...}>内部绘制,使用来自src/brand.ts的品牌令牌(C、GRADIENT、alpha)和 Inter 字体。不要使用新的颜色或字体。 - 注册它 在
src/anim/index.ts的ANIMS中。保持ANIMS为名称的纯对象字面量:npm run lint从源代码中读取它们。 - 可选地,在
src/Gallery.tsx中添加一个示例。如果它是可复用的,它应回到插件的模板中。
生成的图像。 大多数场景应通过代码绘制(来自品牌令牌的 SVG 和 CSS)。如果一个场景确实需要生成的图像(背景、基础图像、纹理瞬间),可以使用图像模型(例如 Codex CLI 的图像生成功能)来生成。将其保存在 public/ 下,使用 staticFile() 加载,保持在品牌调色板内,并检查静态帧。
垂直安全区域(1080x1920)
- 平台 UI 覆盖顶部约 220 像素和底部约 380 像素;两侧保持 72 像素。
- 动画仅在 Stage 内部绘制:从 y = 280(AGILabs 徽标下方)到 y = 1280(两行字幕上方),宽 936 像素。
Stage/stageOf()提供该框。 - 字幕位于 Stage 下方,最多两行,每行约 32 个字符,宽度约 900 像素。
静态帧:仔细检查它们
在渲染视频之前渲染静态帧并读取每个 PNG。一次干净的 lint 或构建不会显示溢出、裁剪的文本或错误的数字。
npx remotion still src/index.ts Main-EN-vertical out/stills/f0150.png --frame=150
为每个场景在其最后一步播放后拍摄一张静态帧,再加上每约 3 秒(30 fps 下每 90 帧)一张。检查:仅 AGILabs 徽标作为品牌元素,没有超出安全区域的内容,没有文本框溢出的文本,每个数字与文章一致,并且屏幕上的字幕与当时动画显示的内容匹配。