Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

**Lessons from a narrated film with 3D: one voice across a one-request take, labels sized from the start, text swaps and readouts, glass in Three.js**
- Why: the maintainer asked that the general lessons from a 2-minute narrated canvas + Three.js film go into the docs as short guidance.
- playbook/04: in a one-request gemini take (`--join all`) every line still carries its own style, so one line with a `[direction]` (often the opening) tends to sound different from the rest; for one voice in a single-narrator film, write one short overall style and no per-line directions (tools/audio/README's "整段合成" points there). When lines in one take run audibly fast and slow, an optional way to bring them closer line by line without synthesizing again, not to one exact pace (cuts refined from the waveform, so a first syllable the ASR placed late is not clipped; long pauses inside a line capped; per-line `atempo` part of the way towards one target pace; the take and the timeline written back, aligned again, captions redone), described, not a command yet: the film went from 4.7–6.7 to 5.6–6.1 CJK chars/s per line; the numbers are a reference, the ear decides. When transcription is unavailable (daily quota, API errors): the take is already on disk; `--resume` later, or take word times from a local whisper and align them with tts.py's `align_lines`, mapping digits back to a script that spells years out (一九五三).
- tools/audio/README: a 429 asking for under 90 s (or naming no delay) is retried like the per-minute limit, up to 5 times a call, so a spent daily quota can look hung; the old text said the daily quota always fails at once.
- playbook/03 §4: write readable labels at the floor from the first draft (44 px for `desktop`; one draft had about 40 at 24–40 px). Source, credit and asset lines go into the description unless a licence or the type doc wants them on screen, and then they are labels.
- playbook/02 layer 3: a strip at each text swap in one place (#17's old-out-before-new-in, unchanged; a reviewer found three that the author's own check missed) and at each rolling readout, whose resting values are checked against NOTES.
- Floors, said explicitly (CONTRIBUTING rule 7): TASTE_CHECKLIST #19 names a readout resting on an interpolated value as a fabricated number (the film's reviewer had judged it under #19); #6 and playbook/03 §4 count a shot's one-line headline as a 主标题, so it is held to 84 px (150 for `feed`), not 44.
- engines/README (Three.js): `RoomEnvironment` reflections drew a bright vertical strip down every glass object, and a full-turn `LatheGeometry` seam faces a +z camera by default; turn the seam away and give glass a fresnel rim shell instead of the environment map. The list's lead-in now says most of its pitfalls come from the intro film.

**`bin/vh tts … minimax`: MiniMax narration in one request, timed by its own subtitles**
- Why: a film's narration was made with MiniMax T2A (`speech-2.8-hd`) through a project script. Sending the whole script in one request kept the voice from drifting between blocks, which the maintainer had heard and disliked with block-by-block synthesis, and the request's own subtitles gave every character's time without an ASR pass. This makes that a provider.
- `minimax` posts to `https://api.minimax.io/v1/t2a_v2` (the China host `api.minimaxi.com` rejects international keys with 2049; `MINIMAX_BASE_URL` for a China-platform key). Key from `MINIMAX_TOKEN_PLAN_KEY` (Token Plan, spends its credits) first, then `MINIMAX_API_KEY` (pay-as-you-go); an error names the variable, never the key, with a hint for 1008 (no balance on that billing) and 1004/2049 (wrong host for the key). `MINIMAX_TTS_MODEL` / `_SPEED` / `_VOL` / `_PITCH` are recorded in `_run.json`, so `--resume` refuses a change; speed, volume and pitch are checked against the API's ranges before anything is deleted, and so is the 10,000-character limit of each request. `--instruct` / `[direction]` count only as one of MiniMax's nine emotion words; other text is ignored and that is said once. Default voices: zh `Chinese (Mandarin)_Reliable_Executive`, en `English_expressive_narrator`.
Expand Down
3 changes: 2 additions & 1 deletion engines/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ window.__vh = {
- **等宽字体的坑**:`ui-monospace, monospace` 能通过字体 lint,快照里看起来也是等宽,但 `hyperframes render` 渲染出来却是比例字体(showcase 00 在第一版全片都中了招)。要用 `@font-face` 显式声明,例如 `@font-face{font-family:"LF Mono";src:local("SF Mono"),local("Menlo")}`。**判断字体要看 mp4 渲染出的帧,不要看快照。**`check --snapshots` 只保存对比度检查用的 PNG,不能替代 `snapshot --at`。
- `snapshot` 在 tween 刚开始的那一刻,可能和最终渲染出的帧不一致。关键帧以渲染出的 mp4 为准,逐帧 strip 的做法见 `playbook/02-verification.md`。
- 完整样板:`showcase/02-short-leo-doppler/`(竖屏科普,单个 `index.html`)。
- **HyperFrames 加 Three.js(3D 世界、一镜到底)的坑**,来自介绍片的制作:
- **HyperFrames 加 Three.js(3D 世界、一镜到底)的坑**,多数来自介绍片的制作:
- 每一帧都用 `renderAt(t)` 从 t 算出摄像机和所有物体的状态。摄像机路线用按 t 参数化的样条,不用 Three.js 的动画时钟。关卡处可以用 stop 关键帧停站,但要叠一层低幅的手持漂移,镜头不要完全停死(原因见 `playbook/08-vfx-and-motion-sources.md` 的"一镜到底"一节)。
- `VideoTexture` 必须**每帧**设置 `texture.needsUpdate = true`,否则渲染出的屏幕是全黑的。视频按 t 去 seek,而且要等 seek 完成。
- `__hf.buildReady` 要在普通 `<script>`(不是 module)里**同步注册**,否则每个并行 worker 的开头都会出现空帧。
Expand All @@ -161,6 +161,7 @@ window.__vh = {
- **把 3D 点投影到屏幕前先 `camera.updateMatrixWorld()`**:同一帧里刚 `lookAt` 过,矩阵还没更新,钉在 3D 点上的标注会错位。
- **`EdgesGeometry(geo, 阈值角)` 只画相邻面夹角大于阈值的边**:20 段的圆柱相邻面差 18°,阈值设 20° 就一条侧边线都画不出来。线框画法的圆柱少分几段(12 段是 30°),或者把阈值调低。
- **物体的位置在建的时候就设好**:只在每帧的动画函数里设 `position` 的话,不参与动画的零件永远停在原点(盒子以中心为原点,会半截埋进地面)。
- **玻璃器皿上一道从上到下的亮条**:用 `RoomEnvironment` 做环境贴图时,它的反射会在每个玻璃件上留下一道亮竖条,一排玻璃件就是一排亮条;`LatheGeometry` 转满一圈的接缝默认也正对 +z 方向的镜头,一起转到背面(`phiStart` 设 `Math.PI`)。玻璃所在的场景不设 `scene.environment`(同一场景里别的材质也跟着没了环境反射,用灯光补),玻璃感改由一层菲涅尔边光壳来给:同一个几何体放大约 1.004 倍,`ShaderMaterial` 加法混合、不写深度,亮度取 `pow(1 − |N·V|, 2.6)`,只有边缘微微发亮。
- **渲染时不要依赖在线 CDN。** importmap 指向 CDN 时,只要页面加载那一刻断网,渲染就会静默挂住:日志 0 字节,也不报错。介绍片的第一次 final 渲染就卡在这里。把 three.js 装进项目,importmap 指向 `node_modules`,snapshot 和 render 都能读到这些文件:

```bash
Expand Down
1 change: 1 addition & 0 deletions playbook/02-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
- **strip**:看时序。按固定步长 0.1–0.15 秒出一串帧,从头到尾像观众那样读,数一数每个 read 分到几帧(24 帧 = 1 秒)。
- **crop**:局部放大,看脸、手、接触点、发光区域。
- 另外,每个转场前后各 0.5 秒都要单独看;整片再按目标屏的尺寸看一遍(BRIEF 的 `Watch on`);循环播放的交付物还要看末帧接首帧(两条命令见下文"手机测试与循环接缝")。
- 同一位置换字、会滚动的读数,各看一条 strip:换字时旧句退完、新句才进(#17;一支 2026-10 的片子作者自查漏了三处,是 reviewer 看出来的);读数每次停下的数都和 NOTES 里的出处对一遍,停在插值算出来的数上算编造(#19)。
4. **VLM 批评要给离散选项**:让模型从锚点网格里选位置(Code2Video 的做法),或者从几个渲染出来的变体里挑一个(Paper2Video 的做法)。不要让它输出"往左移 12px"这种数值微调。每轮最多列 3 个问题,每个问题都附一条可以直接执行的修改。
5. **reviewer subagent**:开一个全新上下文(`standard` 用便宜一档的模型,Claude Code 里 `model: sonnet`;`studio` 可以用最强档),只给它联系表、`TASTE_CHECKLIST.md` 和 `STORYBOARD.md`,让它逐条打分;不合格的退回给写代码的 agent。
- **人设**:严苛的导演,不是为作品自豪的作者。提示词的大意是:"这支片子不是你做的。你是一位挑剔的导演,任务是找出它为什么还不能发。"作者自己看久了会习以为常,换一个没有感情投入的人才看得出。
Expand Down
1 change: 1 addition & 0 deletions playbook/03-motion-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@
- `feed` 那一档的字要大得多,因为横屏片在信息流里本来就小。能另出一个竖版(重新构图,不是裁)就另出,比把横屏的字放大到这一档好。
- **4K**:照常按 1080p 写合成和字号,出片时渲成 4K(HyperFrames `render --resolution 4k`,竖屏 `portrait-4k`,方形 `square-4k`;见 `engines/README.md`),画面和字一起放大一倍,屏幕上看起来一样大。电脑上看 4K,字不需要占画面更大的比例;只有直接按 3840 宽写合成时,表里的 px 才要乘 2。
- 风格预设的 STYLE.md 里写的字号,是它 5 s 样片用过的数(不少标签是 30–42 px),不是下限;借进片子以后,标题、标签、字幕按这张表来。
- **写场景时就从下限写起**:标签、注释、读数这些要读的辅助字,一开始就按这一档的下限写(`desktop` 是 44 px),不要先写小、等自查再放大:一支 2026-10 的横屏片初版有约 40 处辅助字是 24–40 px,事后放大要挨个重排。镜头里当标题用的那行大字(一句话点出这一镜讲什么)算主标题,不算辅助字(`desktop` 是 84 px)。出处、图片署名、素材来源这类小字,许可和类型文档没要求上屏的,放进视频简介;要上屏的(比如数据故事常驻的出处行)按辅助字算,同样不低于下限。
- 来源:`docs/research/06-concept-first-ab.md`(同一个 44 px,竖屏手机上约 15 px,横屏放进信息流只有约 8 px);屏幕宽度是常见设备的 CSS 宽度【综合】。
- **中文字体**【综合】:思源黑体/Noto Sans CJK 用于正文和字幕,思源宋体用于标题和人文类内容;使用前确认本机已安装,或者把字体文件放进项目的 `assets/`。
- **decode / scramble 字**:字像被程序一点点解码出来,是代码感片子的常用效果。守三条规矩:
Expand Down
11 changes: 11 additions & 0 deletions playbook/04-audio.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ bin/vh beats <任意音乐文件> # 外来音乐的节

`--align gemini` 对任何 provider 都能用:每句合成完交给 Gemini 转写,拿回带时间戳的词,再和稿子逐字对齐。结果写进 timeline:`words` 是逐词时间(文字用稿子的写法),字幕和画面逐词同步用它;`asr` 是和稿子的相似度,跑偏、或者句首句尾多出 1 s 以上声音的句子标上 `flag`,命令最后逐句列出来。转写失败也不丢配音:修好原因后,同一条命令加 `--resume` 接着跑。时间步长、费用、限额和实测见 `tools/audio/README.md` 的同名一节。

**转写用不了时**(当天额度用完,或者接口一直报错):额度用完后,`--align gemini` 可能在 429 上等了又试(每次调用最多重试 5 次),看着像卡住。合成好的音频已经在盘上(逐句合成时是 `voiceover.<lang>.wav`,`--join` 时是 `vo/<lang>/_blockNN.wav`),停掉不会丢;可以等额度恢复后加 `--resume` 接着转写,也可以改用本地 whisper 拿词级时间(还没封装;Apple Silicon 上用 mlx-whisper 的 `whisper-large-v3-turbo`,`initial_prompt` 里写上人名和术语;别的做法见下文"选型"的词级时间戳一行),再交给 `tools/audio/tts.py` 的 `align_lines` 和稿子对齐(`--align gemini` 用的也是它)。whisper 常把年份写成阿拉伯数字,和稿子里为念法写的"一九五三"对不上:相似度会掉,自己写的逐字对齐还可能把那句的起点算偏(一支 2026-10 的片子算晚了,剪掉了第一个字)。对齐前先把这些数字换回稿子的写法。

有词级时间时,`bin/vh captions` 会给每条字幕加上 `words`,念得太快的短字幕会往后延到 1.8 s(`tools/audio/README.md` 的"字幕")。

**双人对话、整段合成、音色、费用**:`@speakers` 双人对话、`--join` 整段合成、音色库和设计音色(`bin/vh voices`)、Gemini 的费用和限额,都在 `tools/audio/README.md` 的同名各节。用了 Gemini 旁白的片子,在 `NOTES.md` 写明"旁白为 AI 合成(Gemini TTS,含 SynthID 水印)",发布时按平台要求标注。
Expand Down Expand Up @@ -188,6 +190,7 @@ bin/vh qa mix $A/stems --beats $A/music.beats.json # 只看混音报告(词
- 同一段里相邻两句的语气要有落差:问句接答句,铺垫接爆点,快接慢。
- **标签**:`<short pause>`、`<long pause>`、`<breath>`、`<laugh>`、`<sigh>` 可以直接写进句子里。只有 `gemini` 会演出来,其他 provider 和字幕都会自动去掉(规则见 `tools/audio/README.md`)。`minimax` 用的是停顿标记 `<#0.4#>`(秒),同样只有它会停。中文稿里也写英文标签,官方说这样效果最好。标签只管某一刻的动作(停顿、呼吸、笑),持续的语气写在 `[ ]` 里。
- **给 gemini 的指示要短**:一句话讲清情绪和节奏就够了。年龄、性别、口音属于音色,不要写进指示;长段的人设和导演笔记容易让音色漂移。
- **单人旁白要整篇一个声音时,不写逐句指示**:gemini 整稿一次合成(`--join all`)时,每句仍是单独的一段文字,各带自己的 style(整体语气,加上这句 `[ ]` 里的指示)。只有一句带 `[ ]`(常见的是开头那句),这句容易和其余的听起来不一样。要全片一个声音,就只写一句简短的整体语气,每句都不写 `[ ]`。
- **谁能演**:
- `gemini` 表演力最好,整体和逐句指示都听;
- `minimax` 只认一个情绪词(`calm`、`happy`、`whisper` 等),写别的会被忽略;
Expand Down Expand Up @@ -215,6 +218,14 @@ bin/vh qa mix $A/stems --beats $A/music.beats.json # 只看混音报告(词

**语速的依据和用法**:表里是句内语速,不算句间停顿。普通话正常朗读、去掉 150 ms 以上的停顿后约 5.2 字/秒:七种语言的朗读语料里,汉语是 5.18 音节/秒,一个汉字一个音节(Pellegrino, Coupé & Marsico 2011,*Language* 87(3),[doi:10.1353/lan.2011.0057](https://doi.org/10.1353/lan.2011.0057))。电话闲聊按总时长算是每分钟 228–247 字,约 3.8–4.1 字/秒(Yuan, Liberman & Cieri 2006,Interspeech,[doi:10.21437/interspeech.2006-204](https://doi.org/10.21437/interspeech.2006-204))。知识科普的 4.5–5.5 围着正常朗读,原理讲解慢一档。倒推稿子字数时乘 0.85,留出句间停顿【综合】:45 s 的知识短视频约 170–210 字,3 分钟的讲解约 540–690 字。TTS 的实际语速看音色:本节第 4 小节比较 `duck_ratio` 用的那段测试旁白(0.25 s 间距,本地 Qwen3-TTS 0.6B 的 Serena,7 句 107 字)句内约 4.1 字/秒。所以字数只用来起稿,时长以 `timeline.json` 的实测为准(硬规则 2)。

**一条 take 里快慢差得明显时,可以逐句拉近**:整稿一次合成、不写逐句指示,语气能统一,句与句的语速却可能差不少。句子之间本来就该有快慢,人听着不别扭就不用动;听得出忽快忽慢时,不用重合成,在这条 take 上逐句处理(还没做成命令):
1. 起止按波形定:从对齐给的句首往回走,走到声音静下来为止(ASR 给的句首不一定准,第一个字听错时常常偏晚,照着剪会切掉它),句尾同样往后走;
2. 句内过长的停顿压到约 0.25 s,切口两侧各淡几毫秒;
3. 每句用 `atempo`(不变调)往同一个语速拉近一些,不必拉齐;参照取人听过、认可的那一段(比如开头)。只校正一部分,例如倍数取(参照 ÷ 这句的语速)的 0.8 次方,限在 0.92–1.18,自然的起伏留着;
4. 按标点和段落重排句间停顿(拼接别用 `anullsrc` 加 `concat`,见上文"旁白晚一点进来"),写回 `voiceover.<lang>.wav`;整条再对齐一次,更新 `timeline.<lang>.json` 的逐字时间,再重跑 `bin/vh captions`。

一支 2026-10 的片子,一条 Gemini 整稿各句原来 4.7–6.7 字/秒(第 1、2 步之后量),这样处理后在 5.6–6.1 之间,听起来还是同一条 take。数字只是参考,以人听着顺为准。

**要做 A/B**:`studio` 档位下,同一段旁白至少试两种导演方向,把两版都给人听。实测对比见本节开头:同样三句话,只给一个"纪录片旁白"的整体语气时听起来平;逐句导演、再卡上拍之后,才有起伏和节奏。

### 2. 旁白卡拍(动效类必做)
Expand Down
Loading
Loading