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——足够「往后退」,不会滑出拱门。
Show more