一句话出片:HyperFrames 视频 skill 拆解
上一篇《HTML 转视频:HyperFrames 从崩溃到出片》记录了做一支 15 秒片头的全过程:GSAP 的 CDN 加载失败、headless Chrome 启动崩溃、中文字体没有声明、成片没有声音。文章发出去,人看懂了;但下一次让 AI 做另一支视频,同样的坑它还是会再踩一遍——因为文章是给人读的,不是给 agent 执行的。
于是我把这套流程固化成了一个 skill:hyperframes-video。现在只需要说「做一个 15 秒横屏视频,主题是《为什么用 Docker 部署中间件》,要有中文旁白」,它就会自己建项目、把依赖本地化、写 composition、生成旁白、渲染、校验,最后交付 MP4。

本文拆解这个 skill 的结构与设计取舍,并在附录给出全部源码。它真正解决的不是「怎么让 AI 会写 GSAP」,而是怎么把一次性的踩坑经验,变成可重复执行的运行时约束。
它解决什么问题
做第一支片子时,我花了大部分时间在四个和动画无关的地方:把 GSAP 从 CDN 搬进项目、绕过跑不起来的 headless Chrome、给中文补 @font-face、发现成片是静音的。这些步骤有一个共同点:它们都不是创作问题,但任何一个没做,片子就出不来或不能用。
把流程交给 AI 后,问题变成了另一个样子:
提示词里写「别忘了本地化 GSAP」,它可能理解成「联网时用 CDN 更快」;
提示词里写「注意 Chrome」,它看到
doctor的绿勾就认为没问题;提示词里写「加个旁白」,它可能编一个
data-duration,被check打回;提示词里写「中文字体」,它可能把中文直接写进 PowerShell 命令,渲染出来满屏乱码。
失败后果不是「效果差一点」,而是:动画整条不执行、渲染直接中断、成片无声、文字变成方框。这些都不是靠模型「更聪明」能避免的,只能靠把执行顺序和验收标准写死。
这个 skill 的成功判据很具体:
用户只给主题,不需要回答环境问题;
换主题、换画幅后,不需要重新教它依赖怎么装;
出片后
check0 error,ffprobe 能看到音轨;它知道自己哪里没验证(比如画面构图、TTS 发音),并如实说出来。
核心矛盾
一篇教程可以假设读者有耐心、会排错;一个 skill 必须假设执行者只会照做,而且会在模糊处自行发挥。skill 的价值不在「写了多少知识」,而在「把哪些知识变成了不可绕过的步骤」。
拆解
这个 skill 由四件套组成:输入默认值、运行时规则、可执行脚本、输出验收。四者缺一不可——只有规则会变成一篇没人执行的文档,只有脚本会变成一套不会应变的死流程。
输入:默认值代替采访
用户说「做个视频」时,最不该做的就是反问五个问题。skill 里直接写死一套默认值:
开工前只要求一句话回述计划,然后直接做。只有主题完全缺失、或必须用品牌/真人素材时才问一个问题。
这样做的好处是把决策成本从对话里拿掉。绝大多数视频需求并不真的需要定制,需要的是「先给我一版」。默认值本身就是一种产品判断:15 秒、三幕、有旁白,覆盖了大部分技术分享和产品演示场景。
规则:契约与可量化的画面底线
skill 把 HyperFrames 的契约压缩成五条,写在最显眼的位置:
根元素带
data-composition-id、data-width、data-height、data-duration;有时间行为的元素带
data-start+data-duration,并加class="clip";时间线
paused: true,注册到window.__timelines[compositionId];旁白/音乐用独立
<audio>,data-duration贴着媒体真实时长;只写确定性逻辑,禁止
Date.now()、Math.random()、运行时网络请求。
画面底线则直接用 check 的 Craft 规则,而不是「好看一点」这种无法执行的形容词:
第 0 帧不能只有背景——它是社交平台缩略图;
阅读字号 ≥28px(1080 短边的 2.6%);
每行停留 ≥ 0.5 秒 + 0.3 秒/词,结尾句至少 2 秒;
3–5 个缓动就够,别堆特效。
这些数字不是审美偏好,是 check 会计算的阈值。写进 skill 的规则如果无法验证,最后都会退化成口号;能验证的规则才能真正约束输出。
最典型的是依赖本地化。skill 没有把它写成「建议」,而是写成建项目之后的第 3 步:npm install gsap、复制到 vendor/、script src 指向本地。因为这一步一旦跳过,后面所有工作都没有意义。
脚本:把重复动作从上下文里拿掉
skill 带了三个可执行资产:
assets/starter/index.html——一份已经通过 check 的 15 秒三幕底版。它同时是模板和契约示例:中文用 @font-face 挂了 Microsoft YaHei / PingFang SC 两个 local() 源,GSAP 走本地 vendor/,首帧标题可见,字号和节奏都卡在阈值之上。agent 不需要从零写 HTML,只需要替换文案和配色。
scripts/render.ps1——把「浏览器回退 → check → snapshot → render → ffprobe」串成一条命令。它默认检测系统 Chrome,通过 HYPERFRAMES_BROWSER_PATH 绕开坏掉的 headless shell;用 cmd /c 调用 CLI,规避 PowerShell 5.1 把 npx 的 stderr 当成致命错误的老毛病;最后打印时长、编码、帧数和音轨。
scripts/make-vo.ps1——读取 UTF-8 台词文件,调用 Windows SAPI 生成语音,去首尾静音、逐条归一到 -16 LUFS、转 MP3,并打印每段真实时长。agent 拿到时长后直接写进 data-duration,不用猜。
脚本的意义不只是省 token。凡是需要「精确重复」的动作,都应该从模型上下文里移出去:装依赖的顺序、Chrome 的回退路径、音频时长的测量,这些交给模型每次重做,迟早会漏一步。
输出:机器验收与诚实交付
skill 要求交付前必须核对四件事:
check的 Lint / Runtime / Audio 都是 0 error;抽帧内容随时间变化,证明动画真的在走;
ffprobe 的时长、帧数、音轨符合预期;
音频的 true peak ≤ -1 dBTP,旁白覆盖对应幕、没有削波。
同时要求明确说出没验证的部分。这不是客套,而是因为 agent 有一个真实盲区:它未必能看图、听声。机器指标能证明「有画面、有音轨、没超时」,但证明不了「构图好看、发音正确」。把这条写进 skill,交付时就不会假装全都验证过了。
踩坑与解决
这个 skill 的规则几乎都是从实际失败里长出来的。下面每条都对应一次真实的翻车。
1. AI 跳过依赖本地化,动画静默不执行
问题:模板默认从 jsdelivr 拉 GSAP,提示词里不写清楚,agent 会直接沿用。
复现:同一份 composition 只把 script src 换成 CDN,check 报 request_failed: net::ERR_TIMED_OUT。
根因:CDN 不可达时库根本没加载,整条时间线不执行——不是动画变卡,是动画不存在。
解决:把本地化写成第 3 步,并规定 <script src="vendor/gsap.min.js">;不用「建议」这种可以被权衡的词。
验证:Runtime 0 error,抽帧哈希互不相同。
2. AI 相信 doctor 的绿勾,Chrome 启动即崩
问题:doctor 显示 Chrome ✓,agent 就认为环境没问题。
复现:check 报 Target closed,底层是 0xC000007B(可执行文件与 DLL 位数不匹配)。
根因:doctor 只检查文件是否存在,不启动浏览器。
解决:skill 直接给出系统 Chrome 的回退命令,并说明「doctor 绿不等于能跑」。
验证:check 能跑完 Layout 的 9 个采样点。
3. AI 忘记声音,或者给音频编一个时长
问题:composition 里没有 <audio>,渲染出来就是静音;或者 data-duration 比音频长,check 报 clip_media_fit。
复现:check 的 Audio 段写 audio_none;或渲染时槽位被裁到媒体长度。
根因:渲染器不会凭空加声音;音频时长必须实测,不能估。
解决:把「默认中文旁白」写进规格;用 make-vo.ps1 生成并打印时长;规定 data-duration 向下取两位。
验证:ffprobe 出现 AAC 流,audioCount: 3、hasAudio: true。
4. AI 在 PowerShell 里塞中文,渲染成乱码
问题:中文台词直接写进命令字符串,或 Get-Content/Set-Content 默认按 GBK 解码 UTF-8 文件。
复现:渲染出来是 鑷墭绠′腑闂翠欢 之类的字。
根因:PowerShell 5.1 的默认编码与无 BOM 的 UTF-8 文件不匹配。
解决:台词写进 UTF-8 .txt;脚本用 [System.IO.File]::ReadAllText(..., UTF8) 读取;改文件用显式 UTF-8。
验证:抽帧里中文正常,不是方框。
5. AI 看不见画面,却假装验收通过
问题:check 通过后,agent 倾向于宣布「完成」,但构图、遮挡、TTS 发音都没看过。
复现:Craft 能过,画面仍可能文字贴边、英文换行难看。
根因:模型不一定具备视觉/听觉输入。
解决:skill 把「没验证的部分」列为必报项,并要求交付抽帧路径让用户抽查。
验证:交付报告里明确区分「机器验证过」与「需要人工确认」。
6. 试跑之后,规则才真正完整
技能写完后,我让另一个 agent 只拿 skill、不给上下文,做一支全新的 15 秒视频。它一次跑通,但报告了 9 个摩擦点,其中最值得修的是:
skill 没写「用 starter 覆盖 init 生成的空壳 index.html」,它自己猜的;
它不知道该用哪个中文音色、旁白相对幕起点偏移多少;
它把整片 LUFS(-19.1)当成不达标,而逐条旁白其实已经归一到 -16;
render.ps1早期版本的 stderr 处理会让 PowerShell 打印吓人的红字。
这四条被写回 skill 之后,流程才从「能跑」变成「换了人也能跑」。skill 的测试不是看它第一次能不能成,而是看失败报告里还有多少需要临场发挥。
可迁移的方法论
这套结构不只适用于视频。任何「把经验交给 AI 执行」的场景,都可以照搬:
把失败模式写成步骤,而不是注意事项。 「建议本地化依赖」会被权衡掉;「第 3 步:复制到 vendor」不会。
用默认值代替采访。 大多数需求不需要定制,需要的是先给一版可改的东西。
用可执行底版承载契约。 一份能跑的 starter,比十段解释更能让 agent 理解边界。
把需要精确重复的动作脚本化。 环境回退、时长测量、渲染参数,交给脚本比交给记忆可靠。
验收必须机器可验证。 无法验证的规则最后都会变成口号。
交付时区分「已验证」和「未验证」。 承认盲区不会降低可信度,假装全能才会。
试跑结果
让一个没有上下文的 agent 只用这个 skill 做《为什么用 Docker 部署中间件》:
成品预览
这不是「AI 会做视频了」,而是「一次描述之后,环境、依赖、音频、验收都不再需要人重复交代」。前者是能力,后者才是工程。
附录:skill 完整源码
以下六个文件与本地安装版本一致,可以直接复制成 ~/.config/opencode/skills/hyperframes-video/ 下的目录结构。
hyperframes-video/
├── SKILL.md
├── assets/starter/index.html
├── scripts/render.ps1
├── scripts/make-vo.ps1
└── references/
├── authoring.md
└── troubleshooting.mdA. SKILL.md:入口与流程
---
name: hyperframes-video
description: 用 HTML/CSS + GSAP 通过 HyperFrames 把一句话需求做成可播放 MP4:片头、文章配套短视频、产品/数据演示、横屏与竖屏动效视频,覆盖离线依赖、headless Chrome、中文字体、配音与渲染校验。只要用户说做视频、做片头、把文章/文案做成短视频、生成 MP4、HTML 转视频、HyperFrames,或反馈视频没声音/渲染报错,就使用本技能;即使用户没提 HyperFrames 也应主动使用。
---
# HyperFrames 视频生产
把「一句话需求」直接做成可播放的 MP4。默认:15 秒、1920×1080、30fps、三幕、中文旁白。用户只给主题时不要再盘问,按默认规格开工。
HyperFrames 的契约很窄:HTML/CSS 负责画面,GSAP 负责时间线,CLI 用 headless Chrome 逐帧截图再由 FFmpeg 编码。真正容易翻车的是依赖,不是动画。
## 0. 先定规格(含默认值)
| 项 | 默认 |
| --- | --- |
| 画幅 | 横屏 1920×1080;提到抖音/视频号/小红书/竖屏 → 1080×1920 |
| 时长 | 15 秒(3 幕 × 5 秒);信息多可 18–20 秒 |
| 帧率 | 30fps |
| 音频 | 中文旁白;用户没提音乐就不加 BGM |
| 视觉 | 深色底 + 单一强调色;标题 96–120px,正文 ≥28px |
| 字幕 | 屏幕上的关键句就是字幕,不另做字幕轨 |
开工前用一句话回述计划(「15 秒横屏,三幕:标题 → 三点 → 结论,中文旁白」),然后直接做。只有主题完全缺失、或必须用品牌/真人素材时才问一个问题。
## 1. 环境自检
```powershell
node -v # 需要 >= 22
ffmpeg -version
npx hyperframes@0.8.214 doctor
```
`doctor` 报 Chrome 绿不代表能启动。若 `check` 抛 `Target closed` 或 `0xC000007B`,把浏览器指向系统 Chrome:
```powershell
$env:HYPERFRAMES_BROWSER_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe"
```
```bash
export HYPERFRAMES_BROWSER_PATH="/path/to/chrome"
```
只在当前 shell 设置,不要改全局配置。若系统没有 Chrome,再用 `npx hyperframes browser clear` 后 `npx hyperframes browser ensure --force` 重下缓存。
## 2. 建项目
```powershell
npx hyperframes@0.8.214 init <slug>
cd <slug>
```
项目已有就复用,不要重下。`init` 会尝试从 GitHub 克隆 skills,失败不影响出片;联网后补 `npx hyperframes skills update`。`package.json` 已把 CLI 版本钉死,优先用 `npm run check` / `npm run render`。
脚手架生成的是空壳 `index.html`,用本技能自带的底版覆盖:
```powershell
Copy-Item "<本技能目录>\assets\starter\index.html" .\index.html -Force
```
底版是 15 秒横屏三幕;竖屏就把 `data-width` / `data-height` / `html` 尺寸换成 1080×1920,并加大上下留白。
## 3. 依赖本地化(跳过这步大概率卡住)
GSAP 默认走 CDN,离线或内网会直接 `net::ERR_TIMED_OUT`,动画整条不执行。装进项目:
```powershell
npm install gsap@3.14.2 --no-audit --no-fund
New-Item -ItemType Directory -Force vendor | Out-Null
Copy-Item node_modules\gsap\dist\gsap.min.js vendor\gsap.min.js
```
引用 `<script src="vendor/gsap.min.js"></script>`,不要用 CDN。
任何 `font-family` 都必须在 CSS 里声明,否则 lint 报 `font_family_without_font_face`。系统字体用 `local()`:
```css
@font-face {
font-family: "Video Sans";
src: local("Microsoft YaHei"), local("PingFang SC");
}
```
字体栈写 `"Video Sans", sans-serif`:一个自定义族名挂多个 `local()` 源,Windows 命中雅黑,macOS 命中苹方,只声明一次。
## 4. 写 composition
以 `assets/starter/index.html` 为底版(已按契约写好、可直接渲染)。核心契约:
- 根元素:`data-composition-id`、`data-width`、`data-height`、`data-duration`,可选 `data-fps`
- 有时间行为的元素:`data-start` + `data-duration`,并加 `class="clip"`(`.clip` 提供整帧盒模型)
- 时间线必须 `paused: true`,注册到 `window.__timelines[compositionId]`
- 旁白/音乐用独立 `<audio>`;`data-duration` 必须贴着音频真实时长,否则 `check` 报 `clip_media_fit`
- 只写确定性逻辑:禁止 `Date.now()`、`Math.random()`、运行时网络请求
```html
<div id="root" data-composition-id="main" data-start="0"
data-duration="15" data-width="1920" data-height="1080" data-fps="30">
<section class="scene clip" data-start="0" data-duration="5">…</section>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.9, ease: "power3.out" }, 0);
tl.seek(0);
window.__timelines["main"] = tl;
</script>
```
画面规则(`check` 的 Craft 段会按这些量化):
- 第 0 帧不能只有背景——它是社交平台缩略图,标题一开始就在
- 阅读字号 ≥28px(1080 短边的 2.6%)
- 每行停留 ≥ 0.5 秒 + 0.3 秒/词;结尾句至少留 2 秒
- 每幕 5 秒,最后一幕留出静止时间;3–5 个缓动足够,别堆特效
## 5. 音频
没有 `<audio>` 就是静音,`check` 的 Audio 段会写 `audio_none`。默认用 Windows SAPI 生成中文旁白(离线,不用装模型)。
默认音色是 `Microsoft Huihui Desktop`(zh-CN)。先确认它存在,不存在就让脚本报错后按提示换一个 zh-CN 音色:
```powershell
Add-Type -AssemblyName System.Speech
(New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |
ForEach-Object { $_.VoiceInfo.Name + " | " + $_.VoiceInfo.Culture.Name }
```
步骤:
1. 在 `audio/` 写 `vo-1.txt`、`vo-2.txt`…(UTF-8,一句一幕)
2. 运行 `scripts/make-vo.ps1 -AudioDir audio`(英文可用 `-Voice "Microsoft Zira Desktop"`,语速用 `-Rate` 调):生成、去首尾静音、逐条响度归一到 -16 LUFS、转 MP3,并打印每段时长
3. 按打印的时长挂载:
```html
<audio id="vo1" src="audio/vo-1.mp3" data-start="0.55" data-duration="3.56"></audio>
<audio id="vo2" src="audio/vo-2.mp3" data-start="5.55" data-duration="2.80"></audio>
<audio id="vo3" src="audio/vo-3.mp3" data-start="10.55" data-duration="3.59"></audio>
```
时间换算:`data-start = 幕起点 + 0.5s`。幕起点 0/5/10 → 旁白 0.55/5.55/10.55。`data-duration` 用脚本打印的秒数向下取两位(3.597 → 3.59),别向上取整,否则 `clip_media_fit`。
用户自带音频时,用 `data-media-start` 裁剪、`data-volume` 调增益;旁白与 BGM 同时存在时给 BGM 压到 0.1–0.2。没有可用语音时,先交付静音版并明确说明,不要假装有声音。
## 6. 校验与出片
```powershell
npx hyperframes check
npx hyperframes snapshot --at 1.2,6,11.5
npx hyperframes render -o renders/video.mp4
ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_type,codec_name,width,height,nb_frames,sample_rate,channels -of default=noprint_wrappers=1 renders/video.mp4
```
`scripts/render.ps1 -Project <dir> -Output renders/video.mp4` 会把系统 Chrome 回退、check、snapshot、render、ffprobe 串起来。脚本用 `cmd /c` 调用 CLI,避开 PowerShell 5.1 把 npx 的 stderr 当 `NativeCommandError` 的问题;退出码仍然会拦住失败。
必须核对:
- `check` 0 error;Lint/Runtime/Audio 都要看
- 抽帧哈希或内容随时间变化(证明动画在走)
- ffprobe 的时长、帧数、音轨是否符合预期
- 音频用 `npx hyperframes audio renders/video.mp4`:逐条旁白已归一到 -16 LUFS,整片因为幕间留白常读到 -18~-20 LUFS,这是正常的;验收看 true peak ≤ -1 dBTP、旁白覆盖对应幕、没有削波。该工具按信号而不是语义分类,可能把旁白标成 `sfx`,也会报结尾静音段——都不用管。
两个容易误判的输出:
- `check` 里的 `Snapshots: disabled` 是默认行为,抽帧要用单独的 `snapshot` 命令;`snapshot` 会自动补一帧 14.75s,正常。
- 中英混排(`docker compose up -d`)不要为了塞下而缩字号;先换行或删字,保持阅读字号 ≥28px,再用抽帧看宽度。
Craft 警告(`first_frame_empty`、`text_below_floor`、`line_unread`)不等于失败,但影响可发布性:能修就修,修不了要在交付时说明。
## 7. 交付
报告:MP4 路径、封面抽帧路径、时长/分辨率/编码/大小、音轨信息。明确说出没验证的部分(例如画面构图、TTS 发音),把决定权留给用户。
## 故障速查
| 现象 | 处理 |
| --- | --- |
| `font_family_without_font_face` | 给每个字体加 `@font-face`,系统字体用 `local()` |
| `0xC000007B` / `Target closed` | `HYPERFRAMES_BROWSER_PATH` 指向系统 Chrome,或清缓存重下 |
| `net::ERR_TIMED_OUT` 加载 gsap | CDN 不可达,改本地 `vendor/gsap.min.js` |
| `clip_media_fit` | `data-duration` 改成音频真实时长 |
| `audio_none` | 加 `<audio>` 并按第 5 步挂载 |
| 中文变乱码 | 文件按 UTF-8 读写;PowerShell 显式 `[Text.Encoding]::UTF8` |
| `nested_structure_needs_subcomposition` | 单文件短片可接受;要复用就拆 sub-composition |
更多细节见 `references/authoring.md` 和 `references/troubleshooting.md`。B. assets/starter/index.html:15 秒三幕底版
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="vendor/gsap.min.js"></script>
<style>
@font-face {
font-family: "Video Sans";
src: local("Microsoft YaHei"), local("PingFang SC");
}
:root {
--ground: #0b0f10;
--ink: #f5f7f6;
--muted: #8a9a94;
--accent: #00ed64;
--card: #141b19;
--font-display: "Video Sans", sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 1920px;
height: 1080px;
overflow: hidden;
background: var(--ground);
}
#root {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
font-family: var(--font-display);
color: var(--ink);
background:
radial-gradient(circle at 82% 18%, rgba(0, 237, 100, 0.12), transparent 42%),
radial-gradient(circle at 12% 88%, rgba(0, 237, 100, 0.07), transparent 46%),
var(--ground);
}
.grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
background-size: 80px 80px;
mask-image: radial-gradient(circle at 50% 50%, #000 30%, transparent 78%);
}
.scene {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120px 160px;
}
.kicker {
display: flex;
align-items: center;
gap: 16px;
font-size: 30px;
letter-spacing: 0.24em;
color: var(--accent);
text-transform: uppercase;
}
.kicker::before {
content: "";
width: 44px;
height: 2px;
background: var(--accent);
}
h1 {
margin-top: 40px;
font-size: 108px;
font-weight: 700;
line-height: 1.12;
letter-spacing: -0.02em;
text-align: center;
}
h1 em {
font-style: normal;
color: var(--accent);
}
.sub {
margin-top: 36px;
font-size: 34px;
color: var(--muted);
letter-spacing: 0.06em;
}
.rule {
width: 220px;
height: 3px;
margin-top: 56px;
background: linear-gradient(90deg, var(--accent), transparent);
transform-origin: left center;
}
.steps {
display: flex;
gap: 36px;
margin-top: 64px;
}
.card {
width: 400px;
padding: 44px 40px 48px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), var(--card);
}
.card .idx {
font-size: 30px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.1em;
}
.card h2 {
margin-top: 22px;
font-size: 42px;
font-weight: 600;
}
.card p {
margin-top: 16px;
font-size: 30px;
line-height: 1.6;
color: var(--muted);
}
.quote {
position: relative;
max-width: 1320px;
padding-left: 48px;
font-size: 76px;
font-weight: 600;
line-height: 1.35;
}
.quote::before {
content: "";
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 6px;
border-radius: 3px;
background: var(--accent);
}
.credit {
margin-top: 40px;
font-size: 30px;
letter-spacing: 0.14em;
color: var(--muted);
}
</style>
</head>
<body>
<div
id="root"
data-composition-id="main"
data-start="0"
data-duration="15"
data-width="1920"
data-height="1080"
data-fps="30"
>
<div class="grid" id="grid"></div>
<section id="scene1" class="scene clip" data-start="0" data-duration="5">
<div class="kicker" id="s1-kicker">系列名 · 第 01 期</div>
<h1 id="s1-title">这里放<em>主标题</em></h1>
<div class="sub" id="s1-sub">一句副标题,说清它解决什么</div>
<div class="rule" id="s1-rule"></div>
</section>
<section id="scene2" class="scene clip" data-start="5" data-duration="5">
<div class="kicker" id="s2-kicker">三步讲清</div>
<div class="steps">
<div class="card" id="s2-card1">
<div class="idx">01</div>
<h2>起因</h2>
<p>先说是哪个真实问题</p>
</div>
<div class="card" id="s2-card2">
<div class="idx">02</div>
<h2>过程</h2>
<p>只留关键步骤和命令</p>
</div>
<div class="card" id="s2-card3">
<div class="idx">03</div>
<h2>结果</h2>
<p>用可复现的结果收尾</p>
</div>
</div>
</section>
<section id="scene3" class="scene clip" data-start="10" data-duration="5">
<div class="quote" id="s3-quote">把唯一的结论放在这一屏。</div>
<div class="credit" id="s3-credit">替换成你的来源或命令</div>
</section>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#grid", { opacity: 0, scale: 1.06 }, { opacity: 1, scale: 1, duration: 1.4, ease: "power2.out" }, 0);
tl.fromTo("#s1-title", { filter: "blur(14px)", y: 24 }, { filter: "blur(0px)", y: 0, duration: 0.9, ease: "power4.out" }, 0);
tl.fromTo("#s1-kicker", { opacity: 0, x: -24 }, { opacity: 1, x: 0, duration: 0.6, ease: "power3.out" }, 0.15);
tl.fromTo("#s1-sub", { opacity: 0, y: 18 }, { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, 0.75);
tl.fromTo("#s1-rule", { scaleX: 0 }, { scaleX: 1, duration: 0.9, ease: "power3.inOut" }, 0.95);
tl.to("#scene1", { opacity: 0, duration: 0.45, ease: "power2.in" }, 4.55);
tl.fromTo("#s2-kicker", { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out" }, 5.1);
tl.fromTo("#s2-card1", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.7, ease: "power3.out" }, 5.35);
tl.fromTo("#s2-card2", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.7, ease: "power3.out" }, 5.55);
tl.fromTo("#s2-card3", { opacity: 0, y: 48 }, { opacity: 1, y: 0, duration: 0.7, ease: "power3.out" }, 5.75);
tl.to("#scene2", { opacity: 0, duration: 0.45, ease: "power2.in" }, 9.55);
tl.fromTo("#s3-quote", { opacity: 0, y: 30 }, { opacity: 1, y: 0, duration: 0.85, ease: "power3.out" }, 10.1);
tl.fromTo("#s3-credit", { opacity: 0 }, { opacity: 1, duration: 0.6, ease: "power2.out" }, 10.9);
tl.to("#scene3", { opacity: 0, duration: 0.5, ease: "power2.in" }, 14.4);
tl.seek(0);
window.__timelines["main"] = tl;
</script>
</body>
</html>C. scripts/render.ps1:校验与出片
param(
[string]$Project = ".",
[string]$Output = "renders/video.mp4",
[string]$At = "1.2,6,11.5",
[string]$Quality = "looks",
[string]$Chrome = "C:\Program Files\Google\Chrome\Application\chrome.exe",
[switch]$SkipSnapshots
)
$ErrorActionPreference = "Stop"
function Invoke-Hf {
param([string[]]$Arguments)
$quoted = $Arguments | ForEach-Object {
if ($_ -match '[\s&|<>^]') { '"' + ($_ -replace '"', '""') + '"' } else { $_ }
}
$cmdline = "npx --yes hyperframes@0.8.214 " + ($quoted -join " ") + " 2>&1"
cmd /c $cmdline | ForEach-Object { Write-Host $_ }
$code = $LASTEXITCODE
return $code
}
if (-not $env:HYPERFRAMES_BROWSER_PATH -and (Test-Path -LiteralPath $Chrome)) {
$env:HYPERFRAMES_BROWSER_PATH = $Chrome
Write-Host "browser: $Chrome"
}
Push-Location $Project
try {
$code = Invoke-Hf -Arguments @("check")
if ($code -ne 0) { throw "check failed (exit $code)" }
if (-not $SkipSnapshots -and $At) {
$code = Invoke-Hf -Arguments @("snapshot", "--at", $At)
if ($code -ne 0) { throw "snapshot failed (exit $code)" }
}
$parent = Split-Path -Parent $Output
if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
$code = Invoke-Hf -Arguments @("render", "-o", $Output, "-q", $Quality)
if ($code -ne 0) { throw "render failed (exit $code)" }
if (-not (Test-Path -LiteralPath $Output)) { throw "expected output not found: $Output" }
Write-Output ""
Write-Output "ffprobe:"
$previous = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
ffprobe -v error `
-show_entries format=duration,size `
-show_entries stream=codec_type,codec_name,width,height,nb_frames,sample_rate,channels `
-of default=noprint_wrappers=1 $Output 2>&1 | ForEach-Object { Write-Output $_ }
$ErrorActionPreference = $previous
}
finally {
Pop-Location
}D. scripts/make-vo.ps1:中文旁白
param(
[string]$AudioDir = "audio",
[string]$Voice = "Microsoft Huihui Desktop",
[int]$Rate = 5
)
$ErrorActionPreference = "Stop"
if (-not (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
throw "ffmpeg not found on PATH"
}
if (-not (Test-Path -LiteralPath $AudioDir)) {
throw "audio directory not found: $AudioDir"
}
Add-Type -AssemblyName System.Speech
$synth = New-Object System.Speech.Synthesis.SpeechSynthesizer
$voices = $synth.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }
if ($voices -notcontains $Voice) {
throw "Voice '$Voice' not installed. Available: $($voices -join ', ')"
}
$synth.SelectVoice($Voice)
$synth.Rate = $Rate
$files = Get-ChildItem -LiteralPath $AudioDir -Filter "vo-*.txt" | Sort-Object Name
if (-not $files) {
throw "no vo-*.txt files in $AudioDir"
}
foreach ($file in $files) {
$text = [System.IO.File]::ReadAllText($file.FullName, [System.Text.Encoding]::UTF8).Trim()
if (-not $text) { throw "empty script: $($file.Name)" }
$wav = [System.IO.Path]::ChangeExtension($file.FullName, ".wav")
$mp3 = [System.IO.Path]::ChangeExtension($file.FullName, ".mp3")
$synth.SetOutputToWaveFile($wav)
$synth.Speak($text)
$synth.SetOutputToNull()
$filter = "silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.05," +
"areverse,silenceremove=start_periods=1:start_threshold=-45dB:start_silence=0.08,areverse," +
"loudnorm=I=-16:TP=-1.5:LRA=11"
$previous = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
& ffmpeg -y -v error -i $wav -af $filter -codec:a libmp3lame -b:a 128k $mp3 2>&1 | ForEach-Object { Write-Host $_ }
$code = $LASTEXITCODE
$ErrorActionPreference = $previous
if ($code -ne 0) { throw "ffmpeg failed for $($file.Name)" }
$duration = (& ffprobe -v error -show_entries format=duration -of csv=p=0 $mp3 2>$null).Trim()
Remove-Item -LiteralPath $wav -Force
$seconds = [math]::Round([double]$duration, 2)
Write-Output ("{0} -> {1} ({2}s)" -f $file.Name, (Split-Path $mp3 -Leaf), $seconds)
}E. references/authoring.md:契约与分镜
# HyperFrames 创作参考
## 契约
### 根与时间
```html
<div id="root"
data-composition-id="main"
data-start="0"
data-duration="15"
data-width="1920"
data-height="1080"
data-fps="30">
```
- `data-composition-id` 唯一,时间线注册键必须一致
- `data-start` 是可见性开关:元素只在 `[start, start + duration)` 内出现
- `class="clip"` 不控制显隐,但提供整帧盒模型,lint 会提醒缺失
- `data-track-index` 只影响 Studio 显示,不参与渲染
### 时间线
```js
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#title", { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.9 }, 0.2);
tl.seek(0);
window.__timelines["main"] = tl;
```
- 根时间线必须 paused,由运行时 seek
- 手动加进根时间线的子时间线不要 paused,否则不会前进
- 支持 `set` / `to` / `from` / `fromTo`
- 常用属性:`opacity`、`x`、`y`、`scale`、`rotation`、`width`、`height`、`visibility`
- 禁止非确定性来源:`Date.now()`、`Math.random()`、fetch 网络数据
### 媒体
```html
<audio src="audio/vo.mp3" data-start="0.5" data-duration="3.5" data-volume="1"></audio>
<video src="clip.mp4" data-start="0" data-duration="5"
data-media-start="2" data-volume="0.8" data-has-audio="true"></video>
```
- `data-media-start`:从媒体第几秒开始播
- `data-fade-in` / `data-fade-out`:首尾增益斜坡
- 静音素材用 `muted`;有声素材不要加 `muted`,并标 `data-has-audio="true"`
- 同一时间多轨音频会混音,BGM 建议 `data-volume="0.1"` 到 `"0.2"`
### 嵌套与变量
```html
<div data-composition-id="card-pro"
data-composition-src="compositions/card.html"
data-start="5" data-duration="5"
data-variable-values='{"title":"Pro"}'></div>
```
子 composition 在 `<html>` 上声明变量:
```html
<html data-composition-variables='[
{"id":"title","type":"string","label":"标题","default":"Hello"},
{"id":"color","type":"color","label":"颜色","default":"#22c55e"}
]'>
```
脚本里 `window.__hyperframes.getVariables()` 取合并结果;CLI 用 `--variables '{"title":"X"}'` 覆盖顶层。批量出片用 `--batch`。
## 分镜套路
### 标题幕(0–5s)
- kicker(系列/期数)→ 主标题 → 副标题 → 一条强调线
- 主标题 96–120px,2 行内;标题在第 0 帧就可见(可以模糊或位移,但不要透明到看不见)
- 入点:kicker 0.1s,标题 0–0.2s,副标题 0.8s,强调线 1.0s
- 退场:4.5s 淡出
### 三点幕(5–10s)
- 三张等宽卡片,编号 + 短标题 + 一行说明
- 卡片 5.2/5.4/5.6s 依次上浮,错峰 0.2s
- 卡片正文 ≥28px;宽度不够就减字,不要缩字号
### 结论幕(10–15s)
- 一句结论,加一条竖强调条
- 小字来源/命令放底部
- 结尾留 2 秒不动的画面,别让最后一帧空着
## 节奏与排版
- 1080p 短边 1080,阅读字号下限 28px;标题 96–120px
- 每行停留时间 ≥ 0.5s + 0.3s × 词数;结尾句再留 2s
- 安全区:横屏左右 120–160px,竖屏上下 220px(避开平台 UI)
- 背景深色 `#0b0f10` 一类的底,单一强调色(如 `#00ed64`),文字 `#f5f7f6`
- 网格/渐变只做低对比纹理,别抢文字
- 缓动 2–3 种:`power3.out`(入场)、`power2.in`(退场)、强调用 `power4.out`
- 中英混排(`docker compose up -d`)容易超宽:先换行或删字,别缩到 28px 以下;用 `snapshot` 确认实际宽度
- 每幕 5 秒是保守值;10 秒内的片子可以 3 幕 × 3 秒,但台词要更短
## 中文字体
```css
@font-face {
font-family: "Video Sans";
src: local("Microsoft YaHei"), local("PingFang SC");
}
```
字体栈写 `"Video Sans", sans-serif`。一个自定义族名挂多个 `local()` 源:Windows 命中雅黑,macOS 命中苹方,只声明一次,也不会在缺字体的平台上报 `Fonts FAILED`。不要依赖未声明的系统字体,lint 会拦。
## 音频制作
### 旁白(Windows SAPI)
```powershell
Add-Type -AssemblyName System.Speech
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer
$s.GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }
$s.SelectVoice("Microsoft Huihui Desktop")
$s.Rate = 5
$s.SetOutputToWaveFile("audio/vo-1.wav")
$s.Speak("第一句台词。")
```
台词写在 UTF-8 文本里,用 `[System.IO.File]::ReadAllText($path, [Text.Encoding]::UTF8)` 读取,避免 PowerShell 5.1 按 GBK 解码。
### 归一化
```powershell
ffmpeg -y -i audio/vo-1.wav -af "silenceremove=start_periods=1:start_threshold=-45dB,areverse,silenceremove=start_periods=1:start_threshold=-45dB,areverse,loudnorm=I=-16:TP=-1.5:LRA=11" -codec:a libmp3lame -b:a 128k audio/vo-1.mp3
```
### 挂载与校验
- `data-start = 幕起点 + 0.5s`(幕起点 0/5/10 → 0.55/5.55/10.55)
- `data-duration` 用 ffprobe 的真实时长,向下取两位,别留余量
- 每条旁白单独归一到 -16 LUFS;整片因为幕间留白,integrated loudness 常落在 -18~-20 LUFS,这不是问题
- 验收看 true peak ≤ -1 dBTP、旁白覆盖对应幕、没有削波
- `npx hyperframes audio renders/video.mp4` 按信号分类,可能把旁白标成 `sfx`,也会报结尾静音段,都属正常
### 其他声音来源
- 用户提供的 mp3/wav:直接挂 `<audio>`,用 `data-media-start` 裁剪
- 需要 BGM 但没素材:先交付旁白版并说明;不要凭空生成版权不明的音乐
- `doctor` 里的 whisper-cpp / Kokoro / MusicGen 都是可选依赖,未安装不影响出片
## 渲染选项
```powershell
npx hyperframes render -o renders/video.mp4 -q draft # 快速预览
npx hyperframes render -o renders/video.mp4 -q looks # 默认
npx hyperframes render -o renders/video.mp4 --format webm # 透明背景
npx hyperframes render -o renders/video.mp4 --docker # 确定性渲染
npx hyperframes render --resolution portrait # 1080x1920
```
- `draft` 先看节奏,`looks` 出片,`delivery` 只在最终交付
- 竖屏用 `--resolution portrait`,前提是 composition 本身是 1080×1920
- 4 workers 通常是甜点;内存紧张用 `--low-memory-mode`
- `--strict` 让 lint error 直接失败,CI 里值得开
## 项目结构
```text
<project>/
├── index.html # 主 composition
├── compositions/ # 子 composition
├── audio/ # 旁白、BGM
├── vendor/gsap.min.js # 本地 GSAP
├── renders/ # 输出 MP4
├── hyperframes.json
└── meta.json
```
## 从需求到分镜的映射
- 「介绍一个工具」→ 标题 / 三个能力 / 一句结论
- 「把文章做成短视频」→ 文章标题 / 三个要点 / 核心矛盾
- 「数据/产品演示」→ 大数字 / 对比 / 行动号召
- 「竖屏口播」→ 一行一句,字号更大,每幕只放一句话F. references/troubleshooting.md:故障排查
# HyperFrames 故障排查
按报错找条目。每条给:现象 → 根因 → 处理 → 验证。命令默认在项目根目录执行,CLI 版本 0.8.214。
## 1. 字体:font_family_without_font_face
```text
✗ font_family_without_font_face: Font families used without @font-face declaration:
microsoft yahei, pingfang sc. ... Text will fall back to a generic font ...
```
**根因**:CSS 里直接写系统字体名。HyperFrames 只能内嵌「自动解析列表」里的字体,系统字体必须显式声明,否则渲染器不保证拿到同一套字形。
**处理**:给每个字体补声明:
```css
@font-face {
font-family: "Video Sans";
src: local("Microsoft YaHei"), local("PingFang SC");
}
```
字体栈写 `"Video Sans", sans-serif`。一个族名挂多个 `local()` 源,Chrome 会按顺序取第一个已安装的字体;比分别声明两个族名更省事,也不会在缺字体的平台报 `Fonts FAILED`。
`local()` 的字体名必须和系统里的名字完全一致。用 `src: url(...)` 只在确实要打包字体文件时使用。
**验证**:`check` 的 Lint 变 0 error。
## 2. 浏览器:0xC000007B / Target closed
```text
✗ check_runtime_failure: Protocol error (Target.setDiscoverTargets): Target closed
Chrome exited with code 3221225595 when launched on its own.
0xC000007B: the executable and a DLL do not match (32-bit and 64-bit).
```
**根因**:缓存里的 `chrome-headless-shell` 文件在但跑不起来(DLL 位数不匹配的典型症状)。`doctor` 只做存在性检查,所以仍然显示 ✓。
**处理**(任选其一):
```powershell
$env:HYPERFRAMES_BROWSER_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe"
```
```powershell
npx hyperframes browser clear
npx hyperframes browser ensure --force
```
系统 Chrome 的路径常见为 `C:\Program Files\Google\Chrome\Application\chrome.exe`,Edge 在 `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`。
**验证**:`check` 能跑完 Layout 的 9 个采样点,不再在启动浏览器时退出。
## 3. CDN:net::ERR_TIMED_OUT
```text
✗ request_failed: Failed to load npm/gsap@3.14.2/dist/gsap.min.js: net::ERR_TIMED_OUT
2 error(s), 0 warning(s), 0 info(s)
Check failed
```
**根因**:模板默认从 jsdelivr 拉 GSAP。CDN 不可达时库根本没加载,整条时间线不执行——不是动画变卡,是动画不存在。
**处理**:
```powershell
npm install gsap@3.14.2 --no-audit --no-fund
New-Item -ItemType Directory -Force vendor | Out-Null
Copy-Item node_modules\gsap\dist\gsap.min.js vendor\gsap.min.js
```
```html
<script src="vendor/gsap.min.js"></script>
```
**验证**:Runtime 0 error;不同时间点抽帧内容不同。做对照实验时,只改 `script src` 即可区分「动画写错」和「库没加载」。
## 4. 音频:audio_none 与 clip_media_fit
```text
ℹ audio_none: The film has no sound: no clip in it plays audio.
```
**根因**:composition 里没有 `<audio>`。渲染不会凭空加声音。
```text
⚠ clip_media_fit: Audio "vo1" is 3.57s but its slot (data-duration) is 3.70s
```
**根因**:`data-duration` 与音频真实时长不一致;渲染时槽位会被裁到媒体长度。
**处理**:用 ffprobe 量真实时长,原样写入:
```powershell
ffprobe -v error -show_entries format=duration -of csv=p=0 audio/vo-1.mp3
```
```html
<audio id="vo1" src="audio/vo-1.mp3" data-start="0.55" data-duration="3.56"></audio>
```
**验证**:Audio 段不再报 `audio_none`;渲染日志有 `audioCount` 和 `hasAudio: true`;ffprobe 出现 `aac` 流;`npx hyperframes audio` 看整体 LUFS;逐条旁白归一到 -16,整片因幕间留白常读到 -18~-20,属正常。
## 5. 中文乱码
**现象**:渲染出来满屏 `鑷墭绠′腑闂翠欢` 之类的字。
**根因**:PowerShell 5.1 的 `Get-Content` / `Set-Content` 默认按 ANSI(中文系统是 GBK)解码无 BOM 的 UTF-8 文件,再写回就把中文毁了。这不是 HyperFrames 的问题。
**处理**:编辑中文 composition 时用编辑器,或用显式 UTF-8:
```powershell
$c = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8)
[System.IO.File]::WriteAllText($path, $c, (New-Object System.Text.UTF8Encoding($false)))
```
SAPI 读台词同理:使用 `ReadAllText(..., UTF8)`。
**验证**:`Select-String` 或编辑器里中文正常;渲染后抽帧确认字形不是方框。
## 6. 结构警告:nested_structure_needs_subcomposition
```text
⚠ <section id="scene1"> is a timeline element that contains nested <div id="s1-kicker">...
```
**根因**:根 composition 里一个「有时间」的容器又包了其他元素。Studio 时间线只显示顶层元素。
**处理**:单文件 15 秒短片可以接受;如果要多处复用或做长片,把每幕拆成 `compositions/*.html`,用 `data-composition-src` 挂载。
**验证**:Lint 警告消失,Studio 时间线每行对应一个 sub-composition。
## 7. Craft 警告不是错误
- `first_frame_empty`:第 0 帧只有背景。把标题的入点移到 0 秒,或让首帧自带关键画面。
- `text_below_floor`:阅读字号 <28px(1080 短边的 2.6%)。改字号或删字,别用缩放糊弄。
- `line_unread`:每行停留 < 0.5s + 0.3s/词,结尾句还需 2s。拉长镜头或缩短台词。
这些是「能不能发」的指标,不是「能不能渲」。时间紧可以先出片,但要在交付说明里写明。
## 8. 其他
- **`No composition found`**:当前目录没有 `index.html`,或 `-c` 指错了文件。`render` 的位置参数是项目目录,不是文件。
- **`FFmpeg not found`**:装 FFmpeg 并放进 PATH;`doctor` 会打印实际路径。
- **渲染结果和预览不一致**:字体和 Chrome 版本差异;用 `--docker` 做确定性渲染。
- **内存不足/卡死**:`--low-memory-mode`(固定 1 worker、截图采集),或把 `--frames-cache-dir` 指到空间更大的盘。
- **worker 太多反而慢**:4 个通常是甜点,18 核也不建议超过 8。
- **skills 安装失败**:`init` 需要从 GitHub 克隆 skills,失败不影响脚手架;联网后 `npx hyperframes skills update`。
## 9. PowerShell 5.1:NativeCommandError 吓人但不致命
```text
npx : [hyperframes] check: samples 1/9
+ CategoryInfo : NotSpecified: (...), RemoteException
+ FullyQualifiedErrorId : NativeCommandError
```
**根因**:PowerShell 5.1 把原生程序的 stderr 包装成 ErrorRecord;在 `$ErrorActionPreference = "Stop"` 下会直接中断,即使命令本身没问题。HyperFrames 的进度日志走 stderr,所以几乎每次都会触发。
**处理**(任选其一):
```powershell
# 1. 用技能自带脚本,它已用 cmd /c 规避
.\scripts\render.ps1 -Project <dir> -Output renders/video.mp4
# 2. 手动命令套一层 cmd
cmd /c "npx hyperframes@0.8.214 check 2>&1"
# 3. 临时放宽
$ErrorActionPreference = "Continue"
```
**验证**:看 `$LASTEXITCODE` 和最终的 `Check passed` / `Render complete`,不要被红字误导。
> 同理,`npx hyperframes audio` 会把旁白按信号标成 `sfx`,并报告片尾静音段;它只是分析器,不是验收结论。参考
HyperFrames 官网:https://hyperframes.heygen.com
HyperFrames 仓库:https://github.com/heygen-com/hyperframes
上一篇实战记录:《HTML 转视频:HyperFrames 从崩溃到出片》

