# Storyboard 分镜规格 v2（导演模式契约）

v1 管线（plan.py 规则分类 → 单人原地 pose）只能演独角戏。v2 把管线拆成三层：

```
tts.py(对时,不变) → 【Agent 当导演写 storyboard.json】 → resolve.py(对时+展开成轨道) → build_html2.py / build_ae2.py(纯几何渲染)
```

**职责边界**：语义理解（谁在哪、对谁做什么）只发生在 storyboard.json 的创作里（由 Claude 写）；
resolve.py 把时间引用钉到 timing.json 的真实词时刻并展开成"轨道"；渲染器只解释轨道，不做任何语义判断。

## 1. storyboard.json（导演写的）

```json
{
  "stage": { "ground_y": 200, "locations": {"home": -1400, "bakery": -550} },
  "cast":  { "xiaoming": {"x": "home", "face": "calm"}, "baker": {"x": "bakery", "hidden": false} },
  "props": [ {"id": "shop_bakery", "type": "shop", "label": "面包店", "x": "bakery", "hidden": false},
             {"id": "coin", "type": "coin", "carrier": "xiaoming"} ],
  "scenes": [ { "plan_scene": 1, "camera": {"mode": "fixed", "x": "home", "zoom": 0.9},
                "actions": [ {"verb": "walk_to", "actor": "xiaoming", "to": "bakery",
                              "t0": "s1.start", "t1": "s1.end-0.3"} ] } ]
}
```

- 坐标系 = rig_spec 的 SVG 系：y 向下为正，地面 y=200，小人高约 600 单位；世界 x 任意展开（如 -1800~4600）。
- 摄像机窗口宽 2133 世界单位（1920px/K=0.9），再除以 zoom。
- 字幕与关键词弹字仍由 plan.json 驱动（每个 scene 用 `plan_scene` 关联），渲染器沿用 v1 行为。

### 时间引用语法（resolve.py 解析）
`3.25`（绝对秒）| `sN.start` `sN.end`（plan 第 N 幕边界）| `sN.start+0.5` `sN.end-0.3` |
`kw:一百块`（该关键词被念出的绝对时刻，可 `kw:一百块#2` 取第二次）| `w:银行`（timing.json 里首个匹配词的 start）

### 动作动词（actions）
| verb | 字段 | 语义 |
|---|---|---|
| walk_to / run_to | actor, to(地名或x), t0, t1 | 平移+步态循环，自动转身朝向；run 周期更快+前倾 |
| place | actor, to, at | 瞬移（镜头切换用） |
| pose | actor, preset, at | rig_spec 姿势预设，带 0.2s 预备 + 回弹 |
| emote | actor, face, at | 表情切换 calm/sad/angry/surprised |
| bounce | actor, at, times?, height? | 原地小跳 |
| appear / vanish | actor, at | 0.3s 淡入/淡出 |
| give | item, from, to_actor, at, dur? | 物品从甲手抛物线到乙手，carrier 转移；双方手臂抬起 |
| emerge | item, from_prop, to_actor, t0, t1 | 物品从道具处弧线飞到角色手上 |
| hide_item | item, actor, at | 物品缩没（藏起来），actor 同时下蹲更自然（导演自行加 pose） |
| hops | item, from, to, t0, t1 | 物品沿地面连续弹跳平移（钱自己跑） |
| orbit | item, around_prop, t0, t1, loops? | 物品绕道具转圈，结束后悬停 |
| show / hide | prop, at | 道具淡入/淡出 |
| grow | prop, t0, t1, from?, to? | 道具缩放（带 back.out 弹性） |
| shop_close / shop_open | prop, at | 店铺歪斜+招牌耷拉 / 复原 |
| wave | actor, at | 招手 0.9s（臂举至 -140° 摆 3 次） |
| point | actor, target, at | 抬臂指向目标方向，保持 0.8s 回落 |
| nod / shake_head | actor, at | 点头 ±12°×2 / 快速摇头 ±10°×3 |
| jump | actor, at, height? | 跳起（默认 160）带压缩拉伸，落地自动 dust |
| spin | actor, at | 原地转一圈（庆祝） |
| look | actor, target, at | 头转向目标方向 |
| fx | kind, at, x\|actor\|prop, n?, dur? | 手动触发特效（kind 见特效系统） |

### camera（每 scene 一个，支持镜内硬切）
`{"mode":"fixed","x":<地名或x>}` 或 `{"mode":"follow","target":<actor|item>}`，搭配景别：

| shot | zoom | cam_y | 用途 |
|---|---|---|---|
| `closeup` 近景 | 1.9 | -100 | 单人头肩，配 target 居中该角色 |
| `medium` 中景 | 1.0 | 0 | 完整展现一个小人（默认） |
| `wide` 远景 | 0.5 | 0 | 多个小人群像 |

写 `"shot":"closeup"` 自动取表中 zoom/cam_y，显式 `zoom`/`cam_y` 可覆盖。
**镜内硬切**：scene 里可加 `"cuts": [{"at": <时间引用>, "camera": {...}}]`——分镜感的关键，
近景看动作、切中景看反应。cut 是硬切（无过渡）；scene 之间仍 0.8s 缓动。

## 2. resolve.py 的输出（resolved.json，渲染器唯一输入）

把 verbs 展开为每实体的**轨道**，渲染器零语义、纯插值：

```json
{
  "meta": {"audio_duration": 36.0, "ground_y": 200},
  "actors": {"xiaoming": {
     "x0": -1400, "face0": "calm",
     "moves":  [{"t0":4.0,"t1":5.6,"from":-1400,"to":-550,"gait":"walk"}],
     "places": [{"t":28.45,"x":2700}],
     "poses":  [{"t":5.9,"preset":"cheer"}],
     "faces":  [{"t":20.0,"face":"sad"}],
     "bounces":[{"t":6.5,"times":2,"height":60}],
     "gestures":[{"t":1.0,"name":"wave","dur":0.9},{"t":8.2,"name":"point","dir":1},
                 {"t":12.0,"name":"jump","height":160}],
     "vis":    [{"t":24.4,"on":true}] }},
  "items": {"coin": { "segments": [
     {"mode":"carried","t0":0,"t1":5.9,"carrier":"xiaoming"},
     {"mode":"arc","t0":5.9,"t1":6.5,"from_carrier":"xiaoming","to_carrier":"baker"},
     {"mode":"orbit","t0":28.9,"t1":31.0,"center_x":2900,"radius":420,"loops":2},
     {"mode":"hops","t0":32.2,"t1":35.0,"from":3000,"to":4200} ] }},
  "props": [{"id":"shop_bakery","type":"shop","label":"面包店","x":-550,"hidden":false,
             "events":[{"t":21.0,"kind":"close"}]}],
  "camera": [{"t0":0.1,"t1":3.4,"mode":"fixed","x":-1400,"zoom":0.9,"cam_y":0},
             {"t0":3.96,"t1":5.7,"mode":"follow","target":"xiaoming","zoom":0.9,"cam_y":0},
             {"t0":5.7,"t1":6.96,"mode":"follow","target":"baker","zoom":1.9,"cam_y":-100,"hard_cut":true}],
  "fx": [{"t":6.46,"kind":"sparkle","x":-620,"y":-60},
         {"t":17.8,"kind":"confetti","x":-100,"n":24}]
}
```

### 特效系统（kinds，两后端同套，风格=简笔粗线）
| kind | 形态 | 自动触发（resolve.py 生成，scene 加 `"no_auto_fx":true` 可关） |
|---|---|---|
| burst | 8~12 根放射短线+小星 0.5s | 关键词弹字背后（渲染器内置） |
| speed_lines | 身后 3~4 根水平拖线 | run 段全程 |
| dust | 地面尘团 2~4 朵散开 0.4s | jump/bounce 落地、走跑停步、shop_close、hops 触地 |
| sparkle | 3~5 颗四角星闪 0.6s | give/emerge 接住瞬间（物品处） |
| confetti | 20~30 片三色纸屑上抛后物理下落 ~2s | 手动（庆祝场面用） |
| money_rain | 8~12 枚小 ¥ 从画顶飘落 2s | 手动 |
| shockwave | 扩散圆环 0.5s | grow 结束、show 大道具 |
| emphasis | 头顶弹出 ! ? ♪ 💧 简笔符号 0.8s | 手动（glyph 参数） |
| trail | 物品飞行拖尾（红色渐隐光带，3~5 段残影） | item 的 arc / hops 段全程 |
| flash | 全屏白闪 0.12s（透明度 0→0.55→0） | 每个 hard_cut 镜头切换点 |
| shake | 镜头震动（amp 像素，0.35s 衰减正弦） | shop_close；可手动（amp/dur 参数） |
| ambient | 全片环境微粒（8~12 颗浅色小圆极慢漂浮，opacity≤0.06——再高会读成黑点） | 自动整片一条 |
| light_rays | 从上方斜射 3~4 道光带（accent 色 16% 透明度），1.2s 展开保持 | 手动（高光时刻：银行放贷、蛋糕揭幕） |
| glow_pulse | 道具高光呼吸（柔光环 2 次脉冲） | 手动 |
HTML 实现可用 GSAP 免费插件（assets/ 已备：physics2d/customease/customwiggle/custombounce/drawsvg）；
AE 用 Python 预采样关键帧实现同等效果。

### 视觉风格 v4：精致扁平（kurzgesagt 系，**手绘抖动风已废弃**）
- **禁止**：feDisplacementMap 抖线、feTurbulence 纸纹、Roughen Edges——线条必须干净锐利。
- **渐变天空背景**：sky_top → sky_bottom 垂直渐变（调色板见 assets/props.json 的 palette_v4），
  取代纯色纸底；地面线下方填 shade 色 6% 区分地表。
- **线宽层级**：外轮廓粗（14~16）、内部细节线细（5~8）——细节感的来源；道具按 props.json
  新几何渲染（双色填充 white/shade、窗棂、条纹遮阳棚、柱头、内沿线）。
- **角色细节**：肢体描边保持 18 圆头；手=臂末端小实心圆（r9）、脚=触地短横线（长 26）；
  表情升级：眉毛（随情绪角度变化）、angry 加皱眉斜线、sad 加泪滴；脖颈短线。
- **景深（视差）**：prop 支持 `"parallax": p` 字段（skyline 0.55、cloud 0.7），**精确公式**（两端必须一致）：
  `screen_x = (world_x - cam_x) * p * K * zoom + 960`——即世界偏移整体按 p 压缩，
  道具锚定在自己的 world 位置附近、随相机以 p 倍速度滑动（相机推进时背景"跟着走但走得慢"）。
  ⚠️ 不要实现成 `(world_x - cam_x*p)`：那会让远处道具在相机未到时就被甩出画面。
  cloud 类型另带 ±60 单位 12s 周期自漂移（相位按 id 哈希错开）；动作平面恒 p=1。
- **软阴影**：角色/道具脚下椭圆（opacity 0.08，跳跃时缩小）保留；vignette 极轻（≤5%）保留。
- **字幕（去框）**：无边框无底卡——直接文字，ink 色 + 白色柔光晕（多重 text-shadow / AE 用
  Glow 或白描边副本），字号 46~50；关键词弹字保留白描边 halo + burst。
- **字体**：HTML=LXGW WenKai 子集内嵌；AE=KaiTi。
- **运动模糊**：AE 保留 motionBlur；HTML 不需要。

### 分镜法则 v4（镜头语言）
- **轨道无缝**：resolve.py 保证 camera 段覆盖 [0,片尾] 无空隙（空隙曾导致镜头兜底跳到末段位置）。
  渲染器在段起点 0.8s 内从上段末状态缓动进入（smoothstep），hard_cut 段零过渡 + 自动白闪。
- **跟随前瞻**：follow 镜头加 look-ahead——cam_x 偏向目标移动方向 `(target_x(t+0.3)-target_x(t))*0.8`，
  clamp ±150 世界单位，再做 0.3s 平滑（拍跟拍要"领半步"）。
- **近景三分构图**：closeup 时目标不居中——朝目标面向的反方向偏 10% 视宽（人物看向画面留白侧）。
- **插入镜头**：camera target 允许是 item（如 follow coin + closeup = 钱币特写插入镜头）。
- **节奏**：一个 scene 内镜头最多切 2 次；广角(信息量大)至少停 3s；闪白只配硬切。

### 渲染公式（两个后端必须一致）
- 步态 v2：walk 周期 0.4s 髋 ±22°、膝跟随、手臂反相位 ∓25°、**竖直 bob ±8（2×步频）、前倾 3°**；
  run 周期 0.25s、前倾 8°、幅度 ×1.2。移动中持续循环；**停步用 0.25s 混合回目标姿势**，
  头部跟随过冲（停止时头先前倾 4° 再回正）。
- give 自动臂动作：交接瞬间给方手臂朝目标抬 35°、收方抬 25° 接住，0.3s 回落（渲染器从 arc 段推断，无需分镜声明）。
- 朝向：dx<0 时整个 rig 水平镜像（HTML scaleX(-1)；AE Scale x=-100）。
- carried 物品位置：carrier_x + 朝向×70，y = -40（手附近）；跟随 carrier 实时 x。
- arc：起终点为两 carrier 手位，抛物线峰值高出连线 250；dur 默认 0.6s。
- hops：单跳 0.45s、高 120，水平匀速。
- orbit：圆心 y=-350，loops 圈后停在圆顶。
- 预备/回弹：pose 在 t-0.2 反向 8°，t+0.35 过冲 10%，t+0.6 到位。
- 呼吸（所有可见角色常开）：身体 scaleY 1±0.012 每 2s；头 ±2°。
- 镜头（统一映射，x/y 同式，所有实体——角色/道具/物品——必须走同一个函数，否则纵向会错位）：
  ```
  screen_x = (world_x - cam_x(t)) * K * zoom(t) + 960
  screen_y = world_y * K * zoom(t) + (800 - 180 * zoom(t))   // 世界地面 y=200 恒映射到屏幕 y=800
  ```
  实体自身几何同时乘 zoom（整体相似变换）。AE 等价实现：所有世界实体 parent 到 CamCtrl
  （anchor [0,0]），子层 parent 空间位置 = world*K、自身 Scale=100，CamCtrl 打关键帧：
  Scale=[zoom*100, zoom*100]，Position=[960 - zoom*K*cam_x, 800 - 180*zoom]。
  角色翻面用角色根层 Scale x=±100，与 CamCtrl 缩放相乘不冲突。字幕/关键词弹字在屏幕空间，不进 CamCtrl。

## 3. 出片命令

```
py -3.14 scripts/resolve.py <storyboard.json> <plan.json> <timing.json> <resolved.json>
py -3.14 scripts/build_html2.py <resolved.json> <plan.json> <rig_spec.json> <props.json> <voice.mp3> <out.html>
node scripts/render_mp4.mjs <out.html> <timing.json> <voice.mp3> <out.mp4>
py -3.14 scripts/build_ae2.py <resolved.json> <plan.json> <rig_spec.json> <props.json> <voice.mp3绝对路径> <out.jsx>
```
