From 7b0eaedbd0442fde1db483b5ed5f7c79639b95a4 Mon Sep 17 00:00:00 2001 From: Runan Date: Wed, 7 Oct 2026 09:00:55 -0400 Subject: [PATCH 1/2] Add beat-timed promo mode, punch style pack and delivery-grade sound Short ads and brand films have no narration, so their clock is the music: promo/promo.json sets bpm, key and per-scene bars/energy/transition/cues ("bar:beat"); scenes read frames with usePromo(); tools/score.py writes the score on the same grid. A 15 s 9:16 demo (7 scenes, 128 bpm) promotes the kit. - core: land()/squash() so hits arrive on their frame, ballistic particles, shared font loading, SfxMute for frozen transition copies - punch pack: Slam (RGB split), Hud, Marquee, Badge, CardStrip with motion blur, PatternText, Wipe, LogoReveal, CtaBar; works as a Look too - sound: synth.py instruments + energy-normalized reverb + sidechain; master.py measures, applies linear gain and a look-ahead limiter, and checks true peak on the AAC-encoded file; mix.py now masters to loudness.target (-16, 9x16 -14); audio-report.py prints per-section loudness and draws waveform/spectrogram; five new sfx (impact, riser, revcrash, shimmer, rush) - tools: trace-logo.py (logo image -> vector parts), copy_lint.py (absolute claims barred by China's Advertising Law), promo.mjs, promo-sheet.mjs - verify: two promo stages (every cue renders; determinism) - fix: WAVs written by Python's wave module (minimal header) crash Remotion 4.0.532 renders on Windows with "kill EBADF"; every scene WAV is now written with a LIST/INFO chunk via wav_bytes(), enforced by a test - fix: demo s08 coin/levelup sfx were ~3.5 LU hotter than neighbouring scenes - docs: principle 1 generalized to "one time source: the sound", principle 7 "measure the sound to delivery spec"; README, CLAUDE.md, new-promo skill Co-Authored-By: Claude Opus 5.5 --- .claude/skills/new-promo/SKILL.md | 43 ++++ .claude/skills/new-scene/SKILL.md | 2 +- .claude/skills/publish/SKILL.md | 5 +- .claude/skills/review/SKILL.md | 21 +- CLAUDE.md | 8 +- README.md | 105 ++++++-- docs/principles.md | 59 +++-- kit.config.json | 3 +- package.json | 6 + promo/promo.json | 18 ++ public/audio/s01.wav | Bin 1660612 -> 1660646 bytes public/audio/s02.wav | Bin 1062900 -> 1062934 bytes public/audio/s03.wav | Bin 961756 -> 961790 bytes public/audio/s04.wav | Bin 790328 -> 790362 bytes public/audio/s05.wav | Bin 721756 -> 721790 bytes public/audio/s06.wav | Bin 1053184 -> 1053218 bytes public/audio/s07.wav | Bin 801756 -> 801790 bytes public/audio/s08.wav | Bin 881756 -> 881790 bytes public/audio/s09.wav | Bin 801758 -> 801792 bytes public/sfx/chime.wav | Bin 141164 -> 141198 bytes public/sfx/click.wav | Bin 5336 -> 5370 bytes public/sfx/coin.wav | Bin 34442 -> 34476 bytes public/sfx/draw.wav | Bin 52964 -> 52998 bytes public/sfx/glitch.wav | Bin 28268 -> 28302 bytes public/sfx/impact.wav | Bin 0 -> 123556 bytes public/sfx/levelup.wav | Bin 81624 -> 81658 bytes public/sfx/pop.wav | Bin 14156 -> 14190 bytes public/sfx/revcrash.wav | Bin 0 -> 44178 bytes public/sfx/riser.wav | Bin 0 -> 132378 bytes public/sfx/rush.wav | Bin 0 -> 52998 bytes public/sfx/shimmer.wav | Bin 0 -> 70638 bytes public/sfx/slam.wav | Bin 88244 -> 88278 bytes public/sfx/swish.wav | Bin 35324 -> 35358 bytes public/sfx/thud.wav | Bin 39734 -> 39768 bytes public/sfx/tick.wav | Bin 2690 -> 2724 bytes public/sfx/type.wav | Bin 6218 -> 6252 bytes public/sfx/whip.wav | Bin 22976 -> 23010 bytes public/sfx/whoosh.wav | Bin 44144 -> 44178 bytes requirements.txt | 4 +- src/Root.tsx | 4 + src/brand/logo.json | 9 + src/config.ts | 4 +- src/core/Burst.tsx | 80 ++++++ src/core/Sfx.tsx | 10 +- src/core/Video.tsx | 31 +-- src/core/fonts.tsx | 31 +++ src/core/index.ts | 4 +- src/core/motion.ts | 16 ++ src/promo/Promo.tsx | 100 ++++++++ src/promo/grid.ts | 116 +++++++++ src/promo/scenes/common.tsx | 72 ++++++ src/promo/scenes/index.ts | 11 + src/promo/scenes/p1.tsx | 57 +++++ src/promo/scenes/p2.tsx | 57 +++++ src/promo/scenes/p3.tsx | 35 +++ src/promo/scenes/p4.tsx | 63 +++++ src/promo/scenes/p5.tsx | 51 ++++ src/promo/scenes/p6.tsx | 31 +++ src/promo/scenes/p7.tsx | 68 ++++++ src/punch/index.tsx | 393 ++++++++++++++++++++++++++++++ src/scenes/s08.tsx | 4 +- tests/test_promo.py | 72 ++++++ tools/audio-report.py | 73 ++++++ tools/copy_lint.py | 63 +++++ tools/kit.py | 18 ++ tools/master.py | 95 ++++++++ tools/mix.py | 29 +-- tools/promo-grid.mjs | 33 +++ tools/promo-sheet.mjs | 33 +++ tools/promo.mjs | 32 +++ tools/render.mjs | 7 +- tools/score.py | 182 ++++++++++++++ tools/sfx.py | 69 +++++- tools/synth.py | 169 +++++++++++++ tools/tile.py | 33 +++ tools/trace-logo.py | 114 +++++++++ tools/verify.mjs | 32 +++ tsconfig.json | 2 +- tts/gen.py | 25 +- 79 files changed, 2493 insertions(+), 109 deletions(-) create mode 100644 .claude/skills/new-promo/SKILL.md create mode 100644 promo/promo.json create mode 100644 public/sfx/impact.wav create mode 100644 public/sfx/revcrash.wav create mode 100644 public/sfx/riser.wav create mode 100644 public/sfx/rush.wav create mode 100644 public/sfx/shimmer.wav create mode 100644 src/brand/logo.json create mode 100644 src/core/Burst.tsx create mode 100644 src/core/fonts.tsx create mode 100644 src/promo/Promo.tsx create mode 100644 src/promo/grid.ts create mode 100644 src/promo/scenes/common.tsx create mode 100644 src/promo/scenes/index.ts create mode 100644 src/promo/scenes/p1.tsx create mode 100644 src/promo/scenes/p2.tsx create mode 100644 src/promo/scenes/p3.tsx create mode 100644 src/promo/scenes/p4.tsx create mode 100644 src/promo/scenes/p5.tsx create mode 100644 src/promo/scenes/p6.tsx create mode 100644 src/promo/scenes/p7.tsx create mode 100644 src/punch/index.tsx create mode 100644 tests/test_promo.py create mode 100644 tools/audio-report.py create mode 100644 tools/copy_lint.py create mode 100644 tools/master.py create mode 100644 tools/promo-grid.mjs create mode 100644 tools/promo-sheet.mjs create mode 100644 tools/promo.mjs create mode 100644 tools/score.py create mode 100644 tools/synth.py create mode 100644 tools/tile.py create mode 100644 tools/trace-logo.py diff --git a/.claude/skills/new-promo/SKILL.md b/.claude/skills/new-promo/SKILL.md new file mode 100644 index 0000000..7b798c0 --- /dev/null +++ b/.claude/skills/new-promo/SKILL.md @@ -0,0 +1,43 @@ +--- +name: new-promo +description: 做一条没有旁白、按节拍走的短片:15 秒广告、品牌短片、产品宣传、短视频开场(默认竖屏 9:16,带配乐和音效,母带到 -14 LUFS)。用户说「做个短片」「广告片」「宣传片」「15 秒」「抖音」「让人一看就想来买」「punchy」「showreel」时使用。有旁白的讲解用 new-scene。 +--- + +# 做一条短片 + +先读 `docs/principles.md` 的原理 1(时间来自节拍)和原理 7(声音量出来)。下面每一步都是这两条的具体做法。 + +## 1. 先定节拍和结构,再画画面 + +- **总长 = 整数小节**。128 BPM 一小节 1.875 秒,8 小节正好 15 秒;120 BPM 一小节 2 秒。先在 `promo/promo.json` 里排好每场几小节,再写画面。 +- **一场一个意思,一拍一个动作**。经典结构(演示片就是这样):冷开场三记砸字(1.5 小节)→ 四拍硬切讲四个卖点(1 小节)→ 16 分音符频闪(半小节)→ 间奏放一句主张(1 小节,`break`)→ 商品轮播(1 小节)→ 铺垫:跑马灯 + logo 描边,最后一拍留空(1 小节,`build`)→ 重拍落版:logo 填色、冲击波、彩屑、字标、标语、CTA(2 小节,`drop`)。 +- **能量要有起伏**:`break` 抽掉底鼓,`build` 最后一拍留空,下一个重拍才砸得响。整条都是满能量,反而没有冲击力。 + +## 2. 画面 + +- 用 `punch` 包:`Slam`(在 cue 那一帧砸到位)、`Hud`、`Marquee`、`Badge`、`CardStrip`、`PatternText`、`LogoReveal`、`CtaBar`;粒子用 core 的 ``。 +- **砸字要落在拍上,不是从拍上开始**:`Slam at={cue('x')}` 内部用 `land()`,前 5 帧加速进场,`x` 那一帧到位,同一帧放 `` 或 `slam`。 +- **硬切的场(四拍卖点、频闪)**:每拍换底色 + 换主体 + 一个大字,大字用 `dur={2}` 几乎立刻到位,否则跟不上切换。 +- **转场写在 `promo.json` 的 `in` 里**:`iris`、`wipe`、`flood`、`up` 在重拍前播完。一条片子里用两三种就够;需要更强的「推进」可以在场景里自己做(演示片 p1 冲进「不」字里,用主题色铺满)。 +- **停留要留够**:八分音符的轮播,滑动 4 帧、静止 3 帧;一句主张至少给一拍让人读。 +- **logo 用描的,不用字体代替**:`python tools/trace-logo.py logo.png --split` → `src/brand/logo.json`。截图可以,窗口边框会自动去掉。描完用 `tools/promo-sheet.mjs` 看落版那几帧。 +- **展示字体先逐字检查**:风格化中文字体个别字会认不出来。把文案里每个字渲染出来看一遍,认不出就换思源黑体 900。 + +## 3. 文案 + +- 卖点写感受和事实,不写绝对化用语:`python tools/copy_lint.py` 必须过(`pytest` 也会查)。 +- 产地、价格、「每日上新」「当天到店」这类事实,用户没给就不要写,写进去之前先确认。 +- CTA 要能直接行动:到店、扫码、搜索什么、点链接。没有地址或二维码,就在交付时说明缺了什么。 + +## 4. 声音 + +- 配乐由 `tools/score.py` 按同一张节拍网格写:调 `key`、每场 `energy` 就能改编曲。 +- 音效写在场景里,跟着画面的动作:砸字 `slam`/`impact`、切换 `whip`、刀光转场 `swish`、跑马灯进场 `impact`、铺垫 `riser`(结束在重拍:`at = 重拍 - 45`)+ `revcrash`(`at = 重拍 - 15`)、logo 描边 `shimmer`、落版 `impact` + `chime`。 +- `node tools/promo.mjs` 跑完看两样:`score.py` 打印的总线 RMS(鼓约 0.37、贝斯约 0.23、音乐约 0.15、混响 0.02–0.08,混响或 pad 比鼓响就是糊了),`audio-report.py` 的分段响度(`break` 和 `build` 应该比 `groove` 低几 LU,`drop` 最响)和 `out/audio/` 的波形。 + +## 5. 交付 + +1. `npm run verify`(包含短片两关)。 +2. `node tools/promo.mjs` → `out/Promo-mixed.mp4`,报告里响度 -14 ±1 LUFS、真峰值 ≤ -1 dBTP。 +3. 从成片里抽帧再看一遍(不是只看 Studio):`ffmpeg -ss <秒> -i out/Promo-mixed.mp4 -frames:v 1 x.png`。 +4. 交付时说清楚:哪些文案是你写的、没确认过;缺了哪些素材(地址、二维码、真实产品图)。 diff --git a/.claude/skills/new-scene/SKILL.md b/.claude/skills/new-scene/SKILL.md index 05f6317..204d860 100644 --- a/.claude/skills/new-scene/SKILL.md +++ b/.claude/skills/new-scene/SKILL.md @@ -5,7 +5,7 @@ description: 在 explainer-kit 里新写一场讲解(旁白 + 画面)。用 # 写一场 -动手前先过一遍 `docs/principles.md` 的六条原理;遇到组件里没有现成答案的需求,从原理推(比如「要和某个词同步」就是原理 1:用 `w()` 取帧)。 +动手前先过一遍 `docs/principles.md` 的七条原理;遇到组件里没有现成答案的需求,从原理推(比如「要和某个词同步」就是原理 1:用 `w()` 取帧)。 ## 1. 先写旁白,再想画面 diff --git a/.claude/skills/publish/SKILL.md b/.claude/skills/publish/SKILL.md index 5febf78..a4c665b 100644 --- a/.claude/skills/publish/SKILL.md +++ b/.claude/skills/publish/SKILL.md @@ -29,6 +29,9 @@ publish 会在渲染前先跑 `tools/verify.mjs`(验收不过就停,不会 - 渲染一定要 `--gl=angle`(`tools/render.mjs` 已带上),纯软件 GL 慢约 5 倍。 - 超过 10 分钟的渲染脱离终端跑并看日志:Git Bash `nohup node tools/publish.mjs --formats all > out/publish.log 2>&1 &`;PowerShell 用 `Start-Process` 并重定向输出。 -- edge-tts 原始响度只有约 -24 LUFS,必须经过 `tools/mix.py`(publish 已包含)到 -16 LUFS。 +- edge-tts 原始响度只有约 -24 LUFS,必须经过 `tools/mix.py`(publish 已包含)。它用 `tools/master.py` 做两遍线性母带(测 → 加增益 → 限幅 → 再测),目标是 `kit.config.json` 的 `loudness.target`(默认 -16),某个画幅可以单独设(`formats.9x16.loudness`,默认 -14,给短视频平台)。真峰值在编码后的 AAC 上测,超过 -1 dBTP 会自动降天花板重做。 +- 出片后跑 `python tools/audio-report.py out/-mixed.mp4 --target -16` 看分段响度和波形。 +- 渲染报 `kill EBADF`、而且只在范围里有音频时才崩:是某个 WAV 用 Python `wave` 模块写的(最简文件头)。用 `tools/kit.py` 的 `wav_bytes()` 重写;`pytest` 会查出 `public/` 里不合格的 WAV。 +- 没有旁白的短片、广告用 `node tools/promo.mjs`(见 `new-promo` skill)。 - 只重做某几场的配音:`python tts/gen.py s03 s05`,其他场沿用旧时间轴;然后 `publish --skip-tts`。 - 换了真配音后 cue 位置都会变,交付前按 `review` skill 再审一遍。 diff --git a/.claude/skills/review/SKILL.md b/.claude/skills/review/SKILL.md index 68b3b3e..069b4ab 100644 --- a/.claude/skills/review/SKILL.md +++ b/.claude/skills/review/SKILL.md @@ -42,9 +42,28 @@ node tools/listen.mjs s05:block s05:out:30 # 一段 输出每个声音离哪个 cue 多少帧(如 `hit+0`)。`--mock` 时旁白是静音的,列出来的全是音效和配乐节拍。核对:每个点击、落地、砸字都有声音,且偏差在 ±3 帧内;没有意外的长时间噪声。 +## 量声音(成片) + +```bash +python tools/audio-report.py out/Explainer-mixed.mp4 --target -16 # 讲解片 +python tools/audio-report.py out/Promo-mixed.mp4 --promo --target -14 # 短片 +``` + +整体响度要在目标 ±1 LU,真峰值 ≤ -1 dBTP(在编码后的文件上测)。看分段响度:某一场比相邻的响 3 LU 以上,多半是那场音效没配平(演示片 s08 的方波金币就是这样查出来的);短片的 `break`、`build` 应该比 `groove` 低,`drop` 最响。再看 `out/audio/*_wave.png`:一条平带说明压过头或混响糊了。 + +## 看短片 + +```bash +node tools/promo-sheet.mjs # 每个 cue 后 4 帧一格;OFF=0 看重拍那一帧 +node tools/promo-sheet.mjs 230 236 239 243 # 指定帧,看一段动作(比如轮播停没停住) +``` + +短片不要用 `` 拼图看(转场有预卷,会差几帧);这个工具是逐帧直接渲染的。 + ## 交付前的检查清单 -- `npm run verify` 全部通过(类型、源文件一致、每场每种画幅可渲染、两次渲染逐字节一致) +- `npm run verify` 全部通过(类型、源文件一致、每场每种画幅可渲染、两次渲染逐字节一致、短片每个 cue 可渲染且可复现) +- 成片跑过 `audio-report.py`:响度在目标上、真峰值 ≤ -1 dBTP、没有哪一场突然响一截 - 改过的每场都拼帧看过,主画幅和 9x16 各一遍 - 音效位置用 `listen` 核对过 - 真配音(Windows 上 `python tts/gen.py`)后再看一遍:cue 位置会变,写死的帧数会错位 diff --git a/CLAUDE.md b/CLAUDE.md index ae73d45..0ec4f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,9 +1,11 @@ # explainer-kit -Remotion 讲解视频模板:旁白里写 `[[cue]]` 标记,edge-tts 的逐词时间戳驱动字幕和动画。用法见 `README.md`。 +Remotion 讲解视频模板:旁白里写 `[[cue]]` 标记,edge-tts 的逐词时间戳驱动字幕和动画。没有旁白的短片 / 广告走节拍网格(`promo/promo.json`,`src/promo/`,`node tools/promo.mjs`)。用法见 `README.md`。 - 面向用户的文档用简体中文;代码、命令、标识符用英文。 - 本机是 Windows(渲染、真 TTS 验证在那里做);云端沙箱只做重构、类型检查、`--mock` 时间轴和单测。沙箱里也能看画面和听音效:`tools/` 的渲染脚本会自动用 `/opt/pw-browsers` 预装的 headless shell(`node tools/strip.mjs s01:quote`、`node tools/overview.mjs`、`node tools/listen.mjs s05`),只看,不提交 `out/`。 -- 做事的原则:往原理去理解,随后一通百通;可交付、可重复、稳定输出,才是 AI 赋能的前提。项目的六条原理见 `docs/principles.md`,动手前先对照它想清楚,新需求优先从原理推,不要只套组件。交付前跑 `npm run verify`(`--quick` 只做类型和一致性检查),它不过就不算完成。 -- 常做的事有项目 skill(`.claude/skills/`):`new-scene` 写一场、`review` 审片、`new-style-pack` 新风格包、`publish` 出片。反复手动做的步骤,优先收成 `tools/` 脚本或补进这些 skill。 +- 做事的原则:往原理去理解,随后一通百通;可交付、可重复、稳定输出,才是 AI 赋能的前提。项目的七条原理见 `docs/principles.md`,动手前先对照它想清楚,新需求优先从原理推,不要只套组件。交付前跑 `npm run verify`(`--quick` 只做类型和一致性检查),它不过就不算完成。 +- 常做的事有项目 skill(`.claude/skills/`):`new-scene` 写一场、`review` 审片、`new-style-pack` 新风格包、`publish` 出片、`new-promo` 做短片。反复手动做的步骤,优先收成 `tools/` 脚本或补进这些 skill。 - 不在仓库里提交字体文件和渲染产物(`out/`、`public/fonts/`)。 +- 场景要播的 WAV 一律用 `tools/kit.py` 的 `wav_bytes()` 写:Python `wave` 模块的最简文件头会让 Windows 上的 Remotion 渲染崩溃(只报 `kill EBADF`)。 +- 声音和画面一样要验:出片后看 `tools/audio-report.py`(响度、真峰值、分段响度)。广告文案过 `tools/copy_lint.py`。 diff --git a/README.md b/README.md index 5f094e1..41c179f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # explainer-kit -用 Remotion 做讲解视频的模板。旁白写在 `tts/script.json` 里,用 `[[cue]]` 标出动画触发点;edge-tts 合成语音时记下每个词的出口时间,生成 `src/timings.json`。场景代码只问「这个 cue 在第几帧」「这个词在第几帧」,不写死任何秒数,所以改旁白、重跑语音之后,动画和字幕自动跟上。 +用 Remotion 做讲解视频和短片广告的模板。讲解片的旁白写在 `tts/script.json` 里,用 `[[cue]]` 标出动画触发点;edge-tts 合成语音时记下每个词的出口时间,生成 `src/timings.json`。场景代码只问「这个 cue 在第几帧」「这个词在第几帧」,不写死任何秒数,所以改旁白、重跑语音之后,动画和字幕自动跟上。没有旁白的 15 秒短片和广告走另一条时间线:`promo/promo.json` 写节拍和小节,画面的每个重拍和配乐的鼓点落在同一张节拍网格上(见「短片模式」)。 -仓库自带一支约 3 分钟的演示片(9 场),每场换一套风格包:`film`(电影)、`slides`(幻灯片 + KaTeX 公式)、`paper`(手绘笔记)、`neon`(霓虹终端)、`editorial`(杂志排版)、`math`(数学推导)、`keynote`(发布会)、`pixel`(像素游戏)、`ink`(水墨)。演示片的时间轴和音频是用 `--mock` 生成的静音占位,克隆下来不用联网合成就能预览。 +仓库自带一支约 3 分钟的演示片(9 场),每场换一套风格包:`film`(电影)、`slides`(幻灯片 + KaTeX 公式)、`paper`(手绘笔记)、`neon`(霓虹终端)、`editorial`(杂志排版)、`math`(数学推导)、`keynote`(发布会)、`pixel`(像素游戏)、`ink`(水墨)。演示片的时间轴和音频是用 `--mock` 生成的静音占位,克隆下来不用联网合成就能预览。另有一支 15 秒竖屏短片 `Promo`(7 场,128 BPM,8 小节),用 `punch` 风格包给模板自己做的宣传片,`node tools/promo.mjs` 一条命令出带配乐、母带处理过的成片。 -先读 [`docs/principles.md`](docs/principles.md):六条原理讲清楚这个模板为什么这样设计,看懂它,后面的组件和工具都能推出来。 +先读 [`docs/principles.md`](docs/principles.md):七条原理讲清楚这个模板为什么这样设计,看懂它,后面的组件和工具都能推出来。 ## 快速开始 @@ -17,7 +17,7 @@ npm run studio # 打开 Remotion Studio,直接看演示片(静音 ```bash npm run fonts # 下载 Noto Sans SC / Noto Serif SC 可变字体到 public/fonts/(OFL,不进仓库) -pip install -r requirements.txt # edge-tts、numpy、Pillow、pytest +pip install -r requirements.txt # edge-tts、numpy、scipy、Pillow、pytest(potracer 只在描 logo 时用) python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg;写 public/audio/*.mp3 + src/timings.json ``` @@ -27,7 +27,8 @@ python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg; | 路径 | 作用 | |---|---| -| `kit.config.json` | 唯一配置:合成 ID、尺寸、帧率、旁白音色与语速、引语音色、停顿时长、字幕行长、`--mock` 语速、每场前后留白、品牌色、画幅、音效音量、配乐节拍、字体 | +| `kit.config.json` | 讲解片的唯一配置:合成 ID、尺寸、帧率、旁白音色与语速、引语音色、停顿时长、字幕行长、`--mock` 语速、每场前后留白、品牌色、画幅、音效音量、配乐节拍、响度、字体 | +| `promo/promo.json` | 短片的唯一配置:尺寸、bpm、调性、响度目标、品牌文案、每场小节数 / 能量 / 转场 / cue(「小节:拍」) | | `tts/script.json` | 旁白稿,每场一个 `{id, chapter, text}` | | `tts/gen.py` | 旁白 → 音频 + `src/timings.json`;`--mock` 离线估算 | | `src/core/` | 与风格无关的核心:时间轴 API、动效词汇(`motion.ts`)、跟旁白同步的文字(`Spoken`)、镜头与转场(`Shots`)、`Video` 外壳、布局助手、`Tex`、配色与字体栈 | @@ -40,11 +41,14 @@ python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg; | `src/keynote/` | 发布会风格包(可删) | | `src/pixel/` | 像素游戏风格包(可删) | | `src/ink/` | 水墨风格包(可删) | +| `src/punch/` | 动态排版风格包:砸字、拆色故障、跑马灯、环形徽章、商品轮播、图案填字、logo 揭示、CTA 条(可删) | +| `src/promo/` | 短片:节拍网格(`grid.ts`)、外壳和转场(`Promo.tsx`)、演示短片的 7 场(`scenes/`) | +| `src/brand/logo.json` | 品牌 logo 的矢量部件(`tools/trace-logo.py` 生成;仓库里是手画的演示标志) | | `src/scenes/` | 你的场景;`index.ts` 是登记表 | -| `tools/` | 一键出片、新建场景/风格包、审图、拼帧、听音效、配乐、混音、音效合成、下载字体 | +| `tools/` | 一键出片、新建场景/风格包、审图、拼帧、听音效、配乐、混音、母带、响度报告、音效合成、描 logo、广告文案检查、下载字体 | | `public/fx/` | 胶片颗粒和纸张纹理 | | `public/sfx/` | 音效库(`tools/sfx.py` 合成,无版权问题) | -| `.claude/skills/` | 给 Claude Code 的项目 skill:写一场、审片、新风格包、出片 | +| `.claude/skills/` | 给 Claude Code 的项目 skill:写一场、审片、新风格包、出片、做短片 | ## 配置 `kit.config.json` @@ -59,9 +63,10 @@ python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg; | `pace.lead` / `pace.tail` | 每场旁白前(放章节卡)和旁白后的留白帧数;`leadOverride` / `tailOverride` 按场覆盖 | | `brand` | `name`(片尾、跑马灯里的名字);`accent` / `accent2`(#rrggbb,填了就替换所有风格包的主色和副色,`null` 保留各包原色) | | `theme` | 按包微调单个颜色,例如 `{"neon": {"cyan": "#00FFC2"}}`,键名见各包调色板(`C` `P` `N` `E` `M` `K` `PX` `I`) | -| `formats` | 额外画幅,每个会多一个合成 `-<键名>`:默认 `9x16`(1080×1920)和 `1x1`(1080×1080);`zoom` > 1 时放大画面、裁掉两侧 | +| `formats` | 额外画幅,每个会多一个合成 `-<键名>`:默认 `9x16`(1080×1920)和 `1x1`(1080×1080);`zoom` > 1 时放大画面、裁掉两侧;`loudness` 覆盖这个画幅的响度目标(默认竖屏 -14) | | `sfx.volume` | 音效总音量(0–1) | | `music.bpm` | 配乐节拍;场景里 `useBeat()` 用同一个节拍网格 | +| `loudness` | `tools/mix.py` 的母带目标:`target`(LUFS,默认 -16)和限幅天花板 `ceiling`(dBFS,默认 -2.0,给 AAC 编码留余量) | | `fonts` | `sans` / `serif` / `mono` 的字体名、文件、下载地址和后备字体 | `src/Root.tsx`、`tools/*.mjs`、`tools/*.py`、`tts/gen.py` 都从这里读,不要在别处写死这些值。 @@ -175,12 +180,66 @@ const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时 听音效:`node tools/listen.mjs s05` 渲染这一场的音轨,列出每个声音离哪个 cue 多少帧;看全片:`node tools/overview.mjs`(`--format 9x16` 看竖屏)。 5. **渲染**:`npm run render`(带 `--gl=angle`)→ `out/.mp4`;竖屏 `npm run render -- 9x16` → `out/-9x16.mp4`。 6. **配乐**:`python tools/music.py` → `out/music.wav`。按 timings 每场一段和声,每个章节开头一声轻击,重配音后重跑即可对齐。 -7. **混音**:`python tools/mix.py` → `out/-mixed.mp4`(竖屏 `python tools/mix.py 9x16`)。配乐低通后按旁白做 sidechain 压缩,再整体 `loudnorm=I=-16:TP=-1.5:LRA=11`。 +7. **混音**:`python tools/mix.py` → `out/-mixed.mp4`(竖屏 `python tools/mix.py 9x16`)。配乐低通后按旁白做 sidechain 压缩,再交给 `tools/master.py` 做母带:测响度 → 线性加增益 → 前瞻限幅,到 `loudness.target`(横版 -16、竖版 -14 LUFS),并在编码后的文件上确认真峰值 ≤ -1 dBTP(见「声音」)。 +8. **听报告**:`python tools/audio-report.py out/-mixed.mp4` 打印整体和每一场的响度,画出波形和频谱(`out/audio/`)。 -交付前:`npm run verify`。依次检查类型、源文件一致(旁白稿 ↔ 时间轴 ↔ 音频 ↔ 登记表 ↔ 音效库)、每场在每种画幅下都能渲染、同一批帧渲染两次逐字节一致。`--quick` 只跑前两项;示意图在 `out/verify/`。 +交付前:`npm run verify`。依次检查类型、源文件一致(旁白稿 ↔ 时间轴 ↔ 音频 ↔ 登记表 ↔ 音效库,短片的节拍网格 ↔ 登记表 ↔ 广告文案)、每场在每种画幅下都能渲染、同一批帧渲染两次逐字节一致,再对短片做同样两关(每个 cue 渲染一帧拼成 `out/verify/promo.png`,两帧各渲染两次比对)。`--quick` 只跑前两项;示意图在 `out/verify/`。 第 2、5、6、7 步可以一条命令跑完:`node tools/publish.mjs`(`--formats 9x16,1x1` 或 `all` 同时出其他画幅,`--skip-tts` 沿用现有配音,`--mock` 离线跑通流程)。 +## 短片模式:没有旁白,按节拍走 + +讲解片的时间来自旁白;15 秒的短视频、广告、品牌片没有旁白,**时间来自配乐的节拍**。这一块是从两条真实的水果店广告里提炼出来的:整条片子按 128 BPM、8 小节设计成正好 15 秒,每一下砸字、每一次硬切、每一个转场都落在鼓点上,音效跟着画面的动作走,最后用 logo 揭示和行动号召收尾。 + +```bash +node tools/promo.mjs # 验收(quick) → 渲染 Promo → 按节拍写配乐 → 母带到 -14 LUFS → 响度报告 +node tools/promo-sheet.mjs # 每个 cue 后 4 帧出一张,拼成 out/promo/sheet.png(OFF=0 看重拍那一帧) +npm run studio # 合成 Promo 就是短片 +``` + +成品是 `out/Promo-mixed.mp4`。 + +**写稿 `promo/promo.json`**:`bpm`、`key`(配乐调性,`Am`、`C`……)、`width`/`height`(默认竖屏 1080×1920)、`loudness`、`brand`(名字、标语、CTA),以及每一场: + +```json +{"id": "p4", "bars": 1, "energy": "break", "in": {"tr": "flood", "beats": 1, "color": "#FF5B1F"}, + "cues": {"col": "0:0", "say": "0:1", "tag": "0:2"}} +``` + +| 字段 | 含义 | +|---|---| +| `bars` | 这一场几小节(4 拍一小节;可以是 1.5,但必须是整拍数) | +| `energy` | 配乐在这一场怎么编:`intro`(暗的 pad,每个 cue 一下底鼓加人声切片)、`groove`(四踩、拍手、贝斯、拨弦主旋律)、`break`(抽掉底鼓,让下一个重拍更响)、`build`(军鼓滚奏 + 滤波打开,最后一拍留空)、`drop`(完整律动加人声切片,全片最后一拍收在长和弦上) | +| `in` | 进这一场的转场:`cut`、`iris`(圆形展开)、`wipe`(斜切)、`flood`(液体从下往上涨满,液面在前、新画面跟在后面)、`up`。转场在重拍**之前**播 `beats` 拍,重拍那一帧正好切完;外壳自动配声音 | +| `cues` | 「小节:拍」,都从 0 数,拍可以有小数(`"0:2.5"` 是第 3 拍的后半拍) | + +**写场景**:`src/promo/scenes/p1.tsx`……在 `src/promo/scenes/index.ts` 登记。场景里: + +```tsx +const {f, cue, at, beat, len, every, pulse} = usePromo(); +不将就 // 恰好在这一拍砸到位,带拆色故障 + // 同一帧的声音 + // 每个 16 分音符一下 +``` + +`f` 以这一场的重拍为 0;转场播放时,外壳把下一场冻结在第 0 帧显示,所以进场动画从 0 开始写。整片跨场的东西(进度条)用 `abs` / `total`。 + +**素材从哪来**:logo 用 `python tools/trace-logo.py logo.png`(截图也行,自动去掉窗口边框;`--split` 把字标拆成一个个字)描成 `src/brand/logo.json`,`` 先描边、再填色泛光、指定部件从上方落下。不要找字体去代替 logo 里的字。广告文案先过 `python tools/copy_lint.py`:「最好」「第一」「唯一」「100%」这类绝对化用语,《广告法》第九条不允许用(只是词表,产地、价格、「每日上新」这类事实还要人确认)。 + +## 声音:按交付标准量出来 + +画面有逐帧检查,声音也要有数(`docs/principles.md` 原理 7)。 + +| 工具 | 做什么 | +|---|---| +| `tools/sfx.py` | 音效库,numpy 合成,无版权问题。这次新增给短片用的 `impact`(重拍)、`riser`(1.5 秒上升音,结束在重拍上:`at = 重拍 - 45`)、`revcrash`(0.5 秒反向镲:`at = 重拍 - 15`)、`shimmer`(闪光琶音)、`rush`(液体涌入,`flood` 转场自带) | +| `tools/synth.py` | 配乐的乐器和总线:分层底鼓、拍手、开闭镲、军鼓、七声部超锯齿、带滤波包络的拨弦、正弦 sub 加锯齿上层的贝斯、共振峰合成的人声切片(ah/oh/eh)、能量归一的卷积混响、按底鼓做的侧链 | +| `tools/score.py` | 短片配乐:按 `promo.json` 的节拍、调性和每场 `energy` 编曲,写 `out/promo-score.wav`,并按段打印每条总线的 RMS | +| `tools/master.py` | 母带:测响度 → 线性增益 → 前瞻限幅 → 再测,直到差 0.3 LU 以内;编码成 AAC 后再测真峰值,超过 -1 dBTP 就降天花板重做。`mix.py` 和 `promo.mjs` 都用它 | +| `tools/audio-report.py` | 整体响度、响度范围、真峰值,每一段的响度条,波形图和频谱图;`--target` 偏离 1 LU 以上或真峰值超标就报错 | + +怎么读:总线 RMS 里混响或 pad 比鼓还响,声音一定是糊的;波形是一条平带说明压过头了;铺垫段应该凹下去,重拍应该跳起来;某一场比相邻的响 3 LU,就是那一场的音效没配平。 + ## 画幅和品牌 场景永远按主画幅(1920×1080)写。竖屏和方屏由外壳排版:16:9 画面放在中间,上面是章节标题,下面是按行长自动放大的字幕,最底下一条全片进度条,背景用这一场风格包的底纹铺满。内容集中在画面中间的场景,可以在登记表里放大裁边:`s07: {component: S07, look: keynote, portrait: {zoom: 1.3}}`(`focus` 0–1 决定裁剪时保留哪一侧)。 @@ -195,11 +254,16 @@ const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时 |---|---| | `node tools/new-scene.mjs s10 --look paper --chapter "…" --text "[[a]]……"` | 追加旁白、生成能直接跑的场景文件(每个 cue 一段跟着旁白出现的字)、登记、跑 mock 时间轴 | | `node tools/new-pack.mjs chalk --base "#1F2B26" --accent "#F2C14E"` | 生成一个新风格包骨架:接好品牌色的调色板、会动的背景、章节卡、组件、Look | -| `node tools/publish.mjs --formats all` | 配音 → 验收 → 各画幅渲染 → 配乐 → 混音 | -| `npm run verify` | 交付前的验收:类型、源文件一致、每场可渲染、渲染结果可复现 | +| `node tools/publish.mjs --formats all` | 配音 → 验收 → 各画幅渲染 → 配乐 → 混音 + 母带 | +| `node tools/promo.mjs` | 短片:验收 → 渲染 → 节拍配乐 → 母带 → 响度报告 | +| `node tools/promo-sheet.mjs` | 短片审片:每个 cue 一帧,拼成一张 | +| `python tools/trace-logo.py logo.png --split` | 把 logo 图片描成矢量部件(`src/brand/logo.json`) | +| `python tools/copy_lint.py` | 广告文案的绝对化用语检查 | +| `python tools/audio-report.py 文件 --target -14` | 响度、真峰值、分段响度、波形和频谱 | +| `npm run verify` | 交付前的验收:类型、源文件一致、每场可渲染、渲染结果可复现,短片同样两关 | | `node tools/strip.mjs` / `overview.mjs` / `listen.mjs` | 审动态 / 看全片 / 听音效 | -`.claude/skills/` 里有四个项目 skill,在 Claude Code 里打开这个仓库就能用:`new-scene`(从文稿到一场)、`review`(审片清单)、`new-style-pack`(新风格包的要求)、`publish`(出片和踩过的坑)。 +`.claude/skills/` 里有五个项目 skill,在 Claude Code 里打开这个仓库就能用:`new-scene`(从文稿到一场)、`review`(审片清单)、`new-style-pack`(新风格包的要求)、`publish`(出片和踩过的坑)、`new-promo`(做一条按节拍走的短片或广告)。 ## 风格包 @@ -215,11 +279,13 @@ const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时 - **ink**(`src/ink/`):水墨。宣纸底;`Mountains` 几层远山各自从雾里晕开(先模糊后清晰)并缓慢漂移,`Mist` 雾带,`Birds` 飞鸟;`Brush` 带飞白的毛笔笔触;`InkBlot` 墨滴在纸上洇开;`VText` 竖排文字(配 `Spoken` 念到哪里墨落到哪里,`\n` 换列);`Seal` 红色印章按下。适合讲历史、诗词、传统文化。 - **editorial**(`src/editorial/`):杂志排版 / 动态文字。新闻纸底色、黑红两色;`MaskText` 大标题逐行从遮罩下升起(也能逐行升出去);`Slam` 大数字砸进画面,`hits` 在说到某个词时再顶一下;`Block` 色块推进来再从另一侧推出去(用来遮住换版);`Rule` 画线;`Label` 小标签;`Ticker` 跑马灯。适合讲观点、金句、数据。 +- **punch**(`src/punch/`):现代动态排版,给短片和广告用,也能放进讲解片的一场。深墨绿底加缓慢漂移的网格;`Slam` 大字在指定帧砸到位(加速进场 + 落地回弹,`split` 加两层拆色故障);`Mono` 等宽小标签;`Hud` 裁切角标、标签和进度线;`Marquee` 斜着横穿画面的跑马灯条,能沿自身方向冲进来、再折叠收起;`Badge` 绕圈转的环形文字;`CardStrip` 商品卡片轮播,按给定的帧一张张滑过去,高速时带水平运动模糊、停下时清晰;`PatternText` 用流动的图案填满大字;`Wipe` 擦入;`LogoReveal` 描边 → 填色泛光 → 部件落下;`CtaBar` 从底部推上来的行动号召跑马灯。适合广告、短视频开场、金句。 + 所有包共用 `src/core` 的 `At`(`center` 时居中位移会和你传入的 `transform` 叠加)、`Full`、`Svg`、`DrawLine`、`Tex`、`Shots`、`Spoken` 和动效函数。film 和 slides 的配色在 `src/core/theme.ts`,新包各自带调色板(`P`、`N`、`E`、`M`、`K`、`PX`、`I`)。 字幕样式由风格包的 `look.subtitles` 决定(浅色底的包用 `boxColor` 换成深色底条),单场可以在登记表里覆盖,例如 `s01: {component: S01, look: film, subtitles: {hideQuotes: true}}`:这一场已经把引语大字放在画面上,就不再给引语配字幕。 -**选哪套**:叙事、人物用 film;概念、API 用 slides;流程、因果、拆解步骤用 paper;系统、架构、数据流用 neon;观点、金句、关键数字用 editorial;公式推导、函数图像用 math;产品介绍、操作演示用 keynote;轻松科普、儿童向用 pixel;历史、诗词、传统文化用 ink。同一支片子可以按场混用。 +**选哪套**:叙事、人物用 film;概念、API 用 slides;流程、因果、拆解步骤用 paper;系统、架构、数据流用 neon;观点、金句、关键数字用 editorial;公式推导、函数图像用 math;产品介绍、操作演示用 keynote;轻松科普、儿童向用 pixel;历史、诗词、传统文化用 ink;广告、短视频、金句砸字用 punch。同一支片子可以按场混用。 ### 写一个新风格包 @@ -266,12 +332,19 @@ const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时 - **cue 名 `end` 是保留的**(工具里表示旁白结束),`gen.py` 遇到会直接报错。 - **场景里别用 `Math.random()`**,要随机就用 film 包的 `rng(seed)` 或 Remotion 的 `random(seed)`。渲染是多进程逐帧并行的,同一帧必须画出同样的东西。 - **edge-tts 偶尔断线**,`gen.py` 每段会重试 10 次,间隔递增。 +- **Python `wave` 模块写的 WAV 会让 Windows 上的渲染崩溃**(Remotion 4.0.532)。它写的是最简 44 字节头,`data` 紧跟在 `fmt` 后面;Remotion 一拉这种文件做 `