paper-theatre

Community@xinsong854/paper-theatre
With improvements from @baize
Community skill — not reviewed. It was shared by 安诺涅 and has not been checked by Neta. Read SKILL.md below before letting an agent use it.
Use it in Cohub

Paste this into your agent's chat — it fetches the skill and follows it.

Use the skill: paper-theatre. Run `npx -y @neta-art/neta-search-cli@latest skills use @xinsong854/paper-theatre --via site` and follow what it prints. Read its complete output, redirecting it to a temporary file first if necessary, and resolve relative paths from the skill directory it prints.
Install from a terminal
npx -y @neta-art/neta-search-cli@latest skills use @xinsong854/paper-theatre
Summary

Turn one flat illustration into a pop-up book stage where paper standees act out your script on their own.

  • Your picture is split into depth layers you can look around by dragging or tilting: sky, buildings, a book or platform, characters, and a front curtain.
  • Character standees look like layered cut paper in a chibi style, with a white paper outline, and keep your character designs.
  • Characters walk in from behind the side curtains, speak a line of dialogue, and walk off again, scene by scene.
  • Tap once to start, and the whole show plays itself. Dialogue stays up long enough to read, and a tap closes it early.
  • Works best with a finished illustration that has a clear stage-like setting, plus character reference pictures and a short script.
Improvements from other creators

This version: thanks to @baize.

  • baize@baize1 adopted suggestion · in this version
SKILL.md
# 立体书剧场 — 深度视差舞台 + 分幕演出

把一张平面插画拆成多个深度平面,用 Three.js 做成可视差环视的立体舞台;
再给舞台一套剧本——立牌从幕布后登场、走到固定站位、弹对白、读完退场,
点一下开演,整场自己走完。

不挖空、不局部重绘原图。每一层由生图模型从零生成一张**完整画面**,
叠起来在任何视差角度都不露破面。

**要找可复用的风格 prompt?** 角色立牌那段(两张参考图分工 + 已验证的纸雕 Q 版工艺原话)在 §1b;场景层的规则是「一个字都不写画风」,在 §1。风格锚图在 [papercraft-chibi-style-ref.png](assets/papercraft-chibi-style-ref.png)(init-project 会复制成项目里的 `raw/style-ref.png`)。

---

## ⚠ 上下文经济原则

这套流程涉及大量素材文件和命令行操作。**逐个文件读写、逐条命令执行会把上下文撑爆。**

铁律:**能用脚本一次做完的,绝不分步对话。**

| 操作 | ✗ 逐步对话(爆上下文) | ✓ 一条命令 |
| --- | --- | --- |
| 创建项目骨架 | 逐个 mkdir + cp | `bash tools/init-project.sh <slug> <图> <标题>` |
| 生成所有 plate | 逐个手写 neta generate | 写一次 plates.json → `python3 tools/generate-plates.py plates.json` |
| 抠图 | 逐个 keyout.py | `for f in layers/L*.png; do python3 tools/keyout.py $f; done` |
| 站位规划 | 手算坐标 + 心算重叠 | `python3 tools/plan-blocking.py` |
| 剧情实测 | 手点五幕量坐标 | `node tools/qa-scenes.js` 一条全测 |
| 截图验收 | 逐个 headless Chrome | `bash tools/qa-shot.sh <slug>` 一拍 4 张 |
| 发布 | 分步 publish + cp + curl | `bash tools/publish.sh <slug> <标题>` 一条发布(公开直链另加 `--public`) |

**读文件也要克制。** 只读当前决策需要的那几行。SKILL.md 已记录引擎契约,不需要每次重读源码。

---

## 涉及的技能

| 阶段 | 技能 | 作用 |
| --- | --- | --- |
| 生图 | `generate-image` | 生成每一层的完整 plate |
| 抠图 | `background-removal` 或本技能自带 `tools/keyout.py` | 洋红色键抠透明通道 |
| 网页 | `create-web-work` | 创建作品目录、发布 |
| 验收 | `game-visual-qa` | 截图核验视差与接缝 |
| 收尾 | `next-steps` | 交付后提供后续方向 |

---

## 流程总览

```
读图定层 → 生成场景层 plate → 生成角色立牌(双 ref)→ 抠图
   → 底座切层 → LAYOUT 构建 → 站位规划 → 写剧本 SCENES
   → 位移审计 → 视觉 QA → 剧情 QA → 发布
```

---

## 流程

### 0. 读图,定层

看用户上传的图。不读世界观——spec 只来自图片本身。

确定深度平面拆分。通用深度梯(两代项目验证):

| depth | 角色 | 典型内容 |
| --- | --- | --- |
| 0.00 | 背景 | 天空、远景、纸城市 |
| 0.16–0.30 | 中景 | 建筑群、拱廊、树丛(可拆远近两层) |
| 0.46 | 底座-远面 | 书页表面、台座顶面、地面 |
| 0.56–0.62 | 人物 | 立牌、角色卡、standee |
| 0.66 | 底座-近唇 | 书口页块、台座前缘、台阶前唇 |
| 0.70–0.84 | 悬浮件 | 纸鹤、灯笼、飞鸟、落叶 |
| 0.90–1.00 | 前景框 | **幕布、台口**、屏风、前景植被 |

底座(书/台/桌/地面)只要有体积就**拆两层**:远面和近唇。人物夹在两层之间,视差一动就拉开真实凹陷。

**要演出就必须有幕布层**(depth 0.90+,rect 覆盖左右两侧不透明区)。人物进出都走幕布背后——「从侧幕走出」永远比「原地淡入」更像剧场。

### 1. 生成场景层

> **⚠ 铁律:每一层生图必须以用户上传的原图作为参考图(`--image`)。**
> 不许描述一遍图片内容去「凭空生图」——那样每一层的画风、笔触、色温、纸张质感都会漂移,叠在一起就是一堆拼贴而不是一本书。
> 正确做法:把用户原图缩成 `raw/ref.webp`,每一层 plate 生成时都传 `--image raw/ref.webp`。
> prompt 里写的是这一层要画什么内容,不写画风——画风由参考图锁定。

写一份 `plates.json`(每层一项),一条命令批量生成:

```json
[
  {
    "name": "L1-midset-far",
    "prompt": "车站建筑、钟楼、铁轨与列车,只画这一层……。中间和下方完全空白,没有任何舞台、没有人物、没有书。",
    "aspect": "16:9", "keyed": true
  }
]
```

```bash
python3 tools/generate-plates.py plates.json   # 默认同时生成 4 层;--jobs 1 逐层
```

脚本自动:拼 prompt(keyed 层追加洋红底句)→ 逐层 `--image raw/ref.webp` → 解析下载 URL → 存 `raw/{name}.png` → 汇总成功/失败。层配置里写 `"ref": ["…", "…"]` 可覆盖默认参考图(立牌用,见 §1b)。

**为什么用洋红做色键:** 白色键不安全——纸面、肤色、亮色服装大量接近白色,会连主体一起抠掉。洋红在自然插画里几乎不出现,抠得干净。

**关键约束:**
- **`--image raw/ref.webp` 是必传参数,不可省略**(脚本已内置)
- **只传模型声明过的参数**:脚本启动时读一次 `neta generate image params`,`"aspect"` / `"size"` 只在模型声明了 `aspect_ratio` / `image_size` 时才传。固定 1K 输出的模型(如 gemini-3.1-flash-lite-image)不收 `image_size`,硬传会整条被拒
- 参考图必须是**工作区里的路径**(neta 拒收 `/tmp` 等工作区外的文件);脚本会把工作区外的 ref 先复制进 `raw/`
- 必须 **sync 模式**(不加 `--async`)
- 背景层不抠图(`"keyed": false`,脚本会同时放一份到 `layers/`),直接用 JPEG;其余层用 WebP 带 alpha
- 每层只画自己那一层的内容,明确排除其他层
- **幕布层的 prompt 要写清「围成中空的台口边框,中央完全空白」**——人物要从它背后走过

### 1b. 角色立牌:两张参考图分工

场景层靠一张原图锁画风就够了,**角色立牌不行**——角色的长相要照角色设定图,工艺质感要照纸雕风格。一张参考图同时承担两件事,模型会把两者混掉:要么长相被风格图带跑,要么质感退回扁平动漫贴纸。

解法是**传两张 ref,并在 prompt 里明确写出各自的分工与禁令**。

**风格锚(长期位置,跨项目复用)**:`assets/papercraft-chibi-style-ref.png`(本技能自带。init-project 会把它原样复制成项目里的 `raw/style-ref.png`——neta 只收工作区里的图片,技能目录不一定在工作区里,比如用 `skills use` 试用时在 `/tmp`)

这张是已验证的工艺样张:Q 版三头身、卡纸分层、外缘一圈白色衬纸描边、洋红底。**新项目直接用它当第二张 ref,不要重新生成风格锚**——重生一张,八个立牌之间的纸张厚度和描边宽度就对不齐了。

plates.json 里这样写:

```json
{
  "name": "L4-standee-1",
  "ref": ["raw/cast/hero.webp", "raw/style-ref.png"],
  "aspect": "3:4", "keyed": true,
  "prompt": "<下面这段原话,一字不改>"
}
```

**立牌 prompt 原话**(所有立牌共用这一段,只换第一张 ref):

> 我给了两张参考图,分工不同,请严格分开使用。
> 第一张=外观唯一依据:发色、发型、发饰、瞳色、五官、帽子、上衣、外套、内搭、裙子或裤子、腰带、鞋,以及全部配色,都严格照第一张那个人,不要改动、不要重新设计、不要换配色、不要换服装款式。
> 第二张=比例与工艺唯一依据:照第二张那种 Q 版 chibi 三头身比例(头很大、身体四肢短圆),以及那种**纸雕工艺质感**——每一块衣料、头发、皮肤都是单独裁下来的一层卡纸,层与层之间有真实的纸张厚度和薄薄的投影,纸面带哑光纤维颗粒,边缘是刀裁出来的干净硬边,外缘一圈白色衬纸描边。不要照抄第二张的长相、发色、服装。
> 要点:不要扁平的动漫贴纸感,不要塑料光泽,不要渐变柔化——要看得出是一层层卡纸叠出来的立体纸雕小人。
> 全身完整入画:从头顶到鞋底全部画出来,双脚和鞋子必须完整可见、不要被画面下边缘裁掉,脚下留出一点空白。站在画面正中,正面朝前,自然站姿。
> 只画这一个角色:不要背景、不要地面、不要影子投在地上、不要底座、不要道具堆、不要其他人物、不要文字。

**每一句都在挡一个具体的坏结果,别删:**

| 句子 | 不写会怎样 |
| --- | --- |
| 「分工不同,请严格分开使用」 | 模型把两张图平均混合,长相和风格各丢一半 |
| 「不要照抄第二张的长相、发色、服装」 | 出来八个立牌都长得像风格锚那个金发女孩 |
| 「不要扁平的动漫贴纸感,不要塑料光泽」 | 退回普通 Q 版插画,叠进舞台后和纸雕场景明显不是一套 |
| 「双脚和鞋子必须完整可见、不要被下边缘裁掉」 | 脚被裁掉,立牌立在书页上就是悬空断肢 |
| 「不要影子投在地上、不要底座」 | 抠图后带一坨黑影或纸座,落在书页上是脏的 |
| 「站在画面正中,正面朝前」 | 各种角度朝向不一,一排站开像没排练过 |
| 洋红底(`keyed: true`) | 白底会连纸面、肤色、亮色衣服一起抠掉 |

**没有角色设定图时**,退化成单 ref(只传风格锚),把外观写进 prompt 描述,开头一句改成「照参考图那种 Q 版 chibi 三头身比例与纸雕工艺质感」,其余工艺句、全身句、只画一个角色句原样保留。

### 2. 抠图

```bash
# 通用洋红色键抠图(自动批量)
for f in layers/L*.png; do python3 tools/keyout.py $f; done

# 多对象 plate 按列切分(如三个立牌合成一张)
python3 tools/keyout.py slice layers/L6-standees.png layers/ 3 girl
```

`keyout.py` 做的事:洋红色键 → alpha 通道 → 填补内部小洞 → 1px 羽化 → 输出透明 PNG + sidecar JSON(含 `source_size`/`bbox`,build-assets 要用)。

### 3. 测量并切分底座

底座(书/台/桌/悬崖边)有体积就切成两个深度平面。切在哪靠**测量**,不靠猜。

```bash
# 第一步:探亮度,找材质分界
python3 tools/split-plane.py probe-luminance layers/L3-book.png --x 0.15

# 输出逐行亮度 + r-b,找到亮度断崖 → 材质分界(如 y≈0.615)

# 第二步:切分(近面先淡入,远面后淡出)
python3 tools/split-plane.py split layers/L3-book.png \
    --near-name L3-book-edge --far-name L3-book-page \
    --near-band 0.583 0.636 --far-band 0.663 0.717
```

**四个铁律:**

1. **两片都按父画布原尺寸输出。** 尺寸一致 → 宽高比一致 → LAYOUT 里共用同一个 rect → 静止时逐像素对齐,只在视差下错动。
2. **近面先淡入,远面后淡出。** `near-band` 到达完全不透明早于 `far-band` 开始淡出——被挪开的近面底下露出的是全 alpha 的远面,**永远不露背景**。
3. **`foldDepth` 让刚体同步折起。** 折起动画按 `depth × STAGGER_MS` 错峰,但对切成的两片是错的。manifest 里给两片共用一个 `foldDepth`,真实 `depth` 继续管视差和绘制顺序。
4. **人物夹在两层之间。** 底座远面 depth < 人物 depth < 底座近唇 depth。

### 4. 编排布局

`tools/build-assets.py` 里的 `LAYOUT` 表是**唯一**的布局来源(模板已含演示 LAYOUT,按项目改):

```python
# (name, source, depth, width_fraction, centre_x, anchor, max_width_px)
LAYOUT = [
    ("backdrop",  LAYERS/"L0-backdrop.png",  0.00, 1.000, 0.500, ("centre", 0.500), 960),
    ("book-page", LAYERS/"L3-book-page.png", 0.46, 0.800, 0.500, ("top", 0.620),   1600),
    ("standee-1", LAYERS/"L4-standee-1.png", 0.56, 0.100, 0.380, ("bottom", 0.860), 520),
    # ...
]
```

```bash
python3 tools/build-assets.py   # 缩放 + 压缩 + 写 manifest.json,典型 30MB → 1.4MB
```

**铁律:**
- 层位置**由构图意图编排**,不由源图 bbox 决定
- **高度永远由分片自身宽高比推导**,绝不拉伸
- 底座远面的 top anchor 要对齐:两个切分片共用同一个 rect
- 中景的底脚要被底座远面盖住——景片是**从底座里长出来**的
- 悬浮件给 `float: true`,不参与折叠动画
- **立牌命名必须是 `standee-N`**(scene.js 据此登记进 standeeByName / draggables,剧情系统才能驱动它)

### 5. 站位规划(要演出时)

**不要手写站位坐标。** `tools/plan-blocking.py` 按立牌实测宽度 + 槽位表生成 `js/positions.js`,并核验:槽位两两不压、外缘不超竖屏保底(±4.48)、剧情落点不越界。

改脚本顶部的三张表,跑:

```bash
python3 tools/plan-blocking.py
```

```python
NAME = {'standee-1': '甲', ...}          # 立牌 → 角色名
SLOTS = [                                 # 槽位表:从左到右
    ('甲位', [JIA]),                      # 同一槽里的人互不同台,共用坐标
    ('锚位', [YI]),                       # 锚:全程不动的人
    ...
]
ANCHOR = YI; ANCHOR_X = -0.62             # 锚定一个不动的人,向两侧展开
CASTS = [...]                             # 每段同台名单(核验用)
extras = {'plot_point': ...}              # 剧情落点(追、扑、探头)
```

**站位铁律(四版演化,记在 plan-blocking.py 顶部):**

- **一人一个固定站位,全程不变。只有剧情点到他动,他才动。**
- 每段按剧情重排队列的写法,一半位移是排队算法挤出来的——诺南插队,艾薇让位,而对白里艾薇什么也没做。那种位移在画面上就是「人莫名其妙地平移」。
- 站位是「槽」:互不同台的人共用一个槽(瑞瑞依走了赫柏才来),六个槽装得下八个人
- 有人去别的地方 → 滑到幕布后(extras 里的 `curtain_l/r`),不在原地淡出

产物:`js/positions.js`(home 站位 + extras 落点)+ 终端核验表。改了立牌素材就重跑,坐标自动跟着新宽度重算。

### 6. 写剧本:SCENES 数组

剧本写在 `js/input.js` 的 `const SCENES = [...]`,每段一幕:

```js
{
  moveDur: DUR + 500,       // 本幕登场动作总时长(move 跑完才弹字)
  move(now) {
    enterFrom('standee-3', 'L', now, DUR);        // 从左幕后登场 → 走到固定站位
    enterFrom('standee-4', 'R', now + 400, DUR);  // 错峰:里侧的先进,外侧的后进
  },
  text: '本幕对白。点到名字,位移才有剧情依据。',
  outroDur: DUR + 400,      // afterClose 里退场动作总时长(走完才接下一段)
  afterClose(now) {         // 读完结尾后执行:该走的人走掉
    exitTo('standee-4', 'R', now, DUR);           // 走进右幕后(不淡出)
    slideAt('standee-1', homeX('standee-1'), now, DUR);  // 滑回原位
  },
}
```

**演出动词(4 个,覆盖全部场面调度):**

| 动词 | 语义 | 备注 |
| --- | --- | --- |
| `enterFrom(name, 'L'/'R', startAt, dur)` | 从幕后走上台 | 落到幕外再滑到站位,全程 opacity=1——人是「走出来」的,不是空气里显影 |
| `exitTo(name, 'L'/'R', startAt, dur)` | 走进幕后离场 | 一路滑到幕后坐标**不淡出**;落地后才关 visible。早先在滑程 55% 淡出,人还在画面正中就没了 |
| `slideAt(name, toX, startAt, dur)` | 台上滑移 | toX 来自 positions.js(home 或 extras) |
| `rotatePivot(name, rad, startAt, dur)` | 以脚为轴倾倒/探头 | rad 为负头向右倒 |

**调度规则:**
- **登场放 `move()`,退场放 `afterClose()`。** 先让人读到「诺南被带走」,再看他走进侧幕——反过来的话字还没弹,人已经没了
- **同侧登场要错峰。** 里侧的先进,外侧的后进,否则先到的会挡在幕口
- **移动中横穿别人身旁是真动作**(renderOrder 定前后),只记录不判错;**站定时重叠才是 bug**(qa-scenes 判据)
- **开场台上的人**(hidden 剧情角色除外)不写 enterFrom——她们本来就在台上
- **剧情需要才动。** 对白没点到的人,一步都不要动

**节奏常量(input.js 顶部):** `DUR=2200`(移动)、`FADE=700`、`READ_CPS=8.5`(每秒读几个字,读字时间按对白长度算——49 字和 104 字给同样时间,短的干等长的读不完)、`BEAT=500`(段间留白)。

**自动播放:** 点一下「开演」,之后整场自己走完:移动 → 弹字 → 按字数自动收字 → 退场 → 留白 → 下一段。点击画面可提前收字。`S.autoPlay = false` 时退化为手动逐段推进(qa-scenes 逐段量站位时用)。

**hidden 角色(剧情中段才登场):** manifest 里给层加 `"hidden": true`。scene.js 初始 `opacity=0` 且 `mesh.visible=false`(不进 draggables,不拦截点击);登场走 enterFrom(从幕后走出,不是空气淡入);「从头重看」时由 open.js 复位。

### 7. 位移审计

```bash
python3 tools/audit-motion.py
```

对每一次位移逐条判:**这个人在本段对白里被点到了吗?位移幅度看得出来吗(≥0.25 世界单位)?** enterFrom/exitTo 算「登场/离场」,本身就是剧情事件。

抓的正是 v3 排队算法那种「qa-scenes 全绿但一半位移无剧情依据」的假绿。对白里未必直呼其名的(如「镇民先撤离」),在脚本顶部 `ALIAS` 表里加别名。

### 8. 验收

**视觉 QA(接缝/视差/折叠)** — 不靠肉眼看缩放图,先 numpy 数亮像素:

```bash
bash .agents/skills/paper-theatre/tools/qa-shot.sh <slug>   # 4 张:静止/两对角/折叠中途
node tools/qa-scenes.js                                     # 剧情逐段实测(缺 puppeteer 时:npm i --prefix /tmp/paper-theatre-qa puppeteer-core)
python3 tools/audit-motion.py                               # 位移剧情依据
```

**剧情 QA(qa-scenes.js)在浏览器里量真实 mesh 世界坐标:**

推进用 `S.advanceScene() / S.onDialogClose()` 直接调——真实点击会被 input.js 的门禁挡掉(打到立牌上 downHitStandee=true),早先就是这么静默卡在第二幕的。逐段采样:登场中(移动,只记录交错)→ 对白弹出时(move 跑完,**站定重叠=失败**)→ 退场后(站定)。

**退场判据「走出画框才允许消失」:** 谁 visible=false 了就查他当时的 x。阈值不能拍一个数——每层深度的画框半宽不同、自身宽度也不同,逐层算 `edge = 8×comp + 自身半宽`(实测最严 6.555,早先统一取 6.4 会放走赫柏)。且必须区分「还没上场」(隐身等在站位上)和「半路消失」——只查曾经真的站上台过的人。

**headless Chrome 截图参数**(软件 GL 慢,预算给足):

```bash
google-chrome --headless=new --no-sandbox --disable-dev-shm-usage \
  --disable-gpu --use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader \
  --hide-scrollbars --window-size=1440,810 --virtual-time-budget=24000 \
  --screenshot=/tmp/qa.png "http://127.0.0.1:PORT/index.html"
```

验收清单:
- [ ] 居中静止态:层叠正确,无穿帮
- [ ] 2× 景深极端视差(两个对角):接缝不裂
- [ ] 折叠中途(fold=0.10–0.15):刚体切层的接缝不裂
- [ ] 窄屏后退:关键内容可见,暗角不糊
- [ ] `prefers-reduced-motion`:折叠跳过,过渡 delay 归零
- [ ] 五段全播完:sceneStep === SCENES.length
- [ ] 站定时无重叠;退场全部走出画框才消失
- [ ] 每次位移都有剧情依据(audit-motion 全绿)

### 9. 发布

```bash
bash .agents/skills/paper-theatre/tools/publish.sh <slug> <标题>
```

一条命令:同步 dist/ → `neta work publish`(登记成 Studio 作品,每次交付都做)。作品目录在 init-project 时已由 `neta work init` 登记好,不要手写 `meta.json`。世界没有 slug 时 publish 会报 `world_slug_required`,先 `neta world slug set <slug>`。

**公开直链是可选的**:`/public` 里的东西任何人都能打开。用户明确同意对外公开后,才加 `--public`:

```bash
bash .agents/skills/paper-theatre/tools/publish.sh <slug> <标题> --public
```

它会再 cp 到 `/public/works/{slug}/`(public.cohub.run 直链)并 curl 验证。`$PUBLIC_URL_PREFIX` 从环境变量读,不要硬编码。**注意 `/public` 和 `/workspace/public` 是两个不同目录**,发布源是 `/public`。

---

## 文件结构

```
works/webs/{slug}/      # 老世界是 works/web/{slug}/;以 init-project 打印的目录为准
├── index.html          # 运行时入口(init-project.sh 替换标题/副标题)
├── css/stage.css       # 运行时样式(含对白卡/开演提示)
├── js/                 # 运行时引擎(7 个 JS 模块)
│   ├── config.js       # 常量与特性检测
│   ├── scene.js        # Three.js 场景 + manifest 加载 + 立牌登记
│   ├── camera.js       # 响应式相机策略
│   ├── positions.js    # 站位表(plan-blocking.py 生成,不要手改)
│   ├── input.js        # 输入 + 拖拽立牌 + SCENES 剧本
│   ├── open.js         # 折叠动画 + 剧情复位
│   └── loop.js         # 主循环 + 三个 tween 队列
├── vendor/three.min.js # 本地托管,不走 CDN
├── assets/             # 压缩贴图 + manifest.json(~1.4MB)
├── layers/             # 抠好的全分辨率 plate(中间产物,不上传)
├── raw/                # 生图原始下载 + 参考图(不上传;cast/ 放角色参考图)
├── tools/              # 构建与 QA 脚本(不上传)
├── plates.json         # 层生成配置
├── dist/               # 发布副本
└── meta.json           # neta work init 登记、publish 更新,不要手写
```

`layers/` 与 `raw/` 共约 60MB,只用于重建,不随发布上传。

---

## 运行时引擎契约

引擎完全由 `assets/manifest.json` 驱动,不含题材硬编码:

- `manifest.frame` → `[width, height]`,舞台宽高比
- `manifest.layers[]` → 每层的 `name/file/depth/size/rect/float`
- `name === "backdrop"` → 唯一不透明层,`depthWrite: true`,JPEG
- `name` 以 `standee-` 开头 → 登记进 `standeeByName`(剧情驱动)与 `draggables`(拖拽)
- `"hidden": true` → 初始隐身(opacity=0 + mesh.visible=false),登场走 enterFrom
- `float === true` → 淡入 + 上方落下,不参与折叠
- `foldDepth !== undefined` → 折起时序用此值;视差和绘制仍用 `depth`
- `rect` → `[x0, y0, x1, y1]` 归一化,高度由分片宽高比推导

**模块划分(7 个 JS 模块 + 样式,各司其职):**

| 文件 | 职责 |
| --- | --- |
| `config.js` | 常量与特性检测(CAM_Z=30, STAGE_W=16, DEPTH_SPAN=0.44×CAM_Z, OPEN_MS, STAGGER_MS…) |
| `scene.js` | Three.js 场景、manifest 加载、图层构建、standee 登记、透视补偿 |
| `camera.js` | 宽屏 cover / 窄屏保底后退(MIN_VISIBLE_W = STAGE_W×0.56) |
| `positions.js` | 站位表(plan-blocking.py 生成) |
| `input.js` | 输入、拖拽立牌、剧情状态机(SCENES)、对白卡 |
| `open.js` | 折叠 pivot 铰链动画 + floater 浮入 + 剧情复位 |
| `loop.js` | rAF 主循环、相机视差、三个 tween 队列(slides/fades/spins) |
| `stage.css` | 界面 chrome、对白卡、开演提示、grabbing 光标 |

加载顺序(index.html 固定):`three.min.js → config → scene → camera → positions → input → open → loop`。`loop.js` 是唯一持有 rAF 的模块。

**剧情状态机(input.js):** `phase: idle → moving → dialog → (close) → idle`,`busyUntil` 挡住退场未走完时的推进;`runId` 自增作废所有旧定时器(「从头重看」即点即重置);`later()` 包一层 runId 守卫。对白读字时间按字数算:`readMs = clamp(4200, 1500 + 字数/8.5×1000, 15000)`。

**透视深度补偿:** `comp = (CAM_Z - z) / CAM_Z`。静止态按此比例缩放,堆叠匹配平面合成图,视差只在移动时显现。

**相机锚面:** `camera.lookAt(0, 0, DEPTH_SPAN × 0.57)`——锚在**主体平面**(standees/book ≈ 0.57),不在中景平面。锚在中景上,唯一可见的背景层被钉死,整个画面读起来是平的(实测 midset sweep 从 0.00012 提到 0.054,452 倍)。

**无障碍 / 降级:** `prefers-reduced-motion` 跳过折叠(duration 与 delay 一并归零)、视差 0.25;WebGL 不可用时改 loader 文案不进 Three.js(否则 Three.js 静默失败 loader 永转);`<noscript>` 兜底;键盘 focus-visible 描边;4 秒闲置自动 figure-8 巡游;标签页隐藏暂停;favicon 内联 data URI 防 404。

---

## 常见坑

| 坑 | 解法 |
| --- | --- |
| 白色键把主体一起抠掉 | 用洋红 `#FF00FF` 色键 |
| 底座像贴纸没有体积 | 切成远面 + 近唇两个深度平面,人物夹中间 |
| 切层接缝在视差下开裂 | 近面先淡入到不透明,远面后淡出;两片共用 `foldDepth` |
| 折叠中途两片不同相 | `foldDepth` 解耦视差深度与折起时序 |
| 中景站在书前面 | 缩小抬高,底脚被底座远面盖住 |
| 高度被拉伸 | `h_frac = w_frac × frame_aspect / part_aspect`,永远由宽高比推导 |
| 窄屏内容被切 | 相机后退到 `STAGE_W × 0.56`,上限 `stageH × 2.8` |
| reduced-motion 标题延迟出现 | transition 的 duration **与 delay 一并归零** |
| 截图文字有无误判 | 先 numpy 数亮像素,再裁原生分辨率条带 |
| 虚拟时钟验不过渡 | 关 transition + 强制 reveal 的 QA 页,不靠虚拟时间 |
| 画风不一致 / 层间拼贴感 | 每层生图必须传 `--image raw/ref.webp`,不靠 prompt 描述画风 |
| 发布到 /workspace/public | (只在 `--public` 时)发布源是 `/public`,不是 `/workspace/public` |
| WebGL 不可用 | 启动时检测上下文,不可用时改 loader 文案 |
| 闲置时画面完全静止 | 4 秒无交互后自动 figure-8 巡游 |
| **手机上完全拖不动** | 可变标量必须直写 `window.Stage.*`(见「共享状态契约」) |
| **切出来的近片一半在画面外** | 切之前先量内容带再反推 `rect`:`A=np.asarray(im.getchannel('A'))>8; rows=A.mean(axis=1); nz=np.where(rows>0.01)[0]`,两片内容带之间不能有空档,切完 aspect 变了 `h_frac` 必须重算 |
| **生图把不该有的东西烤进图层** | 图层隔离靠后处理兜底:PIL 掩膜裁掉污染段 |
| console 报 favicon 404 | `index.html` 内联 data URI `<link rel="icon">` |
| **人凭空消失** | 退场必须滑到幕后才关 visible,不在画面里淡出 |
| **人莫名其妙平移** | 站位铁律:固定站位 + 只为剧情动;audit-motion 抓无依据位移 |
| **自动收字时间不合适** | 读字时间按字数算(READ_CPS=8.5),不是固定值 |
| **重看后有人隐形/错位** | open.js 的 startOpen 统一复位:tween 清空、回原位、隐身复位、visible 复位 |
| **同侧登场互相挡幕口** | 里侧的先进,外侧的后进(错峰 400–600ms) |
| **隐身角色拦截点击** | hidden 层 `mesh.visible=false` 才不进 raycaster;只 opacity=0 不够 |
| **「从头重看」后旧定时器还在跑** | 全部定时器走 `later()`,runId 自增即全部作废 |
| **设了 hidden 却还想让 TA 开场就在台上** | 开场就在台上的人不设 hidden、不写 enterFrom |
| **点不动/卡幕** | 推进入口 `S.advanceScene()`;真实点击被门禁挡(打到立牌)|
| **对白没点到的人也在动** | 剧情需要才动;写完剧本必跑 audit-motion |
| **q-style anchor 图放错位置** | 风格锚在技能 `assets/` 下,绝对路径引用,不复制进项目 |

---

## 共享状态契约(input.js ↔ loop.js)

`loop.js` 每帧从 `window.Stage` 读交互状态。所以 `input.js` 里的**可变标量**
(`depthScale / dragging / dragBoost / lastInteract`)**必须直接写
`window.Stage.xxx`**,不能存在模块闭包的 `let` 里再靠 `Object.assign` 发布:

```js
// 错:Object.assign 对标量是按值拷贝,此后闭包里的 let 再变,Stage 上的永远是初值
let dragging = false;
Object.assign(window.Stage, { dragging });      // 快照,不是引用
function onDown() { dragging = true; }           // Stage.dragging 永远 false

// 对:直写
window.Stage.dragging = true;
```

只有**对象**(`pointer` / `eased`)能按引用共享。

这个 bug 的表象是「手机上完全拖不动」:`Stage.dragging` 恒为 `false`,闲置巡游每帧把 `pointer` 覆写回 figure-8 的值,手指写进去的立刻被冲掉。桌面端鼠标看起来「能动」,因为动的是巡游而不是你的手。验收要用触屏模拟实测,连采 12 次,spread < 0.01。

---

## 排查手段谱系(由弱到强)

肉眼估像素坐标会自相矛盾。一律上脚本量化:

1. 单层导出 + `getbbox()` —— 这层到底有没有内容、在哪
2. 按 `rect` 子集渲染 —— 只画可疑的几层
3. 累积 composite 逐层 diff —— 哪一层加进去画面才变
4. 排除法渲染 `comp(skip={...})` —— 去掉这层,瑕疵消失吗
5. 逐行 / 逐列 alpha coverage —— 内容带、缝、遮挡率
6. `probe-luminance` —— 找材质分界
7. **topmost-owner 探针** —— 最强:直接问「画面这一点最终由哪层画的」

```python
def owner(xf, yf):            # 按 manifest 顺序合成,最后一个不透明的就是它
    x, y = int(xf*FW), int(yf*FH); top = None
    for name, a in layers:
        if a[y, x, 3] > 128: top = (name, tuple(a[y, x, :3]))
    return top
```

第 7 条能一击定案:书页上一团深色斑块,探针直接答 `('curtain', ...)`——那是幕布自己往内垂的褶,不是 bug。**下结论前先跑探针,别修不存在的问题。**

8. **可见性 / waste 量化矩阵** —— 算每层有多少像素被上层完全遮住

```python
owner = np.full((FH, FW), -1, dtype=int)
for i, (name, a) in enumerate(layers):
    owner[a[:,:,3] > 128] = i      # 后画的覆盖先画的
for i, (name, a) in enumerate(layers):
    opaque = (a[:,:,3] > 128).sum()
    visible = (owner == i).sum()
    print(f"{name:12s}  opaque={opaque:7d}  visible={visible:7d}"
          f"  vis%={visible/opaque*100:.1f}")
```

waste > 95% 的层是死重:白加载白渲染,删掉。

9. **edge energy 分析** —— 检测远层是否含结构化内容(不只是色块)。backdrop 在 midset silhouette 下方 edge energy 显著高于 baseline,说明它把前景也烤了进去,视差一动产生鬼影。

10. **投影 NDC 横扫矩阵** —— 最终判定「背景到底动没动」。视差来自相机移动,测世界坐标永远是零,必须测**投影后的屏幕坐标**。关键判据:**|sweep| 随与 lookAt 锚面的距离增长**。锚面上的层 sweep≈0;离锚面越远的层(无论前后)sweep 越大。如果唯一可见的背景层恰好钉在锚面上,整个画面读起来就是平的——这是背景分层失败的头号原因。

---

## 背景分层铁律

立体书的背景是观众透过拱门/幕布缝隙看到的「远处世界」。五条互相独立但缺一不可:

### 1. backdrop 必须是干净远平面

只提供**色洗**——天空、远景的柔和色调。它会被遮挡 95%+,不需要也不应该有任何可辨识结构。烤入版 backdrop(含幕布、树、教堂、广场)视差一动,重复内容跟真层滑开,产生鬼影。

**修复手段**(无法重生图时):重度高斯模糊(radius ≥ 45px)+ 降饱和 + 略暗,缩到 480-960px 宽。416KB → 19KB,鬼影消失。

### 2. 中间层不能是整幅场景的微缩复制品

每层 plate 只画**该层的东西**。trees 层里还烤着幽灵女孩、第二本书、路牌——它就不是树层,是一张微缩的全图。waste 99.5%+,删掉画面无变化,鬼影风险消失。

### 3. camera anchor 不能钉在唯一可见的中景层上

`camera.lookAt(0, 0, z)` 的 z 定义**零视差面**。唯一可见的背景层恰好在这个 z 上,观众怎么拖背景都不动——整个拱门读起来是贴在玻璃上的画。锚面放**主体平面**(standees/book)。

### 4. 完全不可见的层必须删除

可见性矩阵测出 visible=0 的层,直接从 LAYOUT 删。纯死重,还可能在边缘锯齿处闪出鬼影。

### 5. midset 不可横向切分时,接受单层 + 移锚点

midset 是连续的拱廊/温室,没有干净的垂直间隙可供切分。强行切分会切过建筑。保持单层,靠**移 camera anchor** 让它脱离零视差面。depth=0.28 + anchor=0.57 时 midset sweep 0.054——足够「往后退」,不会滑出拱门。