瀏覽代碼

feat: HyperFrames渲染后端接入——新动画默认路线+GSAP设计语言配方+产物硬校验

- references/hyperframes-backend.md: 选型边界/合成契约/老demo适配器迁移配方(实测20-30分钟/个)/check流程
- references/gsap-recipes.md: 自研Easing→GSAP映射(数学等价验证)+运动语言8条翻译+五段叙事骨架+seek安全规则
- scripts/verify-video.sh: 产物侧硬校验(分辨率/fps/时长/audio stream/黑帧/LUFS),PASS/FAIL不靠目测
- animation-pitfalls.md §18-20: CSS transition seek不确定/代理tween首帧/contrast门与暗色冲突(2026-07-17实测)
- SKILL.md: 路由表+Step 9+references表三处最小改动,自研Stage保留为弱runtime/单文件fallback

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alchain 1 月之前
父節點
當前提交
d32420b678
共有 6 個文件被更改,包括 885 次插入1 次删除
  1. 4 1
      SKILL.md
  2. 21 0
      references/animation-pitfalls.md
  3. 653 0
      references/gsap-recipes.md
  4. 77 0
      references/hyperframes-backend.md
  5. 17 0
      references/verification.md
  6. 113 0
      scripts/verify-video.sh

+ 4 - 1
SKILL.md

@@ -30,7 +30,7 @@ description: 花叔Design——用HTML做高保真原型、幻灯片、动画、
 | 提到具体品牌/产品名 | 核心原则#0 事实验证 → §1.a 资产协议 → 标准流程 |
 | 没给风格参考(最常见) | Fallback 顾问模式 Phase 1-5 → 回标准流程 Step 2 |
 | 幻灯片/PPT | 标准流程 + Step 1 deck 交付链 + 「技术红线」架构选型 |
-| 动画/导出 MP4/GIF | 标准流程 + Step 9;动手前必读 `references/animation-pitfalls.md` |
+| 动画/导出 MP4/GIF | 标准流程 + Step 9;**新动画项目默认走 HyperFrames 后端**(选型边界+契约 → `references/hyperframes-backend.md`,GSAP 实现配方 → `references/gsap-recipes.md`);动手前必读 `references/animation-pitfalls.md` |
 | 带解说长视频(≥1分钟) | Step 9.5 → `references/voiceover-pipeline.md` |
 | launch film/品牌宣传片(「Apple级」「超级碗品质」) | 先写万字 director's notes → `references/launch-film-director-notes.md` |
 | App/iOS 原型 | 「App / iOS 原型专属守则」(覆盖通用规则) |
@@ -333,6 +333,7 @@ description: 花叔Design——用HTML做高保真原型、幻灯片、动画、
    🛑 **检查点5:交付前自己肉眼过一遍浏览器**。AI写的代码经常有interaction bug。
 8. **总结**:极简,只说caveats和next steps。
 9. **(默认)导出视频 · 必带 SFX + BGM**:动画 HTML 的**默认交付形态是带音频的 MP4**,不是纯画面。无声版本等于半成品——用户潜意识感知「画在动但没声音响应」,廉价感的根源就在这里。流水线:
+   - **新动画项目默认 HyperFrames 后端**:`npm run check`(五门审计,暗色电影风 `--no-contrast`)→ `npx hyperframes render --fps 60` → `scripts/verify-video.sh` 产物硬校验。选型边界与老 demo 适配器配方见 `references/hyperframes-backend.md`;弱 runtime/单文件交付/纯交互演示仍走下面的自研管线
    - `scripts/render-video.js` 录 25fps 纯画面 MP4(只是中间产物,**不是成品**)
    - 需要**真 60fps / 确定性 / B站作品集交付**且动画走 Stage 时钟时,改用 `scripts/render-video-seek.js --fps=60`(逐帧 seek,免插帧、无黑帧,详见 `references/video-export.md`)
    - `scripts/convert-formats.sh` 派生 60fps MP4 + palette 优化 GIF(视平台需要)
@@ -444,6 +445,8 @@ description: 花叔Design——用HTML做高保真原型、幻灯片、动画、
 | 做幻灯片 | `references/slide-decks.md` + `assets/deck_index.html`(默认多文件概览墙)+ `scripts/gen_deck_thumbs.mjs`(画廊缩略图)+ `assets/deck_stage.js`(仅 ≤5 页单文件) |
 | 导出可编辑 PPTX(html2pptx 4 条硬约束) | `references/editable-pptx.md` + `scripts/html2pptx.js` |
 | 做动画/motion(**先读 pitfalls**)| `references/animation-pitfalls.md` + `references/animations.md` + `assets/animations.jsx` |
+| **HyperFrames 渲染后端**(新动画默认;选型边界/合成契约/老demo迁移/check流程) | `references/hyperframes-backend.md` |
+| **设计语言的 GSAP 实现配方**(easing 映射/运动语言8条/五段叙事骨架/seek 安全规则) | `references/gsap-recipes.md` |
 | **动画的正向设计语法**(Anthropic 级叙事/运动/节奏/表达风格)| `references/animation-best-practices.md`(5 段叙事+Expo easing+运动语言 8 条+3 种场景配方)|
 | **带解说的长动画 / 长概念视频**(5-20 分钟带配音、解说驱动画面、TTS 实测时长生成 timeline)| `references/voiceover-pipeline.md`(铁律:连续运动叙事、禁 PowerPoint 切换)+ `assets/narration_stage.jsx` + `scripts/{tts-doubao,narrate-pipeline}.mjs` + `scripts/{mix-voiceover,render-narration}.sh` |
 | 做Tweaks实时调参 | `references/tweaks-system.md` |

+ 21 - 0
references/animation-pitfalls.md

@@ -381,6 +381,24 @@ bash convert-formats.sh input.mp4 --minterpolate
 
 **和坑 #15 的关系**:#15 讲「单文件别用 `src=` 外链 `.jsx`(file:// CORS)」;本坑更进一步——连 React/Babel/字体的**远程 CDN 在受限网络下也会断**,要做到真自包含必须全内联 + 构建期 transpile。
 
+## 18. 【HyperFrames】CSS transition + class 切换在 seek 渲染下不确定
+
+CSS `transition` 走的是墙钟,不是时间轴。逐帧 seek 渲染时每帧都是独立截图,transition 的中间态取决于「seek 到这帧时过了多久墙钟时间」——完全不确定,可能永远停在起始值,也可能随机停在中间。c3 迁移实测(2026-07-17):`.watermark-br` 用 `transition: opacity 0.6s` + class 切换,seek 渲染下透明度不听话。
+
+**修法**:渲染路径上的一切状态变化都用 tween 或 t 的纯函数表达。迁移老 demo 时全文搜 `transition:`,逐个改成 `render(t)` 里的 lerp;新写合成从一开始就不写 transition。hover 等交互态的 transition 无所谓(渲染时不触发)。
+
+## 19. 【HyperFrames】代理 tween 首帧不触发 —— 手动补 `render(0)`
+
+用代理 tween 把 `render(t)` 挂进 GSAP timeline 时(老 demo 适配器路线),timeline 停在 t=0 的状态下 `onUpdate` 不一定被调用——首帧可能是 HTML 的静态未初始化状态而非 `render(0)` 的画面。
+
+**修法**:注册 timeline 后手动同步调一次 `render(0)`。配方全文见 `references/hyperframes-backend.md`。
+
+## 20. 【HyperFrames】contrast 门与暗色电影风冲突 —— 用 `--no-contrast`,其余四门必须 0 error
+
+`npm run check` 的 contrast 门按 WCAG AA 4.5:1 检查所有文字。暗色 cinematic 设计里 16-40% 透明度的水印、mono 标签、装饰性文字是**刻意的**低对比(电影感的一部分),会成片报错,且框架没有逐元素豁免机制。c3 实测 42 个 contrast error 全部是设计本意。
+
+**修法**:暗色电影风产出用 `npx hyperframes check --no-contrast`,lint/runtime/layout/motion 四门仍必须 0 error。**亮底信息型产出不要跳 contrast**——那种场景下的报错通常是真的可读性问题(可读性硬底线见 SKILL.md Fallback 节)。
+
 ## 快速自查清单(开工前 5 秒)
 
 - [ ] 每个 `position: absolute` 的父元素都有 `position: relative`?
@@ -400,3 +418,6 @@ bash convert-formats.sh input.mp4 --minterpolate
 - [ ] 单文件交付的 HTML:`animations.jsx` 是内联的,不是 `src="..."`?(file:// 下 external .jsx 会 CORS 黑屏)
 - [ ] 跨 scene 出现的元素(chapter 标签/水印/scene 编号)没有硬编码颜色?在每个 scene 底色下都可见?
 - [ ] 要离线/真自包含:React+ReactDOM 本地内联、**app 和 `animations.jsx` 引擎都过 Babel transpile**、字体用系统字体?(见坑 #17;引擎含 JSX,漏 transpile 必报 `Unexpected token '<'`)
+- [ ] 【HyperFrames】渲染路径上没有 CSS `transition`?状态变化全是 tween 或 t 的纯函数?(坑 #18)
+- [ ] 【HyperFrames】代理 tween 场景注册后补了 `render(0)`?(坑 #19)
+- [ ] 【HyperFrames】check 过了?暗色电影风用 `--no-contrast`,其余四门 0 error?(坑 #20)

+ 653 - 0
references/gsap-recipes.md

@@ -0,0 +1,653 @@
+# GSAP Recipes · 设计语言到 GSAP Timeline 的翻译层
+
+> 本文件只做一件事:把 huashu-design 已沉淀的动画设计语言
+> (`animation-best-practices.md` 的五段叙事、easing 体系、运动语言 8 条、场景配方,
+> 以及 `cinematic-patterns.md` 的 22 秒 5-scene 模板)翻译成可直接粘贴的
+> GSAP timeline 实现配方,跑在 HyperFrames 渲染后端上。
+>
+> **设计判断以本 skill 自己的 references 为准,GSAP 只是实现工具。**
+> 什么时候该悬停、该用哪种叙事弧线、什么算美,去读 `animation-best-practices.md` §0;
+> 本文件回答的只是「这条规则用 GSAP 怎么写」。
+> HyperFrames 的合成契约(composition root 属性、`.clip` 标记、渲染命令、check 审计)
+> 见 `references/hyperframes-backend.md`,本文只引用不复述。
+
+---
+
+## 0 · 基础样板(每个合成都从这里开始)
+
+```html
+<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
+<script>
+  window.__timelines = window.__timelines || {};
+
+  const tl = gsap.timeline({
+    paused: true,                                   // 必须。HyperFrames 负责 seek
+    defaults: { ease: "expo.out", duration: 0.6 },  // 本 skill 的主 easing(见 §1)
+  });
+
+  // ... 所有 tween 都挂在这条 timeline 上 ...
+
+  window.__timelines["main"] = tl;  // key 必须等于合成根的 data-composition-id
+</script>
+```
+
+硬约束(违反任何一条,渲染结果不确定):
+
+- timeline 必须 `paused: true`,**永远不调用 `tl.play()`** 做渲染关键动画
+- timeline 必须在同步代码里建好,不放进 async / 定时器 / 事件回调
+- 渲染时长来自合成根的 `data-duration`,不是 timeline 长度。不要用空 tween 垫长度
+- 禁 `repeat: -1`。循环动作用可见时长算出有限的 repeat 次数
+- 注意:`defaults: { ease: "expo.out" }` 与 hyperframes-animation 文档里的
+  `power3.out` house default 不同。那是它的品味,本 skill 的既有规则是
+  「expoOut 是默认主 easing」,翻译层遵循自家设计语言
+
+---
+
+## 1 · Easing 映射表 · 自研 Easing → GSAP
+
+`assets/animations.jsx` 里的自研 Easing 函数,逐个对应到 GSAP 写法。
+前三个是数学上**完全同一条曲线**,不是近似。
+
+| 自研 Easing | 数学定义 | GSAP 写法 | 关系 | 用途(既有规则) |
+|---|---|---|---|---|
+| `expoOut` | `1 - 2^(-10t)` | `"expo.out"` | 完全一致 | **默认主 easing**。卡片 rise-in、面板入场、Terminal fade、focus overlay |
+| `overshoot` | easeOutBack,c1=1.70158 | `"back.out"`(默认 1.70158)或 `"back.out(1.7)"` | 完全一致 | Toggle 切换、按钮弹出、强调交互 |
+| `spring` | easeOutElastic,周期 2π/3 | `"elastic.out(1, 0.3)"`(即默认 `"elastic.out"`) | 完全一致 | 几何体归位、物理落位、UI 抖弹 |
+| `easeIn` | `t²` | `"power1.in"` | 完全一致 | 出场、Anticipation 预备段 |
+| `easeOut` | `1-(1-t)²` | `"power1.out"` | 完全一致 | 次要元素的轻动作(说明文字 fade 等) |
+| `easeInOut` | quad inOut | `"power1.inOut"` | 完全一致 | 持续运动(鼠标轨迹插值等对称运动) |
+| `linear` | `t` | `"none"` | 完全一致 | 只用于 proxy 驱动 / 相机匀速运动。**禁止用在元素动效上** |
+| `anticipation` | 分段曲线,先下探 -0.3 再回升 | 无内置等价,用函数 ease(见下) |  | 带预备动作的入场 |
+
+### 1.1 anticipation · 函数 ease
+
+GSAP 接受任意 `(p) => number` 作为 ease,把自研定义原样搬过来即可:
+
+```js
+// 与 animations.jsx 的 Easing.anticipation 逐点一致
+const anticipation = (t) => {
+  if (t < 0.2) return -0.3 * (t / 0.2) * (t / 0.2);   // 前 20%:反向下探
+  const a = (t - 0.2) / 0.8;
+  return -0.012 + 1.012 * a * a * (3 - 2 * a);         // 后 80%:smoothstep 回升
+};
+
+tl.fromTo("#card", { y: 40 }, { y: 0, duration: 0.7, ease: anticipation }, "s2");
+```
+
+注意:这条曲线会越过 0(负值区),**只能用在 transform 上**(y / scale / rotation),
+不要用在 opacity 或颜色上(会推出合法范围)。
+
+### 1.2 spring 的另一个选项 · 烤制弹簧(seek-safe 真物理)
+
+`"elastic.out(1, 0.3)"` 是自研 spring 的精确等价,直接用它没问题。
+当你想要**可调阻尼**的真弹簧手感(比如「落位几乎不过冲、只是尾巴长」),
+用 hyperframes-animation 提供的 `springEase` 闭式解(`adapters/gsap-easing-and-stagger.md`
+有完整 40 行实现,闭式解是时间的纯函数,seek-safe):
+
+```js
+// dampingFraction 1.0 = 无过冲的沉稳落位;0.6-0.7 ≈ 自研 spring 的弹跳感
+const settle = springEase({ response: 0.4, dampingFraction: 0.65 });
+tl.fromTo("#hero", { scale: 0 }, { scale: 1,
+  duration: settle.duration, ease: settle.ease }, "s4");   // duration 必须一起用,它是物理的一部分
+```
+
+**禁止**引入任何实时弹簧库(react-spring 等积分器):状态逐帧累积,无法确定性 seek。
+
+---
+
+## 2 · 五段叙事骨架 · Slow-Fast-Boom-Stop(15/15/40/20/10%)
+
+为什么:均匀节奏的动画是技术演示,有节奏的动画才是叙事(best-practices §1)。
+
+带 label 的 timeline 骨架模板,改 `D` 即可适配任意总时长:
+
+```js
+const D = 15;   // 总时长(秒),与合成根 data-duration 保持一致
+const at = (p) => D * p;
+
+const tl = gsap.timeline({
+  paused: true,
+  defaults: { ease: "expo.out", duration: 0.6 },
+});
+
+// ── 五段 label,比例 15 / 15 / 40 / 20 / 10 ──────────────────
+tl.addLabel("s1_trigger",  at(0));     // 慢 · 触发:给人类反应时间,建立真实感
+tl.addLabel("s2_generate", at(0.15));  // 中 · 生成:视觉惊艳点出现
+tl.addLabel("s3_process",  at(0.30));  // 快 · 过程:展示可控性/密度/细节
+tl.addLabel("s4_boom",     at(0.70));  // Boom · 爆发:拉远/3D pop-out/多面板涌现
+tl.addLabel("s5_hold",     at(0.90));  // 静 · 落幅:Logo 形变 + 戛然而止
+
+// ── S1 触发(节奏慢:单个动作 + 大量留白)─────────────────────
+tl.fromTo("#terminal", { y: 48, autoAlpha: 0 },
+  { y: 0, autoAlpha: 1, duration: 0.8 }, "s1_trigger+=0.1");
+
+// ── S2 生成(一个明确的惊艳点,不堆动作)─────────────────────
+tl.fromTo("#result-panel", { scale: 0.92, autoAlpha: 0 },
+  { scale: 1, autoAlpha: 1, duration: 0.7 }, "s2_generate");
+
+// ── S3 过程(密度最高:stagger、typewriter、focus 切换都在这)──
+tl.fromTo(".row", { y: 10, autoAlpha: 0 },
+  { y: 0, autoAlpha: 1, duration: 0.4, stagger: 0.03 }, "s3_process");
+
+// ── S4 爆发(镜头级动作:拉远 / rotationX / 多元素涌现)───────
+tl.to("#stage", { scale: 0.82, rotationX: 8, duration: 1.2,
+  ease: "expo.inOut" }, "s4_boom");
+
+// ── S5 落幅(Logo 形变收束,见 §3.6;之后什么都不发生)────────
+// 最后 ~0.5s 是有意的静止 hold:不加任何 tween,也绝不 fade to black
+
+window.__timelines["main"] = tl;
+```
+
+要点:
+
+- **S5 之后留白**:`data-duration` 覆盖到最后,但 timeline 上没有 tween,
+  画面 hold 在最终帧。这就是「戛然而止」的实现(禁 fade out 收尾)
+- 22 秒 5-scene 模板(cinematic-patterns Pattern B)同构:把比例换成
+  Invoke 3-4s / Process 5-6s / Insight 4-5s / Output 3-4s / Hero 4-5s,label 同法
+- scene 之间的全屏切换用 autoAlpha 交叠 + 位移,不用 display 切换
+  (`display` / 裸 `visibility` 是渲染器禁区,show/hide 一律 `autoAlpha`)
+
+---
+
+## 3 · 运动语言 8 条 · 逐条翻译
+
+### 3.1 底色不用纯黑纯白
+
+非 timeline 规则:底色是静态 CSS,带色温的中性色,具体色值走品牌 spec。
+唯一的 GSAP 关联:scene 之间要变底色时,tween `backgroundColor`(在允许列表内),
+且两个 scene 的底色应同色系(cinematic-patterns §2 的配色一致约束):
+
+```js
+tl.to("#stage", { backgroundColor: "#F4EFE6", duration: 0.8, ease: "sine.inOut" }, "s4_boom");
+```
+
+### 3.2 Easing 绝不是 linear
+
+为什么:`linear` 让数字元素像机器,`expoOut` 给物理重量感(best-practices §2)。
+
+实现:timeline `defaults` 写 `ease: "expo.out"`(见 §0 样板),
+个别 tween 按 §1 映射表覆盖。`ease: "none"` 只允许出现在两处:
+proxy 驱动 tween(§7)和刻意的机械运动(相机匀速 pan)。
+
+### 3.3 Slow-Fast-Boom-Stop
+
+见 §2 骨架,不重复。
+
+### 3.4 展示「过程」而非「魔法结果」
+
+为什么:产品是协作者不是魔术师,展示 tweak / 报错修复 / redline 打击「一键魔法」
+的 AI slop(best-practices §3.4)。
+
+两个最常用的「过程感」配方:
+
+**Chunk Reveal(模拟 token 流式输出)**。原配方用 `setTimeout + Math.random`,
+两者在 seek 渲染下都非法。翻译成「预计算时刻表 + proxy 驱动」,双向 seek 安全:
+
+```js
+// 为什么不用 tl.call():回调不可逆,preview 里往回拖会残留状态
+const rand = mulberry32(42);                              // 种子随机,见 §7.4
+const text = "为你生成了三个候选方案,第一个最激进。";
+const chunks = text.split(/(?=[,。、;])|(?<=[,。、;])/); // 中文按标点切 chunk
+const times = []; let acc = 0;
+chunks.forEach(() => { acc += 0.04 + rand() * 0.08; times.push(acc); }); // 不规律 40-120ms
+
+const tw = { t: 0 };
+tl.to(tw, {
+  t: acc, duration: acc, ease: "none",
+  onUpdate: () => {   // 每帧从 t 重算完整可见文本:纯函数,回拖也正确
+    let n = 0;
+    while (n < times.length && times[n] <= tw.t) n++;
+    document.querySelector("#stream").textContent = chunks.slice(0, n).join("");
+  },
+}, "s2_generate+=0.3");
+```
+
+**数字 counter(展示真实数据在涨)**:
+
+```js
+// snap 保证整数;innerText 是 HyperFrames 认可的 counter 写法
+tl.fromTo("#metric", { innerText: 0 },
+  { innerText: 237, snap: { innerText: 1 }, duration: 1.2, ease: "expo.out" }, "s3_process");
+```
+
+带千分位 / 后缀格式化时改用 proxy + onUpdate(`tw.v` 推导 `toLocaleString`),套路同上。
+
+### 3.5 鼠标轨迹 · 弧线 + 手抖
+
+为什么:直线插值的鼠标有潜意识机器感,真人是「加速、弧线、减速修正」
+(best-practices §3.5)。
+
+贝塞尔弧线没法用普通属性 tween 表达,用 proxy 驱动。手抖不用 Perlin
+(原实现依赖运行时噪声),用两条不可通约频率的正弦叠加,确定性等效:
+
+```js
+const mouse = { p: 0 };
+const P0 = [100, 100];                       // 起点
+const P2 = [tx, ty];                          // 终点(点击目标)
+const P1 = [tx - 200, ty + 80];               // 控制点:偏离中点,制造弧线
+
+tl.to(mouse, {
+  p: 1, duration: 1.1, ease: "power1.inOut",  // 对称 easing:起步加速 + 到达减速
+  onUpdate: () => {
+    const t = mouse.p;
+    let x = (1-t)*(1-t)*P0[0] + 2*(1-t)*t*P1[0] + t*t*P2[0];
+    let y = (1-t)*(1-t)*P0[1] + 2*(1-t)*t*P1[1] + t*t*P2[1];
+    x += Math.sin(t * 47.13) * 2 * (1 - t);   // ±2px 手抖,接近目标时收敛
+    y += Math.sin(t * 33.7 + 1.3) * 2 * (1 - t);
+    gsap.set("#cursor", { x, y });            // 一切由 p 推导,seek-safe
+  },
+}, "s1_trigger+=0.5");
+
+// 点击反馈:Anticipation 缩小再回弹
+tl.to("#cursor", { scale: 0.85, duration: 0.08, ease: "power1.in" }, ">");
+tl.to("#cursor", { scale: 1, duration: 0.25, ease: "back.out" }, ">");
+```
+
+### 3.6 Logo 形变收束(Morph)
+
+为什么:Logo 淡入没有叙事收束感,要让前一个视觉元素「坍缩」再「膨胀」成 Logo,
+让叙事在品牌点上坍缩(best-practices §3.6)。
+
+blur 走 CSS 变量(`filter` 是 paint-only、seek-safe,官方 depth-of-field-blur
+rule 认可的做法):
+
+```css
+#lastVisual, #logo { --blur: 0px; filter: blur(var(--blur)); will-change: filter; }
+```
+
+```js
+tl.addLabel("morph", "s5_hold-=0.3");
+
+// 坍缩:前一个视觉元素缩成色块,motion blur 升起
+tl.to("#lastVisual", { scale: 0.1, "--blur": "6px",
+  duration: 0.5, ease: "expo.out" }, "morph");
+
+// 膨胀:Logo 从色块中心弹出,blur 收敛到锐利
+tl.fromTo("#logo",
+  { scale: 0.1, "--blur": "6px", autoAlpha: 0 },
+  { scale: 1, "--blur": "0px", autoAlpha: 1, duration: 0.6, ease: "back.out" },
+  "morph+=0.35");                              // 150ms 量级交叠 = 快切
+
+tl.to("#lastVisual", { autoAlpha: 0, duration: 0.15 }, "morph+=0.5");
+// 之后:hold,无 tween,戛然而止
+```
+
+### 3.7 衬线 + 无衬线双字体
+
+非 timeline 规则:静态 CSS,字体选择走品牌 spec。
+HyperFrames 编译器会自动抓取 Google Fonts 并注入确定性 @font-face
+(Phase 0 实测,自研管线的字体时序坑在新后端不存在),CSS 里正常引 Google Fonts 即可。
+
+### 3.8 焦点切换 = 背景减弱 + 前景锐化 + Flash 引导
+
+为什么:只降 opacity 时非焦点元素还是锐利的,必须加 blur 才真的退到后景
+(best-practices §3.8)。
+
+filter 三件套全部走 CSS 变量,GSAP tween 变量本身:
+
+```css
+.tile {
+  --f: 0;   /* focusIntensity 0→1 */
+  filter: brightness(calc(1 - 0.5 * var(--f)))
+          saturate(calc(1 - 0.3 * var(--f)))
+          blur(calc(var(--f) * 4px));          /* ← 关键:blur 让非焦点真的退后 */
+  will-change: filter;
+}
+```
+
+```js
+tl.addLabel("focus", "s3_process+=1.5");
+
+// 非焦点元素:三滤镜 + dim 一次 tween 完成
+tl.to(".tile:not(.focus-target)", {
+  "--f": 1, opacity: 0.4, duration: 0.5, ease: "expo.out",
+}, "focus");
+
+// Flash highlight 引导视线回流。
+// 注意:原配方用 element.animate()(WAAPI),那走墙钟,seek 下不确定,必须翻译成 tween
+tl.fromTo("#focusFlash",
+  { backgroundColor: "rgba(255,255,255,0.3)" },
+  { backgroundColor: "rgba(255,255,255,0)", duration: 0.15, ease: "power1.out" },
+  "focus+=0.5");
+
+// 焦点释放:settle sharp。交给下一个 scene 前必须把 blur 收回 0,
+// 停在半虚化状态会被观众读成「渲染出 bug 了」
+tl.to(".tile", { "--f": 0, opacity: 1, duration: 0.5, ease: "power2.inOut" }, "focus+=2.5");
+```
+
+性能约束(来自官方 DoF rule):blur 半径大面积元素上 ≤24px;优先「dim + 适度 blur」
+而不是把 blur 拉满;`will-change: filter` 只加在真的动 blur 的元素上。
+
+---
+
+## 4 · 具体运动技巧 · §4 代码片段的 GSAP 版
+
+### 4.1 FLIP / Shared Element(按钮膨胀成输入框)
+
+为什么:同一个元素在两种状态间过渡,不是两个元素 cross-fade(best-practices §4.1)。
+
+原配方用 Framer Motion layoutId,GSAP 侧不引入 Flip 插件(在 HyperFrames 下未验证),
+直接手算:合成的视口是固定的(data-width/height),两个状态的几何都是设计稿常量,
+用 fromTo 写死即可。位移缩放全走 transform,元素保持在最终布局位置:
+
+```css
+/* 元素以「终态」布局,起态由 transform 表达 */
+#search-box { width: 560px; height: 56px; }   /* 静态终态,不 tween 尺寸 */
+```
+
+```js
+// 起态几何:按钮 120x44 在 (400, 300),终态输入框 560x56 在 (200, 300)
+tl.fromTo("#search-box",
+  { x: 200, y: 0, scaleX: 120/560, scaleY: 44/56, transformOrigin: "left top" },
+  { x: 0,   y: 0, scaleX: 1, scaleY: 1, duration: 0.6, ease: "expo.out" },
+  "s2_generate");
+// 内层文字反向补偿或延后进场,避免被 scaleX 拉伸(同 §4.2 的处理)
+tl.fromTo("#search-box .placeholder", { autoAlpha: 0 },
+  { autoAlpha: 1, duration: 0.3 }, "s2_generate+=0.4");
+```
+
+### 4.2 呼吸式展开(先展开、再注水)
+
+为什么:面板不该同时拉 width 和 height,先横向展开再纵向撑起才像物理世界
+(best-practices §4.2)。
+
+原配方直接 tween width/height,这在 HyperFrames 是 reflow 禁区(整数像素 snap,
+慢速段肉眼可见抖动,§7.2)。翻译成 scaleX/scaleY,时间错位保持不变:
+
+```js
+// L = 展开总时长;前 40% 拉横、30% 处开始撑纵,两段交叠
+const L = 0.9;
+tl.fromTo("#panel",
+  { scaleX: 0, scaleY: 0.12, transformOrigin: "left top" },
+  { scaleX: 1, duration: 0.4 * L, ease: "expo.out" }, "open");
+tl.to("#panel", { scaleY: 1, duration: 0.7 * L, ease: "expo.out" }, "open+=" + 0.3 * L);
+
+// 内容在壳展开完成后才浮现:既符合「先展开再注水」的意象,
+// 又让 scale 过程中的内容拉伸变形不可见
+tl.fromTo("#panel .content", { autoAlpha: 0, y: 8 },
+  { autoAlpha: 1, y: 0, duration: 0.35 }, "open+=" + 0.75 * L);
+```
+
+注意 scale 版不是逐像素忠实(圆角和边框会随比例变形)。展开壳是纯色 / 大圆角面板时
+不可察觉;如果面板边框细节重要,改用「壳固定 + 内容 clip-path 揭示」的方案并实测截帧。
+
+### 4.3 Staggered Fade-up(30ms stagger)
+
+为什么:列表挨个入场比整块出现更有「物体感」,30ms 是既定间隔(best-practices §4.3)。
+
+```js
+tl.fromTo(".row",
+  { y: 10, autoAlpha: 0 },
+  { y: 0, autoAlpha: 1, duration: 0.4, ease: "expo.out", stagger: 0.03 },
+  "s3_process");
+
+// 变体:从中心向两侧涌现(S4 爆发的多面板涌现常用)
+tl.fromTo(".panel",
+  { y: 24, autoAlpha: 0, scale: 0.96 },
+  { y: 0, autoAlpha: 1, scale: 1, duration: 0.5, ease: "expo.out",
+    stagger: { each: 0.03, from: "center" } },
+  "s4_boom");
+```
+
+用 `fromTo` 不用 `from`:sub-composition 会被反复 re-seek,`from` 在注册时刻
+快照起始状态,回拖后可能错位;`fromTo` 两端显式声明,永远一致。
+
+### 4.4 关键结果前悬停 0.5s
+
+为什么:机器执行快且连贯,但人脑需要反应时间,关键结果前停 0.5 秒是礼让观众
+(best-practices §4.4,§0.2 核心信念第 3 条)。
+
+GSAP 里「悬停」就是 position 参数上的一段空档,用 label 把停顿写成显式设计决策:
+
+```js
+// 生成完成的时刻
+tl.addLabel("generated", "s2_generate+=1.2");
+// loading 态停住 0.5s:这 0.5s 内没有任何 tween,观众盯着加载状态
+tl.addLabel("reveal", "generated+=0.5");
+
+tl.fromTo("#result", { scale: 0.94, autoAlpha: 0 },
+  { scale: 1, autoAlpha: 1, duration: 0.7, ease: "expo.out" }, "reveal");
+```
+
+### 4.5 Anticipation → Action → Follow-through
+
+为什么:只有 Action 的动画是 PowerPoint 动画,Disney 三段给动作生命感
+(best-practices §4.6)。
+
+三段顺序 tween,easing 按 §1 映射(预备 power1.in、主动 expo.out、回弹 elastic):
+
+```js
+tl.addLabel("pop", "s2_generate+=0.2");
+tl.to("#card", { scale: 0.95, duration: 0.12, ease: "power1.in"  }, "pop");        // 预备
+tl.to("#card", { scale: 1.05, duration: 0.30, ease: "expo.out"   }, ">");          // 主动
+tl.to("#card", { scale: 1.00, duration: 0.35, ease: "elastic.out(1, 0.3)" }, ">"); // 回弹
+```
+
+单 tween 版:`ease: anticipation`(§1.1)一步完成「预备 + 主动」,回弹再补一段。
+
+### 4.6 3D Perspective + translateZ 分层
+
+为什么:rotateX 8° / rotateY -4° 模拟镜头在桌面左上角俯视的 natural angle
+(best-practices §4.7)。
+
+透视和分层是静态 CSS(照抄原配方,perspective / translateZ 不需要动);
+动的部分(入场时立起来、S4 拉远)用 GSAP 的 3D transform 别名:
+
+```css
+.stage-wrap { perspective: 2400px; perspective-origin: 50% 30%; }
+.card-grid  { transform-style: preserve-3d; }
+.card:nth-child(3n) { transform: translateZ(30px); }
+.card:nth-child(5n) { transform: translateZ(-20px); }
+.card:nth-child(7n) { transform: translateZ(60px); }
+```
+
+```js
+// 入场:从正视缓慢立到黄金角
+tl.fromTo("#card-grid", { rotationX: 0, rotationY: 0 },
+  { rotationX: 8, rotationY: -4, duration: 1.4, ease: "expo.out" }, "s2_generate");
+```
+
+### 4.7 斜向 Pan · 同时动 XY,频率不同
+
+为什么:X 和 Y 用不同频率避免 Lissajous 循环规则化,模拟手持镜头的斜向漂移
+(best-practices §4.8)。
+
+原配方是 `Math.sin(flowT * ...)` 逐帧算,GSAP 版用两条不同 duration 的
+yoyo tween 叠加(GSAP 对 x / y 独立追踪,两条 tween 不打架)。repeat 必须有限:
+
+```js
+// 周期不同(4.6s vs 2.9s)= 频率不同,路径不闭合
+// repeat 数从可见时长算出:Math.ceil(D / dur) 保证覆盖全片
+tl.to("#stage", { x: 40, duration: 4.6, ease: "sine.inOut",
+  yoyo: true, repeat: Math.ceil(D / 4.6) }, 0);
+tl.to("#stage", { y: 30, duration: 2.9, ease: "sine.inOut",
+  yoyo: true, repeat: Math.ceil(D / 2.9) }, 0);
+```
+
+### 4.8 戛然而止收尾
+
+为什么:fade out 没有决定感,最后一帧要清晰、肯定(best-practices §0.3 留白)。
+
+实现上是「不写代码」:S5 的 Logo 落位后,timeline 上不再有任何 tween,
+`data-duration` 比最后一个 tween 的结束时刻长 0.5-1s,画面 hold 在终态。
+如果有 BGM,用 volume tween 在尾部收音(volume 在允许列表内):
+
+```js
+tl.to("#bgm", { volume: 0, duration: 0.4 }, "s5_hold+=0.8");  // 音频截停,画面不动
+```
+
+---
+
+## 5 · 场景配方 A/B/C · timeline 结构要点
+
+设计判断(选哪种、SFX 密度、BGM 风格)见 best-practices §5,这里只给 timeline 侧的差异。
+
+### 配方 A · Apple Keynote 戏剧式
+
+- 骨架:§2 五段结构原样,S4 的 Boom 做足
+- defaults:`ease: "expo.out"`,强调交互处覆盖 `"back.out"`
+- S4 标志动作:镜头急拉远 + drop。`tl.to("#stage", { scale: 0.78, y: -40, duration: 1.1, ease: "expo.inOut" }, "s4_boom")`
+- S5:Logo Morph(§3.6)+ 空灵单音 + hold
+
+### 配方 B · 一镜到底工具式
+
+- 骨架:**不用**五段峰值结构,一条持续 flow。label 按 BGM 小节打:
+  `tl.addLabel("bar1", 0); tl.addLabel("bar2", 60/88*4);`(88 BPM,一小节 ≈ 2.73s)
+- 关键 UI 动作的 position 参数直接写在 kick/snare 时刻上,音乐律动即交互音效
+- easing:`springEase`(§1.2)+ `"expo.out"`,落位感多于爆发感
+- 没有 S4 式 Boom,收尾同样戛然而止
+
+### 配方 C · 办公效率叙事式
+
+- 骨架:多 scene 硬切。每个 scene 一个 label,scene 间 autoAlpha 快切(0.15s)
+  而不是长交叠;配合 Dolly In/Out:
+  `tl.fromTo("#scene2", { scale: 1.06 }, { scale: 1, duration: 1.2, ease: "expo.out" }, "sc2")`
+- toggle 类交互一律 `"back.out"`,面板一律 `"expo.out"`
+- 全片必有一处高光:3D pop-out(§4.6 的 rotationX + translateZ 元素浮起),
+  只做一次,到处炫技是廉价信号(§0.3 克制)
+
+---
+
+## 6 · seek 安全规则(Phase 0 实测,全部踩过)
+
+HyperFrames 渲染是逐帧 seek + 截屏。任何不是「时间的纯函数」的状态都会在
+渲染里出现不确定结果,而且**preview 里看起来往往是好的**,只有渲染产物才暴露。
+
+### 6.1 禁 CSS transition + class 切换 · 一律用 tween 表达
+
+CSS transition 走浏览器墙钟,不走时间轴。逐帧 seek 时每帧都是一次「状态突变」,
+transition 要么不触发、要么起点错乱,Phase 0 迁移 c3 时实测中招。
+
+```css
+/* ✗ 旧写法:JS 里 classList.add('lit'),靠 transition 过渡 */
+.capsule { transition: transform 0.3s ease; }
+.capsule.lit { transform: scale(1.06); }
+```
+
+```js
+// ✓ 新写法:状态变化本身是 timeline 上的一段 tween
+tl.to("#capsule", { scale: 1.06, duration: 0.3, ease: "expo.out" }, "lit_at");
+tl.to("#capsule", { scale: 1.0,  duration: 0.3, ease: "expo.out" }, "lit_at+=1.2");
+```
+
+同类禁区:`element.animate()`(WAAPI,同样走墙钟,§3.8 的 Flash 已给翻译)、
+CSS `@keyframes` animation 用于渲染关键动画。
+交付前扫一遍:`grep -n "transition:\|animation:\|\.animate(" index.html`,
+命中的每一处要么删掉、要么翻译成 tween。
+
+### 6.2 禁 animate 触发 reflow 的属性 · 用 transform 代替
+
+layout 属性在浏览器 layout 阶段 snap 到整数设备像素。快速 tween 看不出来;
+慢速 ease-out 尾巴上每帧移动不足 1px,就会「憋几帧、跳 1px」,肉眼可见的抖动。
+Phase 0 的 lint 当场抓到 letterSpacing 逐帧抖动,正是这类无报警视觉 bug。
+
+| ✗ 禁 tween | ✓ 忠实替代 |
+|---|---|
+| `width` / `height` | `scaleX` / `scaleY` + `transformOrigin`(内容处理见 §4.2) |
+| `top` / `left` / `right` / `bottom` | 元素停在 CSS 终态位,tween `x` / `y` 偏移量 |
+| `fontSize` | `scale`(视觉等价,sub-pixel 平滑) |
+| `letterSpacing` / `wordSpacing` | 逐字 split 后 tween 每个字符的 `x`(uniform scale 不是同一个效果,它缩放字形而不是字距) |
+| `margin*` / `padding*` | 布局写死,动 `x` / `y` |
+
+修复原则:**重现同一个视觉,只去掉抖动**。过 lint 不是标准,和原动画逐帧对比才是。
+
+### 6.3 t=0 时 onUpdate 不触发 · 代理 tween 必须手动补首帧
+
+timeline seek 到 0 时 proxy tween 的 `onUpdate` 可能不触发,首帧就是白屏 / 初始 DOM。
+所有 proxy 驱动的场景(§3.4 chunk reveal、§3.5 鼠标、§7 老 demo 适配器),
+注册完 timeline 后手动调一次:
+
+```js
+window.__timelines["main"] = tl;
+render(0);   // 首帧保险:把 t=0 的画面显式画出来
+```
+
+### 6.4 禁 Math.random / Date.now · 随机用种子函数
+
+同一帧每次 seek 必须得到同一画面。运行时随机 = 每次渲染不同 = 无法逐帧渲染。
+需要「随机感」(粒子、抖动、不规律间隔)时用 mulberry32,**建 timeline 前**
+一次性生成所有随机值(Phase 0 的 3D 粒子 demo 实测写法):
+
+```js
+function mulberry32(seed) {
+  return function () {
+    seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
+    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
+    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+const rand = mulberry32(20260717);   // 种子写死,改种子 = 换一版随机
+
+// 用法:预生成,不在 onUpdate 里现抽
+const offsets = Array.from({ length: 40 }, () => (rand() - 0.5) * 24);
+```
+
+同理禁用:`Date.now()`、`performance.now()`、任何事件驱动状态(渲染模式没有输入事件)。
+
+---
+
+## 7 · 老 demo 适配器配方 · render(t) 挂进 GSAP
+
+21 个自研引擎老 demo 的动画核心都是 `render(t)` 纯函数。迁移不重写动画逻辑,
+用一个代理 tween 把 render(t) 挂到 GSAP timeline 上(Phase 0 实测:单个 demo
+20-30 分钟,动画代码一行不改,c3 电影级 demo 1134 行验证通过)。
+
+### 7.1 代理 tween 模板(12 行,c3 实测原版)
+
+```js
+// =============== HyperFrames adapter ===============
+// 代理 tween 驱动原 render(t)。每一帧都是时间轴时间的纯函数:
+// 无 rAF、无时钟、无输入状态。
+window.__timelines = window.__timelines || {};
+const proxy = { t: 0 };
+const tl = gsap.timeline({ paused: true });
+tl.to(proxy, {
+  t: T.DURATION,            // 老 demo 的总时长常量
+  duration: T.DURATION,
+  ease: "none",             // 时间必须匀速映射,easing 在 render(t) 内部
+  onUpdate: () => render(proxy.t),
+}, 0);
+window.__timelines["main"] = tl;
+
+// 首帧保险(timeline 停在 t=0 时 onUpdate 不触发,§6.3)
+render(0);
+```
+
+### 7.2 迁移四步
+
+1. **包 root / clip**:给最外层容器加合成根属性
+   (`data-composition-id="main"` + `data-duration` + 尺寸),
+   舞台元素加 `.clip` 及 `data-start` / `data-duration` / `data-track-index`。
+   完整契约见 `hyperframes-backend.md`
+2. **删自驱**:删掉 rAF 循环、`setInterval`、自动 play 逻辑、
+   `performance.now()` 起点。`render(t)` 只吃参数 t,不再自己找时间
+3. **挂 proxy**:粘 §7.1 模板,`T.DURATION` 对上 `data-duration`,末尾 `render(0)`
+4. **扫 transition**:`grep -n "transition:\|animation:\|\.animate(\|Math.random\|Date.now\|performance.now"`
+   逐条清零。class 切换类效果按 §6.1 改成 t 的纯函数(老 demo 最常见的残留
+   就是「classList.add + transition」组合)
+
+迁完跑一次 `npx hyperframes check`(暗色 cinematic 用 `--no-contrast`,
+其余四门必须 0 error),再抽 3-4 个关键时刻截帧和老版对比。
+
+### 7.3 什么时候不用适配器
+
+适配器是**存量迁移**方案。新写的动画直接用本文件 §0-§5 的原生 timeline 写法:
+label 可读、stagger 声明式、GSAP inspector 能逐 tween 检查,
+proxy 大黑盒里的动画对审计工具是不透明的。
+
+---
+
+## 8 · 交付前自检(GSAP 侧,补充 best-practices §7 清单)
+
+- [ ] timeline `paused: true`,注册 key 等于 `data-composition-id`?
+- [ ] defaults 是 `expo.out`,没有裸 `linear` / `ease` 出现在元素动效上?
+- [ ] 五段 label 齐全,S5 之后有 hold 留白(没有 fade out)?
+- [ ] `grep "transition:\|\.animate(\|Math.random\|Date.now"` 结果为 0?
+- [ ] 没有 tween width / height / top / left / letterSpacing / fontSize?
+- [ ] 所有 `repeat` 是有限数?
+- [ ] proxy 场景末尾补了 `render(0)`?
+- [ ] blur / filter 全部走 CSS 变量,动过 blur 的元素有 `will-change: filter`?
+- [ ] sub-composition 里入场全用 `fromTo` 不用 `from`?
+- [ ] `npx hyperframes check` 通过(暗色片 `--no-contrast`,其余 0 error)?

+ 77 - 0
references/hyperframes-backend.md

@@ -0,0 +1,77 @@
+# HyperFrames 渲染后端 · 选型边界与操作手册
+
+> 2026-07-17 实测验证通过后引入(工具链/中文字体/代理环境/迁移/3D 五项全过,关键数据已内嵌本文)。
+> HyperFrames 是 HeyGen 开源的 HTML→视频框架(Apache 2.0):纯 HTML + 暂停的 GSAP timeline,headless 浏览器逐帧 seek 确定性渲染。
+
+## 选型边界(先看这张表再开工)
+
+| 场景 | 用哪条渲染路线 |
+|---|---|
+| 新动画项目(默认) | **HyperFrames**。审计套件白送、3D/GSAP/Lottie/shader 全解锁 |
+| 需要 3D / 粒子 / 物理惯性 / shader 转场 | HyperFrames(自研 Stage 做不到) |
+| 老 Stage demo 要复用/改版 | 顺手迁移(适配器配方见下,20-30 分钟/个);只重渲不改就仍用 render-video-seek.js |
+| 弱 runtime(无 npm / 无法装依赖 / 单文件交付给用户双击打开) | 自研 Stage(assets/animations.jsx),老流程不变 |
+| 交互演示(用户要在浏览器里玩,不导出视频) | 自研 Stage 或普通 HTML,HyperFrames 是渲染管线不是交互框架 |
+| 批量参数化视频(千人千面/模板换字) | Remotion(见规划方向5,独立于本 skill 主流程) |
+
+**设计语言永远是甲方**:叙事结构、easing 体系、SFX/BGM 双轨制照旧全部生效(animation-best-practices.md / audio-design-rules.md),HyperFrames 只是实现和渲染工具。GSAP 实现配方见 `references/gsap-recipes.md`。
+
+## 项目脚手架
+
+```bash
+npx -y hyperframes init 项目名 --example blank   # 非交互必须带 --example
+cd 项目名 && npm install
+```
+
+生成 index.html / hyperframes.json / meta.json / package.json(pin 了 CLI 版本)+ 项目级 CLAUDE.md。init 会把 19 个 hyperframes skill 装到 `~/.claude/skills/`(本机已装)。合成写法契约读 `~/.claude/skills/hyperframes-core/SKILL.md`,本地文档 `npx hyperframes docs <topic>`(data-attributes / gsap / rendering / troubleshooting)。
+
+**版本策略**:项目 package.json 会 pin 精确版本(当前实测过的是 0.7.61)。它迭代极快(300+ releases),升级先 `npx hyperframes@latest upgrade --project . --check` 看 delta,跑一遍回归 demo 再动。
+
+## 合成契约速查(完整版读 hyperframes-core)
+
+- 根容器:`data-composition-id` + `data-start` + `data-duration` + `data-width/height`
+- 每个计时元素:`class="clip"` + `data-start` + `data-duration` + `data-track-index`
+- timeline 必须 paused 并注册:`window.__timelines["合成id"] = gsap.timeline({paused:true})`
+- 视频素材用 `muted`,音轨单独 `<audio>` 元素
+- **只允许确定性逻辑**:禁 `Date.now()` / `Math.random()` / 运行时网络 fetch;随机用种子函数
+- 字体:Google Fonts 会被编译器自动抓取并注入确定性 @font-face(缓存 `~/.cache/hyperframes/fonts/`);纯系统字体(PingFang SC 等)加一行 `@font-face { font-family:"PingFang SC"; src: local("PingFang SC"); }` 过 lint
+- Three.js 走 `hf-seek` 事件适配器(`~/.claude/skills/hyperframes-animation/adapters/three.md`),根容器必须显式 `data-duration`
+
+## 老 demo 迁移 · 适配器配方(实测 20-30 分钟/个)
+
+自研 Stage/纯 render(t) 动画不用重写,四步:
+
+1. **包容器**:外套 `#root` 带合成 data 属性;整个 `.stage` 作为唯一 clip 最省事(`class="stage clip"` + data-start/duration/track-index);`.stage` 从 fixed 居中改 absolute inset:0,html/body 定死 1920×1080
+2. **删自驱**:rAF tick 循环、fitStage/resize 监听、replay 按钮、`__ready/__setTime/__seek` 协议全删(渲染器不需要)
+3. **挂代理 tween**(核心 12 行):
+   ```js
+   const proxy = { t: 0 };
+   const tl = gsap.timeline({ paused: true });
+   tl.to(proxy, { t: DURATION, duration: DURATION, ease: "none",
+     onUpdate: () => render(proxy.t) }, 0);
+   window.__timelines = window.__timelines || {};
+   window.__timelines["main"] = tl;
+   render(0);   // 必须:timeline 停在 t=0 时 onUpdate 不触发,不补这句首帧可能未初始化
+   ```
+4. **扫 transition**:全文搜 `transition:` 声明。CSS transition + class 切换走墙钟,逐帧 seek 下不确定,必须改成 render(t) 里对 t 的纯函数(lerp)
+
+## 校验与渲染
+
+```bash
+npm run check                        # lint+runtime+layout+motion+contrast 五门审计
+npx hyperframes check --no-contrast  # 暗色电影风专用(见下)
+npx -y hyperframes@<pin版本> render --fps 60   # 终渲;默认 30fps
+```
+
+- **check 必须 0 error 才渲染**(contrast 门除外)。lint 能拦 letterSpacing 抖动、字体缺失、非确定性等一整类「无报警视觉 bug」
+- **contrast 门取舍**:它按 WCAG 4.5:1 检查,和暗色电影风的低对比水印/装饰文字(16-40% 透明度)根本冲突,且无逐元素豁免。暗色 cinematic 产出统一 `--no-contrast`,其余四门仍必须 0 error。亮底信息型产出不要跳,contrast 报错通常是真问题
+- **两级渲染**:先默认 30fps 快速出片,肉眼+截帧检查通过后再 `--fps 60` 终渲。60fps 600 帧 1080p 实测约 20 秒
+- 渲染产物侧校验(audio stream / 黑帧 / 响度 / 时长)用 `scripts/verify-video.sh`(见 verification.md)
+
+## 音频
+
+HyperFrames 合成里 `<audio>` 元素可直接进时间轴(BGM/解说随片渲染)。当前音频流程不变:SFX/BGM 双轨制照 audio-design-rules.md,用 add-music.sh / mix-voiceover.sh 后期混流也可以。哪条路更好在实战中定,先不强制。
+
+## pitfalls 增量(相对自研管线)
+
+自研管线 pitfalls(animation-pitfalls.md §7/10/12/13 录制协议类、§6 字体时序、§15/17 网络类)在 HyperFrames 后端上**不适用**:录制协议由框架内部处理,字体编译期抓取,CDN 实测代理下可通。新增的坑只有三条,已录入 animation-pitfalls.md §18-20:CSS transition 非确定性、代理 tween 首帧、contrast 门冲突。

+ 17 - 0
references/verification.md

@@ -181,3 +181,20 @@ python verify.py design.html --output ./screenshots/
 # headless=false,打开真实浏览器给你看
 python verify.py design.html --show
 ```
+
+## 视频产物硬校验(verify-video.sh)
+
+渲染出的 MP4/成片不靠肉眼过,用脚本硬校验(HTML 合成侧的校验由 `hyperframes check` 五门审计负责,这个脚本只管产物侧):
+
+```bash
+# 成品(默认要求有音轨)
+bash scripts/verify-video.sh final.mp4 --duration=22 --fps=60 --width=1920 --height=1080
+
+# 无声中间产物
+bash scripts/verify-video.sh raw.mp4 --duration=10 --fps=60 --no-audio
+
+# 刻意黑场开场的电影风
+bash scripts/verify-video.sh film.mp4 --duration=30 --fps=60 --allow-black-open
+```
+
+检查项:分辨率/帧率、时长误差(±2%)、audio stream 存在性(无音轨=半成品铁律的机器执行)、首尾黑帧(blackdetect,录制起点偏移/loop 回跳的典型症状)、LUFS 响度(成品目标 -14±4)。exit code 非 0 就不许交付。

+ 113 - 0
scripts/verify-video.sh

@@ -0,0 +1,113 @@
+#!/bin/bash
+# verify-video.sh — 渲染产物侧硬校验(PASS/FAIL,不靠agent目测)
+#
+# 检查项:分辨率/fps、时长误差、audio stream存在性、首尾黑帧、LUFS响度、体积
+# 合成侧的校验(lint/layout/motion/contrast)由 hyperframes check 负责,此脚本只管产物。
+#
+# Usage:
+#   bash verify-video.sh video.mp4 [--duration=10] [--fps=60] [--width=1920] [--height=1080]
+#                        [--no-audio]        # 明确无音频的中间产物,跳过audio+响度检查
+#                        [--allow-black-open] # 片头刻意黑场开场时跳过片头黑帧检查
+#
+# Exit code: 0 = 全PASS;1 = 有FAIL
+
+set -u
+FILE="${1:-}"
+if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
+  echo "Usage: bash verify-video.sh video.mp4 [--duration=N] [--fps=N] [--width=N] [--height=N] [--no-audio] [--allow-black-open]"
+  exit 1
+fi
+shift || true
+
+EXP_DURATION=""; EXP_FPS=""; EXP_W=""; EXP_H=""; NO_AUDIO=0; ALLOW_BLACK_OPEN=0
+for a in "$@"; do
+  case "$a" in
+    --duration=*) EXP_DURATION="${a#*=}" ;;
+    --fps=*)      EXP_FPS="${a#*=}" ;;
+    --width=*)    EXP_W="${a#*=}" ;;
+    --height=*)   EXP_H="${a#*=}" ;;
+    --no-audio)   NO_AUDIO=1 ;;
+    --allow-black-open) ALLOW_BLACK_OPEN=1 ;;
+  esac
+done
+
+FAILS=0
+pass() { echo "  ✓ PASS  $1"; }
+fail() { echo "  ✗ FAIL  $1"; FAILS=$((FAILS+1)); }
+warn() { echo "  ⚠ WARN  $1"; }
+
+echo "▸ verify-video: $FILE"
+
+# ---------- 基本流信息 ----------
+INFO=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height,avg_frame_rate -show_entries format=duration,size -of default=noprint_wrappers=1 "$FILE" 2>/dev/null)
+W=$(echo "$INFO" | grep '^width=' | cut -d= -f2)
+H=$(echo "$INFO" | grep '^height=' | cut -d= -f2)
+FPS_RAW=$(echo "$INFO" | grep '^avg_frame_rate=' | cut -d= -f2)
+DUR=$(echo "$INFO" | grep '^duration=' | cut -d= -f2)
+SIZE=$(echo "$INFO" | grep '^size=' | cut -d= -f2)
+FPS=$(python3 -c "print(round(eval('${FPS_RAW:-0}' if '${FPS_RAW:-0}'!='0/0' else '0'),2))" 2>/dev/null || echo "?")
+
+[ -z "$W" ] && { fail "无法读取视频流(文件损坏或非视频)"; echo "✗ 1项FAIL"; exit 1; }
+echo "  info: ${W}x${H} · ${FPS}fps · ${DUR%.*}s · $((SIZE/1024))KB"
+
+# ---------- 分辨率 / fps ----------
+if [ -n "$EXP_W" ]; then
+  [ "$W" = "$EXP_W" ] && [ "$H" = "$EXP_H" ] && pass "分辨率 ${W}x${H}" || fail "分辨率 ${W}x${H},期望 ${EXP_W}x${EXP_H}"
+fi
+if [ -n "$EXP_FPS" ]; then
+  python3 -c "exit(0 if abs($FPS-$EXP_FPS)<=0.5 else 1)" 2>/dev/null && pass "帧率 ${FPS}fps" || fail "帧率 ${FPS}fps,期望 ${EXP_FPS}fps"
+fi
+
+# ---------- 时长误差(±2% 或 ±0.2s 取大者)----------
+if [ -n "$EXP_DURATION" ]; then
+  python3 -c "
+d=float('$DUR'); e=float('$EXP_DURATION')
+tol=max(e*0.02,0.2)
+exit(0 if abs(d-e)<=tol else 1)" 2>/dev/null && pass "时长 ${DUR%.*}s(期望 ${EXP_DURATION}s)" || fail "时长 ${DUR}s,期望 ${EXP_DURATION}s(容差2%)"
+fi
+
+# ---------- audio stream ----------
+HAS_AUDIO=$(ffprobe -v error -select_streams a -show_entries stream=codec_type -of csv=p=0 "$FILE" 2>/dev/null | head -1)
+if [ "$NO_AUDIO" = "1" ]; then
+  [ -z "$HAS_AUDIO" ] && pass "无音轨(--no-audio 中间产物)" || warn "声明--no-audio但存在音轨"
+else
+  if [ -n "$HAS_AUDIO" ]; then
+    pass "audio stream 存在"
+    # ---------- LUFS 响度(成品参考 -14 LUFS ±4)----------
+    LUFS=$(ffmpeg -i "$FILE" -af loudnorm=print_format=summary -f null - 2>&1 | grep 'Input Integrated' | grep -oE '\-?[0-9]+\.?[0-9]*')
+    if [ -n "$LUFS" ]; then
+      python3 -c "exit(0 if -18<=float('$LUFS')<=-10 else 1)" 2>/dev/null \
+        && pass "响度 ${LUFS} LUFS(目标区间 -18~-10)" \
+        || warn "响度 ${LUFS} LUFS 偏离 -14±4 区间,检查混音增益"
+    fi
+  else
+    fail "无 audio stream——skill铁律:动画默认交付形态是带SFX+BGM的MP4,无声=半成品"
+  fi
+fi
+
+# ---------- 首尾黑帧 ----------
+BLACK=$(ffmpeg -i "$FILE" -vf "blackdetect=d=0.1:pix_th=0.10" -an -f null - 2>&1 | grep -oE 'black_start:[0-9.]+ black_end:[0-9.]+' )
+if [ -n "$BLACK" ]; then
+  HEAD_BLACK=$(echo "$BLACK" | awk -F'[: ]' '$2<0.3{print}' | head -1)
+  TOTAL=${DUR%.*}
+  TAIL_BLACK=$(echo "$BLACK" | awk -F'[: ]' -v t="$TOTAL" '$4>t-0.3{print}' | head -1)
+  if [ -n "$HEAD_BLACK" ] && [ "$ALLOW_BLACK_OPEN" = "0" ]; then
+    fail "片头黑帧($HEAD_BLACK)——录制起点偏移的典型症状;刻意黑场开场用 --allow-black-open"
+  else
+    [ -n "$HEAD_BLACK" ] && pass "片头黑场(--allow-black-open 已声明)"
+  fi
+  [ -n "$TAIL_BLACK" ] && fail "片尾黑帧($TAIL_BLACK)——loop回跳或时长超录的典型症状"
+  [ -z "$HEAD_BLACK" ] && [ -z "$TAIL_BLACK" ] && warn "片中存在黑帧段(如是刻意转场可忽略):$(echo "$BLACK" | head -2 | tr '\n' ' ')"
+else
+  pass "无黑帧"
+fi
+
+# ---------- 汇总 ----------
+echo ""
+if [ "$FAILS" = "0" ]; then
+  echo "◇ verify-video: 全部PASS"
+  exit 0
+else
+  echo "✗ verify-video: ${FAILS}项FAIL"
+  exit 1
+fi