diff --git a/.claude/skills/new-scene/SKILL.md b/.claude/skills/new-scene/SKILL.md new file mode 100644 index 0000000..3feff54 --- /dev/null +++ b/.claude/skills/new-scene/SKILL.md @@ -0,0 +1,56 @@ +--- +name: new-scene +description: 在 explainer-kit 里新写一场讲解(旁白 + 画面)。用户给一个主题、一段文稿或一个要讲的概念,想做成视频里的一场时使用;也用于把已有的一场改写成另一种风格。 +--- + +# 写一场 + +## 1. 先写旁白,再想画面 + +旁白就是时间轴。在纸面上把文稿切成「一句话一个画面」,每个画面变化前放一个 `[[cue]]`: + +- 第一个 cue 放在文稿最前面,否则章节卡淡出后会空几秒。 +- 一个 cue 对应一个画面变化(新元素进场、转场、镜头推近)。一场 4–7 个 cue 比较合适,太密画面会乱。 +- 戏剧停顿用 `||`;引语换声音用 `<>`。cue 名只能用字母、数字、下划线,`end` 是保留名。 +- 开头那半句(比如「再换一套:」)也要配字或画面,标题要等念到才出的话前面会空。 + +## 2. 选风格 + +| 内容 | 风格包 | +|---|---| +| 叙事、人物 | `film` | +| 概念、API | `slides` | +| 流程、因果、拆步骤 | `paper` | +| 系统、架构、数据流 | `neon` | +| 观点、金句、关键数字 | `editorial` | +| 公式推导、函数图像 | `math` | +| 产品介绍、操作演示 | `keynote` | +| 轻松科普、儿童向 | `pixel` | +| 历史、诗词、传统文化 | `ink` | + +## 3. 一条命令搭好骨架 + +```bash +node tools/new-scene.mjs s10 --look paper --chapter "第十场 · 标题" --text "[[start]]……[[next]]……" +``` + +它会追加 `tts/script.json`、生成能直接跑的 `src/scenes/s10.tsx`(每个 cue 一段跟着旁白出现的字)、登记到 `src/scenes/index.ts`、跑 `--mock` 时间轴。之后改旁白只需 `python tts/gen.py --mock s10`。 + +## 4. 把占位画面换成真正的画面 + +只用帧号说话,永远不写死秒数: + +- `c('cue')`、`w('词')`、`rel('词', 'cue')` 取帧;`useSpoken().chars/span` 取逐字帧。 +- 进场和退场成对:`const {p, q} = life(f, at, out)`,样式用 `move('rise', p, q)`。会被新内容替换的元素一定要有 `out`。 +- 屏幕上的字用 ``,按配音逐字出现;`text` 必须和旁白一字不差(可以加 `\n` 换行)。 +- 分镜用 ``,转场选 `push` `up` `zoom` `wipe` `whip` `iris` `black`;相似画面不要用 `fade`(会重影)。 +- 落定的元素加一点 `drift(f, seed)`,背景要有不停的小动作,不要冻住。 +- 音效 `` 放在场景最外层(不要放进 Shots 的镜头里,镜头切走会把声音截断)。可用的声音见 `src/core/Sfx.tsx` 的 `SFX`。点击配 `click`,落地配 `thud`,大字配 `slam`,画线配 `draw`,推拉配 `whoosh`。 +- 想踩在配乐节拍上:`const beat = useBeat()`,`beat.next(帧)` 吸附到下一拍,`beat.pulse()` 每拍一次的 0..1。 +- 不要用 `Math.random()`,用 `rng(seed)` / `noise(seed, x)`。 + +各风格包的组件看 README「风格包」一节和对应的 `src//index.tsx`;演示场 `src/scenes/s01–s09.tsx` 是每个包的完整用法示例。 + +## 5. 交付前 + +`npx tsc --noEmit` 通过,然后按 `review` skill 审一遍动态、声音和竖屏。 diff --git a/.claude/skills/new-style-pack/SKILL.md b/.claude/skills/new-style-pack/SKILL.md new file mode 100644 index 0000000..b630c14 --- /dev/null +++ b/.claude/skills/new-style-pack/SKILL.md @@ -0,0 +1,35 @@ +--- +name: new-style-pack +description: 给 explainer-kit 新增一套视觉风格包(配色、背景、章节卡、组件、Look)。用户想要「再来一种风格」「做成 XX 风」或提供参考视频/参考风格时使用。 +--- + +# 新风格包 + +## 1. 先定方向 + +一句话说清:底色(深/浅)、一个主色、它的「标志性动作」是什么(例:paper 是线条一笔笔画出来并持续轻抖,neon 是网格地面一直往前滚,keynote 是一个形状不断变形)。和现有 9 套比,要有明显不同的东西,而不只是换颜色。 + +## 2. 搭骨架 + +```bash +node tools/new-pack.mjs chalk --base "#1F2B26" --accent "#F2C14E" +node tools/new-scene.mjs s10 --look chalk --chapter "第十场 · 黑板风" --text "[[start]]……" +``` + +`new-pack` 生成 `src/chalk/index.tsx`:经过 `themed()` 的调色板、带缓慢光斑的背景、章节卡、`Title`/`Card` 组件、`Look`。 + +## 3. 每个包都要满足 + +- 调色板必须经过 `themed('', {...}, {accent: [...], accent2: [...]})`,把主色挂到 accent 槽位,品牌色才能一键替换;颜色写 `#rrggbb`。 +- 背景永远有一点动作(漂移光斑、滚动纹理、闪烁),不能整屏静止。 +- 章节卡和场景开头的版式尽量一致(editorial 的章节卡和正文同一套刊头),切进正文时不跳。 +- `look.subtitles`:浅底配深色字幕条(`boxColor`),深底反之;`quoteColor` 用主色。 +- 组件只用 `useCurrentFrame()` 和传进来的帧号;进场退场用 `life` + `move`;随机用 `rng`/`noise`。 +- 标志性动作做成组件,并配好音效建议(例:画线配 `draw`,落地配 `thud`)。 +- 包内不 import 其他风格包;core 不 import 任何包。 + +## 4. 演示和文档 + +- 写一场演示(`src/scenes/sNN.tsx`),把这个包最有代表性的动作都用上,按 `review` skill 审一遍,包括 `FORMAT=9x16`。 +- README「风格包」加一条介绍、「选哪套」加一句用途、目录表加一行;英文段落的包列表同步。 +- `node tools/overview.mjs` 看新包和其他包放在一起是否协调;再设一个 `brand.accent` 看换色效果。 diff --git a/.claude/skills/publish/SKILL.md b/.claude/skills/publish/SKILL.md new file mode 100644 index 0000000..41b0a81 --- /dev/null +++ b/.claude/skills/publish/SKILL.md @@ -0,0 +1,32 @@ +--- +name: publish +description: 出片:从真配音到最终带配乐、响度标准化的 mp4,包括横屏、竖屏 9:16、方屏 1:1 和品牌色设置。用户说「出片」「渲染成片」「导出竖屏」「发布」时使用。真配音和正式渲染在 Windows 本机做。 +--- + +# 出片 + +## 一条命令 + +```bash +node tools/publish.mjs # 真 edge-tts → 渲染 → 配乐 → 混音,主画幅 +node tools/publish.mjs --formats 9x16,1x1 # 同时出竖屏和方屏(或 --formats all) +node tools/publish.mjs --skip-tts # 只改了画面,沿用现有配音和时间轴 +node tools/publish.mjs --mock # 离线跑通整条流水线(静音旁白) +``` + +成品:`out/-mixed.mp4`、`out/-9x16-mixed.mp4`…… + +## 出片前确认 + +1. **字体**:`npm run fonts` 下载 Noto Sans/Serif SC,否则会退回系统字体。 +2. **品牌**:`kit.config.json` 的 `brand.name`(片尾、跑马灯里的名字)、`brand.accent` / `brand.accent2`(#rrggbb,替换所有风格包的主色),单个包微调用 `theme..`。改完先跑 `node tools/overview.mjs` 看一眼。 +3. **画幅**:`formats` 里每个格式都会多一个合成 `-<格式>`。竖屏把 16:9 画面放在中间,上面是章节标题,下面是大字幕;某一场内容集中在中间时,在 `src/scenes/index.ts` 给它 `portrait: {zoom: 1.3}` 放大裁边(`focus` 选保留哪一侧)。 +4. **音量**:`sfx.volume` 是音效总音量;`music.bpm` 决定配乐节拍(场景里 `useBeat()` 用同一个网格)。改了音效配方要跑 `python tools/sfx.py`。 + +## 踩过的坑 + +- 渲染一定要 `--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。 +- 只重做某几场的配音:`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 new file mode 100644 index 0000000..1f84b0e --- /dev/null +++ b/.claude/skills/review/SKILL.md @@ -0,0 +1,50 @@ +--- +name: review +description: 审片:检查 explainer-kit 某一场或整片的动态、声音、竖屏/方屏版式和品牌色。改完场景、换了风格包或品牌色、要交付之前使用;用户说「看看效果」「检查一下」「审一下」时也用。 +--- + +# 审片 + +渲染工具在没有 Remotion 浏览器的环境(比如云端沙箱)会自动用 `/opt/pw-browsers` 里预装的 headless shell,也可以用 `REMOTION_BROWSER=…` 指定。输出都在 `out/`,不要提交。 + +## 看动态:拼帧,不要只看静帧 + +```bash +node tools/strip.mjs s05:block # cue 前 6 帧到后 84 帧,每 6 帧一格 +node tools/strip.mjs s05:block:-4:120:8 # 场景:cue:起:止:步长 +FORMAT=9x16 node tools/strip.mjs s07:card # 竖屏版式 +``` + +逐格对照这些问题: + +1. **死帧**:cue 之后有没有超过 1 秒什么都不动、或者画面是空的(常见于标题等念到才出、开头半句没配画面)。 +2. **重影**:两个相似画面叠化时互相透出来。换成 `push`,或让旧元素先 `life(..., out)` 离场。 +3. **重叠**:旧内容还没走完新内容就进来了(退场要在下一个 cue 之前结束)。 +4. **冻住**:元素落定后就完全不动。加 `drift`,或背景带一点持续动作。 +5. **字和声音对不上**:屏幕大字跑在配音前面或落后太多。用 ``,不要按固定速度打字。 +6. **出画/遮挡**:元素压到字幕区(画面底部约 120px)或被裁掉。 + +## 看全片和品牌色 + +```bash +node tools/overview.mjs # 每场一格的总览图 +node tools/overview.mjs --format 9x16 # 竖屏总览 +``` + +改 `kit.config.json` 的 `brand.accent` 后跑一次总览,确认九套风格换色后都还看得清。 + +## 听声音(没有扬声器也行) + +```bash +node tools/listen.mjs s05 # 整场 +node tools/listen.mjs s05:block s05:out:30 # 一段 +``` + +输出每个声音离哪个 cue 多少帧(如 `hit+0`)。`--mock` 时旁白是静音的,列出来的全是音效和配乐节拍。核对:每个点击、落地、砸字都有声音,且偏差在 ±3 帧内;没有意外的长时间噪声。 + +## 交付前的检查清单 + +- `npx tsc --noEmit`、`pytest` 通过 +- 改过的每场都拼帧看过,主画幅和 9x16 各一遍 +- 音效位置用 `listen` 核对过 +- 真配音(Windows 上 `python tts/gen.py`)后再看一遍:cue 位置会变,写死的帧数会错位 diff --git a/CLAUDE.md b/CLAUDE.md index b9be65b..88e0d84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,5 +3,6 @@ Remotion 讲解视频模板:旁白里写 `[[cue]]` 标记,edge-tts 的逐词时间戳驱动字幕和动画。用法见 `README.md`。 - 面向用户的文档用简体中文;代码、命令、标识符用英文。 -- 本机是 Windows(渲染、真 TTS 验证在那里做);云端沙箱只做重构、类型检查、`--mock` 时间轴和单测。 +- 本机是 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/`。 +- 常做的事有项目 skill(`.claude/skills/`):`new-scene` 写一场、`review` 审片、`new-style-pack` 新风格包、`publish` 出片。反复手动做的步骤,优先收成 `tools/` 脚本或补进这些 skill。 - 不在仓库里提交字体文件和渲染产物(`out/`、`public/fonts/`)。 diff --git a/README.md b/README.md index a5828c8..3461d62 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 用 Remotion 做讲解视频的模板。旁白写在 `tts/script.json` 里,用 `[[cue]]` 标出动画触发点;edge-tts 合成语音时记下每个词的出口时间,生成 `src/timings.json`。场景代码只问「这个 cue 在第几帧」「这个词在第几帧」,不写死任何秒数,所以改旁白、重跑语音之后,动画和字幕自动跟上。 -仓库自带一支约 60 秒的演示片(2 场):第一场用 `film` 风格包,第二场用 `slides` 风格包和一条 KaTeX 公式。演示片的时间轴和音频是用 `--mock` 生成的静音占位,克隆下来不用联网合成就能预览。 +仓库自带一支约 3 分钟的演示片(9 场),每场换一套风格包:`film`(电影)、`slides`(幻灯片 + KaTeX 公式)、`paper`(手绘笔记)、`neon`(霓虹终端)、`editorial`(杂志排版)、`math`(数学推导)、`keynote`(发布会)、`pixel`(像素游戏)、`ink`(水墨)。演示片的时间轴和音频是用 `--mock` 生成的静音占位,克隆下来不用联网合成就能预览。 ## 快速开始 @@ -25,15 +25,24 @@ python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg; | 路径 | 作用 | |---|---| -| `kit.config.json` | 唯一配置:合成 ID、尺寸、帧率、旁白音色与语速、引语音色、停顿时长、字幕行长、`--mock` 语速、每场前后留白、字体 | +| `kit.config.json` | 唯一配置:合成 ID、尺寸、帧率、旁白音色与语速、引语音色、停顿时长、字幕行长、`--mock` 语速、每场前后留白、品牌色、画幅、音效音量、配乐节拍、字体 | | `tts/script.json` | 旁白稿,每场一个 `{id, chapter, text}` | | `tts/gen.py` | 旁白 → 音频 + `src/timings.json`;`--mock` 离线估算 | -| `src/core/` | 与风格无关的核心:时间轴 API、`Video` 外壳、布局助手、`Tex`、配色与字体栈 | +| `src/core/` | 与风格无关的核心:时间轴 API、动效词汇(`motion.ts`)、跟旁白同步的文字(`Spoken`)、镜头与转场(`Shots`)、`Video` 外壳、布局助手、`Tex`、配色与字体栈 | | `src/film/` | 电影风格包(可删) | | `src/slides/` | 幻灯片风格包(可删) | +| `src/paper/` | 手绘笔记风格包(可删) | +| `src/neon/` | 霓虹终端风格包(可删) | +| `src/editorial/` | 杂志排版风格包(可删) | +| `src/math/` | 数学推导风格包(可删) | +| `src/keynote/` | 发布会风格包(可删) | +| `src/pixel/` | 像素游戏风格包(可删) | +| `src/ink/` | 水墨风格包(可删) | | `src/scenes/` | 你的场景;`index.ts` 是登记表 | -| `tools/` | 审图、拼图、配乐、混音、下载字体 | +| `tools/` | 一键出片、新建场景/风格包、审图、拼帧、听音效、配乐、混音、音效合成、下载字体 | | `public/fx/` | 胶片颗粒和纸张纹理 | +| `public/sfx/` | 音效库(`tools/sfx.py` 合成,无版权问题) | +| `.claude/skills/` | 给 Claude Code 的项目 skill:写一场、审片、新风格包、出片 | ## 配置 `kit.config.json` @@ -46,6 +55,11 @@ python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg; | `maxLine` | 字幕每行最多字数 | | `mockCharsPerSec` | `--mock` 估算语速,默认 4.2 字/秒 | | `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 时放大画面、裁掉两侧 | +| `sfx.volume` | 音效总音量(0–1) | +| `music.bpm` | 配乐节拍;场景里 `useBeat()` 用同一个节拍网格 | | `fonts` | `sans` / `serif` / `mono` 的字体名、文件、下载地址和后备字体 | `src/Root.tsx`、`tools/*.mjs`、`tools/*.py`、`tts/gen.py` 都从这里读,不要在别处写死这些值。 @@ -76,7 +90,7 @@ python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg; | `rel('词', 'cue')` | 从这个 cue 到它之后第一次说到这个词,隔了多少帧 | | `f` `total` `end` `lead` | 当前帧、本场总帧数、旁白结束帧、开场留白帧数 | -配套的动画助手:`ease(f, at, dur)` 缓动 0→1,`pop(f, at)` 带回弹,`fu(p)` 上浮淡入样式,`lerp(p, a, b)`,`window_(f, a, b)` 在 a..b 之间可见并带淡入淡出,`num(v)` 千分位。 +配套的动画助手:`ease(f, at, dur)` 缓动 0→1,`pop(f, at)` 带回弹,`fu(p)` 上浮淡入样式,`lerp(p, a, b)`,`window_(f, a, b)` 在 a..b 之间可见并带淡入淡出,`num(v)` 千分位。更完整的动效词汇见下面的「动效」。 ```tsx const {f, c, w, rel} = useScene(); @@ -88,27 +102,128 @@ const stampAt = w('重新对一遍'); // 说到这个词时盖 `w()` 找不到词、`c()` 找不到 cue 都会直接抛错,改稿后 Studio 里会立刻报出来。 +## 动效 + +讲解视频的画面最容易「死」:元素淡入以后一动不动,下一个镜头靠叠化硬切过去,屏幕上的大字按固定速度打出来、跟嘴型对不上。`src/core` 提供一套跟风格无关的动效词汇来避开这些问题,所有风格包都能用。 + +**进场和退场**(`motion.ts`) + +| 函数 | 作用 | +|---|---| +| `tween(f, at, dur, ease)` | 0→1,`ease` 可选 `out`(进场)、`in`(退场)、`inOut`(运镜、转场)、`expo`(大标题)、`back`(回弹) | +| `life(f, at, out)` | 返回 `{p, q}`:进场进度和退场进度。要离场的元素都应该给 `out` | +| `move(kind, p, q)` | 把进度变成样式:`rise` `fade` `scale` `blur` `left` `right` `drop`。退场沿进场方向继续走(升上来的就升出去),不会原路倒回 | +| `stagger(at, i, gap)` | 第 i 个元素的起始帧,用于错峰 | +| `springAt(f, at, kind)` | 弹簧:`soft` 无回弹、`snappy` 一点、`bouncy` 很多 | +| `drift(f, seed)` | 缓慢的有机漂移(CSS transform)。元素落定后加一点,就不会冻住 | +| `pulse(f, period)` | 0..1 的正弦呼吸 | +| `punch(f, at)` | 说到某个词时放大一下再回落 | +| `shake(f, at)` | 衰减的镜头震动(盖章、砸数字) | +| `noise(seed, x)` `rng(seed)` | 确定性的平滑噪声和随机数 | + +```tsx +const {p, q} = life(f, c('cards'), c('formula')); // 卡片在 formula 时离场 +… +``` + +**跟旁白同步的字**(`Spoken.tsx`) + +`` 让每个字在旁白念到它的那一帧出现,用的就是字幕的逐词时间,不再是固定的每秒几个字。`text` 必须是旁白里原样出现的一段(可以加 `\n` 换行);找不到会抛错。`mode`:`rise` `blur` `fade` `scale` `drop` `pop`(弹出),或 `ink`(先显示暗字,念到才点亮)。`hot={['关键词']}` 让关键词念到时变色。`useSpoken().chars(text)` 返回每个字的帧,`span(text)` 返回整段 `[开始, 结束]`。film 的 `BigQuote` 加 `spoken` 就按引语的配音逐字出现。 + +**镜头和转场**(`shots.tsx`) + +`Shots` 按 cue 切镜头,每个镜头自带本地时间 `t` 和长度 `d`。转场 `tr`: + +| 转场 | 效果 | +|---|---| +| `fade` / `slow` | 叠化(12 / 26 帧)——两个相似的画面叠化会有重影,换别的 | +| `cut` / `flash` | 硬切 / 白闪切 | +| `black` | 先落到底色再出来 | +| `push` / `up` | 新镜头把旧镜头横向 / 向上推走 | +| `zoom` | 旧镜头冲过镜头,新镜头落定 | +| `wipe` | 带亮边的划像 | +| `whip` | 快速甩镜,带运动模糊 | +| `iris` | 新镜头从画面中心的圆里打开 | + +`dur` 可以按镜头覆盖转场时长。`Shots` 原来在 film 包里,现在在 core,film 继续从原位置导出。 + +**几条动效原则**(多数来自整理 Opus 5.5 生成视频提示词的 [awesome-opus5-5-videos](https://github.com/yihui-dev/awesome-opus5-5-videos) 里反复出现的要求): + +- 不要死帧:超过 1 秒什么都不动,观众就会走神。落定的元素加 `drift`,背景自己带一点不停的动作。 +- 能变形就别切:同一个形状改尺寸、圆角、颜色,内容跟着换(keynote 包的 `Morph`),比叠化到另一张图更连贯。 +- 容器先动,内容后进;内容先走,容器再变。这样文字永远不会和下一个状态叠在一起。 +- 弹簧只要一点点回弹;大回弹留给少数需要「砸」的时刻。 +- 镜头跟着重点走:推近到正在讲的那一块(keynote 的 `ZoomCam`、film 的 `Cam`)。 +- 每个画面变化都落在旁白的 cue 或词上;有配乐的片子,也可以落在节拍上。 + +**音效和节拍**(`Sfx.tsx`) + +`` 在本场某一帧播放 `public/sfx/pop.wav`;同一个声音打在多帧用 ``。声音库:`click` `pop` `whoosh` `whip` `swish` `thud` `slam` `tick` `type` `coin` `levelup` `glitch` `chime` `draw`,都由 `python tools/sfx.py` 用 numpy 合成(改配方后重跑)。**放在场景最外层**,不要放进 `Shots` 的镜头里,镜头切走会把声音截断。`Shots` 会给移动类转场自动配声音(`push`/`up`/`zoom` 配 `whoosh`,`whip` 配 `whip`,`wipe`/`iris` 配 `swish`),`sfx={false}` 关掉。音效跟画面一起进渲染出来的音轨,`tools/mix.py` 再把配乐压在下面。 + +配乐(`tools/music.py`)在 `music.bpm` 的节拍网格上加了很轻的鼓点。场景里 `const beat = useBeat()`:`beat.next(帧)` 把事件吸附到下一拍,`beat.nearest(帧)` 吸附到最近一拍,`beat.pulse()` 每拍一次从 1 衰减到 0(neon 的地平线、pixel 小人原地踏步都用它)。 + +**审动态**:静帧只能看到元素落定后的样子,看不到它怎么动。`node tools/strip.mjs s01:quote` 把 cue 前 6 帧到后 84 帧、每 6 帧一张拼成一张图(`场景:cue:起:止:步长`,最多 16 格),一次渲染出结果,输出 `out/strips/_.png`。重点看三件事:cue 之后有没有一两秒什么都没有;元素是不是落定之后就再也不动;两个镜头叠化时有没有重影。 + ## 工作流 1. **写稿**:编辑 `tts/script.json`。 2. **合成**:`python tts/gen.py`(只重做某几场:`python tts/gen.py s02`;离线占位:`python tts/gen.py --mock`)。 3. **预览**:`npm run studio`。 -4. **审图**:`node tools/stills.mjs s01 s02` 在每个 cue 后约 2.5 秒出一张图(`OFF=30 node tools/stills.mjs` 改偏移),`python tools/sheet.py s01` 拼成 2×2 的检查表;只看某几帧用 `node tools/pick.mjs s01:quote:40 s02:end:-10`(`场景:cue:偏移帧`,`end` 表示旁白结束)。输出在 `out/stills`、`out/sheets`、`out/pick`。 -5. **渲染**:`npm run render`(带 `--gl=angle`)→ `out/.mp4`。 +4. **审图**:`node tools/stills.mjs s01 s02` 在每个 cue 后约 2.5 秒出一张图(`OFF=30 node tools/stills.mjs` 改偏移),`python tools/sheet.py s01` 拼成 2×2 的检查表;只看某几帧用 `node tools/pick.mjs s01:quote:40 s02:end:-10`(`场景:cue:偏移帧`,`end` 表示旁白结束);看动态用 `node tools/strip.mjs s01:quote`(见「动效」)。输出在 `out/stills`、`out/sheets`、`out/pick`、`out/strips`。 + 听音效:`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`。配乐低通后按旁白做 sidechain 压缩,再整体 `loudnorm=I=-16:TP=-1.5:LRA=11`。 +7. **混音**:`python tools/mix.py` → `out/-mixed.mp4`(竖屏 `python tools/mix.py 9x16`)。配乐低通后按旁白做 sidechain 压缩,再整体 `loudnorm=I=-16:TP=-1.5:LRA=11`。 + +第 2、5、6、7 步可以一条命令跑完:`node tools/publish.mjs`(`--formats 9x16,1x1` 或 `all` 同时出其他画幅,`--skip-tts` 沿用现有配音,`--mock` 离线跑通流程)。 + +## 画幅和品牌 + +场景永远按主画幅(1920×1080)写。竖屏和方屏由外壳排版:16:9 画面放在中间,上面是章节标题,下面是按行长自动放大的字幕,最底下一条全片进度条,背景用这一场风格包的底纹铺满。内容集中在画面中间的场景,可以在登记表里放大裁边:`s07: {component: S07, look: keynote, portrait: {zoom: 1.3}}`(`focus` 0–1 决定裁剪时保留哪一侧)。 + +换品牌色只改 `kit.config.json`:`"brand": {"name": "你的产品", "accent": "#FF5A1F", "accent2": "#7B61FF"}`,九套风格包的主色、副色一起换掉;单个颜色再用 `theme` 微调。每个包把哪些颜色当作主色,写在它调色板的 `themed()` 第三个参数里。 + +## 自动化和 skill + +反复手动做的事都收成了脚本: + +| 命令 | 做什么 | +|---|---| +| `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` | 配音 → 各画幅渲染 → 配乐 → 混音 | +| `node tools/strip.mjs` / `overview.mjs` / `listen.mjs` | 审动态 / 看全片 / 听音效 | + +`.claude/skills/` 里有四个项目 skill,在 Claude Code 里打开这个仓库就能用:`new-scene`(从文稿到一场)、`review`(审片清单)、`new-style-pack`(新风格包的要求)、`publish`(出片和踩过的坑)。 ## 风格包 外壳 `src/core/Video.tsx` 按场景登记表里的 `look` 给每一场「穿衣服」:底色、背景层、画面上的叠加层(颗粒、暗角)、最上层的框(黑边)、章节卡、字幕样式。核心代码从不 import 风格包,不用的包整个删掉即可。 -- **film**(`src/film/`):`Shots` 按 cue 切镜头(`fade`/`cut`/`black`/`flash`/`slow` 转场),`Cam` 缓推运镜,`Graded` 调色(past/warm/cold/dusk/neutral),`Grain`、`Vignette`、`Letterbox`(2.2:1),`Place` 年份地点字幕,`Tag`、`Line`、`BigQuote`,`Paper`、`Doc`、`Stamp`、`Mark`、`InkBars`、`Count`,背景 `Sky`、`Glow`、`Void`、`Desk`,章节卡 `Chapter`。字幕放在下黑边里。 +- **film**(`src/film/`):`Shots` 按 cue 切镜头(转场见「动效」),`Cam` 缓推运镜(`hand` 加手持晃动),`Graded` 调色(past/warm/cold/dusk/neutral),`Grain`、`Vignette`、`Letterbox`(2.2:1),`Place` 年份地点字幕,`Tag`、`Line`、`BigQuote`,`Paper`、`Doc`、`Stamp`、`Mark`、`InkBars`、`Count`(`Doc` 的行可以 `strike: 帧号`,到时用红笔划掉),背景 `Sky`、`Glow`、`Void`、`Desk`,章节卡 `Chapter`。字幕放在下黑边里。 - **slides**(`src/slides/`):`Panel`、`Chip`、`Kicker`、`H`,点阵背景和简单章节卡;字幕是半透明底条,按逐词时间点亮。 +- **paper**(`src/paper/`):手绘笔记。米色方格纸底;`RoughBox`、`RoughCircle`、`RoughArrow`、`RoughLine`、`Squiggle`、`LabelBox` 是会一笔一笔画出来的手绘线条(放在 `` 里,`p` 是绘制进度),画完以后每 4 帧重抖一次(`BOIL`),像手绘动画那样不会僵住;`Note` 便利贴带回弹落下、可以 `out` 撕走;`Marker` 荧光笔;`Hand` 手写感标题。适合讲流程、因果。 +- **neon**(`src/neon/`):深色科技。会一直往镜头滚的透视网格地面、星空、扫描线;`Neon` 霓虹字(像灯管一样闪着亮起);`Term` 终端窗口逐字敲命令;`Net` 节点图,节点带波纹弹出、连线画出后有数据脉冲一直流动;`Glitch` 包住整个镜头做故障切换;`Brackets` HUD 角框。适合讲系统、架构。 +- **math**(`src/math/`):数学推导,Manim 的路子。深色底;`Axes` 网格从原点展开、坐标轴画出来;`Plot` 把函数曲线一笔描上去;`Tangent` 切线和点贴着曲线走;`Readout` 实时数值;`Steps` 一行一行推导 KaTeX 公式,最新一步点亮、旧的变暗。坐标映射用 `Plane` + `px()`/`py()`。适合讲数学、物理。 +- **keynote**(`src/keynote/`):发布会 / 产品演示。暖灰底加缓慢漂移的色块;`Morph` 是贯穿全场的一个形状,按状态表在 logo、按钮、进度条、玻璃卡片之间弹簧变形,内容带短模糊切换;`Cursor` 光标滑到目标并点击(带按下和波纹);`ZoomCam` 屏幕录制式推拉镜头;`Rise` 文字从遮罩线下升起。`chain()` 把多次改变的弹簧叠加起来,中途改目标也不会跳。适合讲产品、做功能演示。 +- **pixel**(`src/pixel/`):像素游戏。所有东西画在 320×180 的 canvas 上再无平滑放大 6 倍,连中文也先栅格化再二值化成硬像素。`drawWorld` 画天空、云、两层视差山和草地(`scroll` 跟随镜头);`hero` 会走路的小人、`coin` 旋转金币、`bar` 分格经验条、`box` RPG 对话框;精灵用字符串画。场景里用 ` …}>` 逐帧画,对话框的字用 `useSpoken().chars()` 跟旁白出现。适合轻松科普、儿童向内容。 +- **ink**(`src/ink/`):水墨。宣纸底;`Mountains` 几层远山各自从雾里晕开(先模糊后清晰)并缓慢漂移,`Mist` 雾带,`Birds` 飞鸟;`Brush` 带飞白的毛笔笔触;`InkBlot` 墨滴在纸上洇开;`VText` 竖排文字(配 `Spoken` 念到哪里墨落到哪里,`\n` 换列);`Seal` 红色印章按下。适合讲历史、诗词、传统文化。 +- **editorial**(`src/editorial/`):杂志排版 / 动态文字。新闻纸底色、黑红两色;`MaskText` 大标题逐行从遮罩下升起(也能逐行升出去);`Slam` 大数字砸进画面,`hits` 在说到某个词时再顶一下;`Block` 色块推进来再从另一侧推出去(用来遮住换版);`Rule` 画线;`Label` 小标签;`Ticker` 跑马灯。适合讲观点、金句、数据。 + +所有包共用 `src/core` 的 `At`(`center` 时居中位移会和你传入的 `transform` 叠加)、`Full`、`Svg`、`DrawLine`、`Tex`、`Shots`、`Spoken` 和动效函数。film 和 slides 的配色在 `src/core/theme.ts`,新包各自带调色板(`P`、`N`、`E`、`M`、`K`、`PX`、`I`)。 -两个包共用 `src/core` 的 `At`(`center` 时居中位移会和你传入的 `transform` 叠加)、`Full`、`Svg`、`DrawLine`、`Tex`,配色统一在 `src/core/theme.ts`。 +字幕样式由风格包的 `look.subtitles` 决定(浅色底的包用 `boxColor` 换成深色底条),单场可以在登记表里覆盖,例如 `s01: {component: S01, look: film, subtitles: {hideQuotes: true}}`:这一场已经把引语大字放在画面上,就不再给引语配字幕。 + +**选哪套**:叙事、人物用 film;概念、API 用 slides;流程、因果、拆解步骤用 paper;系统、架构、数据流用 neon;观点、金句、关键数字用 editorial;公式推导、函数图像用 math;产品介绍、操作演示用 keynote;轻松科普、儿童向用 pixel;历史、诗词、传统文化用 ink。同一支片子可以按场混用。 + +### 写一个新风格包 + +`node tools/new-pack.mjs <名字> --base <底色> --accent <主色>` 生成骨架,再照着 `src/paper/index.tsx` 这类成熟的包扩展:调色板走 `themed()`(品牌色才能替换)、背景带一点不停的小动作(滚动、闪烁、纸纹跳动)、章节卡、几个道具组件,最后导出一个 `Look`。道具只用 `useCurrentFrame()` 和传进来的帧号,不要用 `Math.random()`。要求清单见 `.claude/skills/new-style-pack/SKILL.md`。 ## 加一个场景 +最快:`node tools/new-scene.mjs s10 --look slides --chapter "第十场 · 标题" --text "[[a]]……[[b]]……"`,下面三步它都会做好。手动的话: + 1. 在 `tts/script.json` 里加一项,比如 `{"id": "s03", "chapter": "第三场 · 标题", "text": "[[a]]……[[b]]……"}`,跑 `python tts/gen.py s03`(或 `--mock`)。 2. 新建 `src/scenes/s03.tsx`: @@ -134,6 +249,12 @@ const stampAt = w('重新对一遍'); // 说到这个词时盖 - **超过 10 分钟左右的渲染要脱离终端跑,看日志**,别挂在会超时的后台 shell 上,超时会把渲染一起杀掉。Git Bash:`nohup npm run render > out/render.log 2>&1 &`;PowerShell:`Start-Process npm.cmd -ArgumentList 'run','render' -RedirectStandardOutput out/render.log -RedirectStandardError out/render.err -WindowStyle Hidden`,然后看 `out/render.log`。 - **edge-tts 原始输出只有 -24 LUFS 左右**,直接发布会明显偏小声。发布前一定跑 `tools/mix.py`(或至少做一遍 loudnorm)。 - **一场的第一个 cue 太晚,开场会空 3–6 秒**。镜头从 cue 才开始画的话,章节卡淡出后到第一个 cue 之间什么都没有。第一个 cue 放在文稿最前面,或者让第一个镜头从第 0 帧开始(演示片 s01 就是这样)。 +- **标题用 `Spoken` 等念到才出,前面那半句也会空**。「换一套衣服:手绘笔记风」里标题要等一秒多才念到。给开头那半句也配上字(演示片 s03–s05 的小标签),或者把标题提前放好、用 `mode="ink"` 念到再点亮。 +- **相似画面别用 `fade` 转场**。两张纸、两组卡片叠化时会叠出重影;用 `push`,或者先让旧元素 `life(..., out)` 离场、再让新元素进场(演示片 s02)。 +- **只看静帧审不出动效问题**。静帧默认在 cue 后 2.5 秒,那时一切都已落定。用 `tools/strip.mjs` 看 cue 前后一段。 +- **没装 Remotion 自带浏览器的环境**(比如云端沙箱):`tools/` 里的渲染脚本会自动用 `/opt/pw-browsers` 里预装的 headless shell,也可以设 `REMOTION_BROWSER=/path/to/chrome-headless-shell` 指定。 +- **音效放进 `Shots` 的镜头里会被截断**:镜头一切走就卸载了。`` 放在场景最外层。 +- **多了几个合成以后,直接 `remotion render src/index.ts` 会问你渲染哪一个**。用 `npm run render`(`tools/render.mjs`),它按 `kit.config.json` 选好合成和输出路径。 - **`zh-CN-YunyangNeural` 在 +6% 时只有约 3.3 字/秒**,听着拖。用它的话语速调到 +15% 左右。 - **`--mock` 的时间只是估算**(每字 1/4.2 秒),用来搭画面足够,但换成真配音后每个 cue 的位置都会变;按 cue 和词取帧的写法不受影响,写死的帧数会错位。 - **只重做某几场时,其他场沿用旧 timings**:`python tts/gen.py s02` 不会动 s01 的音频和时间。 @@ -145,7 +266,7 @@ const stampAt = w('重新对一遍'); // 说到这个词时盖 explainer-kit is a Remotion template for narrated explainer videos. Narration lives in `tts/script.json` with inline markers: `[[cue]]` marks an animation/shot trigger, `||` inserts a dramatic pause, and `<>` reads a quote with an alternate voice. `tts/gen.py` synthesizes each scene with edge-tts, records word boundaries and writes `public/audio/.mp3` plus `src/timings.json`. Scenes read frames through `useScene()` — `c('cue')`, `w('word')`, `rel('word', 'cue')` — so durations and animations follow the audio after any rewrite. -Quick start: `npm i && npm run studio` shows the bundled ~60 s demo, whose timings and silent audio were produced by `python tts/gen.py --mock` (no network; word times estimated from character count). `npm run fonts` downloads the Noto Sans SC / Noto Serif SC variable fonts (OFL) into `public/fonts/`; without them the stacks fall back to system fonts. All size, fps, voice, pace and font settings live in `kit.config.json`. Two optional style packs are included: `film` (letterbox, grain, grade, cue-driven shots, paper props) and `slides` (panels, chips, karaoke subtitles). Review with `tools/stills.mjs`, `tools/pick.mjs` and `tools/sheet.py`; render with `npm run render` (uses `--gl=angle`); add a synthesized score with `tools/music.py` and mix/normalize to -16 LUFS with `tools/mix.py`. +Quick start: `npm i && npm run studio` shows the bundled ~3 min, nine-scene demo (one scene per style pack), whose timings and silent audio were produced by `python tts/gen.py --mock` (no network; word times estimated from character count). `npm run fonts` downloads the Noto Sans SC / Noto Serif SC variable fonts (OFL) into `public/fonts/`; without them the stacks fall back to system fonts. All size, fps, voice, pace and font settings live in `kit.config.json`. Nine optional style packs are included: `film` (letterbox, grain, grade, cue-driven shots, paper props), `slides` (panels, chips, karaoke subtitles), `paper` (hand-drawn strokes that draw on and boil, sticky notes, highlighter), `neon` (scrolling grid floor, scanlines, glowing type, terminal, node graph with flowing pulses, glitch), `editorial` (masked kinetic headlines, slammed numbers, colour-block wipes, ticker), `math` (Manim-style axes, traced plots, sliding tangent, step-by-step TeX), `keynote` (one morphing shape, cursor clicks, liquid glass, screen-studio zoom), `pixel` (320×180 canvas upscaled with hard pixels, walking hero, coins, RPG dialog) and `ink` (ink-wash mountains bleeding through mist, brush strokes, vertical calligraphy, red seal). `src/core/motion.ts` holds a shared motion vocabulary (enter/exit with `life` + `move`, springs, idle `drift`, `punch`, `shake`), `` reveals on-screen text exactly as the narrator says it, and `Shots` cuts on cues with `fade`/`push`/`up`/`zoom`/`wipe`/`whip`/`iris`/`black`/`flash` transitions. Sound effects are synthesized by `tools/sfx.py` into `public/sfx/` and placed with `` (moving `Shots` transitions add their own); the score pulses on a `music.bpm` grid that scenes can follow with `useBeat()`. `brand.accent`/`accent2` in `kit.config.json` recolour every pack, `theme.` overrides single keys, and `formats` adds portrait (9:16) and square (1:1) compositions that lay the 16:9 picture out with a title band, large captions and a progress bar. `tools/publish.mjs` runs TTS → render (all formats) → score → mix in one go; `tools/new-scene.mjs` and `tools/new-pack.mjs` scaffold scenes and style packs; `.claude/skills/` holds Claude Code skills for writing a scene, reviewing, building a pack and publishing. Review with `tools/stills.mjs`, `tools/pick.mjs`, `tools/sheet.py`, `tools/strip.mjs` (a filmstrip of frames around a cue, to judge motion rather than end states), `tools/overview.mjs` (one frame per scene) and `tools/listen.mjs` (renders a range's audio and lists sound events by cue); render with `npm run render` (uses `--gl=angle`); add a synthesized score with `tools/music.py` and mix/normalize to -16 LUFS with `tools/mix.py`. ## License diff --git a/kit.config.json b/kit.config.json index 45eb729..f3fe562 100644 --- a/kit.config.json +++ b/kit.config.json @@ -17,6 +17,18 @@ "leadOverride": {}, "tailOverride": {} }, + "brand": { + "name": "explainer-kit", + "accent": null, + "accent2": null + }, + "theme": {}, + "formats": { + "9x16": {"width": 1080, "height": 1920, "zoom": 1}, + "1x1": {"width": 1080, "height": 1080, "zoom": 1} + }, + "sfx": {"volume": 0.5}, + "music": {"bpm": 96}, "fonts": { "sans": { "family": "NotoSansSC", diff --git a/package.json b/package.json index a22f4a0..77e5a14 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,23 @@ "type": "module", "scripts": { "studio": "remotion studio src/index.ts", - "render": "remotion render src/index.ts --gl=angle", + "render": "node tools/render.mjs", "still": "remotion still src/index.ts", "tts": "python tts/gen.py", "tts:mock": "python tts/gen.py --mock", "fonts": "node tools/fonts.mjs", "stills": "node tools/stills.mjs", "pick": "node tools/pick.mjs", + "strip": "node tools/strip.mjs", + "overview": "node tools/overview.mjs", + "listen": "node tools/listen.mjs", "sheet": "python tools/sheet.py", "music": "python tools/music.py", "mix": "python tools/mix.py", + "sfx": "python tools/sfx.py", + "publish": "node tools/publish.mjs", + "new:scene": "node tools/new-scene.mjs", + "new:pack": "node tools/new-pack.mjs", "typecheck": "tsc --noEmit", "test": "pytest" }, diff --git a/public/audio/s03.wav b/public/audio/s03.wav new file mode 100644 index 0000000..d6a9e94 Binary files /dev/null and b/public/audio/s03.wav differ diff --git a/public/audio/s04.wav b/public/audio/s04.wav new file mode 100644 index 0000000..7f52e40 Binary files /dev/null and b/public/audio/s04.wav differ diff --git a/public/audio/s05.wav b/public/audio/s05.wav new file mode 100644 index 0000000..4714615 Binary files /dev/null and b/public/audio/s05.wav differ diff --git a/public/audio/s06.wav b/public/audio/s06.wav new file mode 100644 index 0000000..d529155 Binary files /dev/null and b/public/audio/s06.wav differ diff --git a/public/audio/s07.wav b/public/audio/s07.wav new file mode 100644 index 0000000..379b0a9 Binary files /dev/null and b/public/audio/s07.wav differ diff --git a/public/audio/s08.wav b/public/audio/s08.wav new file mode 100644 index 0000000..14ee7fc Binary files /dev/null and b/public/audio/s08.wav differ diff --git a/public/audio/s09.wav b/public/audio/s09.wav new file mode 100644 index 0000000..d1dd9e5 Binary files /dev/null and b/public/audio/s09.wav differ diff --git a/public/sfx/chime.wav b/public/sfx/chime.wav new file mode 100644 index 0000000..a414c51 Binary files /dev/null and b/public/sfx/chime.wav differ diff --git a/public/sfx/click.wav b/public/sfx/click.wav new file mode 100644 index 0000000..4e60a4b Binary files /dev/null and b/public/sfx/click.wav differ diff --git a/public/sfx/coin.wav b/public/sfx/coin.wav new file mode 100644 index 0000000..78eab2b Binary files /dev/null and b/public/sfx/coin.wav differ diff --git a/public/sfx/draw.wav b/public/sfx/draw.wav new file mode 100644 index 0000000..0663dc4 Binary files /dev/null and b/public/sfx/draw.wav differ diff --git a/public/sfx/glitch.wav b/public/sfx/glitch.wav new file mode 100644 index 0000000..a2b9ae3 Binary files /dev/null and b/public/sfx/glitch.wav differ diff --git a/public/sfx/levelup.wav b/public/sfx/levelup.wav new file mode 100644 index 0000000..fd2a742 Binary files /dev/null and b/public/sfx/levelup.wav differ diff --git a/public/sfx/pop.wav b/public/sfx/pop.wav new file mode 100644 index 0000000..7e981e8 Binary files /dev/null and b/public/sfx/pop.wav differ diff --git a/public/sfx/slam.wav b/public/sfx/slam.wav new file mode 100644 index 0000000..658b195 Binary files /dev/null and b/public/sfx/slam.wav differ diff --git a/public/sfx/swish.wav b/public/sfx/swish.wav new file mode 100644 index 0000000..065be94 Binary files /dev/null and b/public/sfx/swish.wav differ diff --git a/public/sfx/thud.wav b/public/sfx/thud.wav new file mode 100644 index 0000000..3f9ebbd Binary files /dev/null and b/public/sfx/thud.wav differ diff --git a/public/sfx/tick.wav b/public/sfx/tick.wav new file mode 100644 index 0000000..9361c4d Binary files /dev/null and b/public/sfx/tick.wav differ diff --git a/public/sfx/type.wav b/public/sfx/type.wav new file mode 100644 index 0000000..f9e4966 Binary files /dev/null and b/public/sfx/type.wav differ diff --git a/public/sfx/whip.wav b/public/sfx/whip.wav new file mode 100644 index 0000000..a813613 Binary files /dev/null and b/public/sfx/whip.wav differ diff --git a/public/sfx/whoosh.wav b/public/sfx/whoosh.wav new file mode 100644 index 0000000..5917234 Binary files /dev/null and b/public/sfx/whoosh.wav differ diff --git a/src/Root.tsx b/src/Root.tsx index da15653..2e162e5 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -1,9 +1,34 @@ import React from 'react'; import {Composition} from 'remotion'; import {CFG} from './config'; +import {Strip, type StripProps, stripTile} from './core/Strip'; import {totalFrames} from './core/timeline'; import {Video} from './core/Video'; +const frames = Math.max(1, totalFrames()); + export const Root: React.FC = () => ( - + <> + + {/* one composition per extra format: "-9x16", "-1x1", … (kit.config.json "formats") */} + {Object.entries(CFG.formats ?? {}).map(([key, fm]) => ( + + ))} + {/* review tool (tools/strip.mjs); as long as the film so the frozen s inside aren't clipped */} + { + if (!props.format) return {}; + const cols = props.cols ?? 2; + const t = stripTile(cols, props.format); + return {width: Math.round(cols * t.w), height: Math.round(Math.ceil(props.frames.length / cols) * t.h)}; + }} + /> + ); diff --git a/src/config.ts b/src/config.ts index bf35979..965ba8e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,14 @@ export type KitConfig = { maxLine: number; mockCharsPerSec: number; pace: {lead: number; tail: number; leadOverride: Record; tailOverride: Record}; + /** brand colours (#rrggbb) applied to every style pack's accent slots; null keeps the pack's own colour */ + brand: {name: string; accent: string | null; accent2: string | null}; + /** per-pack palette overrides, e.g. {"neon": {"cyan": "#00FFC2"}} */ + theme: Record>; + /** extra output formats besides the main width × height; each becomes a composition "-" */ + formats: Record; + sfx: {volume: number}; + music: {bpm: number}; fonts: {sans: FontEntry; serif: FontEntry; mono: FontEntry}; }; diff --git a/src/core/Sfx.tsx b/src/core/Sfx.tsx new file mode 100644 index 0000000..649dd6c --- /dev/null +++ b/src/core/Sfx.tsx @@ -0,0 +1,60 @@ +/** + * Sound effects and the beat grid. + * + * plays public/sfx/pop.wav at a scene-local frame (tools/sfx.py synthesizes them). + * Put them at the top level of a scene, not inside a Shots element: a shot that ends unmounts its sounds mid-play. + */ +import React from 'react'; +import {Audio, Sequence, staticFile} from 'remotion'; +import {CFG, FPS} from '../config'; +import {sceneStarts, useScene} from './timeline'; + +export const SFX = ['click', 'pop', 'whoosh', 'whip', 'swish', 'thud', 'slam', 'tick', 'type', 'coin', 'levelup', 'glitch', 'chime', 'draw'] as const; +export type SfxName = (typeof SFX)[number]; + +export const Sfx: React.FC<{at: number; name: SfxName; volume?: number; rate?: number}> = ({at, name, volume = 1, rate}) => { + if (!Number.isFinite(at)) return null; + return ( + + + + ); +}; + +/** the same sound at several frames (e.g. a keystroke per typed character) */ +export const SfxAt: React.FC<{frames: number[]; name: SfxName; volume?: number}> = ({frames, name, volume}) => ( + <> + {frames.map((fr, i) => ( + + ))} + +); + +const STARTS = sceneStarts(); + +/** + * The music's beat grid (kit.config.json "music.bpm", counted from the start of the film — tools/music.py plays its + * pulse on the same grid). All frames are scene-local, like everything else in a scene. + * - next(fr): first beat at or after fr — snap an event onto the music + * - nearest(fr): closest beat (may be slightly before fr) + * - pulse(): 1 on each beat, decaying to 0 before the next — drive a glow or a bob with it + */ +export const useBeat = () => { + const {id, f} = useScene(); + const bpm = CFG.music?.bpm ?? 96; + const period = (60 / bpm) * FPS; + const off = STARTS[id] ?? 0; + const g = (fr: number) => fr + off; + return { + bpm, + period, + next: (fr: number) => Math.round(Math.ceil(g(fr) / period - 1e-6) * period) - off, + nearest: (fr: number) => Math.round(Math.round(g(fr) / period) * period) - off, + pulse: (decay = 0.35) => { + const k = (g(f) % period) / period; + return Math.exp(-k / decay); + }, + /** index of the current beat in the bar (0..3) */ + count: () => Math.floor(g(f) / period) % 4, + }; +}; diff --git a/src/core/Spoken.tsx b/src/core/Spoken.tsx new file mode 100644 index 0000000..3102e6b --- /dev/null +++ b/src/core/Spoken.tsx @@ -0,0 +1,141 @@ +/** + * Text that appears exactly as the narrator says it. The per-character frames come from the same + * word boundaries as the subtitles, so on-screen type never runs ahead of or behind the voice. + */ +import React from 'react'; +import {FPS} from '../config'; +import {C} from './theme'; +import {type MoveKind, springAt, tween} from './motion'; +import {TIMINGS, leadOf, useScene} from './timeline'; + +type CharSpan = {start: number; end: number}; +// keyed by the timings object itself, so re-running gen.py while Studio is open never serves stale spans +const cache = new WeakMap(); + +/** the scene's whole narration as one string, with [start, end) seconds for every character */ +const narration = (id: string) => { + const hit = cache.get(TIMINGS[id]); + if (hit) return hit; + let text = ''; + const spans: CharSpan[] = []; + for (const ln of TIMINGS[id].lines) { + let last = ln.start; + for (let i = 0; i < ln.text.length; i++) { + const k = ln.words.findIndex((x) => i >= x.s && i < x.e); + const start = k >= 0 ? (last = ln.words[k].t) : last; + const next = k >= 0 ? ln.words[k + 1] : undefined; + spans.push({start, end: next ? next.t : ln.end}); + } + text += ln.text; + } + const out = {text, spans}; + cache.set(TIMINGS[id], out); + return out; +}; + +/** + * Inside a scene: frames at which each character of `sub` is spoken (n-th occurrence). + * `sub` may contain "\n" for layout; it is ignored for matching. Throws if the phrase isn't in the narration. + */ +export const useSpoken = () => { + const {id} = useScene(); + const lead = leadOf(id); + const fr = (s: number) => lead + Math.round(s * FPS); + const find = (sub: string, nth = 0) => { + const {text, spans} = narration(id); + const flat = sub.replace(/\n/g, ''); + let idx = -1; + for (let k = 0, from = 0; k <= nth; k++, from = idx + 1) { + idx = text.indexOf(flat, from); + if (idx < 0) throw new Error(`"${flat}" (occurrence ${nth}) not spoken in ${id}`); + } + return spans.slice(idx, idx + flat.length); + }; + return { + /** frame each character starts being said */ + chars: (sub: string, nth = 0) => find(sub, nth).map((s) => fr(s.start)), + /** [first frame, last frame] the phrase is being said — handy for highlights and exits */ + span: (sub: string, nth = 0): [number, number] => { + const s = find(sub, nth); + return [fr(s[0].start), fr(s[s.length - 1].end)]; + }, + }; +}; + +export type SpokenMode = MoveKind | 'pop' | 'ink'; + +/** + * Renders `text`, each character entering when it is spoken. + * - mode 'rise' | 'blur' | 'fade' | 'scale' | 'left' | 'drop': per-character entrance + * - mode 'pop': springy scale per character + * - mode 'ink': everything visible but dim, characters light up as they are said (karaoke) + * `hot` colours given substrings with `hotColor` once they are said. + */ +export const Spoken: React.FC<{ + text: string; + nth?: number; + mode?: SpokenMode; + dur?: number; + /** start each character this many frames before it is said (anticipation reads better than lag) */ + early?: number; + hot?: string[]; + hotColor?: string; + dim?: number; + style?: React.CSSProperties; +}> = ({text, nth = 0, mode = 'rise', dur = 10, early = 2, hot = [], hotColor = C.gold, dim = 0.22, style}) => { + const {f} = useScene(); + const {chars} = useSpoken(); + const at = chars(text, nth); + const flat = text.replace(/\n/g, ''); + const isHot = flat.split('').map(() => false); + for (const h of hot) { + for (let i = flat.indexOf(h); i >= 0; i = flat.indexOf(h, i + 1)) for (let j = i; j < i + h.length; j++) isHot[j] = true; + } + let k = 0; + return ( + + {text.split('\n').map((row, ri) => ( + + {ri > 0 ?
: null} + {row.split('').map((ch) => { + const i = k++; + const t0 = at[i] - early; + const p = tween(f, t0, dur, 'out'); + const color = isHot[i] && f >= at[i] ? hotColor : undefined; + let css: React.CSSProperties; + if (mode === 'ink') css = {opacity: dim + (1 - dim) * p}; + else if (mode === 'pop') { + const s = springAt(f, t0, 'bouncy'); + css = {opacity: Math.min(1, s * 2), transform: `scale(${0.4 + 0.6 * s})`}; + } else { + css = mode === 'fade' ? {opacity: p} : movePerChar(mode, p); + } + return ( + + {ch} + + ); + })} +
+ ))} +
+ ); +}; + +const movePerChar = (kind: MoveKind, p: number): React.CSSProperties => { + const a = 1 - p; + switch (kind) { + case 'blur': + return {opacity: p, filter: `blur(${a * 10}px)`}; + case 'scale': + return {opacity: p, transform: `scale(${0.6 + 0.4 * p})`}; + case 'left': + return {opacity: p, transform: `translateX(${a * 24}px)`}; + case 'drop': + return {opacity: p, transform: `translateY(${-a * 0.5}em)`}; + case 'right': + return {opacity: p, transform: `translateX(${-a * 24}px)`}; + default: + return {opacity: p, transform: `translateY(${a * 0.45}em)`}; + } +}; diff --git a/src/core/Strip.tsx b/src/core/Strip.tsx new file mode 100644 index 0000000..8b167fd --- /dev/null +++ b/src/core/Strip.tsx @@ -0,0 +1,35 @@ +/** Motion review: several frames of the film tiled into one still (see tools/strip.mjs). */ +import React from 'react'; +import {AbsoluteFill, Freeze} from 'remotion'; +import {CFG, HEIGHT, WIDTH} from '../config'; +import {Video} from './Video'; + +export type StripProps = {frames: number[]; labels?: string[]; cols?: number; format?: string}; + +/** size of one tile and how much the film is shrunk to fit it */ +export const stripTile = (cols: number, format?: string) => { + const fm = format ? CFG.formats[format] : undefined; + // main format: tiles split the canvas (cols × cols grid); other formats: quarter-size tiles in a row-major grid + return fm ? {w: fm.width / 4, h: fm.height / 4, fw: fm.width, fh: fm.height, k: 1 / 4} : {w: WIDTH / cols, h: HEIGHT / cols, fw: WIDTH, fh: HEIGHT, k: 1 / cols}; +}; + +export const Strip: React.FC = ({frames, labels = [], cols = 4, format}) => { + const t = stripTile(cols, format); + const max = format ? frames.length : cols * cols; + return ( + + {frames.slice(0, max).map((fr, i) => ( +
+
+ + +
+
+ {labels[i] ?? fr} +
+
+ ))} +
+ ); +}; diff --git a/src/core/Video.tsx b/src/core/Video.tsx index 3a76117..5c2219f 100644 --- a/src/core/Video.tsx +++ b/src/core/Video.tsx @@ -1,10 +1,15 @@ -/** The shell: sequences scenes, plays each scene's narration, draws chapter cards and subtitles, loads fonts. */ +/** + * The shell: sequences scenes, plays each scene's narration, draws chapter cards and subtitles, loads fonts. + * Scenes are always authored on the main WIDTH × HEIGHT canvas; for the extra formats in kit.config.json + * ("9x16", "1x1"…) the shell fits that picture into the taller frame with a title band above and big captions below. + */ import React, {useEffect, useState} from 'react'; import {AbsoluteFill, Audio, continueRender, delayRender, interpolate, Sequence, staticFile, useCurrentFrame} from 'remotion'; +import {HEIGHT, WIDTH} from '../config'; import {CFG, FPS} from '../config'; import {SCENES} from '../scenes'; import type {ChapterProps, Look, SubtitleStyle} from './look'; -import {C, FONT} from './theme'; +import {C, FONT, inkOn, lum} from './theme'; import {SCENE_IDS, SceneProvider, TIMINGS, audioOf, leadOf, sceneFrames, sceneStarts} from './timeline'; const STARTS = sceneStarts(); @@ -51,12 +56,14 @@ const Subtitles: React.FC<{id: string; s: SubtitleStyle}> = ({id, s}) => { const f = useCurrentFrame(); const t = (f - leadOf(id)) / FPS; const line = TIMINGS[id].lines.find((l) => t >= l.start && t < l.end); - if (!line) return null; + if (!line || (line.q && s.hideQuotes)) return null; const text = line.text.replace(/[,。;:、,.;:]$/, '').trimEnd(); const q = Boolean(line.q); const op = interpolate(t - line.start, [0, 0.1], [0, 1], {extrapolateRight: 'clamp'}) * interpolate(line.end - t, [0, 0.08], [0, 1], {extrapolateRight: 'clamp'}); + // a new line settles up into place instead of popping + const rise = interpolate(t - line.start, [0, 0.18], [8, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); const color = q ? (s.quoteColor ?? C.gold) : (s.color ?? 'rgba(242,237,228,0.94)'); let body: React.ReactNode = text; if (s.karaoke) { @@ -68,7 +75,7 @@ const Subtitles: React.FC<{id: string; s: SubtitleStyle}> = ({id, s}) => { at[i] = wd ? (last = wd.t) : last; } body = text.split('').map((ch, i) => ( - = at[i] ? 1 : 0.45}}> + = at[i] ? 1 : (s.dim ?? 0.45)}}> {ch} )); @@ -93,9 +100,13 @@ const Subtitles: React.FC<{id: string; s: SubtitleStyle}> = ({id, s}) => { fontSize: s.size ?? 38, fontWeight: q ? 700 : 500, letterSpacing: 2, - whiteSpace: 'pre', + whiteSpace: s.maxWidth ? 'pre-wrap' : 'pre', + maxWidth: s.maxWidth, + textAlign: 'center', + lineHeight: 1.35, color, - ...(s.box ? {padding: '10px 30px', borderRadius: 14, background: 'rgba(3,10,10,0.62)'} : null), + transform: `translateY(${rise}px)`, + ...(s.box ? {padding: '10px 30px', borderRadius: 14, background: s.boxColor ?? 'rgba(3,10,10,0.62)'} : null), }} > {q ? '「' : null} @@ -111,7 +122,9 @@ const splitChapter = (s: string) => { return rest.length ? {kicker: k, title: rest.join(' · ')} : {kicker: '', title: k}; }; -const SceneShell: React.FC<{id: string}> = ({id}) => { +export type FormatKey = string | undefined; + +const SceneShell: React.FC<{id: string; format?: FormatKey}> = ({id, format}) => { const f = useCurrentFrame(); const n = sceneFrames(id); const lead = leadOf(id); @@ -124,31 +137,126 @@ const SceneShell: React.FC<{id: string}> = ({id}) => { const Chapter = look.chapter === undefined ? DefaultChapter : look.chapter; const op = interpolate(f, [n - 18, n], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); const ch = splitChapter(TIMINGS[id].chapter); - return ( - + const subs = {...(look.subtitles ?? DEFAULT_SUBS), ...def?.subtitles}; + const audio = ( + + + ); + const picture = ( + <> {Bg ? : null} {Comp ? : null} {Over ? : null} {Chapter && lead > 30 ? : null} - - {Frame ? : null} - + + ); + + const fmt = format ? CFG.formats[format] : undefined; + if (!fmt) { + return ( + + {picture} + {audio} + + + ); + } + + // ── portrait / square: title band, the 16:9 picture, caption band ── + const W = fmt.width; + const H = fmt.height; + const zoom = def?.portrait?.zoom ?? fmt.zoom ?? 1; + const ps = (W / WIDTH) * zoom; + const pw = WIDTH * ps; + const ph = HEIGHT * ps; + const focus = def?.portrait?.focus ?? 0.5; + const px = Math.min(0, Math.max(W - pw, W / 2 - focus * pw)); + const tall = H / W > 1.3; + // picture centred; the title band takes the space above it and the captions the space below + const py = Math.round((H - ph) / 2); + const capTop = py + ph; + const light = lum(look.base) > 0.5; + const ink = inkOn(look.base); + const accent = subs.quoteColor ?? C.gold; + // the background layer alone, scaled to cover the whole frame and softened, so the bands carry the pack's ground + const cover = Math.max(W / WIDTH, H / HEIGHT); + const titleIn = interpolate(f, [0, 14], [0, 1], {extrapolateRight: 'clamp'}); + return ( + + + {Bg ? ( +
+ +
+ ) : null} + + + + {/* title band: chapter kicker + title, sitting just above the picture */} +
+ {ch.kicker ?
{ch.kicker}
: null} +
{ch.title}
+
+ +
+
{picture}
+
+ {audio} + + + {/* overall progress, the way short-video players show it */} +
); }; -export const Video: React.FC = () => { +const TOTAL = SCENE_IDS.reduce((a, id) => a + sceneFrames(id), 0); +const ProgressBar: React.FC<{id: string; W: number; color: string}> = ({id, W, color}) => { + const f = useCurrentFrame(); + const p = (STARTS[id] + f) / Math.max(1, TOTAL); + return ( + <> +
+
+ + ); +}; + +export const Video: React.FC<{format?: FormatKey}> = ({format}) => { useFonts(); return ( {SCENE_IDS.map((id) => ( - + ))} diff --git a/src/core/index.ts b/src/core/index.ts index 5f045a6..bc1e678 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,6 +1,10 @@ export * from './timeline'; +export * from './motion'; export * from './layout'; export * from './theme'; export * from './look'; +export {Shots, type Shot, type Tr} from './shots'; +export {Spoken, useSpoken, type SpokenMode} from './Spoken'; +export {SFX, Sfx, SfxAt, useBeat, type SfxName} from './Sfx'; export {Tex} from './Tex'; export {CFG, FPS, WIDTH, HEIGHT} from '../config'; diff --git a/src/core/look.ts b/src/core/look.ts index fb01509..3ea894f 100644 --- a/src/core/look.ts +++ b/src/core/look.ts @@ -25,6 +25,14 @@ export type SubtitleStyle = { color?: string; /** lines read by an alternate voice (<>) are set in serif, this colour, with 「」 */ quoteColor?: string; + /** background of the pill when `box` is set */ + boxColor?: string; + /** opacity of not-yet-spoken characters in karaoke mode */ + dim?: number; + /** don't subtitle alternate-voice lines (when the scene already shows the quote big on screen) */ + hideQuotes?: boolean; + /** wrap long lines at this width (px) instead of keeping each line on one row */ + maxWidth?: number; }; export type Look = { @@ -41,4 +49,14 @@ export type Look = { base?: string; }; -export type SceneDef = {component: React.FC; look?: Look}; +export type SceneDef = { + component: React.FC; + look?: Look; + /** per-scene tweaks merged over the look's subtitle style */ + subtitles?: Partial; + /** + * How the 16:9 picture sits in a portrait / square format: `zoom` > 1 crops the sides to make it bigger, + * `focus` (0..1, default 0.5) picks which horizontal part stays in view when cropped. + */ + portrait?: {zoom?: number; focus?: number}; +}; diff --git a/src/core/motion.ts b/src/core/motion.ts new file mode 100644 index 0000000..660fbb5 --- /dev/null +++ b/src/core/motion.ts @@ -0,0 +1,139 @@ +/** + * Motion vocabulary shared by every style pack: easing presets, enter/exit, idle drift, impacts. + * Everything is a pure function of the frame, so parallel rendering stays deterministic. + */ +import type React from 'react'; +import {Easing, interpolate, spring} from 'remotion'; +import {FPS} from '../config'; + +export const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); + +/** Easing presets. `out` for entrances, `in` for exits, `inOut` for camera moves and transitions. */ +export const EASE = { + out: Easing.bezier(0.16, 0.84, 0.24, 1), + in: Easing.bezier(0.55, 0, 0.85, 0.3), + inOut: Easing.bezier(0.45, 0, 0.25, 1), + /** fast start, long settle — titles, big numbers */ + expo: Easing.bezier(0.08, 0.9, 0.12, 1), + /** overshoots then settles */ + back: Easing.bezier(0.3, 1.45, 0.45, 1), + linear: (x: number) => x, +}; +export type EaseName = keyof typeof EASE; + +/** 0→1 from frame `at` over `dur` frames with a named easing. */ +export const tween = (f: number, at: number, dur = 18, e: EaseName = 'out') => + interpolate(f, [at, at + Math.max(1, dur)], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: EASE[e]}); + +/** ease-in-out 0→1 from frame `a` over `dur` frames (camera moves, crossfades) */ +export const eio = (t: number, a: number, dur = 18) => tween(t, a, dur, 'inOut'); +/** linear 0→1 between frames a and b */ +export const lin = (t: number, a: number, b: number) => clamp01((t - a) / Math.max(1, b - a)); + +/** Spring presets (0→1). `soft` has no overshoot, `snappy` a little, `bouncy` a lot. */ +export const SPRING = { + soft: {damping: 200, stiffness: 120, mass: 1}, + snappy: {damping: 14, stiffness: 180, mass: 0.7}, + bouncy: {damping: 9, stiffness: 160, mass: 0.8}, +} as const; +export const springAt = (f: number, at: number, kind: keyof typeof SPRING = 'snappy') => + f < at ? 0 : spring({frame: f - at, fps: FPS, config: SPRING[kind]}); + +/** + * Enter at `at`, leave at `out`: returns {p, q} where p is entrance progress (0→1) + * and q is exit progress (0→1). Things that never leave just omit `out`. + */ +export const life = (f: number, at: number, out = Infinity, inDur = 16, outDur = 12) => ({ + p: tween(f, at, inDur, 'out'), + q: out === Infinity ? 0 : tween(f, out, outDur, 'in'), +}); + +export type MoveKind = 'rise' | 'fade' | 'scale' | 'blur' | 'left' | 'right' | 'drop'; + +/** + * Style for something entering with progress p and leaving with progress q. + * Exits keep moving in the entering direction (a rise leaves upward), so motion reads as one flow. + */ +export const move = (kind: MoveKind, p: number, q = 0, dist = 36): React.CSSProperties => { + const op = p * (1 - q); + const a = 1 - p; // still to travel in + switch (kind) { + case 'fade': + return {opacity: op}; + case 'scale': + return {opacity: op, transform: `scale(${0.86 + 0.14 * p + 0.08 * q})`}; + case 'blur': + return {opacity: op, filter: `blur(${(a + q) * 14}px)`, transform: `scale(${1.04 - 0.04 * p})`}; + case 'left': + return {opacity: op, transform: `translateX(${a * dist - q * dist}px)`}; + case 'right': + return {opacity: op, transform: `translateX(${-a * dist + q * dist}px)`}; + case 'drop': + return {opacity: op, transform: `translateY(${-a * dist + q * dist}px)`}; + case 'rise': + default: + return {opacity: op, transform: `translateY(${a * dist - q * dist * 0.7}px)`}; + } +}; + +/** frame of the i-th item in a staggered group */ +export const stagger = (at: number, i: number, gap = 5) => at + i * gap; + +// ───────────────────────── idle motion ───────────────────────── + +const hash = (n: number) => { + let t = (n + 0x6d2b79f5) >>> 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +}; + +/** smooth deterministic 1-D noise in [-1, 1] */ +export const noise = (seed: number, x: number) => { + const i = Math.floor(x); + const fr = x - i; + const a = hash(i * 7919 + seed * 104729); + const b = hash((i + 1) * 7919 + seed * 104729); + const u = fr * fr * (3 - 2 * fr); + return (a + (b - a) * u) * 2 - 1; +}; + +/** + * Slow organic drift so settled elements keep breathing instead of freezing. + * Returns a CSS transform; amp in px, rot in degrees. + */ +export const drift = (f: number, seed = 1, amp = 6, rot = 0.4, speed = 0.012) => + `translate(${noise(seed, f * speed) * amp}px, ${noise(seed + 17, f * speed) * amp}px) rotate(${noise(seed + 31, f * speed) * rot}deg)`; + +/** gentle 0..1 sine pulse with the given period in frames */ +export const pulse = (f: number, period = 60, phase = 0) => 0.5 - 0.5 * Math.cos(((f + phase) / period) * Math.PI * 2); + +// ───────────────────────── impacts ───────────────────────── + +/** decaying scale bump at frame `at` (e.g. when a word is said): 1 → 1+amp → settles to 1 */ +export const punch = (f: number, at: number, amp = 0.08, dur = 16) => { + if (f < at || f > at + dur) return 1; + const k = (f - at) / dur; + return 1 + amp * Math.sin(k * Math.PI) * (1 - k); +}; + +/** decaying camera shake at frame `at`; returns a CSS transform */ +export const shake = (f: number, at: number, amp = 10, dur = 14) => { + if (f < at || f > at + dur) return 'none'; + const k = 1 - (f - at) / dur; + const x = noise(3, f * 0.9) * amp * k * k; + const y = noise(9, f * 0.9) * amp * k * k; + return `translate(${x}px, ${y}px) rotate(${noise(5, f) * 0.3 * k}deg)`; +}; + +/** deterministic PRNG (same frame → same picture, which rendering in parallel requires) */ +export const rng = (seed: number) => { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +}; diff --git a/src/core/shots.tsx b/src/core/shots.tsx new file mode 100644 index 0000000..0c8a810 --- /dev/null +++ b/src/core/shots.tsx @@ -0,0 +1,104 @@ +/** Shots: a scene as a list of beats that cut on narration cues, with a transition into each beat. */ +import React from 'react'; +import {interpolate, useCurrentFrame} from 'remotion'; +import {EASE, clamp01, tween} from './motion'; +import {Sfx, type SfxName} from './Sfx'; + +/** + * Transition into a shot: + * - fade / slow: crossfade (12 / 26 frames) - cut: hard cut + * - black: dip to the look's base colour - flash: cut with a white flash + * - push / up: the new shot pushes the old one out sideways / upward + * - zoom: the old shot zooms past the camera while the new one settles in + * - wipe: the new shot is revealed left to right behind a moving edge + * - whip: fast blurred pan, for energetic cuts + * - iris: the new shot opens out of a circle in the middle of the frame + */ +export type Tr = 'fade' | 'cut' | 'black' | 'flash' | 'slow' | 'push' | 'up' | 'zoom' | 'wipe' | 'whip' | 'iris'; +export type Shot = {at: number; tr?: Tr; dur?: number; el: (t: number, d: number) => React.ReactNode}; + +const DUR: Record = {fade: 12, slow: 26, cut: 0, black: 16, flash: 12, push: 18, up: 18, zoom: 16, wipe: 20, whip: 10, iris: 20}; +/** sound played with each transition (none for the quiet ones) */ +const TR_SFX: Partial> = {push: 'whoosh', up: 'whoosh', zoom: 'whoosh', whip: 'whip', wipe: 'swish', iris: 'swish', flash: 'thud'}; +const LAYERED = new Set(['fade', 'slow', 'push', 'up', 'zoom', 'wipe', 'whip', 'iris']); + +const layer = (style: React.CSSProperties, child: React.ReactNode, key: string) => ( +
+ {child} +
+); + +/** styles for [outgoing, incoming] at transition progress p */ +const styles = (tr: Tr, p: number, t: number): [React.CSSProperties, React.CSSProperties] => { + switch (tr) { + case 'push': + return [{transform: `translateX(${-p * 100}%)`}, {transform: `translateX(${(1 - p) * 100}%)`}]; + case 'up': + return [{transform: `translateY(${-p * 100}%)`}, {transform: `translateY(${(1 - p) * 100}%)`}]; + case 'zoom': + return [ + {transform: `scale(${1 + p * 0.5})`, opacity: 1 - p, filter: `blur(${p * 8}px)`}, + {transform: `scale(${0.88 + 0.12 * p})`, opacity: p}, + ]; + case 'wipe': + return [{}, {clipPath: `inset(0 ${(1 - p) * 100}% 0 0)`}]; + case 'iris': + // 75% of the frame's diagonal-ish radius covers the corners of a 16:9 frame + return [{transform: `scale(${1 + p * 0.08})`}, {clipPath: `circle(${p * 75}% at 50% 50%)`}]; + case 'whip': { + const b = Math.sin(p * Math.PI) * 28; + return [ + {transform: `translateX(${-p * 100}%)`, filter: `blur(${b}px)`}, + {transform: `translateX(${(1 - p) * 100}%)`, filter: `blur(${b}px)`}, + ]; + } + case 'black': + return [{}, {opacity: tween(t, 4, 12)}]; + case 'fade': + case 'slow': + return [{}, {opacity: p}]; + default: + return [{}, {}]; + } +}; + +/** + * Each shot renders with its own local time `t` and its full length `d`, so every shot can carry its own + * camera move. `bg` paints behind the shots (transparent by default, so the look's base shows through). + * Moving transitions bring their own sound effect; `sfx={false}` turns that off. + */ +export const Shots: React.FC<{shots: Shot[]; end: number; bg?: string; edge?: string; sfx?: boolean}> = ({shots, end, bg, edge = 'rgba(255,255,255,0.9)', sfx = true}) => { + const f = useCurrentFrame(); + const list = [...shots].sort((a, b) => a.at - b.at); + let i = 0; + for (let k = 0; k < list.length; k++) if (f >= list[k].at) i = k; + const cur = list[i]; + const nextAt = (k: number) => (k + 1 < list.length ? list[k + 1].at : end); + const d = Math.max(1, nextAt(i) - cur.at); + const t = f - cur.at; + const tr: Tr = i === 0 ? 'cut' : (cur.tr ?? 'fade'); + const dur = cur.dur ?? DUR[tr]; + const p = dur > 0 ? interpolate(t, [0, dur], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: tr === 'whip' ? EASE.inOut : tr === 'zoom' ? EASE.expo : EASE.inOut}) : 1; + const [outS, inS] = styles(tr, p, t); + const prev = i > 0 ? list[i - 1] : null; + const layers: React.ReactNode[] = []; + if (prev && LAYERED.has(tr) && t < dur) layers.push(layer(outS, prev.el(f - prev.at, Math.max(1, cur.at - prev.at)), `p${i}`)); + layers.push(layer(t < dur || tr === 'black' ? inS : {}, cur.el(t, d), `c${i}`)); + const flash = tr === 'flash' ? interpolate(t, [0, 2, 12], [0, 0.85, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}) : 0; + return ( +
+ {layers} + {tr === 'wipe' && t < dur ? ( +
+ ) : null} + {flash > 0 ?
: null} + {/* every shot's transition sound is always mounted, so it plays out even after the cut */} + {sfx + ? list.slice(1).map((sh, k) => { + const name = TR_SFX[sh.tr ?? 'fade']; + return name ? : null; + }) + : null} +
+ ); +}; diff --git a/src/core/theme.ts b/src/core/theme.ts index 2fadec1..0d8ea93 100644 --- a/src/core/theme.ts +++ b/src/core/theme.ts @@ -1,7 +1,32 @@ /** Shared colour tokens and font stacks. Every token used by core, film and slides lives here. */ import {CFG} from '../config'; -export const C = { +type Slots = Partial>; + +/** + * A pack's palette with the brand applied: kit.config.json "brand.accent" / "brand.accent2" replace the keys the + * pack lists as its accent slots, then "theme." overrides individual keys. Colours must be #rrggbb. + */ +export const themed = >(pack: string, base: T, slots: Slots = {}): T => { + const out: Record = {...base}; + for (const slot of ['accent', 'accent2'] as const) { + const v = CFG.brand?.[slot]; + if (v) for (const k of slots[slot] ?? []) out[k as string] = v; + } + Object.assign(out, CFG.theme?.[pack] ?? {}); + return out as T; +}; + +/** relative luminance of a #rrggbb colour (0 dark … 1 light); anything else counts as dark */ +export const lum = (c?: string) => { + if (!c || !/^#[0-9a-fA-F]{6}$/.test(c)) return 0; + const [r, g, b] = [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +/** readable text colour on a ground colour */ +export const inkOn = (ground?: string) => (lum(ground) > 0.5 ? '#141414' : '#F4F1EA'); + +export const C = themed('core', { // neutrals black: '#050505', ink: '#F2EDE4', @@ -24,7 +49,7 @@ export const C = { paper: '#EFE6D2', paperInk: '#2a241c', stamp: '#B8322A', -}; +}, {accent: ['gold', 'orange'], accent2: ['teal']}); const GENERIC = new Set(['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui']); const stack = (f: {family?: string; fallback: string[]}) => diff --git a/src/editorial/index.tsx b/src/editorial/index.tsx new file mode 100644 index 0000000..e2235e6 --- /dev/null +++ b/src/editorial/index.tsx @@ -0,0 +1,205 @@ +/** Editorial style pack: magazine layout and kinetic type — huge headlines masked up line by line, colour blocks that wipe, rules, a ticker. Delete this folder if unused. */ +import React from 'react'; +import {AbsoluteFill, interpolate, staticFile, useCurrentFrame} from 'remotion'; +import type {ChapterProps, Look} from '../core/look'; +import {EASE, punch, tween} from '../core/motion'; +import {FONT, themed} from '../core/theme'; + +export const E = themed('editorial', { + paper: '#F1ECE2', + ink: '#121212', + red: '#E4322B', + grey: '#8B857A', + rule: 'rgba(18,18,18,0.85)', +}, {accent: ['red']}); + +/** Flat newsprint with faint column guides and a little grain. */ +export const EdGround: React.FC = () => ( + + + + +); + +/** + * Kinetic headline: each line slides up from behind its own mask, staggered by `gap` frames. + * With `out`, lines continue upward out of the mask in the same order. + */ +export const MaskText: React.FC<{ + at: number; + out?: number; + lines: React.ReactNode[]; + size?: number; + weight?: number; + color?: string; + gap?: number; + lh?: number; + serif?: boolean; + align?: React.CSSProperties['textAlign']; + style?: React.CSSProperties; +}> = ({at, out, lines, size = 120, weight = 900, color = E.ink, gap = 5, lh = 1.12, serif = false, align = 'left', style}) => { + const f = useCurrentFrame(); + return ( +
+ {lines.map((ln, i) => { + const p = tween(f, at + i * gap, 20, 'expo'); + const q = out === undefined ? 0 : tween(f, out + i * gap, 14, 'in'); + return ( +
+
{ln}
+
+ ); + })} +
+ ); +}; + +/** A word that slams in (big → settle) at `at`, with optional punches when later words are said. */ +export const Slam: React.FC<{at: number; children: React.ReactNode; size?: number; color?: string; hits?: number[]; style?: React.CSSProperties}> = ({ + at, + children, + size = 260, + color = E.ink, + hits = [], + style, +}) => { + const f = useCurrentFrame(); + if (f < at) return null; + const p = tween(f, at, 12, 'expo'); + const s = 1.7 - 0.7 * p; + const k = hits.reduce((acc, h) => acc * punch(f, h, 0.06), 1); + return ( +
+ {children} +
+ ); +}; + +/** + * Colour block that wipes in from `from` at `at`; with `out` it wipes away toward the opposite side, + * so it reads as one sweep across the frame. + */ +export const Block: React.FC<{x: number; y: number; w: number; h: number; at: number; out?: number; color?: string; from?: 'left' | 'right' | 'top' | 'bottom'; dur?: number; children?: React.ReactNode}> = ({ + x, + y, + w, + h, + at, + out, + color = E.red, + from = 'left', + dur = 16, + children, +}) => { + const f = useCurrentFrame(); + const p = tween(f, at, dur, 'inOut'); + const q = out === undefined ? 0 : tween(f, out, dur, 'inOut'); + if (p <= 0 || q >= 1) return null; + const horiz = from === 'left' || from === 'right'; + const start = {left: 'left', right: 'right', top: 'top', bottom: 'bottom'}[from]; + const end = {left: 'right', right: 'left', top: 'bottom', bottom: 'top'}[from]; + const k = q > 0 ? 1 - q : p; + return ( +
0 ? end : start, + transform: horiz ? `scaleX(${k})` : `scaleY(${k})`, + overflow: 'hidden', + }} + > + {children} +
+ ); +}; + +/** Horizontal rule that draws from the left at `at`. */ +export const Rule: React.FC<{x: number; y: number; w: number; at: number; color?: string; thick?: number}> = ({x, y, w, at, color = E.rule, thick = 3}) => { + const f = useCurrentFrame(); + const p = tween(f, at, 22, 'expo'); + return
; +}; + +/** Small tracked-out label, e.g. "No. 05 — STYLE". */ +export const Label: React.FC<{children: React.ReactNode; color?: string; size?: number; style?: React.CSSProperties}> = ({children, color = E.red, size = 26, style}) => ( +
{children}
+); + +/** Endless marquee band: text scrolls across forever (idle motion that never freezes). */ +export const Ticker: React.FC<{y: number; text: string; at?: number; color?: string; bg?: string; size?: number; speed?: number; h?: number}> = ({ + y, + text, + at = 0, + color = E.paper, + bg = E.ink, + size = 34, + speed = 3, + h = 70, +}) => { + const f = useCurrentFrame(); + const p = tween(f, at, 16, 'inOut'); + const unit = `${text} ✱ `; + const approxW = unit.length * size; + const off = -((f * speed) % approxW); + return ( +
+
+ {Array.from({length: 8}, () => unit).join('')} +
+
+ ); +}; + +/** Chapter card: red block sweeps across, kicker label, title masks up in heavy type. */ +export const EdChapter: React.FC = ({f, lead, kicker, title}) => { + const out = interpolate(f, [lead - 8, lead], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + if (out <= 0) return null; + const sweep = EASE.inOut(Math.min(1, f / 18)); + return ( + + +
+ {/* same masthead positions as a typical editorial scene (label, rule, headline), so the cut into the scene feels continuous */} +
+ +
+ +
+ +
+ + ); +}; + +/** Newsprint ground, kinetic chapter card, solid ink subtitles. */ +export const editorial: Look = { + base: E.paper, + background: EdGround, + chapter: EdChapter, + subtitles: {bottom: 54, box: true, karaoke: true, size: 38, color: E.paper, boxColor: E.ink, quoteColor: '#FF6B5E', dim: 0.4}, +}; diff --git a/src/film/film.tsx b/src/film/film.tsx index d50af57..94e97c8 100644 --- a/src/film/film.tsx +++ b/src/film/film.tsx @@ -1,7 +1,9 @@ /** Film pack: cue-driven shots, camera moves, colour grade, grain, letterbox, documentary titles, paper and stamps. */ import React from 'react'; -import {Easing, interpolate, staticFile, useCurrentFrame} from 'remotion'; +import {interpolate, staticFile, useCurrentFrame} from 'remotion'; import {FPS, HEIGHT} from '../config'; +import {drift, eio, rng} from '../core/motion'; +import {useSpoken} from '../core/Spoken'; import {C, FONT} from '../core/theme'; import {ease, lerp} from '../core/timeline'; @@ -9,70 +11,9 @@ import {ease, lerp} from '../core/timeline'; export const BAR = Math.round(HEIGHT / 11.25); export const MID_Y = HEIGHT / 2; -export const clamp01 = (v: number) => Math.max(0, Math.min(1, v)); -const INOUT = Easing.bezier(0.45, 0, 0.25, 1); -/** ease-in-out 0→1 from frame `a` over `dur` frames (camera moves, crossfades) */ -export const eio = (t: number, a: number, dur = 18) => - interpolate(t, [a, a + dur], [0, 1], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: INOUT}); -/** linear 0→1 between frames a and b */ -export const lin = (t: number, a: number, b: number) => clamp01((t - a) / Math.max(1, b - a)); - -/** deterministic PRNG (same frame → same picture, which rendering in parallel requires) */ -export const rng = (seed: number) => { - let a = seed >>> 0; - return () => { - a = (a + 0x6d2b79f5) >>> 0; - let t = a; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -}; - -// ───────────────────────── shots ───────────────────────── - -export type Tr = 'fade' | 'cut' | 'black' | 'flash' | 'slow'; -export type Shot = {at: number; tr?: Tr; el: (t: number, d: number) => React.ReactNode}; - -/** - * A scene is a list of shots that cut on narration cues. Each shot renders with its own local time `t` - * and its full length `d`, so every shot can carry its own camera move. - */ -export const Shots: React.FC<{shots: Shot[]; end: number}> = ({shots, end}) => { - const f = useCurrentFrame(); - const list = [...shots].sort((a, b) => a.at - b.at); - let i = 0; - for (let k = 0; k < list.length; k++) if (f >= list[k].at) i = k; - const cur = list[i]; - const nextAt = (k: number) => (k + 1 < list.length ? list[k + 1].at : end); - const d = Math.max(1, nextAt(i) - cur.at); - const t = f - cur.at; - const tr = cur.tr ?? 'fade'; - const FADE = tr === 'slow' ? 26 : 12; - const prev = i > 0 ? list[i - 1] : null; - const layers: React.ReactNode[] = []; - if (prev && (tr === 'fade' || tr === 'slow') && t < FADE) { - const pd = Math.max(1, cur.at - prev.at); - layers.push( -
- {prev.el(f - prev.at, pd)} -
, - ); - } - const op = tr === 'fade' || tr === 'slow' ? (i === 0 ? 1 : eio(t, 0, FADE)) : tr === 'black' ? ease(t, 4, 12) : 1; - layers.push( -
- {cur.el(t, d)} -
, - ); - const flash = tr === 'flash' ? interpolate(t, [0, 2, 12], [0, 0.85, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}) : 0; - return ( -
- {layers} - {flash > 0 ?
: null} -
- ); -}; +// Shared with every pack; re-exported so film scenes can keep importing them from here. +export {Shots, type Shot, type Tr} from '../core/shots'; +export {clamp01, eio, lin, rng} from '../core/motion'; /** Slow camera move across a shot: zoom and drift, eased in-out over the whole shot. */ export const Cam: React.FC<{ @@ -83,16 +24,19 @@ export const Cam: React.FC<{ y?: [number, number]; ox?: number; oy?: number; + /** handheld sway in px (0 = locked-off tripod) */ + hand?: number; children: React.ReactNode; -}> = ({t, d, z = [1, 1.06], x = [0, 0], y = [0, 0], ox = 50, oy = 50, children}) => { +}> = ({t, d, z = [1, 1.06], x = [0, 0], y = [0, 0], ox = 50, oy = 50, hand = 0, children}) => { const p = eio(t, 0, Math.max(30, d + 20)); + const f = useCurrentFrame(); return (
{children} @@ -274,8 +218,12 @@ export const Line: React.FC<{t: number; at?: number; text: React.ReactNode; size ); }; -/** Quote revealed character by character, big serif with corner brackets. Use "\n" for line breaks. */ -export const BigQuote: React.FC<{t: number; at?: number; text: string; who?: string; cps?: number; size?: number; color?: string; y?: number}> = ({ +/** + * Quote revealed character by character, big serif with corner brackets. Use "\n" for line breaks. + * With `spoken`, each character appears exactly when the narrator (or the quote voice) says it, + * instead of at a fixed `cps` — the text must then match the narration. + */ +export const BigQuote: React.FC<{t: number; at?: number; text: string; who?: string; cps?: number; size?: number; color?: string; y?: number; spoken?: boolean}> = ({ t, at = 0, text, @@ -284,10 +232,19 @@ export const BigQuote: React.FC<{t: number; at?: number; text: string; who?: str size = 86, color = C.ink, y = MID_Y, + spoken, }) => { - const n = Math.max(0, Math.floor(((t - at) / FPS) * cps)); + const f = useCurrentFrame(); + const {chars} = useSpoken(); const flat = text.replace(/\n/g, ''); - const done = n >= flat.length; + // shown[i]: 0..1 visibility of character i + const spokenAt = spoken ? chars(text) : null; + const shown = flat.split('').map((_, i) => + spokenAt ? ease(f, spokenAt[i] - 2, 8) : i < Math.floor(((t - at) / FPS) * cps) ? 1 : 0, + ); + const done = shown[shown.length - 1] >= 1; + // the opening bracket shows up front, so the frame isn't empty while the narrator leads into the quote + const first = ease(t, at - 6, 10); let k = 0; const rows = text.split('\n'); return ( @@ -295,11 +252,12 @@ export const BigQuote: React.FC<{t: number; at?: number; text: string; who?: str
{rows.map((row, ri) => (
- {ri === 0 ? 「 : null} + {ri === 0 ? 「 : null} {row.split('').map((ch) => { const i = k++; + const v = shown[i]; return ( - + {ch} ); diff --git a/src/film/props.tsx b/src/film/props.tsx index e6e6b20..bd740c5 100644 --- a/src/film/props.tsx +++ b/src/film/props.tsx @@ -56,7 +56,8 @@ export const Desk: React.FC<{tone?: 'warm' | 'cold'; lx?: number; ly?: number}> /> ); -export type DocRow = {k: React.ReactNode; v?: string; at?: number; mark?: boolean; strike?: boolean}; +/** `strike`: true = struck through from the start; a number = the shot-local frame at which a pen line crosses it out */ +export type DocRow = {k: React.ReactNode; v?: string; at?: number; mark?: boolean; strike?: boolean | number}; /** Typed document: title, optional kicker, rows revealed one by one (each row's `at`, else staggered from `at`). */ export const Doc: React.FC<{ @@ -79,22 +80,26 @@ export const Doc: React.FC<{ {rows.map((r, i) => { const rowAt = r.at ?? at + 10 + i * 8; const p = ease(t, rowAt, 12); + const sp = r.strike === true ? 1 : typeof r.strike === 'number' ? ease(t, r.strike, 9) : 0; return (
{r.mark ? {r.k} : r.k} {r.v ? {r.v} : null} + {sp > 0 ? ( +
+ ) : null}
); })} diff --git a/src/ink/index.tsx b/src/ink/index.tsx new file mode 100644 index 0000000..c6f5ba5 --- /dev/null +++ b/src/ink/index.tsx @@ -0,0 +1,247 @@ +/** Ink style pack (水墨): rice paper, ink-wash mountains that bleed in through mist, brush strokes, vertical calligraphy, a red seal. Delete this folder if unused. */ +import React from 'react'; +import {AbsoluteFill, interpolate, staticFile, useCurrentFrame} from 'remotion'; +import {HEIGHT, WIDTH} from '../config'; +import type {ChapterProps, Look} from '../core/look'; +import {clamp01, noise, rng, tween} from '../core/motion'; +import {FONT, themed} from '../core/theme'; + +export const I = themed('ink', { + paper: '#EFE6D3', + ink: '#1B1A18', + wash: '#5B5A55', + mist: 'rgba(239,230,211,0.9)', + seal: '#B3261E', +}, {accent: ['seal']}); + +/** Rice paper: warm ground, fibre texture, soft darker edges. */ +export const RicePaper: React.FC = () => ( + + + + +); + +/** Shared SVG filters: rough ink edges and soft bleeding. Render once per that uses them. */ +const InkDefs: React.FC<{id: string; rough?: number; seed?: number}> = ({id, rough = 6, seed = 3}) => ( + + + + + + + + + + + +); + +/** + * Ink-wash mountain range (full-frame SVG). Each layer bleeds in from the mist at its own frame: + * blurred and faint first, then sharpening — like wet ink spreading into paper. Layers drift slowly. + */ +export const Mountains: React.FC<{layers: {at: number; base: number; amp: number; tone: number; seed: number}[]}> = ({layers}) => { + const f = useCurrentFrame(); + return ( + + + {layers.map((L, i) => { + const p = tween(f, L.at, 40, 'out'); + if (p <= 0) return null; + const drift = f * (0.06 + i * 0.05); + let d = `M-40,${HEIGHT}`; + for (let x = -40; x <= WIDTH + 40; x += 16) { + const n = noise(L.seed, (x + drift) / 260) * 0.65 + noise(L.seed + 7, (x + drift) / 90) * 0.35; + d += ` L${x},${(L.base - (n * 0.5 + 0.5) * L.amp).toFixed(1)}`; + } + d += ` L${WIDTH + 40},${HEIGHT} Z`; + const gid = `mtn-g${i}`; + return ( + + + + + + + + + + + ); + })} + + ); +}; + +/** A low band of mist that drifts across the frame. */ +export const Mist: React.FC<{y: number; h?: number; op?: number}> = ({y, h = 220, op = 0.75}) => { + const f = useCurrentFrame(); + return ( +
+ ); +}; + +/** One brush stroke along `d` (SVG path in frame pixels), swept on from `at` over `dur` frames. */ +export const Brush: React.FC<{d: string; at: number; dur?: number; w?: number; color?: string; seed?: number}> = ({d, at, dur = 16, w = 46, color = I.ink, seed = 5}) => { + const f = useCurrentFrame(); + const p = tween(f, at, dur, 'inOut'); + if (p <= 0) return null; + const id = `brush${seed}`; + return ( + + + + + {/* dry-brush streaks along the stroke */} + {[-0.32, -0.1, 0.18, 0.36].map((o, i) => ( + + ))} + + + ); +}; + +/** Ink drop that spreads into the paper from `at` (centre x, y; final radius r). */ +export const InkBlot: React.FC<{x: number; y: number; r: number; at: number; dur?: number; op?: number; seed?: number}> = ({x, y, r, at, dur = 30, op = 0.85, seed = 2}) => { + const f = useCurrentFrame(); + const p = tween(f, at, dur, 'expo'); + if (p <= 0) return null; + const id = `blot${seed}`; + return ( + + + + + ); +}; + +/** Distant ink birds that keep flying across. */ +export const Birds: React.FC<{n?: number; at?: number}> = ({n = 4, at = 0}) => { + const f = useCurrentFrame(); + if (f < at) return null; + const r = rng(11); + return ( + + {Array.from({length: n}, (_, i) => { + const sx = r() * 400; + const sy = 160 + r() * 140; + const x = -60 + sx + (f - at) * (1.4 + i * 0.25); + const y = sy + Math.sin((f + i * 20) / 30) * 10; + const flap = Math.sin((f + i * 7) / 4) * 6; + const s = 0.7 + r() * 0.5; + return ; + })} + + ); +}; + +/** Vertical calligraphy: columns read top to bottom, right to left. Put inside to ink it as it's said. */ +export const VText: React.FC<{children: React.ReactNode; size?: number; height?: number; color?: string; style?: React.CSSProperties}> = ({children, size = 84, height, color = I.ink, style}) => ( +
+ {children} +
+); + +/** Square red seal with characters cut in white; stamps down at `at`. */ +export const Seal: React.FC<{text: string; at: number; x: number; y: number; size?: number; rot?: number}> = ({text, at, x, y, size = 120, rot = -3}) => { + const f = useCurrentFrame(); + if (f < at) return null; + const k = f - at; + const s = interpolate(k, [0, 5, 9], [1.6, 0.95, 1], {extrapolateRight: 'clamp'}); + const op = interpolate(k, [0, 4], [0, 0.92], {extrapolateRight: 'clamp'}); + const chars = text.split(''); + const half = Math.ceil(chars.length / 2); + // seal text reads right column first + const cols = chars.length > 2 ? [chars.slice(0, half), chars.slice(half)] : [chars]; + return ( +
2 ? size * 0.36 : size * 0.42, + lineHeight: 1.05, + mixBlendMode: 'multiply', + maskImage: `url(${staticFile('fx/grain1.png')})`, + WebkitMaskImage: `url(${staticFile('fx/grain1.png')})`, + maskSize: '200px', + WebkitMaskSize: '200px', + }} + > + {cols.map((col, i) => ( +
+ {col.map((ch, j) => ( + {ch} + ))} +
+ ))} +
+ ); +}; + +/** Chapter card: an ink drop spreads, the title is written vertically beside it, kicker in red. */ +export const InkChapter: React.FC = ({f, lead, kicker, title}) => { + const out = interpolate(f, [lead - 12, lead], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + if (out <= 0) return null; + return ( + + + +
+ + {title} + + + {kicker} + +
+
+ ); +}; + +/** Rice paper ground, ink-drop chapter card, light paper subtitles. */ +export const ink: Look = { + base: I.paper, + background: RicePaper, + chapter: InkChapter, + subtitles: {bottom: 50, box: true, karaoke: true, size: 36, color: I.ink, boxColor: 'rgba(239,230,211,0.88)', quoteColor: I.seal, dim: 0.35}, +}; diff --git a/src/keynote/index.tsx b/src/keynote/index.tsx new file mode 100644 index 0000000..aef0226 --- /dev/null +++ b/src/keynote/index.tsx @@ -0,0 +1,219 @@ +/** + * Keynote style pack: launch-film UI motion. One shape that never cuts — it morphs between states while its + * content swaps; a cursor drives every change; the camera zooms so each moment fills the frame; liquid glass. + * Delete this folder if unused. + */ +import React from 'react'; +import {AbsoluteFill, interpolate, spring, useCurrentFrame} from 'remotion'; +import {FPS} from '../config'; +import type {ChapterProps, Look} from '../core/look'; +import {clamp01, noise, tween} from '../core/motion'; +import {FONT, themed} from '../core/theme'; + +export const K = themed('keynote', { + canvas: '#EEECE8', + ink: '#111111', + sub: '#6E6C68', + line: 'rgba(17,17,17,0.10)', + accent: '#0A84FF', + green: '#30B158', + glass: 'rgba(255,255,255,0.62)', +}, {accent: ['accent']}); + +/** UI spring: quick, with only a hair of overshoot. */ +const ui = (f: number, at: number) => (f < at ? 0 : spring({frame: f - at, fps: FPS, config: {damping: 20, stiffness: 190, mass: 0.75}})); + +/** + * Value driven by a list of targets: one spring per change, summed — so a change that lands + * before the previous one has settled stays continuous instead of jumping. + */ +export const chain = (f: number, keys: {at: number; v: number}[]) => + keys.reduce((acc, k, i) => (i === 0 ? k.v : acc + (k.v - keys[i - 1].v) * ui(f, k.at)), 0); + +const hex = (c: string) => [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16)); +const chainColor = (f: number, keys: {at: number; c: string}[]) => { + const ch = [0, 1, 2].map((j) => Math.round(Math.max(0, Math.min(255, chain(f, keys.map((k) => ({at: k.at, v: hex(k.c)[j]}))))))); + return `rgb(${ch.join(',')})`; +}; + +/** Warm grey canvas with soft colour blobs drifting behind (so glass has something to blur). */ +export const KeyGround: React.FC = () => { + const f = useCurrentFrame(); + const blob = (seed: number, color: string, x: number, y: number, r: number) => ( +
+ ); + return ( + + {blob(1, '#9CC8FF', 1300, 380, 620)} + {blob(2, '#FFC9A8', 560, 720, 560)} + {blob(3, '#D8C8FF', 1500, 860, 420)} + + ); +}; + +/** + * One shape, many states. Each state sets the shape's centre, size, corner radius and fill at frame `at`; + * geometry springs from state to state, and each state's content blurs in after the shape starts moving + * and blurs out just before the next change, so content never overlaps. + */ +export type MorphState = {at: number; x: number; y: number; w: number; h: number; r: number; bg: string; glass?: boolean; content?: React.ReactNode}; +export const Morph: React.FC<{states: MorphState[]}> = ({states}) => { + const f = useCurrentFrame(); + const v = (k: 'x' | 'y' | 'w' | 'h' | 'r') => chain(f, states.map((s) => ({at: s.at, v: s[k]}))); + const x = v('x'); + const y = v('y'); + const w = Math.max(0, v('w')); + const h = Math.max(0, v('h')); + const r = Math.max(0, Math.min(v('r'), w / 2, h / 2)); + let cur = 0; + states.forEach((s, i) => { + if (f >= s.at) cur = i; + }); + const glass = states[cur].glass; + return ( +
({at: s.at, c: s.bg.startsWith('#') ? s.bg : '#ffffff'}))), + ...(glass + ? {background: K.glass, backdropFilter: 'blur(30px) saturate(1.6)', border: '1.5px solid rgba(255,255,255,0.8)', boxShadow: '0 30px 80px rgba(40,30,20,0.18)'} + : {boxShadow: '0 18px 50px rgba(0,0,0,0.16)'}), + overflow: 'hidden', + }} + > + {states.map((s, i) => { + if (!s.content) return null; + const next = states[i + 1]; + const p = tween(f, s.at + 5, 10, 'out'); + const q = next ? tween(f, next.at - 5, 5, 'in') : 0; + const op = p * (1 - q); + if (op <= 0) return null; + return ( +
+ {s.content} +
+ ); + })} +
+ ); +}; + +/** macOS-style arrow cursor that glides between points and clicks (scene frames). */ +export type CursorKey = {at: number; x: number; y: number; click?: number}; +export const Cursor: React.FC<{keys: CursorKey[]; from?: number}> = ({keys, from = keys[0]?.at ?? 0}) => { + const f = useCurrentFrame(); + if (f < from) return null; + const x = chain(f, keys.map((k) => ({at: k.at, v: k.x}))); + const y = chain(f, keys.map((k) => ({at: k.at, v: k.y}))); + const clicks = keys.map((k) => k.click).filter((c): c is number => c !== undefined); + const press = clicks.reduce((acc, c) => acc * (f >= c && f < c + 8 ? 0.82 + 0.18 * ((f - c) / 8) : 1), 1); + return ( + <> + {clicks.map((c) => { + const k = tween(f, c, 18, 'out'); + if (f < c || k >= 1) return null; + return ( +
+ ); + })} + + + + + ); +}; + +/** + * Screen-studio style camera: each key frames the point (x, y) at scale s. Wrap the whole picture in it. + */ +export const ZoomCam: React.FC<{keys: {at: number; x: number; y: number; s: number}[]; children: React.ReactNode}> = ({keys, children}) => { + const f = useCurrentFrame(); + const x = chain(f, keys.map((k) => ({at: k.at, v: k.x}))); + const y = chain(f, keys.map((k) => ({at: k.at, v: k.y}))); + const s = chain(f, keys.map((k) => ({at: k.at, v: k.s}))); + return ( + + {children} + + ); +}; + +/** Text that rises out of a mask line at `at` and (optionally) sinks back at `out`. */ +export const Rise: React.FC<{at: number; out?: number; children: React.ReactNode; size?: number; weight?: number; color?: string; style?: React.CSSProperties}> = ({ + at, + out, + children, + size = 64, + weight = 800, + color = K.ink, + style, +}) => { + const f = useCurrentFrame(); + const p = ui(f, at); + const q = out === undefined ? 0 : tween(f, out, 10, 'in'); + return ( +
+
+ {children} +
+
+ ); +}; + +/** Chapter card: warm canvas, kicker and a wordmark rising out of a mask line. */ +export const KeyChapter: React.FC = ({f, lead, kicker, title}) => { + const out = interpolate(f, [lead - 8, lead], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + if (out <= 0) return null; + return ( + + + + + {kicker} + + + {title} + + + + ); +}; + +/** Warm canvas with drifting colour, glass subtitles. */ +export const keynote: Look = { + base: K.canvas, + background: KeyGround, + chapter: KeyChapter, + subtitles: {bottom: 50, box: true, karaoke: true, size: 36, color: K.ink, boxColor: 'rgba(255,255,255,0.78)', quoteColor: K.accent, dim: 0.35}, +}; diff --git a/src/math/index.tsx b/src/math/index.tsx new file mode 100644 index 0000000..cdb83a9 --- /dev/null +++ b/src/math/index.tsx @@ -0,0 +1,216 @@ +/** Math style pack: Manim-like derivations — a number plane that draws itself, curves traced on, a sliding tangent, TeX that transforms step by step. Delete this folder if unused. */ +import React from 'react'; +import {AbsoluteFill, interpolate, useCurrentFrame} from 'remotion'; +import type {ChapterProps, Look} from '../core/look'; +import {clamp01, tween} from '../core/motion'; +import {Tex} from '../core/Tex'; +import {FONT, themed} from '../core/theme'; + +export const M = themed('math', { + bg: '#0F1222', + text: '#ECEFF4', + dim: '#7D86A8', + grid: 'rgba(88,196,221,0.14)', + axis: '#C9D1E6', + blue: '#58C4DD', + yellow: '#F7D046', + green: '#83C167', + red: '#FC6255', + purple: '#A88BE8', +}, {accent: ['yellow'], accent2: ['blue']}); + +/** Dark ground with a faint background grid, like Manim's NumberPlane behind everything. */ +export const MathGround: React.FC = () => ( + + + + +); + +/** A coordinate system: maths range [x0,x1]×[y0,y1] mapped onto a pixel box. */ +export type Plane = {x0: number; x1: number; y0: number; y1: number; left: number; top: number; w: number; h: number}; +export const px = (pl: Plane, x: number) => pl.left + ((x - pl.x0) / (pl.x1 - pl.x0)) * pl.w; +export const py = (pl: Plane, y: number) => pl.top + (1 - (y - pl.y0) / (pl.y1 - pl.y0)) * pl.h; + +/** Grid, axes and tick labels (inside ). The grid fans out from the origin, then the axes draw. */ +export const Axes: React.FC<{pl: Plane; at: number; step?: number; labels?: boolean}> = ({pl, at, step = 1, labels = true}) => { + const f = useCurrentFrame(); + const g = tween(f, at, 24, 'out'); + const a = tween(f, at + 4, 22, 'inOut'); + const ox = px(pl, 0); + const oy = py(pl, 0); + const xs: number[] = []; + for (let x = Math.ceil(pl.x0 / step) * step; x <= pl.x1 + 1e-9; x += step) xs.push(x); + const ys: number[] = []; + for (let y = Math.ceil(pl.y0 / step) * step; y <= pl.y1 + 1e-9; y += step) ys.push(y); + return ( + + {xs.map((x, i) => { + const k = clamp01(g * 1.6 - Math.abs(x) / (pl.x1 - pl.x0)); + return ; + })} + {ys.map((y, i) => { + const k = clamp01(g * 1.6 - Math.abs(y) / (pl.y1 - pl.y0)); + return ; + })} + + + {labels + ? xs + .filter((x) => x !== 0) + .map((x, i) => ( + + {x} + + )) + : null} + {labels + ? ys + .filter((y) => y !== 0 && y % 2 === 0) + .map((y, i) => ( + + {y} + + )) + : null} + + ); +}; + +const curvePath = (pl: Plane, fn: (x: number) => number, from: number, to: number, n = 160) => { + let d = ''; + for (let i = 0; i <= n; i++) { + const x = from + ((to - from) * i) / n; + const y = Math.max(pl.y0 - 2, Math.min(pl.y1 + 2, fn(x))); + d += `${i ? 'L' : 'M'}${px(pl, x).toFixed(1)},${py(pl, y).toFixed(1)}`; + } + return d; +}; + +/** Graph of fn traced on from left to right starting at `at` (inside ), with a glow like Manim strokes. */ +export const Plot: React.FC<{pl: Plane; fn: (x: number) => number; at: number; dur?: number; color?: string; w?: number; from?: number; to?: number}> = ({ + pl, + fn, + at, + dur = 40, + color = M.blue, + w = 6, + from = pl.x0, + to = pl.x1, +}) => { + const f = useCurrentFrame(); + const p = tween(f, at, dur, 'inOut'); + if (p <= 0) return null; + const d = curvePath(pl, fn, from, to); + return ( + + + + + ); +}; + +/** A dot riding the curve at x, with the tangent line through it (inside ). */ +export const Tangent: React.FC<{pl: Plane; fn: (x: number) => number; x: number; op?: number; len?: number; color?: string; dotColor?: string}> = ({ + pl, + fn, + x, + op = 1, + len = 2.4, + color = M.yellow, + dotColor = M.text, +}) => { + const h = 1e-3; + const k = (fn(x + h) - fn(x - h)) / (2 * h); + const y = fn(x); + // keep the visible length constant in maths units along the line + const dx = len / 2 / Math.sqrt(1 + k * k); + return ( + + + + + + ); +}; + +export type TexStep = {at: number; tex: string; color?: string}; + +/** + * A derivation: each step slides in under the previous one; the newest step is lit, older ones dim. + * With `replace`, each step takes the previous one's place instead (old slides up and fades out). + */ +export const Steps: React.FC<{steps: TexStep[]; size?: number; gap?: number; replace?: boolean}> = ({steps, size = 64, gap = 110, replace}) => { + const f = useCurrentFrame(); + let cur = -1; + steps.forEach((s, i) => { + if (f >= s.at) cur = i; + }); + return ( +
+ {steps.map((s, i) => { + const p = tween(f, s.at, 18, 'out'); + if (p <= 0) return null; + const next = steps[i + 1]; + const q = replace && next ? tween(f, next.at, 14, 'in') : 0; + if (q >= 1) return null; + const y = replace ? 0 : i * gap; + const lit = i === cur; + return ( +
+ +
+ ); + })} +
+ ); +}; + +/** Live numeric readout, e.g. a slope that changes as the point slides. */ +export const Readout: React.FC<{label: string; value: number; digits?: number; color?: string; size?: number}> = ({label, value, digits = 2, color = M.yellow, size = 44}) => ( +
+ {label} = + {(value >= 0 ? ' ' : '') + value.toFixed(digits)} +
+); + +/** Chapter card: centred serif title with a blue underline that draws out from the middle. */ +export const MathChapter: React.FC = ({f, lead, kicker, title}) => { + const out = interpolate(f, [lead - 10, lead], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + if (out <= 0) return null; + const p = tween(f, 4, 20, 'inOut'); + return ( + + + +
{kicker}
+
{title}
+
+ + + ); +}; + +/** Dark Manim ground, underline chapter card, quiet dark subtitles. */ +export const math: Look = { + base: M.bg, + background: MathGround, + chapter: MathChapter, + subtitles: {bottom: 50, box: true, karaoke: true, size: 38, color: M.text, boxColor: 'rgba(15,18,34,0.82)', quoteColor: M.yellow, dim: 0.4}, +}; diff --git a/src/neon/index.tsx b/src/neon/index.tsx new file mode 100644 index 0000000..6c6b963 --- /dev/null +++ b/src/neon/index.tsx @@ -0,0 +1,313 @@ +/** Neon style pack: dark tech — scrolling perspective grid, scanlines, glowing type, terminal, node graph, glitch. Delete this folder if unused. */ +import React from 'react'; +import {AbsoluteFill, interpolate, useCurrentFrame} from 'remotion'; +import {FPS} from '../config'; +import type {ChapterProps, Look} from '../core/look'; +import {clamp01, pulse, rng, springAt, tween} from '../core/motion'; +import {useBeat} from '../core/Sfx'; +import {FONT, themed} from '../core/theme'; + +export const N = themed('neon', { + bg: '#04060E', + sky: '#0B1033', + cyan: '#3BE3FF', + magenta: '#FF3DA8', + lime: '#B4FF39', + violet: '#8C7BFF', + text: '#E8F2FF', + dim: '#5D6C91', + line: 'rgba(59,227,255,0.28)', + glass: 'rgba(10,16,40,0.72)', +}, {accent: ['magenta'], accent2: ['cyan']}); + +/** Night sky over a perspective grid floor that keeps scrolling toward the camera. */ +export const GridFloor: React.FC<{speed?: number; color?: string}> = ({speed = 1.6, color = N.cyan}) => { + const f = useCurrentFrame(); + const beat = useBeat().pulse(); + const r = rng(77); + const stars = Array.from({length: 70}, () => [r() * 1920, r() * 560, r() * 1.8 + 0.6, r() * 60] as const); + return ( + + + {stars.map(([x, y, s, ph], i) => ( + + ))} + +
+ {/* the horizon flares on every beat of the music */} +
+ + ); +}; + +/** CRT scanlines, a slow rolling bright band and a vignette. */ +export const Scanlines: React.FC = () => { + const f = useCurrentFrame(); + const roll = ((f * 4) % 1400) - 200; + return ( + + +
+ + + ); +}; + +/** flicker-on: a fluorescent tube catching, deterministic per seed */ +const flickerOn = (f: number, at: number, seed = 1, dur = 12) => { + if (f < at) return 0; + if (f >= at + dur) return 1; + const r = rng(seed * 997 + (f - at)); + return r() < 0.35 + ((f - at) / dur) * 0.6 ? 1 : 0.15; +}; + +/** Glowing text that flickers on at frame `at` (scene frames). */ +export const Neon: React.FC<{children: React.ReactNode; at?: number; color?: string; size?: number; mono?: boolean; seed?: number; style?: React.CSSProperties}> = ({ + children, + at = 0, + color = N.cyan, + size = 72, + mono, + seed = 1, + style, +}) => { + const f = useCurrentFrame(); + const on = flickerOn(f, at, seed); + const hum = 0.92 + 0.08 * pulse(f, 7 + seed, seed); + return ( +
+ {children} +
+ ); +}; + +/** + * Full-frame glitch: for `dur` frames from `at`, the picture is sliced into bands that jump sideways + * with an RGB split. Wrap a whole shot (or a full-frame layer) in it. + */ +export const Glitch: React.FC<{at: number; dur?: number; amp?: number; children: React.ReactNode}> = ({at, dur = 10, amp = 40, children}) => { + const f = useCurrentFrame(); + const k = f - at; + if (k < 0 || k >= dur) return {children}; + const r = rng(at * 31 + k); + const fade = 1 - k / dur; + const bands = Array.from({length: 7}, () => { + const top = r() * 100; + return {top, h: 3 + r() * 14, dx: (r() - 0.5) * 2 * amp * fade}; + }); + return ( + + + {children} + + {bands.map((b, i) => ( + + {children} + + ))} + + ); +}; + +export type TermLine = {text: string; at: number; out?: boolean; color?: string}; + +/** + * Terminal window at (x, y). Command lines type in at 28 chars/s from their `at` (scene frames); + * output lines (`out: true`) appear at once. + */ +export const Term: React.FC<{x: number; y: number; w?: number; at: number; title?: string; lines: TermLine[]; size?: number}> = ({ + x, + y, + w = 900, + at, + title = 'zsh — explainer-kit', + lines, + size = 30, +}) => { + const f = useCurrentFrame(); + const s = springAt(f, at, 'snappy'); + if (f < at) return null; + let caretRow = -1; + const rows = lines.map((ln, i) => { + if (f < ln.at) return null; + let shown = ln.text; + if (!ln.out) { + const n = Math.floor(((f - ln.at) / FPS) * 28); + shown = ln.text.slice(0, n); + if (n <= ln.text.length) caretRow = i; + } else caretRow = i; + return ( +
+ {ln.out ? null : $ } + {shown} + {i === caretRow && !ln.out && Math.floor(f / 8) % 2 === 0 ? ▌ : null} +
+ ); + }); + return ( +
+
+ {[N.magenta, '#FFC14D', N.lime].map((c) => ( + + ))} + {title} +
+
{rows}
+
+ ); +}; + +export type NetNode = {id: string; x: number; y: number; label?: string; at: number; color?: string}; +export type NetEdge = {a: string; b: string; at: number}; + +/** Node graph (full-frame SVG): nodes pop in with a ripple, edges draw, then data pulses keep flowing along them. */ +export const Net: React.FC<{nodes: NetNode[]; edges: NetEdge[]; flowFrom?: number; r?: number}> = ({nodes, edges, flowFrom = Infinity, r = 26}) => { + const f = useCurrentFrame(); + const byId = Object.fromEntries(nodes.map((n) => [n.id, n])); + return ( + + + + + + + + + + + {edges.map((e, i) => { + const A = byId[e.a]; + const B = byId[e.b]; + const p = tween(f, e.at, 16, 'inOut'); + if (p <= 0) return null; + const ex = A.x + (B.x - A.x) * p; + const ey = A.y + (B.y - A.y) * p; + const pulses = f >= flowFrom ? [0, 0.33, 0.66].map((o) => ((f - flowFrom) / 45 + o + i * 0.17) % 1) : []; + return ( + + + {pulses.map((k, j) => ( + + ))} + + ); + })} + {nodes.map((n, i) => { + if (f < n.at) return null; + const s = springAt(f, n.at, 'bouncy'); + const ring = tween(f, n.at, 24, 'out'); + const c = n.color ?? N.cyan; + return ( + + + + + + + {n.label ? ( + + {n.label} + + ) : null} + + ); + })} + + ); +}; + +/** HUD corner brackets around a box; they slide out from its centre at frame `at`. */ +export const Brackets: React.FC<{x: number; y: number; w: number; h: number; at: number; color?: string; len?: number}> = ({x, y, w, h, at, color = N.cyan, len = 34}) => { + const f = useCurrentFrame(); + const p = tween(f, at, 16, 'expo'); + if (p <= 0) return null; + const cx = x + w / 2; + const cy = y + h / 2; + const corners: [number, number, number, number][] = [ + [x, y, 1, 1], + [x + w, y, -1, 1], + [x, y + h, 1, -1], + [x + w, y + h, -1, -1], + ]; + return ( + + {corners.map(([X, Y, sx, sy], i) => { + const px = cx + (X - cx) * p; + const py = cy + (Y - cy) * p; + return ; + })} + + ); +}; + +/** Chapter card: grid floor, typed mono kicker, glitching neon title. */ +export const NeonChapter: React.FC = ({f, lead, kicker, title}) => { + const out = interpolate(f, [lead - 10, lead], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + if (out <= 0) return null; + const n = Math.floor((f / FPS) * 30); + return ( + + + + +
{`> ${kicker}`.slice(0, n + 2)}
+ + {title} + +
+
+ +
+ ); +}; + +/** Grid-floor night, scanlines over the picture, glass karaoke subtitles. */ +export const neon: Look = { + base: N.bg, + background: GridFloor, + overlay: Scanlines, + chapter: NeonChapter, + subtitles: {bottom: 54, box: true, karaoke: true, size: 38, color: N.text, boxColor: 'rgba(4,6,14,0.78)', quoteColor: N.magenta, dim: 0.35}, +}; diff --git a/src/paper/index.tsx b/src/paper/index.tsx new file mode 100644 index 0000000..7f4c52c --- /dev/null +++ b/src/paper/index.tsx @@ -0,0 +1,193 @@ +/** Paper style pack: hand-drawn notebook — cream grid paper, pen strokes that draw on and boil, sticky notes. Delete this folder if unused. */ +import React from 'react'; +import {AbsoluteFill, interpolate, staticFile, useCurrentFrame} from 'remotion'; +import type {ChapterProps, Look} from '../core/look'; +import {Svg} from '../core/layout'; +import {drift, rng, springAt, tween} from '../core/motion'; +import {FONT} from '../core/theme'; +import {P} from './palette'; +import {RoughBox, Squiggle} from './rough'; + +export {P} from './palette'; +export * from './rough'; + +/** Cream grid paper. The fibre texture jumps every few frames, like a stop-motion camera over real paper. */ +export const PaperGround: React.FC = () => { + const f = useCurrentFrame(); + const r = rng(Math.floor(f / 4) + 5); + return ( + + + + + + ); +}; + +/** Hand-lettered text: ink colour, a hair of rotation. */ +export const Hand: React.FC<{children: React.ReactNode; size?: number; color?: string; rot?: number; serif?: boolean; style?: React.CSSProperties}> = ({ + children, + size = 56, + color = P.ink, + rot = -1, + serif = true, + style, +}) => ( +
+ {children} +
+); + +/** Highlighter swipe behind inline text, swept from frame `at` (scene frames). */ +export const Marker: React.FC<{at: number; children: React.ReactNode; color?: string}> = ({at, children, color = P.highlight}) => { + const f = useCurrentFrame(); + const p = tween(f, at, 12, 'inOut'); + return ( + + + {children} + + ); +}; + +/** + * Sticky note centred on (x, y) that drops onto the page at frame `at` (scene frames) with a little bounce, + * then sways gently. Leaves (peels up and away) at `out` if given. + */ +export const Note: React.FC<{ + at: number; + out?: number; + x: number; + y: number; + w?: number; + h?: number; + rot?: number; + color?: string; + seed?: number; + children?: React.ReactNode; +}> = ({at, out, x, y, w = 300, h = 260, rot = -3, color = P.noteYellow, seed = 1, children}) => { + const f = useCurrentFrame(); + if (f < at) return null; + const s = springAt(f, at, 'bouncy'); + const q = out === undefined ? 0 : tween(f, out, 14, 'in'); + const lift = 1 - Math.min(1, s); + return ( +
+
+ {children} +
+ ); +}; + +/** Chapter card: kicker in red pen, title lettered in, underline scribbled under it. */ +export const PaperChapter: React.FC = ({f, lead, kicker, title}) => { + const out = interpolate(f, [lead - 12, lead], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + if (out <= 0) return null; + const p = tween(f, 2, 16); + const w = Math.min(1500, 120 + title.length * 96); + return ( + + + + + {kicker} + + + {title} + + + + + + + ); +}; + +/** Hand-drawn box with a label that's written in once the outline is mostly drawn (inside ). */ +export const LabelBox: React.FC<{ + x: number; + y: number; + width: number; + height: number; + p: number; + label: React.ReactNode; + color?: string; + textColor?: string; + fill?: string; + seed?: number; + size?: number; +}> = ({x, y, width, height, p, label, color = P.ink, textColor = P.ink, fill, seed = 1, size = 44}) => ( + + + +
+ {label} +
+
+
+); + +/** Cream notebook ground, hand-drawn chapter card, dark pill karaoke subtitles. */ +export const paper: Look = { + base: P.ground, + background: PaperGround, + chapter: PaperChapter, + subtitles: {bottom: 54, box: true, karaoke: true, size: 38, color: '#F7F2E6', boxColor: 'rgba(42,40,51,0.88)', quoteColor: '#FFD95A', dim: 0.5}, +}; diff --git a/src/paper/palette.ts b/src/paper/palette.ts new file mode 100644 index 0000000..865a248 --- /dev/null +++ b/src/paper/palette.ts @@ -0,0 +1,16 @@ +/** Paper pack colours: cream notebook, ink pens, highlighter, sticky notes. */ +import {themed} from '../core/theme'; +export const P = themed('paper', { + ground: '#F3EDE0', + grid: 'rgba(52,86,140,0.10)', + ink: '#2A2833', + muted: '#7A7468', + blue: '#2F5DA8', + red: '#D2453B', + green: '#3B8B5A', + highlight: 'rgba(255,208,52,0.6)', + noteYellow: '#FFE680', + noteBlue: '#C4E2FF', + notePink: '#FFC9D8', + noteGreen: '#CFEFC2', +}, {accent: ['red'], accent2: ['blue']}); diff --git a/src/paper/rough.tsx b/src/paper/rough.tsx new file mode 100644 index 0000000..ade99b2 --- /dev/null +++ b/src/paper/rough.tsx @@ -0,0 +1,202 @@ +/** + * Hand-drawn strokes for the paper pack. Every shape is a jittered path that draws itself on (p: 0→1), + * is stroked twice like a real pen going over it, and "boils" — re-jitters slightly every few frames — + * so finished drawings stay alive the way hand-drawn animation does. + */ +import React from 'react'; +import {useCurrentFrame} from 'remotion'; +import {clamp01, rng} from '../core/motion'; +import {P} from './palette'; + +type Pt = [number, number]; + +/** frames per boil step (classic animation on fours); 0 disables boiling */ +export const BOIL = 4; +const useBoil = (seed: number, on = true) => { + const f = useCurrentFrame(); + return on && BOIL > 0 ? seed + Math.floor(f / BOIL) * 7 : seed; +}; + +/** Catmull-Rom spline through points → SVG path */ +const spline = (pts: Pt[]) => { + if (pts.length < 2) return ''; + let d = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] ?? p2; + const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6]; + const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6]; + d += ` C${c1[0].toFixed(1)},${c1[1].toFixed(1)} ${c2[0].toFixed(1)},${c2[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`; + } + return d; +}; + +const jitter = (pts: Pt[], seed: number, amp: number): Pt[] => { + const r = rng(seed); + return pts.map(([x, y]) => [x + (r() - 0.5) * 2 * amp, y + (r() - 0.5) * 2 * amp]); +}; + +/** points along a segment, bowed sideways by `bow` px at the middle */ +const seg = (a: Pt, b: Pt, n: number, bow: number): Pt[] => { + const len = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; + const nx = -(b[1] - a[1]) / len; + const ny = (b[0] - a[0]) / len; + return Array.from({length: n + 1}, (_, i) => { + const k = i / n; + const s = Math.sin(k * Math.PI) * bow; + return [a[0] + (b[0] - a[0]) * k + nx * s, a[1] + (b[1] - a[1]) * k + ny * s]; + }); +}; + +/** a path that draws on with progress p (uses pathLength so any shape works) */ +const Stroke: React.FC<{d: string; p: number; color: string; w: number; op?: number}> = ({d, p, color, w, op = 1}) => + p <= 0 ? null : ( + + ); + +type Common = {p: number; color?: string; w?: number; seed?: number; boil?: boolean}; + +/** wobbly straight line (inside ) */ +export const RoughLine: React.FC = ({ + x1, + y1, + x2, + y2, + p, + bow = 4, + color = P.ink, + w = 4, + seed = 1, + boil = true, +}) => { + const s = useBoil(seed, boil); + const base = seg([x1, y1], [x2, y2], 6, bow); + return ( + + + + + ); +}; + +/** wobbly rectangle drawn in one continuous stroke that overshoots its start (inside ) */ +export const RoughBox: React.FC = ({ + x, + y, + width, + height, + p, + fill, + color = P.ink, + w = 4, + seed = 1, + boil = true, +}) => { + const s = useBoil(seed, boil); + const c: Pt[] = [ + [x, y], + [x + width, y], + [x + width, y + height], + [x, y + height], + [x + 14, y - 3], + ]; + const pts: Pt[] = []; + for (let i = 0; i < 4; i++) pts.push(...seg(c[i], c[i + 1], 4, (rng(seed + i)() - 0.5) * 6).slice(i ? 1 : 0)); + const r = rng(seed * 31); + const fillOp = clamp01((p - 0.8) * 5); + return ( + + {fill && fillOp > 0 ? ( + + ) : null} + + + + ); +}; + +/** hand-drawn loop around something — goes a bit past a full turn, like circling a word (inside ) */ +export const RoughCircle: React.FC = ({ + cx, + cy, + rx, + ry = rx, + p, + color = P.red, + w = 4, + seed = 1, + boil = true, +}) => { + const s = useBoil(seed, boil); + const r = rng(s); + const n = 22; + const a0 = -2.2; + const pts: Pt[] = Array.from({length: n + 1}, (_, i) => { + const a = a0 + (i / n) * Math.PI * 2.18; + const k = 1 + (r() - 0.5) * 0.06 + (i / n) * 0.06; + return [cx + Math.cos(a) * rx * k, cy + Math.sin(a) * ry * k]; + }); + return ; +}; + +/** curved arrow; the head is flicked on after the shaft is drawn (inside ) */ +export const RoughArrow: React.FC = ({ + x1, + y1, + x2, + y2, + p, + bow = -30, + head = 22, + color = P.ink, + w = 4, + seed = 1, + boil = true, +}) => { + const s = useBoil(seed, boil); + const pts = jitter(seg([x1, y1], [x2, y2], 8, bow), s, 1.6); + const n = pts.length; + const [ex, ey] = pts[n - 1]; + const [px, py] = pts[n - 2]; + const ang = Math.atan2(ey - py, ex - px); + const hp = clamp01((p - 0.8) * 5); + const wing = (da: number) => `M${ex},${ey} L${ex - head * Math.cos(ang + da)},${ey - head * Math.sin(ang + da)}`; + return ( + + + + + + ); +}; + +/** quick wavy underline (inside ) */ +export const Squiggle: React.FC = ({ + x, + y, + width, + p, + amp = 6, + color = P.red, + w = 4, + seed = 1, + boil = true, +}) => { + const s = useBoil(seed, boil); + const n = Math.max(6, Math.round(width / 22)); + const pts: Pt[] = Array.from({length: n + 1}, (_, i) => [x + (i / n) * width, y + (i % 2 ? amp : -amp) * 0.6]); + return ; +}; diff --git a/src/pixel/index.tsx b/src/pixel/index.tsx new file mode 100644 index 0000000..d8d4689 --- /dev/null +++ b/src/pixel/index.tsx @@ -0,0 +1,229 @@ +/** + * Pixel style pack: everything is drawn on a 320×180 canvas and blown up 6× with no smoothing, + * text included (glyphs are thresholded to 1-bit). Sprites are string maps. Delete this folder if unused. + */ +import React, {useLayoutEffect, useRef} from 'react'; +import {AbsoluteFill, useCurrentFrame} from 'remotion'; +import {HEIGHT, WIDTH} from '../config'; +import type {ChapterProps, Look} from '../core/look'; +import {noise} from '../core/motion'; +import {FONT, themed} from '../core/theme'; + +export const PW = 320; +export const PH = 180; +export const GROUND = 140; + +export const PX = themed('pixel', { + sky0: '#3E7FD6', + sky1: '#6FB1F0', + sky2: '#A6D8FA', + cloud: '#F4FAFF', + hill0: '#6E8FC9', + hill1: '#4F9A5E', + grass: '#5DC04A', + grassDark: '#3E8E36', + dirt: '#8A5A2E', + dirtDark: '#6B4220', + ink: '#16121F', + white: '#FFFFFF', + gold: '#FFD23F', + goldDark: '#D99A00', + red: '#E8443A', + skin: '#F6C49A', + blue: '#3A6FE0', + box: '#1B1834', + boxLine: '#F4FAFF', + exp: '#7CE85A', +}, {accent: ['red'], accent2: ['blue']}); + +/** Drawing helpers on the low-res canvas. */ +export type Px = { + ctx: CanvasRenderingContext2D; + rect: (x: number, y: number, w: number, h: number, c: string) => void; + sprite: (rows: string[], pal: Record, x: number, y: number, flip?: boolean, scale?: number) => void; + text: (s: string, x: number, y: number, c: string, size?: number, align?: 'left' | 'center' | 'right', shadow?: string) => number; + /** width in canvas pixels that text() would use */ + measure: (s: string, size?: number) => number; +}; + +const scratch = typeof document !== 'undefined' ? document.createElement('canvas') : null; + +const makePx = (ctx: CanvasRenderingContext2D): Px => ({ + ctx, + measure: (s, size = 12) => { + ctx.font = `700 ${size}px ${FONT.sans}`; + return Math.ceil(ctx.measureText(s).width) + 2; + }, + rect: (x, y, w, h, c) => { + ctx.fillStyle = c; + ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); + }, + sprite: (rows, pal, x, y, flip, scale = 1) => { + const X = Math.round(x); + const Y = Math.round(y); + rows.forEach((row, j) => { + for (let i = 0; i < row.length; i++) { + const c = pal[row[i]]; + if (!c) continue; + ctx.fillStyle = c; + ctx.fillRect(X + (flip ? row.length - 1 - i : i) * scale, Y + j * scale, scale, scale); + } + }); + }, + // glyphs are rasterised small, then thresholded to hard 1-bit pixels so they read as pixel type + text: (s, x, y, c, size = 12, align = 'left', shadow) => { + if (!scratch || !s) return 0; + const font = `700 ${size}px ${FONT.sans}`; + const m = ctx; + m.font = font; + const w = Math.ceil(m.measureText(s).width) + 2; + const h = size + 4; + scratch.width = w; + scratch.height = h; + const sc = scratch.getContext('2d', {willReadFrequently: true})!; + sc.font = font; + sc.textBaseline = 'top'; + sc.fillStyle = '#000'; + sc.fillText(s, 1, 1); + const img = sc.getImageData(0, 0, w, h); + const x0 = Math.round(align === 'center' ? x - w / 2 : align === 'right' ? x - w : x); + const y0 = Math.round(y); + const put = (dx: number, dy: number, col: string) => { + ctx.fillStyle = col; + for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) if (img.data[(j * w + i) * 4 + 3] > 110) ctx.fillRect(x0 + i + dx, y0 + j + dy, 1, 1); + }; + if (shadow) put(1, 1, shadow); + put(0, 0, c); + return w; + }, +}); + +/** A 320×180 canvas scaled up to the frame with hard pixels; `draw` runs every frame. */ +export const PixelCanvas: React.FC<{draw: (g: Px, f: number) => void; style?: React.CSSProperties}> = ({draw, style}) => { + const ref = useRef(null); + const f = useCurrentFrame(); + useLayoutEffect(() => { + const cv = ref.current; + if (!cv) return; + const ctx = cv.getContext('2d')!; + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, PW, PH); + draw(makePx(ctx), f); + }); + return ; +}; + +// ───────────────────────── sprites ───────────────────────── + +const HERO_PAL: Record = {k: PX.ink, r: PX.red, s: PX.skin, b: PX.blue, w: PX.white, d: '#2A3F8F', h: '#5A3420'}; +const HERO_HEAD = ['....kkkkk...', '...krrrrrk..', '..krrrrrrrk.', '..kkhhsskk..', '.khshsskssk.', '.khsshssssk.', '..kssssskk..', '...kssssk...']; +const HERO_A = [...HERO_HEAD, '..kbbrbbk...', '.kbbbrrbbk..', '.ksbbbbbsk..', '..kbbbbbk...', '..kdd.ddk...', '.kdd...ddk..', '.kk.....kk..', '............']; +const HERO_B = [...HERO_HEAD, '..kbbrbbk...', '.kbbbrrbbk..', '.ksbbbbbsk..', '..kbbbbbk...', '...kdddk....', '...kddk.....', '...kkkk.....', '............']; + +/** 12×16 hero standing on y (feet), at `scale` canvas pixels per sprite pixel; alternates legs while walking. */ +export const hero = (g: Px, x: number, y: number, walking: boolean, f: number, flip = false, scale = 1) => + g.sprite(walking && Math.floor(f / 6) % 2 ? HERO_B : HERO_A, HERO_PAL, x, y - 16 * scale, flip, scale); + +const COIN = [ + ['..kkkk..', '.kggggk.', 'kggyyggk', 'kgyggggk', 'kgyggggk', 'kggggggk', '.kggggk.', '..kkkk..'], + ['..kkk...', '.kgggk..', '.kgygk..', '.kgygk..', '.kgygk..', '.kgggk..', '.kgggk..', '..kkk...'], + ['...kk...', '...kgk..', '...kgk..', '...kgk..', '...kgk..', '...kgk..', '...kgk..', '...kk...'], +]; +/** spinning 8×8 coin centred on (x, y) */ +export const coin = (g: Px, x: number, y: number, f: number, scale = 1) => { + const k = [0, 1, 2, 1][Math.floor(f / 5) % 4]; + g.sprite(COIN[k], {k: PX.goldDark, g: PX.gold, y: '#FFF6B0'}, x - 4 * scale, y - 4 * scale, false, scale); +}; + +/** framed RPG box */ +export const box = (g: Px, x: number, y: number, w: number, h: number) => { + g.rect(x, y, w, h, PX.box); + g.rect(x + 1, y + 1, w - 2, 1, PX.boxLine); + g.rect(x + 1, y + h - 2, w - 2, 1, PX.boxLine); + g.rect(x + 1, y + 1, 1, h - 2, PX.boxLine); + g.rect(x + w - 2, y + 1, 1, h - 2, PX.boxLine); +}; + +/** segmented bar, `p` 0..1 filled */ +export const bar = (g: Px, x: number, y: number, segs: number, p: number, color = PX.exp) => { + g.rect(x, y, segs * 6 + 2, 7, PX.ink); + const n = Math.floor(p * segs + 1e-6); + for (let i = 0; i < segs; i++) g.rect(x + 2 + i * 6, y + 2, 4, 3, i < n ? color : '#3A3550'); +}; + +// ───────────────────────── world ───────────────────────── + +/** sky bands, drifting clouds, two parallax hill layers, grass and dirt. `scroll` in px moves the world left. */ +export const drawWorld = (g: Px, f: number, scroll = 0) => { + const bands = [PX.sky0, PX.sky1, PX.sky2]; + bands.forEach((c, i) => g.rect(0, i * 30, PW, 30 + (i === 2 ? 60 : 0), c)); + // dithered seams between bands + for (let i = 1; i < 3; i++) for (let x = 0; x < PW; x += 2) g.rect(x + ((i * 30) % 2), i * 30 - 1, 1, 1, bands[i]); + for (let k = 0; k < 4; k++) { + const cx = ((k * 97 - f * (0.15 + k * 0.03) - scroll * 0.1) % (PW + 60) + PW + 60) % (PW + 60) - 30; + const cy = 14 + k * 11; + g.rect(cx, cy, 22, 4, PX.cloud); + g.rect(cx + 4, cy - 3, 12, 3, PX.cloud); + } + const hills = (amp: number, base: number, speed: number, seed: number, c: string) => { + for (let x = 0; x < PW; x++) { + const h = Math.round(base - (noise(seed, (x + scroll * speed) / 38) * 0.5 + 0.5) * amp); + g.rect(x, h, 1, GROUND - h, c); + } + }; + hills(34, 118, 0.25, 3, PX.hill0); + hills(22, 134, 0.5, 9, PX.hill1); + g.rect(0, GROUND, PW, PH - GROUND, PX.dirt); + g.rect(0, GROUND, PW, 4, PX.grass); + for (let x = -16; x < PW + 16; x += 16) { + const ox = Math.round(x - (scroll % 16)); + g.rect(ox, GROUND + 4, 8, 1, PX.grassDark); + g.rect(ox + 3, GROUND + 10, 2, 2, PX.dirtDark); + g.rect(ox + 11, GROUND + 22, 2, 2, PX.dirtDark); + } +}; + +/** Look background: the world with clouds drifting. */ +export const PixelWorld: React.FC = () => drawWorld(g, f)} />; + +/** Faint pixel grid over the picture, so the 6× pixels read as a screen. */ +export const PixelGrid: React.FC = () => ( + +); + +/** Chapter card: black screen, stage number, title, blinking PRESS START. */ +export const PixelChapter: React.FC = ({f, lead, kicker, title}) => { + if (f >= lead) return null; + return ( + + { + g.rect(0, 0, PW, PH, PX.ink); + for (let i = 0; i < 40; i++) { + const x = (i * 71) % PW; + const y = (i * 37) % 120; + if ((Math.floor(f / 8) + i) % 3) g.rect(x, y, 1, 1, '#6C6890'); + } + g.text(kicker, PW / 2, 52, PX.gold, 12, 'center'); + g.text(title, PW / 2, 72, PX.white, 24, 'center', '#E8443A'); + if (Math.floor(f / 10) % 2 === 0) g.text('PRESS START', PW / 2, 124, PX.white, 10, 'center'); + }} + /> + + ); +}; + +/** Pixel world behind, faint pixel grid on top, chunky dark subtitles. */ +export const pixel: Look = { + base: PX.sky1, + background: PixelWorld, + overlay: PixelGrid, + chapter: PixelChapter, + subtitles: {bottom: 30, box: true, karaoke: true, size: 36, color: PX.white, boxColor: 'rgba(22,18,31,0.88)', quoteColor: PX.gold, dim: 0.4}, +}; diff --git a/src/scenes/index.ts b/src/scenes/index.ts index e34d2a6..c54412c 100644 --- a/src/scenes/index.ts +++ b/src/scenes/index.ts @@ -1,11 +1,34 @@ /** Scene registry: id (as in tts/script.json) → component + look. Scenes with no entry render as empty frames. */ import type {SceneDef} from '../core/look'; +import {editorial} from '../editorial'; import {film} from '../film'; +import {ink} from '../ink'; +import {keynote} from '../keynote'; +import {math} from '../math'; +import {neon} from '../neon'; +import {paper} from '../paper'; +import {pixel} from '../pixel'; import {slides} from '../slides'; import {S01} from './s01'; import {S02} from './s02'; +import {S03} from './s03'; +import {S04} from './s04'; +import {S05} from './s05'; +import {S06} from './s06'; +import {S07} from './s07'; +import {S08} from './s08'; +import {S09} from './s09'; export const SCENES: Record = { - s01: {component: S01, look: film}, + // s01 shows its quote big on screen, so the quote line isn't subtitled twice + s01: {component: S01, look: film, subtitles: {hideQuotes: true}}, s02: {component: S02, look: slides}, + s03: {component: S03, look: paper}, + s04: {component: S04, look: neon}, + s05: {component: S05, look: editorial}, + s06: {component: S06, look: math}, + // everything in s07 happens around the centre, so the portrait cut can crop the sides and show it bigger + s07: {component: S07, look: keynote, portrait: {zoom: 1.3}}, + s08: {component: S08, look: pixel}, + s09: {component: S09, look: ink}, }; diff --git a/src/scenes/s01.tsx b/src/scenes/s01.tsx index b319013..80240f5 100644 --- a/src/scenes/s01.tsx +++ b/src/scenes/s01.tsx @@ -1,6 +1,6 @@ /** s01 · film pack demo: shots cut on cues, a stamp lands on a spoken word, a quote in the alternate voice. */ import React from 'react'; -import {Svg, ease, useScene} from '../core'; +import {Sfx, Spoken, Svg, ease, shake, useScene} from '../core'; import {C, FONT} from '../core/theme'; import {BigQuote, Cam, Count, Desk, Doc, Glow, Graded, Line, MID_Y, Place, Shots, Sky, Stamp, Tag, Void} from '../film'; @@ -50,30 +50,35 @@ export const S01: React.FC = () => { }, { at: c('old'), + // the new document pushes the old one off the desk instead of dissolving through it + tr: 'push' as const, el: (t: number, d: number) => ( - - - ({ - k, - v: `00:${String(3 + i * 4).padStart(2, '0')}.${(i * 37) % 100}`, - at: 6 + i * 6, - // once the narration says 改一句, every hand-timed row is wrong - strike: t >= rel('改一句', 'old') + 8 + i * 4, - }))} - /> - - {/* rel(): frames from the cue to a spoken word — exactly the shot-local time t */} - + {/* the stamp lands hard enough to shake the camera */} +
+ + + ({ + k, + v: `00:${String(3 + i * 4).padStart(2, '0')}.${(i * 37) % 100}`, + at: 6 + i * 6, + // once the narration says 改一句, a pen crosses out every hand-timed row, one after another + strike: rel('改一句', 'old') + 8 + i * 5, + }))} + /> + + {/* rel(): frames from the cue to a spoken word — exactly the shot-local time t */} + +
), }, @@ -85,7 +90,8 @@ export const S01: React.FC = () => { - + {/* Spoken: each character lands as the narrator says it */} + } size={88} y={MID_Y - 20} sub="narration is the timeline" /> ), }, @@ -117,10 +123,13 @@ export const S01: React.FC = () => { { at: c('quote'), tr: 'black' as const, - el: (t: number) => ( + el: (t: number, d: number) => ( <> - - + + + + {/* spoken: characters follow the quote voice; the bracket and attribution fill the lead-in */} + ), }, @@ -134,7 +143,7 @@ export const S01: React.FC = () => { - + } size={72} y={400} />
{ }, }, ]; - return ; + return ( + <> + + {/* sounds live outside the shots so a cut never chops them off */} + + + + + + ); }; diff --git a/src/scenes/s02.tsx b/src/scenes/s02.tsx index 348d89c..fb11684 100644 --- a/src/scenes/s02.tsx +++ b/src/scenes/s02.tsx @@ -1,6 +1,6 @@ -/** s02 · slides pack demo: c() / w() / rel() shown with their live values, a timeline of cues, one KaTeX formula. */ +/** s02 · slides pack demo: c() / w() / rel() shown with their live values, a timeline of cues, a KaTeX formula built term by term as it's said. */ import React from 'react'; -import {At, DrawLine, FPS, Full, Svg, Tex, TIMINGS, ease, fu, lerp, pop, useScene, window_} from '../core'; +import {At, DrawLine, FPS, Full, Sfx, Svg, Tex, TIMINGS, drift, ease, fu, lerp, life, move, pop, stagger, tween, useScene} from '../core'; import {C, FONT} from '../core/theme'; import {Chip, H, Kicker, Panel} from '../slides'; @@ -14,9 +14,11 @@ export const S02: React.FC = () => { const cues = Object.keys(TIMINGS[id].cues); const head = ease(f, c('api'), 20); - const cards = window_(f, c('c') - 4, c('formula') + 10, 14); - const formula = window_(f, c('formula'), c('done') + 6, 14); - const done = ease(f, c('done'), 20); + // cards leave (upward, one after another) before the formula comes in — never dissolve through each other + const cardsOut = c('formula'); + const formulaIn = cardsOut + 12; + const formulaOut = life(f, formulaIn, c('done'), 16, 12).q; + const done = ease(f, c('done') + 6, 20); const relFrom = c('rel'); const relTo = relFrom + rel('多少帧', 'rel'); @@ -28,6 +30,12 @@ export const S02: React.FC = () => { // the formula, filled in with this scene's real numbers for the `formula` cue const sec = TIMINGS[id].cues.formula; + const terms = [ + {tex: String.raw`\mathrm{frame} =`, at: formulaIn}, + {tex: String.raw`\mathrm{lead}`, at: w('开场留白')}, + {tex: String.raw`+\ \operatorname{round}(t`, at: w('秒数')}, + {tex: String.raw`\times \mathrm{fps})`, at: w('帧率')}, + ]; return ( @@ -38,11 +46,13 @@ export const S02: React.FC = () => { - - {api.map((a, i) => { - const p = ease(f, c(a.cue), 16); - return ( - + {api.map((a, i) => { + const {p, q} = life(f, c(a.cue), stagger(cardsOut, i, 3), 16, 12); + if (q >= 1) return null; + return ( + + {/* settled cards keep breathing a little instead of freezing */} +
{a.code} @@ -50,28 +60,35 @@ export const S02: React.FC = () => {
{a.desc}
= {a.val}
- +
+
+ ); + })} + + {/* the formula is built term by term, each term arriving as it is said; the term being said is lit */} + + {terms.map((tm, i) => { + const p = tween(f, tm.at - 3, 14); + const lit = f >= tm.at && f < (terms[i + 1]?.at ?? c('done')); + return ( + + + ); })} -
- - - - - - {/* At `center` composes its own translate(-50%,-50%) with fu()'s translateY */} - - - {`${lead} + round(${sec} × ${FPS}) = ${c('formula')}`} - - - + + {/* At `center` composes its own translate(-50%,-50%) with move()'s translateY */} + + + {`${lead} + round(${sec} × ${FPS}) = ${c('formula')}`} + + {['script.json', 'gen.py', 'timings.json', '动画'].map((s, i) => ( - {i > 0 ? → : null} - + {i > 0 ? → : null} + {s} @@ -98,6 +115,18 @@ export const S02: React.FC = () => { + + {api.map((a) => ( + + ))} + + {terms.map((tm, i) => ( + + ))} + + {[0, 1, 2, 3].map((i) => ( + + ))}
); }; diff --git a/src/scenes/s03.tsx b/src/scenes/s03.tsx new file mode 100644 index 0000000..0f8de93 --- /dev/null +++ b/src/scenes/s03.tsx @@ -0,0 +1,87 @@ +/** s03 · paper pack demo: strokes draw on and boil, sticky notes drop, arrows connect, the camera pulls back to sum up. */ +import React from 'react'; +import {Full, Sfx, Spoken, Svg, drift, eio, lerp, tween, useScene} from '../core'; +import {FONT} from '../core/theme'; +import {Hand, LabelBox, Marker, Note, P, RoughArrow, RoughCircle, Squiggle} from '../paper'; + +const BW = 360; +const GAP = 220; +const BOXES = [ + {x: 200, label: '旁白', fill: 'rgba(47,93,168,0.12)'}, + {x: 200 + BW + GAP, label: '时间戳', fill: 'rgba(255,208,52,0.28)'}, + {x: 200 + 2 * (BW + GAP), label: '动画', fill: 'rgba(210,69,59,0.12)'}, +]; +const BY = 330; +const BH = 190; + +export const S03: React.FC = () => { + const {f, c, w} = useScene(); + // camera: a slow push-in while things are drawn, then a pull back to show the whole page on [[sum]] + const pull = eio(f, c('sum'), 30); + const cam = `scale(${lerp(pull, 1 + 0.03 * tween(f, 0, 400, 'inOut'), 0.93)}) ${drift(f, 2, 3, 0.15)}`; + + return ( + + {/* the first words are said before the title, so they get a kicker — the frame is never empty */} +
+ + + +
+ {/* title appears exactly as it's said, then gets underlined */} +
+ + + +
+ + + + {BOXES.map((b, i) => ( + + ))} + + {/* "画完也不会僵住": circle the last box when the narrator says 抖动 */} + + + + + + +
+ + 每 4 帧重抖一次 + +
+ + +
[[cue]]
+
动画的触发点
+
+ +
w('词')
+
这个词第几帧
+
+ +
+ + 讲流程、讲因果 + +
+ + {/* pencil on paper for every stroke, a slap for each sticky note */} + + {BOXES.map((b, i) => ( + + ))} + + + + + + + + +
+ ); +}; diff --git a/src/scenes/s04.tsx b/src/scenes/s04.tsx new file mode 100644 index 0000000..cfa0a40 --- /dev/null +++ b/src/scenes/s04.tsx @@ -0,0 +1,95 @@ +/** s04 · neon pack demo: a terminal types, a node graph lights up node by node, pulses flow, a glitch cut, a zoom-through. */ +import React from 'react'; +import {AbsoluteFill} from 'remotion'; +import {Sfx, SfxAt, Shots, Spoken, useScene} from '../core'; +import {FONT} from '../core/theme'; +import {Brackets, Glitch, N, Neon, Net, Term} from '../neon'; + +const STEPS = ['script.json', 'gen.py', 'timings.json', 'Remotion']; + +export const S04: React.FC = () => { + const {c, w, total} = useScene(); + const netAt = c('net'); + const nodes = STEPS.map((label, i) => ({id: label, label, x: 1140 + (i % 2) * 460, y: 330 + Math.floor(i / 2) * 300, at: netAt + i * 9, color: i === 3 ? N.magenta : N.cyan})); + const edges = [ + {a: STEPS[0], b: STEPS[1], at: netAt + 5}, + {a: STEPS[1], b: STEPS[2], at: netAt + 14}, + {a: STEPS[2], b: STEPS[3], at: netAt + 23}, + ]; + const shots = [ + { + at: 0, + el: () => ( + <> +
+ {'> '} + +
+
+ + + +
+ + + + ), + }, + { + at: c('glitch'), + tr: 'cut' as const, + el: () => ( + // a second, smaller burst lands on the word itself + + + +
{''}
+ + 故障感 + +
+ +
+
+ ), + }, + { + at: c('fit'), + tr: 'zoom' as const, + el: () => ( + + + 讲系统 · 讲架构 + +
+ +
+
+ ), + }, + ]; + const cmd = 'python tts/gen.py --mock'; + // a keystroke every other character of the typed command (Term types at 28 chars/s) + const keys = Array.from({length: Math.ceil(cmd.length / 2)}, (_, i) => c('term') + 6 + Math.round((i * 2 * 30) / 28)); + return ( + <> + + + + n.at)} name="pop" volume={0.8} /> + + + + ); +}; diff --git a/src/scenes/s05.tsx b/src/scenes/s05.tsx new file mode 100644 index 0000000..dd1f079 --- /dev/null +++ b/src/scenes/s05.tsx @@ -0,0 +1,62 @@ +/** s05 · editorial pack demo: masked headlines, type that drops in word by word with the voice, a block wipe, a slammed number. */ +import React from 'react'; +import {CFG, Full, Sfx, Spoken, life, move, tween, useScene} from '../core'; +import {Block, E, Label, MaskText, Rule, Slam, Ticker} from '../editorial'; + +export const S05: React.FC = () => { + const {f, c, w} = useScene(); + // the red block covers the frame on [[block]]; everything before it leaves while covered + const wipe = c('block'); + const covered = wipe + 16; + const num = life(f, w('一百二十') - 2, c('out'), 10, 14); + + return ( + + {f < covered ? ( + <> +
+ +
+ +
+ {/* already in place under the chapter card (which uses the same masthead), so the card dissolves into the scene */} + +
+
+ +
+ + ) : ( + <> +
+ +
+ +
+ + 120 + +
+
+ +
+
+ +
+
+ +
+ + + )} + + + + + + +
+ ); +}; diff --git a/src/scenes/s06.tsx b/src/scenes/s06.tsx new file mode 100644 index 0000000..74d7d3f --- /dev/null +++ b/src/scenes/s06.tsx @@ -0,0 +1,70 @@ +/** s06 · math pack demo: axes draw, y = x² is traced, a tangent slides with a live slope, the derivative is derived step by step, then plotted. */ +import React from 'react'; +import {Full, Sfx, Spoken, Svg, tween, useScene} from '../core'; +import {FONT} from '../core/theme'; +import {Axes, M, type Plane, Plot, Readout, Steps, Tangent, px, py} from '../math'; + +const PL: Plane = {x0: -3, x1: 3, y0: -1, y1: 9, left: 120, top: 120, w: 900, h: 760}; +const fn = (x: number) => x * x; + +export const S06: React.FC = () => { + const {f, c, w} = useScene(); + const t0 = c('tan'); + // the point never stops: it eases in from the left and keeps swinging, so the slope keeps changing + const x = f < t0 ? -2.2 : -2.2 * Math.cos((f - t0) / 40); + const tanOp = tween(f, t0, 12); + const k = 2 * x; + const derived = f >= c('result'); + + return ( + + + + + {/* once f'(x) = 2x is derived, it is drawn too */} + 2 * v} at={c('fit')} dur={30} color={M.green} w={5} from={-0.5} to={3} /> + {tanOp > 0 ? : null} + + y = x² + + + y = 2x + + + + {/* the narration starts with 第六套, so that lands first and the frame is never empty */} +
+ +
+
+ +
+
+ +
+ + +
+ +
+ +
+ + + + + + + + + + ); +}; diff --git a/src/scenes/s07.tsx b/src/scenes/s07.tsx new file mode 100644 index 0000000..7629e25 --- /dev/null +++ b/src/scenes/s07.tsx @@ -0,0 +1,105 @@ +/** s07 · keynote pack demo: one shape — logo → button → progress bar → glass card → logo — driven by a cursor, with a camera push-in. */ +import React from 'react'; +import {useCurrentFrame} from 'remotion'; +import {CFG, Full, Sfx, Spoken, tween, useScene} from '../core'; +import {FONT} from '../core/theme'; +import {Cursor, K, Morph, Rise, ZoomCam} from '../keynote'; + +const CX = 960; +const CY = 470; + +/** progress fill that runs from `from` to `to` (scene frames) */ +const Progress: React.FC<{from: number; to: number}> = ({from, to}) => { + const f = useCurrentFrame(); + const p = tween(f, from, to - from, 'inOut'); + return ( +
+
+
+ ); +}; + +const Card: React.FC = () => ( +
+
+
▶
+
+
+
+
{CFG.brand.name}.mp4
+
1920 × 1080 · 30 fps · 03:02
+
+
✓ 已完成
+
+
+); + +export const S07: React.FC = () => { + const {f, c, w} = useScene(); + const click = w('一点'); + const bar = click + 4; + const card = c('card'); + const fin = c('fin'); + + return ( + + + }, + {at: c('btn'), x: CX, y: CY, w: 440, h: 116, r: 58, bg: K.ink, content:
开始渲染 ▶
}, + {at: bar, x: CX, y: CY, w: 1000, h: 28, r: 14, bg: '#D8D5CF', content: }, + {at: card, x: CX, y: CY, w: 760, h: 440, r: 40, bg: '#FFFFFF', glass: true, content: }, + {at: fin, x: CX - 300, y: CY, w: 150, h: 150, r: 75, bg: K.ink, content:
}, + ]} + /> + + {/* title under the logo; it gets out of the way as soon as the shape starts working */} +
+ + + + + 发布会风 + +
+ {/* progress label rides above the bar */} +
+ 渲染中 {Math.round(tween(f, bar + 6, card - 4 - bar - 6, 'inOut') * 100)}% +
+ + {/* the ending lands back on the first frame's logo, with the wordmark beside it */} +
+ + {CFG.brand.name} + +
+ + + + + + + + + + + + ); +}; diff --git a/src/scenes/s08.tsx b/src/scenes/s08.tsx new file mode 100644 index 0000000..dbd71c1 --- /dev/null +++ b/src/scenes/s08.tsx @@ -0,0 +1,113 @@ +/** s08 · pixel pack demo: a hero walks in, collects a coin per point, an EXP bar fills, an RPG dialog types along with the voice, LEVEL UP. */ +import React from 'react'; +import {Full, Sfx, SfxAt, rng, springAt, tween, useBeat, useScene, useSpoken} from '../core'; +import {PW, PX, PixelCanvas, type Px, GROUND, bar, box, coin, drawWorld, hero} from '../pixel'; + +const COINS = [170, 235, 300]; +const TITLE = '第八套:像素游戏风'; +const LINE = '对话框里的字,跟着旁白一个一个蹦出来'; + +/** draw `s` with only the characters already spoken, from a fixed left edge so nothing shifts */ +const spokenText = (g: Px, s: string, at: number[], f: number, x: number, y: number, c: string, size: number, shadow?: string) => { + let n = 0; + while (n < s.length && f >= at[n] - 1) n++; + g.text(s.slice(0, n), x, y, c, size, 'left', shadow); + return n; +}; + +export const S08: React.FC = () => { + const {c} = useScene(); + const {chars} = useSpoken(); + const titleAt = chars(TITLE); + const lineAt = chars(LINE); + const walk = c('walk'); + const coinAt = c('coin'); + const barAt = c('bar'); + const talk = c('talk'); + const win = c('win'); + const beat = useBeat(); + // same pickup moment the drawing uses: when the hero's x passes the coin + const pickAt = (cx: number) => coinAt + ((cx - 90) / 230) * (barAt - coinAt - 10); + + return ( + + { + // hero position in world pixels; the camera follows once he passes x = 120 + const worldX = f < walk ? -14 : f < coinAt ? -14 + 104 * tween(f, walk, coinAt - walk, 'linear') : 90 + 230 * tween(f, coinAt, barAt - coinAt - 10, 'linear'); + const moving = f >= walk && f < barAt - 10; + const camX = Math.max(0, worldX - 120); + drawWorld(g, f, camX); + + // coins: bob until collected, then a +1 floats up + let got = 0; + COINS.forEach((cx, i) => { + const sx = cx - camX; + const pickedAt = pickAt(cx); + if (f < pickedAt) { + coin(g, sx, GROUND - 44 + Math.round(Math.sin((f + i * 9) / 8) * 2), f + i * 3, 2); + } else { + got++; + const k = f - pickedAt; + if (k < 24) g.text('+1', sx, GROUND - 60 - k, PX.gold, 12, 'center', PX.ink); + } + }); + + // hero: walks, idles with a bob, jumps on LEVEL UP + const jump = f >= win ? Math.abs(Math.sin(((f - win) / 14) * Math.PI)) * 14 * Math.max(0, 1 - (f - win) / 60) : 0; + // standing still, he bobs on the music's beat + hero(g, worldX - camX - 12, GROUND - Math.round(jump) + (moving ? 0 : beat.count() % 2), moving, f, false, 2); + + // HUD: coin counter and EXP bar + if (f >= coinAt) { + coin(g, PW - 52, 12, f); + g.text(`× ${got}`, PW - 44, 6, PX.white, 10, 'left', PX.ink); + } + if (f >= barAt) { + g.text(f >= win ? 'LV 2' : 'LV 1', 8, 6, PX.white, 10, 'left', PX.ink); + const p = Math.min(1, Math.floor(tween(f, barAt, talk - barAt - 6, 'linear') * 10) / 10); + bar(g, 40, 9, 10, f >= win ? 0.1 : p); + } + + // title until the dialog takes over the top of the screen + if (f < talk) { + const tw = g.measure(TITLE, 16); + spokenText(g, TITLE, titleAt, f, Math.round(PW / 2 - tw / 2), 28, PX.white, 16, PX.ink); + } else { + const open = Math.min(1, (f - talk) / 6); + const bw = Math.round(296 * open); + box(g, Math.round(PW / 2 - bw / 2), 24, Math.max(4, bw), 34); + if (open >= 1) { + hero(g, 18, 52, false, f); + const n = spokenText(g, LINE, lineAt, f, 36, 34, PX.white, 12); + if (n >= LINE.length && Math.floor(f / 10) % 2 === 0) g.text('▼', 296, 46, PX.gold, 8, 'center'); + } + } + + // LEVEL UP: bouncing text and pixel fireworks + if (f >= win) { + const s = springAt(f, win, 'bouncy'); + g.text('LEVEL UP!', PW / 2, 70 - Math.round((1 - s) * 30), PX.gold, 22, 'center', PX.red); + for (let b = 0; b < 3; b++) { + const t0 = win + b * 10; + const k = f - t0; + if (k < 0 || k > 30) continue; + const r = rng(b * 31 + 7); + const cx = 60 + b * 100; + const cy = 50 + b * 8; + for (let i = 0; i < 14; i++) { + const a = (i / 14) * Math.PI * 2 + r(); + const d = k * (1.1 + r() * 0.5); + g.rect(cx + Math.cos(a) * d, cy + Math.sin(a) * d + k * k * 0.02, 2, 2, [PX.gold, PX.red, PX.exp, PX.white][i % 4]); + } + } + } + }} + /> + + barAt + ((k + 1) * (talk - barAt - 6)) / 10)} name="tick" volume={0.6} /> + + + + ); +}; diff --git a/src/scenes/s09.tsx b/src/scenes/s09.tsx new file mode 100644 index 0000000..51dcb00 --- /dev/null +++ b/src/scenes/s09.tsx @@ -0,0 +1,56 @@ +/** s09 · ink pack demo: mountains bleed in layer by layer, a brush sweeps, vertical text inks in as it's said, a red seal stamps. */ +import React from 'react'; +import {Full, Sfx, Spoken, drift, tween, useScene} from '../core'; +import {FONT} from '../core/theme'; +import {Birds, Brush, I, Mist, Mountains, Seal, VText} from '../ink'; + +export const S09: React.FC = () => { + const {f, c, w} = useScene(); + const hill = w('远山') - 4; + return ( + + {/* far → near; each layer arrives on its own beat of "一层一层" */} + + {/* mist only gathers once there are mountains for it to hang between */} + + + + {/* one sweep of the brush under the poem */} + + +
+ + + + + + +
+ + {/* one phrase per column, right to left; each character inks in as it is said */} +
+ + + +
+ + + {/* the last line gets a small red annotation, like a reader's mark in the margin */} +
= c('close') ? 1 : 0}}> + + + +
+ + + + +
+ ); +}; diff --git a/src/timings.json b/src/timings.json index e369913..bbfd742 100644 --- a/src/timings.json +++ b/src/timings.json @@ -1152,5 +1152,2543 @@ ], "aligned": 78, "words": 78 + }, + "s03": { + "chapter": "第三场 · 手绘笔记", + "duration": 20.036, + "audio": "audio/s03.wav", + "cues": { + "draw": 0.0, + "box": 2.857, + "boil": 5.238, + "note": 10.988, + "arrow": 12.893, + "sum": 16.702 + }, + "lines": [ + { + "text": "换一套衣服:手绘笔记风。", + "start": 0.0, + "words": [ + { + "t": 0.0, + "s": 0, + "e": 1 + }, + { + "t": 0.238, + "s": 1, + "e": 2 + }, + { + "t": 0.476, + "s": 2, + "e": 3 + }, + { + "t": 0.714, + "s": 3, + "e": 4 + }, + { + "t": 0.952, + "s": 4, + "e": 5 + }, + { + "t": 1.429, + "s": 6, + "e": 7 + }, + { + "t": 1.667, + "s": 7, + "e": 8 + }, + { + "t": 1.905, + "s": 8, + "e": 9 + }, + { + "t": 2.143, + "s": 9, + "e": 10 + }, + { + "t": 2.381, + "s": 10, + "e": 11 + } + ], + "end": 2.857 + }, + { + "text": "线条一笔一笔画出来,画完也不会僵住,", + "start": 2.857, + "words": [ + { + "t": 2.857, + "s": 0, + "e": 1 + }, + { + "t": 3.095, + "s": 1, + "e": 2 + }, + { + "t": 3.333, + "s": 2, + "e": 3 + }, + { + "t": 3.571, + "s": 3, + "e": 4 + }, + { + "t": 3.81, + "s": 4, + "e": 5 + }, + { + "t": 4.048, + "s": 5, + "e": 6 + }, + { + "t": 4.286, + "s": 6, + "e": 7 + }, + { + "t": 4.524, + "s": 7, + "e": 8 + }, + { + "t": 4.762, + "s": 8, + "e": 9 + }, + { + "t": 5.238, + "s": 10, + "e": 11 + }, + { + "t": 5.476, + "s": 11, + "e": 12 + }, + { + "t": 5.714, + "s": 12, + "e": 13 + }, + { + "t": 5.952, + "s": 13, + "e": 14 + }, + { + "t": 6.19, + "s": 14, + "e": 15 + }, + { + "t": 6.429, + "s": 15, + "e": 16 + }, + { + "t": 6.667, + "s": 16, + "e": 17 + } + ], + "end": 7.143 + }, + { + "text": "会像手绘动画那样轻轻抖动。", + "start": 7.143, + "words": [ + { + "t": 7.143, + "s": 0, + "e": 1 + }, + { + "t": 7.381, + "s": 1, + "e": 2 + }, + { + "t": 7.619, + "s": 2, + "e": 3 + }, + { + "t": 7.857, + "s": 3, + "e": 4 + }, + { + "t": 8.095, + "s": 4, + "e": 5 + }, + { + "t": 8.333, + "s": 5, + "e": 6 + }, + { + "t": 8.571, + "s": 6, + "e": 7 + }, + { + "t": 8.81, + "s": 7, + "e": 8 + }, + { + "t": 9.048, + "s": 8, + "e": 9 + }, + { + "t": 9.286, + "s": 9, + "e": 10 + }, + { + "t": 9.524, + "s": 10, + "e": 11 + }, + { + "t": 9.762, + "s": 11, + "e": 12 + } + ], + "end": 10.488 + }, + { + "text": "便利贴啪地落下,", + "start": 10.988, + "words": [ + { + "t": 10.988, + "s": 0, + "e": 1 + }, + { + "t": 11.226, + "s": 1, + "e": 2 + }, + { + "t": 11.464, + "s": 2, + "e": 3 + }, + { + "t": 11.702, + "s": 3, + "e": 4 + }, + { + "t": 11.94, + "s": 4, + "e": 5 + }, + { + "t": 12.178, + "s": 5, + "e": 6 + }, + { + "t": 12.417, + "s": 6, + "e": 7 + } + ], + "end": 12.893 + }, + { + "text": "箭头把旁白、时间戳和动画串起来。", + "start": 12.893, + "words": [ + { + "t": 12.893, + "s": 0, + "e": 1 + }, + { + "t": 13.131, + "s": 1, + "e": 2 + }, + { + "t": 13.369, + "s": 2, + "e": 3 + }, + { + "t": 13.607, + "s": 3, + "e": 4 + }, + { + "t": 13.845, + "s": 4, + "e": 5 + }, + { + "t": 14.321, + "s": 6, + "e": 7 + }, + { + "t": 14.559, + "s": 7, + "e": 8 + }, + { + "t": 14.798, + "s": 8, + "e": 9 + }, + { + "t": 15.036, + "s": 9, + "e": 10 + }, + { + "t": 15.274, + "s": 10, + "e": 11 + }, + { + "t": 15.512, + "s": 11, + "e": 12 + }, + { + "t": 15.75, + "s": 12, + "e": 13 + }, + { + "t": 15.988, + "s": 13, + "e": 14 + }, + { + "t": 16.226, + "s": 14, + "e": 15 + } + ], + "end": 16.702 + }, + { + "text": "讲流程、讲因果,用它正合适。", + "start": 16.702, + "words": [ + { + "t": 16.702, + "s": 0, + "e": 1 + }, + { + "t": 16.94, + "s": 1, + "e": 2 + }, + { + "t": 17.178, + "s": 2, + "e": 3 + }, + { + "t": 17.655, + "s": 4, + "e": 5 + }, + { + "t": 17.893, + "s": 5, + "e": 6 + }, + { + "t": 18.131, + "s": 6, + "e": 7 + }, + { + "t": 18.607, + "s": 8, + "e": 9 + }, + { + "t": 18.845, + "s": 9, + "e": 10 + }, + { + "t": 19.083, + "s": 10, + "e": 11 + }, + { + "t": 19.321, + "s": 11, + "e": 12 + }, + { + "t": 19.559, + "s": 12, + "e": 13 + } + ], + "end": 20.036 + } + ], + "aligned": 70, + "words": 70 + }, + "s04": { + "chapter": "第四场 · 霓虹终端", + "duration": 16.464, + "audio": "audio/s04.wav", + "cues": { + "boot": 0.0, + "term": 2.619, + "net": 4.762, + "flow": 7.143, + "glitch": 10.036, + "fit": 13.131 + }, + "lines": [ + { + "text": "再换一套:深色科技风。", + "start": 0.0, + "words": [ + { + "t": 0.0, + "s": 0, + "e": 1 + }, + { + "t": 0.238, + "s": 1, + "e": 2 + }, + { + "t": 0.476, + "s": 2, + "e": 3 + }, + { + "t": 0.714, + "s": 3, + "e": 4 + }, + { + "t": 1.19, + "s": 5, + "e": 6 + }, + { + "t": 1.429, + "s": 6, + "e": 7 + }, + { + "t": 1.667, + "s": 7, + "e": 8 + }, + { + "t": 1.905, + "s": 8, + "e": 9 + }, + { + "t": 2.143, + "s": 9, + "e": 10 + } + ], + "end": 2.619 + }, + { + "text": "终端里敲一行命令,节点一个接一个亮起,", + "start": 2.619, + "words": [ + { + "t": 2.619, + "s": 0, + "e": 1 + }, + { + "t": 2.857, + "s": 1, + "e": 2 + }, + { + "t": 3.095, + "s": 2, + "e": 3 + }, + { + "t": 3.333, + "s": 3, + "e": 4 + }, + { + "t": 3.571, + "s": 4, + "e": 5 + }, + { + "t": 3.81, + "s": 5, + "e": 6 + }, + { + "t": 4.048, + "s": 6, + "e": 7 + }, + { + "t": 4.286, + "s": 7, + "e": 8 + }, + { + "t": 4.762, + "s": 9, + "e": 10 + }, + { + "t": 5.0, + "s": 10, + "e": 11 + }, + { + "t": 5.238, + "s": 11, + "e": 12 + }, + { + "t": 5.476, + "s": 12, + "e": 13 + }, + { + "t": 5.714, + "s": 13, + "e": 14 + }, + { + "t": 5.952, + "s": 14, + "e": 15 + }, + { + "t": 6.19, + "s": 15, + "e": 16 + }, + { + "t": 6.429, + "s": 16, + "e": 17 + }, + { + "t": 6.667, + "s": 17, + "e": 18 + } + ], + "end": 7.143 + }, + { + "text": "数据沿着连线流动。", + "start": 7.143, + "words": [ + { + "t": 7.143, + "s": 0, + "e": 1 + }, + { + "t": 7.381, + "s": 1, + "e": 2 + }, + { + "t": 7.619, + "s": 2, + "e": 3 + }, + { + "t": 7.857, + "s": 3, + "e": 4 + }, + { + "t": 8.095, + "s": 4, + "e": 5 + }, + { + "t": 8.333, + "s": 5, + "e": 6 + }, + { + "t": 8.571, + "s": 6, + "e": 7 + }, + { + "t": 8.81, + "s": 7, + "e": 8 + } + ], + "end": 9.536 + }, + { + "text": "切换镜头时,来一点故障感。", + "start": 10.036, + "words": [ + { + "t": 10.036, + "s": 0, + "e": 1 + }, + { + "t": 10.274, + "s": 1, + "e": 2 + }, + { + "t": 10.512, + "s": 2, + "e": 3 + }, + { + "t": 10.75, + "s": 3, + "e": 4 + }, + { + "t": 10.988, + "s": 4, + "e": 5 + }, + { + "t": 11.465, + "s": 6, + "e": 7 + }, + { + "t": 11.703, + "s": 7, + "e": 8 + }, + { + "t": 11.941, + "s": 8, + "e": 9 + }, + { + "t": 12.179, + "s": 9, + "e": 10 + }, + { + "t": 12.417, + "s": 10, + "e": 11 + }, + { + "t": 12.655, + "s": 11, + "e": 12 + } + ], + "end": 13.131 + }, + { + "text": "讲系统、讲架构,就用这一套。", + "start": 13.131, + "words": [ + { + "t": 13.131, + "s": 0, + "e": 1 + }, + { + "t": 13.369, + "s": 1, + "e": 2 + }, + { + "t": 13.607, + "s": 2, + "e": 3 + }, + { + "t": 14.084, + "s": 4, + "e": 5 + }, + { + "t": 14.322, + "s": 5, + "e": 6 + }, + { + "t": 14.56, + "s": 6, + "e": 7 + }, + { + "t": 15.036, + "s": 8, + "e": 9 + }, + { + "t": 15.274, + "s": 9, + "e": 10 + }, + { + "t": 15.512, + "s": 10, + "e": 11 + }, + { + "t": 15.75, + "s": 11, + "e": 12 + }, + { + "t": 15.988, + "s": 12, + "e": 13 + } + ], + "end": 16.464 + } + ], + "aligned": 56, + "words": 56 + }, + "s05": { + "chapter": "第五场 · 杂志排版", + "duration": 15.036, + "audio": "audio/s05.wav", + "cues": { + "big": 0.0, + "type": 2.381, + "block": 5.952, + "num": 7.619, + "hit": 10.0, + "out": 12.179 + }, + "lines": [ + { + "text": "最后一套:杂志排版。", + "start": 0.0, + "words": [ + { + "t": 0.0, + "s": 0, + "e": 1 + }, + { + "t": 0.238, + "s": 1, + "e": 2 + }, + { + "t": 0.476, + "s": 2, + "e": 3 + }, + { + "t": 0.714, + "s": 3, + "e": 4 + }, + { + "t": 1.19, + "s": 5, + "e": 6 + }, + { + "t": 1.429, + "s": 6, + "e": 7 + }, + { + "t": 1.667, + "s": 7, + "e": 8 + }, + { + "t": 1.905, + "s": 8, + "e": 9 + } + ], + "end": 2.381 + }, + { + "text": "大字跟着旁白,一个一个跳出来;", + "start": 2.381, + "words": [ + { + "t": 2.381, + "s": 0, + "e": 1 + }, + { + "t": 2.619, + "s": 1, + "e": 2 + }, + { + "t": 2.857, + "s": 2, + "e": 3 + }, + { + "t": 3.095, + "s": 3, + "e": 4 + }, + { + "t": 3.333, + "s": 4, + "e": 5 + }, + { + "t": 3.571, + "s": 5, + "e": 6 + }, + { + "t": 4.048, + "s": 7, + "e": 8 + }, + { + "t": 4.286, + "s": 8, + "e": 9 + }, + { + "t": 4.524, + "s": 9, + "e": 10 + }, + { + "t": 4.762, + "s": 10, + "e": 11 + }, + { + "t": 5.0, + "s": 11, + "e": 12 + }, + { + "t": 5.238, + "s": 12, + "e": 13 + }, + { + "t": 5.476, + "s": 13, + "e": 14 + } + ], + "end": 5.952 + }, + { + "text": "色块推开画面,数字直接砸在屏幕上:", + "start": 5.952, + "words": [ + { + "t": 5.952, + "s": 0, + "e": 1 + }, + { + "t": 6.19, + "s": 1, + "e": 2 + }, + { + "t": 6.429, + "s": 2, + "e": 3 + }, + { + "t": 6.667, + "s": 3, + "e": 4 + }, + { + "t": 6.905, + "s": 4, + "e": 5 + }, + { + "t": 7.143, + "s": 5, + "e": 6 + }, + { + "t": 7.619, + "s": 7, + "e": 8 + }, + { + "t": 7.857, + "s": 8, + "e": 9 + }, + { + "t": 8.095, + "s": 9, + "e": 10 + }, + { + "t": 8.333, + "s": 10, + "e": 11 + }, + { + "t": 8.571, + "s": 11, + "e": 12 + }, + { + "t": 8.81, + "s": 12, + "e": 13 + }, + { + "t": 9.048, + "s": 13, + "e": 14 + }, + { + "t": 9.286, + "s": 14, + "e": 15 + }, + { + "t": 9.524, + "s": 15, + "e": 16 + } + ], + "end": 10.0 + }, + { + "text": "一百二十帧。", + "start": 10.0, + "words": [ + { + "t": 10.0, + "s": 0, + "e": 1 + }, + { + "t": 10.238, + "s": 1, + "e": 2 + }, + { + "t": 10.476, + "s": 2, + "e": 3 + }, + { + "t": 10.714, + "s": 3, + "e": 4 + }, + { + "t": 10.952, + "s": 4, + "e": 5 + } + ], + "end": 11.679 + }, + { + "text": "讲观点、讲金句,就用它。", + "start": 12.179, + "words": [ + { + "t": 12.179, + "s": 0, + "e": 1 + }, + { + "t": 12.417, + "s": 1, + "e": 2 + }, + { + "t": 12.655, + "s": 2, + "e": 3 + }, + { + "t": 13.131, + "s": 4, + "e": 5 + }, + { + "t": 13.369, + "s": 5, + "e": 6 + }, + { + "t": 13.608, + "s": 6, + "e": 7 + }, + { + "t": 14.084, + "s": 8, + "e": 9 + }, + { + "t": 14.322, + "s": 9, + "e": 10 + }, + { + "t": 14.56, + "s": 10, + "e": 11 + } + ], + "end": 15.036 + } + ], + "aligned": 50, + "words": 50 + }, + "s06": { + "chapter": "第六场 · 数学推导", + "duration": 21.94, + "audio": "audio/s06.wav", + "cues": { + "plane": 0.0, + "curve": 2.381, + "tan": 6.429, + "tex": 12.417, + "limit": 15.036, + "result": 16.465, + "fit": 18.607 + }, + "lines": [ + { + "text": "第六套:数学推导风。", + "start": 0.0, + "words": [ + { + "t": 0.0, + "s": 0, + "e": 1 + }, + { + "t": 0.238, + "s": 1, + "e": 2 + }, + { + "t": 0.476, + "s": 2, + "e": 3 + }, + { + "t": 0.952, + "s": 4, + "e": 5 + }, + { + "t": 1.19, + "s": 5, + "e": 6 + }, + { + "t": 1.429, + "s": 6, + "e": 7 + }, + { + "t": 1.667, + "s": 7, + "e": 8 + }, + { + "t": 1.905, + "s": 8, + "e": 9 + } + ], + "end": 2.381 + }, + { + "text": "坐标轴画出来,函数曲线一笔描上去,", + "start": 2.381, + "words": [ + { + "t": 2.381, + "s": 0, + "e": 1 + }, + { + "t": 2.619, + "s": 1, + "e": 2 + }, + { + "t": 2.857, + "s": 2, + "e": 3 + }, + { + "t": 3.095, + "s": 3, + "e": 4 + }, + { + "t": 3.333, + "s": 4, + "e": 5 + }, + { + "t": 3.571, + "s": 5, + "e": 6 + }, + { + "t": 4.048, + "s": 7, + "e": 8 + }, + { + "t": 4.286, + "s": 8, + "e": 9 + }, + { + "t": 4.524, + "s": 9, + "e": 10 + }, + { + "t": 4.762, + "s": 10, + "e": 11 + }, + { + "t": 5.0, + "s": 11, + "e": 12 + }, + { + "t": 5.238, + "s": 12, + "e": 13 + }, + { + "t": 5.476, + "s": 13, + "e": 14 + }, + { + "t": 5.714, + "s": 14, + "e": 15 + }, + { + "t": 5.952, + "s": 15, + "e": 16 + } + ], + "end": 6.429 + }, + { + "text": "切线跟着点在曲线上滑动,", + "start": 6.429, + "words": [ + { + "t": 6.429, + "s": 0, + "e": 1 + }, + { + "t": 6.667, + "s": 1, + "e": 2 + }, + { + "t": 6.905, + "s": 2, + "e": 3 + }, + { + "t": 7.143, + "s": 3, + "e": 4 + }, + { + "t": 7.381, + "s": 4, + "e": 5 + }, + { + "t": 7.619, + "s": 5, + "e": 6 + }, + { + "t": 7.857, + "s": 6, + "e": 7 + }, + { + "t": 8.095, + "s": 7, + "e": 8 + }, + { + "t": 8.333, + "s": 8, + "e": 9 + }, + { + "t": 8.571, + "s": 9, + "e": 10 + }, + { + "t": 8.81, + "s": 10, + "e": 11 + } + ], + "end": 9.286 + }, + { + "text": "斜率的数字实时在变。", + "start": 9.286, + "words": [ + { + "t": 9.286, + "s": 0, + "e": 1 + }, + { + "t": 9.524, + "s": 1, + "e": 2 + }, + { + "t": 9.762, + "s": 2, + "e": 3 + }, + { + "t": 10.0, + "s": 3, + "e": 4 + }, + { + "t": 10.238, + "s": 4, + "e": 5 + }, + { + "t": 10.476, + "s": 5, + "e": 6 + }, + { + "t": 10.714, + "s": 6, + "e": 7 + }, + { + "t": 10.952, + "s": 7, + "e": 8 + }, + { + "t": 11.19, + "s": 8, + "e": 9 + } + ], + "end": 11.917 + }, + { + "text": "公式也能一步一步变形,先写成极限,", + "start": 12.417, + "words": [ + { + "t": 12.417, + "s": 0, + "e": 1 + }, + { + "t": 12.655, + "s": 1, + "e": 2 + }, + { + "t": 12.893, + "s": 2, + "e": 3 + }, + { + "t": 13.131, + "s": 3, + "e": 4 + }, + { + "t": 13.369, + "s": 4, + "e": 5 + }, + { + "t": 13.607, + "s": 5, + "e": 6 + }, + { + "t": 13.846, + "s": 6, + "e": 7 + }, + { + "t": 14.084, + "s": 7, + "e": 8 + }, + { + "t": 14.322, + "s": 8, + "e": 9 + }, + { + "t": 14.56, + "s": 9, + "e": 10 + }, + { + "t": 15.036, + "s": 11, + "e": 12 + }, + { + "t": 15.274, + "s": 12, + "e": 13 + }, + { + "t": 15.512, + "s": 13, + "e": 14 + }, + { + "t": 15.75, + "s": 14, + "e": 15 + }, + { + "t": 15.988, + "s": 15, + "e": 16 + } + ], + "end": 16.465 + }, + { + "text": "再化简成二倍的x。", + "start": 16.465, + "words": [ + { + "t": 16.465, + "s": 0, + "e": 1 + }, + { + "t": 16.703, + "s": 1, + "e": 2 + }, + { + "t": 16.941, + "s": 2, + "e": 3 + }, + { + "t": 17.179, + "s": 3, + "e": 4 + }, + { + "t": 17.417, + "s": 4, + "e": 5 + }, + { + "t": 17.655, + "s": 5, + "e": 6 + }, + { + "t": 17.893, + "s": 6, + "e": 7 + }, + { + "t": 18.131, + "s": 7, + "e": 8 + } + ], + "end": 18.607 + }, + { + "text": "讲数学、讲物理,用它最顺手。", + "start": 18.607, + "words": [ + { + "t": 18.607, + "s": 0, + "e": 1 + }, + { + "t": 18.846, + "s": 1, + "e": 2 + }, + { + "t": 19.084, + "s": 2, + "e": 3 + }, + { + "t": 19.56, + "s": 4, + "e": 5 + }, + { + "t": 19.798, + "s": 5, + "e": 6 + }, + { + "t": 20.036, + "s": 6, + "e": 7 + }, + { + "t": 20.512, + "s": 8, + "e": 9 + }, + { + "t": 20.75, + "s": 9, + "e": 10 + }, + { + "t": 20.988, + "s": 10, + "e": 11 + }, + { + "t": 21.227, + "s": 11, + "e": 12 + }, + { + "t": 21.465, + "s": 12, + "e": 13 + } + ], + "end": 21.94 + } + ], + "aligned": 77, + "words": 77 + }, + "s07": { + "chapter": "第七场 · 发布会", + "duration": 16.702, + "audio": "audio/s07.wav", + "cues": { + "logo": 0.0, + "btn": 2.143, + "click": 5.476, + "card": 8.571, + "zoom": 10.476, + "fin": 12.893 + }, + "lines": [ + { + "text": "第七套:发布会风。", + "start": 0.0, + "words": [ + { + "t": 0.0, + "s": 0, + "e": 1 + }, + { + "t": 0.238, + "s": 1, + "e": 2 + }, + { + "t": 0.476, + "s": 2, + "e": 3 + }, + { + "t": 0.952, + "s": 4, + "e": 5 + }, + { + "t": 1.19, + "s": 5, + "e": 6 + }, + { + "t": 1.429, + "s": 6, + "e": 7 + }, + { + "t": 1.667, + "s": 7, + "e": 8 + } + ], + "end": 2.143 + }, + { + "text": "画面里自始至终只有一个形状,光标一点,", + "start": 2.143, + "words": [ + { + "t": 2.143, + "s": 0, + "e": 1 + }, + { + "t": 2.381, + "s": 1, + "e": 2 + }, + { + "t": 2.619, + "s": 2, + "e": 3 + }, + { + "t": 2.857, + "s": 3, + "e": 4 + }, + { + "t": 3.095, + "s": 4, + "e": 5 + }, + { + "t": 3.333, + "s": 5, + "e": 6 + }, + { + "t": 3.571, + "s": 6, + "e": 7 + }, + { + "t": 3.81, + "s": 7, + "e": 8 + }, + { + "t": 4.048, + "s": 8, + "e": 9 + }, + { + "t": 4.286, + "s": 9, + "e": 10 + }, + { + "t": 4.524, + "s": 10, + "e": 11 + }, + { + "t": 4.762, + "s": 11, + "e": 12 + }, + { + "t": 5.0, + "s": 12, + "e": 13 + }, + { + "t": 5.476, + "s": 14, + "e": 15 + }, + { + "t": 5.714, + "s": 15, + "e": 16 + }, + { + "t": 5.952, + "s": 16, + "e": 17 + }, + { + "t": 6.19, + "s": 17, + "e": 18 + } + ], + "end": 6.667 + }, + { + "text": "它就变成进度条,再长成一张卡片,", + "start": 6.667, + "words": [ + { + "t": 6.667, + "s": 0, + "e": 1 + }, + { + "t": 6.905, + "s": 1, + "e": 2 + }, + { + "t": 7.143, + "s": 2, + "e": 3 + }, + { + "t": 7.381, + "s": 3, + "e": 4 + }, + { + "t": 7.619, + "s": 4, + "e": 5 + }, + { + "t": 7.857, + "s": 5, + "e": 6 + }, + { + "t": 8.095, + "s": 6, + "e": 7 + }, + { + "t": 8.571, + "s": 8, + "e": 9 + }, + { + "t": 8.81, + "s": 9, + "e": 10 + }, + { + "t": 9.048, + "s": 10, + "e": 11 + }, + { + "t": 9.286, + "s": 11, + "e": 12 + }, + { + "t": 9.524, + "s": 12, + "e": 13 + }, + { + "t": 9.762, + "s": 13, + "e": 14 + }, + { + "t": 10.0, + "s": 14, + "e": 15 + } + ], + "end": 10.476 + }, + { + "text": "镜头跟着推近。", + "start": 10.476, + "words": [ + { + "t": 10.476, + "s": 0, + "e": 1 + }, + { + "t": 10.714, + "s": 1, + "e": 2 + }, + { + "t": 10.952, + "s": 2, + "e": 3 + }, + { + "t": 11.19, + "s": 3, + "e": 4 + }, + { + "t": 11.429, + "s": 4, + "e": 5 + }, + { + "t": 11.667, + "s": 5, + "e": 6 + } + ], + "end": 12.393 + }, + { + "text": "讲产品、做功能演示,就用这一套。", + "start": 12.893, + "words": [ + { + "t": 12.893, + "s": 0, + "e": 1 + }, + { + "t": 13.131, + "s": 1, + "e": 2 + }, + { + "t": 13.369, + "s": 2, + "e": 3 + }, + { + "t": 13.845, + "s": 4, + "e": 5 + }, + { + "t": 14.083, + "s": 5, + "e": 6 + }, + { + "t": 14.322, + "s": 6, + "e": 7 + }, + { + "t": 14.56, + "s": 7, + "e": 8 + }, + { + "t": 14.798, + "s": 8, + "e": 9 + }, + { + "t": 15.274, + "s": 10, + "e": 11 + }, + { + "t": 15.512, + "s": 11, + "e": 12 + }, + { + "t": 15.75, + "s": 12, + "e": 13 + }, + { + "t": 15.988, + "s": 13, + "e": 14 + }, + { + "t": 16.226, + "s": 14, + "e": 15 + } + ], + "end": 16.702 + } + ], + "aligned": 57, + "words": 57 + }, + "s08": { + "chapter": "第八场 · 像素游戏", + "duration": 18.369, + "audio": "audio/s08.wav", + "cues": { + "start": 0.0, + "walk": 2.381, + "coin": 4.524, + "bar": 8.095, + "talk": 11.226, + "win": 15.75 + }, + "lines": [ + { + "text": "第八套:像素游戏风。", + "start": 0.0, + "words": [ + { + "t": 0.0, + "s": 0, + "e": 1 + }, + { + "t": 0.238, + "s": 1, + "e": 2 + }, + { + "t": 0.476, + "s": 2, + "e": 3 + }, + { + "t": 0.952, + "s": 4, + "e": 5 + }, + { + "t": 1.19, + "s": 5, + "e": 6 + }, + { + "t": 1.429, + "s": 6, + "e": 7 + }, + { + "t": 1.667, + "s": 7, + "e": 8 + }, + { + "t": 1.905, + "s": 8, + "e": 9 + } + ], + "end": 2.381 + }, + { + "text": "小人从左边走进来,每讲一个要点,", + "start": 2.381, + "words": [ + { + "t": 2.381, + "s": 0, + "e": 1 + }, + { + "t": 2.619, + "s": 1, + "e": 2 + }, + { + "t": 2.857, + "s": 2, + "e": 3 + }, + { + "t": 3.095, + "s": 3, + "e": 4 + }, + { + "t": 3.333, + "s": 4, + "e": 5 + }, + { + "t": 3.571, + "s": 5, + "e": 6 + }, + { + "t": 3.81, + "s": 6, + "e": 7 + }, + { + "t": 4.048, + "s": 7, + "e": 8 + }, + { + "t": 4.524, + "s": 9, + "e": 10 + }, + { + "t": 4.762, + "s": 10, + "e": 11 + }, + { + "t": 5.0, + "s": 11, + "e": 12 + }, + { + "t": 5.238, + "s": 12, + "e": 13 + }, + { + "t": 5.476, + "s": 13, + "e": 14 + }, + { + "t": 5.714, + "s": 14, + "e": 15 + } + ], + "end": 6.19 + }, + { + "text": "就吃到一枚金币,经验条一格一格涨满。", + "start": 6.19, + "words": [ + { + "t": 6.19, + "s": 0, + "e": 1 + }, + { + "t": 6.429, + "s": 1, + "e": 2 + }, + { + "t": 6.667, + "s": 2, + "e": 3 + }, + { + "t": 6.905, + "s": 3, + "e": 4 + }, + { + "t": 7.143, + "s": 4, + "e": 5 + }, + { + "t": 7.381, + "s": 5, + "e": 6 + }, + { + "t": 7.619, + "s": 6, + "e": 7 + }, + { + "t": 8.095, + "s": 8, + "e": 9 + }, + { + "t": 8.333, + "s": 9, + "e": 10 + }, + { + "t": 8.571, + "s": 10, + "e": 11 + }, + { + "t": 8.81, + "s": 11, + "e": 12 + }, + { + "t": 9.048, + "s": 12, + "e": 13 + }, + { + "t": 9.286, + "s": 13, + "e": 14 + }, + { + "t": 9.524, + "s": 14, + "e": 15 + }, + { + "t": 9.762, + "s": 15, + "e": 16 + }, + { + "t": 10.0, + "s": 16, + "e": 17 + } + ], + "end": 10.726 + }, + { + "text": "对话框里的字,跟着旁白一个一个蹦出来。", + "start": 11.226, + "words": [ + { + "t": 11.226, + "s": 0, + "e": 1 + }, + { + "t": 11.464, + "s": 1, + "e": 2 + }, + { + "t": 11.702, + "s": 2, + "e": 3 + }, + { + "t": 11.94, + "s": 3, + "e": 4 + }, + { + "t": 12.178, + "s": 4, + "e": 5 + }, + { + "t": 12.416, + "s": 5, + "e": 6 + }, + { + "t": 12.893, + "s": 7, + "e": 8 + }, + { + "t": 13.131, + "s": 8, + "e": 9 + }, + { + "t": 13.369, + "s": 9, + "e": 10 + }, + { + "t": 13.607, + "s": 10, + "e": 11 + }, + { + "t": 13.845, + "s": 11, + "e": 12 + }, + { + "t": 14.083, + "s": 12, + "e": 13 + }, + { + "t": 14.321, + "s": 13, + "e": 14 + }, + { + "t": 14.559, + "s": 14, + "e": 15 + }, + { + "t": 14.797, + "s": 15, + "e": 16 + }, + { + "t": 15.036, + "s": 16, + "e": 17 + }, + { + "t": 15.274, + "s": 17, + "e": 18 + } + ], + "end": 15.75 + }, + { + "text": "做轻松的科普,就用它。", + "start": 15.75, + "words": [ + { + "t": 15.75, + "s": 0, + "e": 1 + }, + { + "t": 15.988, + "s": 1, + "e": 2 + }, + { + "t": 16.226, + "s": 2, + "e": 3 + }, + { + "t": 16.464, + "s": 3, + "e": 4 + }, + { + "t": 16.702, + "s": 4, + "e": 5 + }, + { + "t": 16.94, + "s": 5, + "e": 6 + }, + { + "t": 17.416, + "s": 7, + "e": 8 + }, + { + "t": 17.655, + "s": 8, + "e": 9 + }, + { + "t": 17.893, + "s": 9, + "e": 10 + } + ], + "end": 18.369 + } + ], + "aligned": 64, + "words": 64 + }, + "s09": { + "chapter": "第九场 · 水墨", + "duration": 16.702, + "audio": "audio/s09.wav", + "cues": { + "ink": 0.0, + "hill": 1.905, + "brush": 5.238, + "poem": 6.905, + "seal": 11.702, + "close": 13.845 + }, + "lines": [ + { + "text": "第九套:水墨风。", + "start": 0.0, + "words": [ + { + "t": 0.0, + "s": 0, + "e": 1 + }, + { + "t": 0.238, + "s": 1, + "e": 2 + }, + { + "t": 0.476, + "s": 2, + "e": 3 + }, + { + "t": 0.952, + "s": 4, + "e": 5 + }, + { + "t": 1.19, + "s": 5, + "e": 6 + }, + { + "t": 1.429, + "s": 6, + "e": 7 + } + ], + "end": 1.905 + }, + { + "text": "远山一层一层,从雾里晕出来,", + "start": 1.905, + "words": [ + { + "t": 1.905, + "s": 0, + "e": 1 + }, + { + "t": 2.143, + "s": 1, + "e": 2 + }, + { + "t": 2.381, + "s": 2, + "e": 3 + }, + { + "t": 2.619, + "s": 3, + "e": 4 + }, + { + "t": 2.857, + "s": 4, + "e": 5 + }, + { + "t": 3.095, + "s": 5, + "e": 6 + }, + { + "t": 3.571, + "s": 7, + "e": 8 + }, + { + "t": 3.81, + "s": 8, + "e": 9 + }, + { + "t": 4.048, + "s": 9, + "e": 10 + }, + { + "t": 4.286, + "s": 10, + "e": 11 + }, + { + "t": 4.524, + "s": 11, + "e": 12 + }, + { + "t": 4.762, + "s": 12, + "e": 13 + } + ], + "end": 5.238 + }, + { + "text": "毛笔一笔扫过。", + "start": 5.238, + "words": [ + { + "t": 5.238, + "s": 0, + "e": 1 + }, + { + "t": 5.476, + "s": 1, + "e": 2 + }, + { + "t": 5.714, + "s": 2, + "e": 3 + }, + { + "t": 5.952, + "s": 3, + "e": 4 + }, + { + "t": 6.19, + "s": 4, + "e": 5 + }, + { + "t": 6.429, + "s": 5, + "e": 6 + } + ], + "end": 6.905 + }, + { + "text": "竖排的字,念到哪里,墨就落到哪里。", + "start": 6.905, + "words": [ + { + "t": 6.905, + "s": 0, + "e": 1 + }, + { + "t": 7.143, + "s": 1, + "e": 2 + }, + { + "t": 7.381, + "s": 2, + "e": 3 + }, + { + "t": 7.619, + "s": 3, + "e": 4 + }, + { + "t": 8.095, + "s": 5, + "e": 6 + }, + { + "t": 8.333, + "s": 6, + "e": 7 + }, + { + "t": 8.571, + "s": 7, + "e": 8 + }, + { + "t": 8.81, + "s": 8, + "e": 9 + }, + { + "t": 9.286, + "s": 10, + "e": 11 + }, + { + "t": 9.524, + "s": 11, + "e": 12 + }, + { + "t": 9.762, + "s": 12, + "e": 13 + }, + { + "t": 10.0, + "s": 13, + "e": 14 + }, + { + "t": 10.238, + "s": 14, + "e": 15 + }, + { + "t": 10.476, + "s": 15, + "e": 16 + } + ], + "end": 11.202 + }, + { + "text": "最后盖上一方红印。", + "start": 11.702, + "words": [ + { + "t": 11.702, + "s": 0, + "e": 1 + }, + { + "t": 11.94, + "s": 1, + "e": 2 + }, + { + "t": 12.178, + "s": 2, + "e": 3 + }, + { + "t": 12.416, + "s": 3, + "e": 4 + }, + { + "t": 12.654, + "s": 4, + "e": 5 + }, + { + "t": 12.892, + "s": 5, + "e": 6 + }, + { + "t": 13.131, + "s": 6, + "e": 7 + }, + { + "t": 13.369, + "s": 7, + "e": 8 + } + ], + "end": 13.845 + }, + { + "text": "讲历史、讲诗词,就用它。", + "start": 13.845, + "words": [ + { + "t": 13.845, + "s": 0, + "e": 1 + }, + { + "t": 14.083, + "s": 1, + "e": 2 + }, + { + "t": 14.321, + "s": 2, + "e": 3 + }, + { + "t": 14.797, + "s": 4, + "e": 5 + }, + { + "t": 15.035, + "s": 5, + "e": 6 + }, + { + "t": 15.273, + "s": 6, + "e": 7 + }, + { + "t": 15.75, + "s": 8, + "e": 9 + }, + { + "t": 15.988, + "s": 9, + "e": 10 + }, + { + "t": 16.226, + "s": 10, + "e": 11 + } + ], + "end": 16.702 + } + ], + "aligned": 55, + "words": 55 } } diff --git a/tests/test_sfx.py b/tests/test_sfx.py new file mode 100644 index 0000000..5aeead7 --- /dev/null +++ b/tests/test_sfx.py @@ -0,0 +1,27 @@ +import re +import wave +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def test_sfx_names_match_recipes_and_files(): + """the names scenes may use (src/core/Sfx.tsx), the synth recipes (tools/sfx.py) and public/sfx/*.wav agree""" + ts = (ROOT / "src" / "core" / "Sfx.tsx").read_text(encoding="utf-8") + names = re.findall(r"'(\w+)'", re.search(r"export const SFX = \[(.*?)\]", ts).group(1)) + py = (ROOT / "tools" / "sfx.py").read_text(encoding="utf-8") + recipes = re.findall(r'^\s+"(\w+)": \w+,$', re.search(r"SOUNDS = \{(.*?)\n\}", py, re.S).group(1), re.M) + assert sorted(names) == sorted(recipes) + for n in names: + p = ROOT / "public" / "sfx" / f"{n}.wav" + assert p.exists(), f"{p} missing — run python tools/sfx.py" + with wave.open(str(p)) as w: + assert w.getnframes() > 0 + + +def test_scenes_only_use_known_sfx(): + ts = (ROOT / "src" / "core" / "Sfx.tsx").read_text(encoding="utf-8") + names = set(re.findall(r"'(\w+)'", re.search(r"export const SFX = \[(.*?)\]", ts).group(1))) + for f in (ROOT / "src" / "scenes").glob("*.tsx"): + used = set(re.findall(r'name="(\w+)"', f.read_text(encoding="utf-8"))) + assert used <= names, f"{f.name} uses unknown sounds {used - names}" diff --git a/tools/listen.mjs b/tools/listen.mjs new file mode 100644 index 0000000..6d5b444 --- /dev/null +++ b/tools/listen.mjs @@ -0,0 +1,50 @@ +// Hear without speakers: render a scene's audio and list where sounds happen, relative to the cues. +// usage: node tools/listen.mjs s05 → whole scene +// node tools/listen.mjs s05:block s05:out → from one spot to another (scene:cue[:offset]) +// output: out/listen/.wav and a timeline like "block+2 0.95s ▇▇▇▇▇" +// With --mock timings the narration is silent, so every event listed is a sound effect or the music pulse. +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import {renderMedia, selectComposition} from '@remotion/renderer'; +import {CFG, FPS, browserExecutable, nearestCue, open, parseSpot, root, sceneLen, starts} from './timeline.mjs'; + +const [a, b] = process.argv.slice(2); +if (!a) { + console.error('usage: node tools/listen.mjs scene[:cue[:offset]] [scene:cue[:offset]]'); + process.exit(1); +} +const from = a.includes(':') ? parseSpot(a) : {id: a, frame: starts[a]}; +if (!(from.id in starts)) throw new Error(`unknown scene ${a}`); +const to = b ? parseSpot(b) : {id: from.id, frame: starts[from.id] + sceneLen(from.id) - 1}; +const outDir = path.join(root, 'out/listen'); +fs.mkdirSync(outDir, {recursive: true}); +const output = path.join(outDir, `${from.id}.wav`); +const {serveUrl} = await open(CFG.id); +const composition = await selectComposition({serveUrl, id: CFG.id, browserExecutable}); +await renderMedia({composition, serveUrl, codec: 'wav', outputLocation: output, frameRange: [from.frame, to.frame], browserExecutable, logLevel: 'error'}); + +// ffmpeg: where is it not silent? +// (silencedetect reports on stderr) +const err = spawnSync('ffmpeg', ['-hide_banner', '-i', output, '-af', 'silencedetect=noise=-45dB:d=0.12', '-f', 'null', '-'], {encoding: 'utf8'}).stderr; +const marks = [...err.matchAll(/silence_(start|end): ([\d.]+)/g)].map((m) => [m[1], Number(m[2])]); +const events = []; +let soundFrom = 0; +let inSound = true; +for (const [kind, t] of marks) { + if (kind === 'start' && inSound) { + if (t - soundFrom > 0.02) events.push([soundFrom, t]); + inSound = false; + } else if (kind === 'end') { + soundFrom = t; + inSound = true; + } +} +const dur = (to.frame - from.frame) / FPS; +if (inSound && dur - soundFrom > 0.02) events.push([soundFrom, dur]); +console.log(`${output} ${dur.toFixed(2)}s`); +for (const [s, e] of events) { + const local = from.frame - starts[from.id] + Math.round(s * FPS); + console.log(` ${nearestCue(from.id, local).padEnd(14)} ${s.toFixed(2)}s ${'▇'.repeat(Math.max(1, Math.round((e - s) * 10)))}`); +} +if (!events.length) console.log(' (silent)'); diff --git a/tools/mix.py b/tools/mix.py index bf37696..501570d 100644 --- a/tools/mix.py +++ b/tools/mix.py @@ -1,6 +1,7 @@ """Mix the music under the narration, ducking it with a sidechain, and loudness-normalize to -16 LUFS. python tools/mix.py # out/.mp4 + out/music.wav → out/-mixed.mp4 + python tools/mix.py 9x16 # out/-9x16.mp4 → out/-9x16-mixed.mp4 (a kit.config.json format) python tools/mix.py VIDEO MUSIC OUTPUT # explicit paths is the composition id from kit.config.json (what `npm run render` writes by default). @@ -20,9 +21,12 @@ def main(argv): out_dir = ROOT / "out" - video = argv[0] if len(argv) > 0 else str(out_dir / f"{CFG['id']}.mp4") + name = CFG["id"] + if argv and argv[0] in CFG.get("formats", {}): + name = f"{name}-{argv.pop(0)}" + video = argv[0] if len(argv) > 0 else str(out_dir / f"{name}.mp4") music = argv[1] if len(argv) > 1 else str(out_dir / "music.wav") - output = argv[2] if len(argv) > 2 else str(out_dir / f"{CFG['id']}-mixed.mp4") + output = argv[2] if len(argv) > 2 else str(out_dir / f"{name}-mixed.mp4") cmd = [ "ffmpeg", "-y", "-i", video, "-i", music, "-filter_complex", FILTER, diff --git a/tools/music.py b/tools/music.py index 190557e..9512ee3 100644 --- a/tools/music.py +++ b/tools/music.py @@ -2,6 +2,8 @@ - a low drone pad, one section per scene: each scene gets its own chord progression (cycled from PROGRESSIONS) - a soft hit (low thump + bell) at the start of each scene, i.e. on each chapter card +- a quiet pulse on the beat grid (kit.config.json "music.bpm", counted from 0): a soft kick on every beat, a shaker + on the off-beats. Scenes see the same grid through useBeat() (src/core/Sfx.tsx), so visuals can land on the music. Scene boundaries come from src/timings.json + kit.config.json, so the score follows re-TTS automatically. Then mix it under the narration with tools/mix.py. """ @@ -9,10 +11,12 @@ import numpy as np -from kit import ROOT, scene_spans, timings +from kit import CFG, ROOT, scene_spans, timings SR = 44100 -BAR = 9.0 # seconds per chord +BPM = CFG.get("music", {}).get("bpm", 96) +BEAT = 60.0 / BPM +BAR = 16 * BEAT # seconds per chord: four bars of 4/4 # MIDI notes, four chords per progression; scene i uses PROGRESSIONS[i % len] PROGRESSIONS = [ [[38, 45, 50, 53], [34, 41, 50, 53], [36, 43, 48, 55], [33, 40, 49, 52]], # Dm Bb C A @@ -80,6 +84,19 @@ def add(i0, sig): bell = (np.sin(2 * np.pi * hz(m) * tt) + 0.3 * np.sin(2 * np.pi * hz(m) * 2.76 * tt) * np.exp(-tt * 3)) * np.exp(-tt * 1.1) add(int(at * SR), 0.22 * bell) + # ── pulse on the beat grid: kick on every beat (accent on beat 1), shaker on the off-beats ── + k = int(0.35 * SR) + tt = np.arange(k) / SR + kick = np.sin(2 * np.pi * np.cumsum(70 * np.exp(-tt * 18) + 45) / SR) * np.exp(-tt * 9) + k2 = int(0.06 * SR) + shaker = rng.standard_normal(k2) * np.exp(-np.arange(k2) / SR * 60) + shaker = shaker - np.convolve(shaker, np.ones(6) / 6, mode="same") # crude high-pass + nb = int(total / BEAT) + for b in range(nb): + at = b * BEAT + add(int(at * SR), (0.22 if b % 4 == 0 else 0.14) * kick) + add(int((at + BEAT / 2) * SR), 0.05 * shaker) + # fades + normalize y = out / (np.max(np.abs(out)) + 1e-9) fade = min(int(3 * SR), n // 2) diff --git a/tools/new-pack.mjs b/tools/new-pack.mjs new file mode 100644 index 0000000..47c6fdb --- /dev/null +++ b/tools/new-pack.mjs @@ -0,0 +1,134 @@ +// Scaffold a style pack: palette (brand-aware), a ground that never stands still, a chapter card, a title and a card +// component, and the Look that dresses scenes — the parts every pack in src/ has. +// usage: node tools/new-pack.mjs chalk --base "#1F2B26" --accent "#F2C14E" +// --base ground colour (#rrggbb); text and subtitles pick dark or light ink from it +// --accent the pack's accent; kit.config.json "brand.accent" replaces it when set +// then: node tools/new-scene.mjs s10 --look chalk +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const argv = process.argv.slice(2); +const name = argv[0]; +const opt = (k, d) => { + const i = argv.indexOf(k); + return i >= 0 ? argv[i + 1] : d; +}; +if (!name || !/^[a-z][a-z0-9]*$/.test(name)) { + console.error('usage: node tools/new-pack.mjs [--base #rrggbb] [--accent #rrggbb]'); + process.exit(1); +} +const base = opt('--base', '#16181D'); +const accent = opt('--accent', '#FF7A45'); +for (const [k, v] of [['--base', base], ['--accent', accent]]) { + if (!/^#[0-9a-fA-F]{6}$/.test(v)) { + console.error(`${k} must be #rrggbb`); + process.exit(1); + } +} +const dir = path.join(root, 'src', name); +if (fs.existsSync(dir)) { + console.error(`src/${name} already exists`); + process.exit(1); +} +const lum = (c) => { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(c.slice(i, i + 2), 16) / 255); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const light = lum(base) > 0.5; +const ink = light ? '#141414' : '#F4F1EA'; +const sub = light ? '#6B6760' : '#9A968E'; +const PAL = name.slice(0, 2).toUpperCase() + 'L'; +const Cap = name.charAt(0).toUpperCase() + name.slice(1); + +fs.mkdirSync(dir); +fs.writeFileSync( + path.join(dir, 'index.tsx'), + `/** ${Cap} style pack (scaffolded by tools/new-pack.mjs). Delete this folder if unused. */ +import React from 'react'; +import {AbsoluteFill, interpolate, useCurrentFrame} from 'remotion'; +import type {ChapterProps, Look} from '../core/look'; +import {life, move, noise, tween} from '../core/motion'; +import {FONT, themed} from '../core/theme'; + +/** palette; kit.config.json "brand.accent" replaces \`accent\`, "theme.${name}" overrides any key */ +export const ${PAL} = themed( + '${name}', + { + ground: '${base}', + ink: '${ink}', + sub: '${sub}', + accent: '${accent}', + line: '${light ? 'rgba(20,20,20,0.12)' : 'rgba(255,255,255,0.12)'}', + }, + {accent: ['accent']}, +); + +/** The ground. Keep something moving here (a drifting light, a texture) so no frame is ever frozen. */ +export const ${Cap}Ground: React.FC = () => { + const f = useCurrentFrame(); + const x = 50 + noise(1, f * 0.004) * 25; + const y = 40 + noise(2, f * 0.004) * 20; + return ( + + + + + ); +}; + +/** Big title that enters at \`at\` and (optionally) leaves at \`out\` (scene frames). */ +export const Title: React.FC<{at: number; out?: number; children: React.ReactNode; size?: number; style?: React.CSSProperties}> = ({at, out, children, size = 96, style}) => { + const f = useCurrentFrame(); + const {p, q} = life(f, at, out); + return
{children}
; +}; + +/** A content card with an accent edge. */ +export const Card: React.FC<{at: number; out?: number; children: React.ReactNode; style?: React.CSSProperties}> = ({at, out, children, style}) => { + const f = useCurrentFrame(); + const {p, q} = life(f, at, out); + return ( +
+ {children} +
+ ); +}; + +/** Chapter card shown during the lead-in. */ +export const ${Cap}Chapter: React.FC = ({f, lead, kicker, title}) => { + const out = interpolate(f, [lead - 10, lead], [1, 0], {extrapolateLeft: 'clamp', extrapolateRight: 'clamp'}); + if (out <= 0) return null; + return ( + + <${Cap}Ground /> + +
{kicker}
+
{title}
+
+
+ ); +}; + +export const ${name}: Look = { + base: ${PAL}.ground, + background: ${Cap}Ground, + chapter: ${Cap}Chapter, + subtitles: {bottom: 54, box: true, karaoke: true, size: 38, color: ${PAL}.ink, boxColor: '${light ? 'rgba(255,255,255,0.8)' : 'rgba(0,0,0,0.6)'}', quoteColor: ${PAL}.accent, dim: 0.4}, +}; +`, +); +console.log(`created src/${name}/index.tsx (palette ${PAL}, look \`${name}\`) +next: node tools/new-scene.mjs --look ${name} then open it in Studio and make the pack your own`); diff --git a/tools/new-scene.mjs b/tools/new-scene.mjs new file mode 100644 index 0000000..eaaafc3 --- /dev/null +++ b/tools/new-scene.mjs @@ -0,0 +1,124 @@ +// Scaffold a scene: narration entry, a working scene file, registry line, mock timings — in one go. +// usage: node tools/new-scene.mjs s10 --look paper --chapter "第十场 · 标题" --text "[[start]]……[[next]]……" +// --look a style pack folder under src/ (film slides paper neon editorial math keynote pixel ink, or your own) +// --chapter "kicker · title" for the chapter card (default " · 新场景") +// --text narration with [[cue]] markers (default: a two-cue placeholder to overwrite in tts/script.json) +// The scene it writes already runs: one beat per cue, each phrase appearing as it is said, leaving on the next cue, +// with a pop on every cue. Replace those beats with your own visuals. +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const argv = process.argv.slice(2); +const id = argv[0]; +const opt = (k, d) => { + const i = argv.indexOf(k); + return i >= 0 ? argv[i + 1] : d; +}; +if (!id || id.startsWith('--') || !/^\w+$/.test(id)) { + console.error('usage: node tools/new-scene.mjs --look [--chapter "kicker · title"] [--text "[[a]]……"]'); + process.exit(1); +} +const look = opt('--look', 'slides'); +const chapter = opt('--chapter', `${id} · 新场景`); +const text = opt('--text', '[[start]]这里写第一句旁白。[[next]]第二个画面从这个标记开始。'); + +const packFile = path.join(root, 'src', look, 'index.tsx'); +if (!fs.existsSync(packFile) || !new RegExp(`export const ${look}: Look`).test(fs.readFileSync(packFile, 'utf8'))) { + console.error(`no style pack "${look}" (expected src/${look}/index.tsx exporting \`const ${look}: Look\`)`); + process.exit(1); +} + +// 1. narration +const scriptPath = path.join(root, 'tts/script.json'); +const script = JSON.parse(fs.readFileSync(scriptPath, 'utf8')); +if (script.scenes.some((s) => s.id === id)) { + console.error(`${id} already exists in tts/script.json`); + process.exit(1); +} +const cues = [...text.matchAll(/\[\[(\w+)\]\]/g)].map((m) => m[1]); +if (!cues.length) { + console.error('the narration needs at least one [[cue]]'); + process.exit(1); +} +if (cues.includes('end')) { + console.error('cue name "end" is reserved'); + process.exit(1); +} +script.scenes.push({id, chapter, text}); +fs.writeFileSync(scriptPath, JSON.stringify(script, null, 2) + '\n'); + +// 2. one beat per cue: the first clause after it, exactly as spoken (so can find it) +const clean = (s) => s.replace(/\[\[\w+\]\]|\|\||<<\w+\||>>/g, ''); +const parts = text.split(/\[\[\w+\]\]/).slice(1); +const beats = cues.map((cue, i) => { + const said = clean(parts[i] ?? '').trim(); + const clause = (said.split(/[,。!?;:,.!?;:]/)[0] || said).slice(0, 14); + return {cue, text: clause}; +}); +const Comp = id.charAt(0).toUpperCase() + id.slice(1); +const scenePath = path.join(root, 'src/scenes', `${id}.tsx`); +if (fs.existsSync(scenePath)) { + console.error(`src/scenes/${id}.tsx already exists`); + process.exit(1); +} +fs.writeFileSync( + scenePath, + `/** ${id} · ${chapter}. Scaffolded by tools/new-scene.mjs — replace the placeholder beats with your own visuals. */ +import React from 'react'; +import {Full, Sfx, Spoken, life, move, useScene} from '../core'; +import {FONT, inkOn} from '../core/theme'; +import {${look}} from '../${look}'; + +// each beat enters on its cue and leaves just before the next one; its text appears as it is said +const BEATS = ${JSON.stringify(beats, null, 2).replace(/"(\w+)":/g, '$1:')}; + +export const ${Comp}: React.FC = () => { + const {f, c, end} = useScene(); + const ink = inkOn(${look}.base); + return ( + + {BEATS.map((b, i) => { + const next = BEATS[i + 1]; + const {p, q} = life(f, c(b.cue), next ? c(next.cue) - 12 : end + 10); + if (p <= 0 || q >= 1) return null; + return ( +
+ +
+ ); + })} + {BEATS.map((b) => ( + + ))} +
+ ); +}; +`, +); + +// 3. registry +const regPath = path.join(root, 'src/scenes/index.ts'); +let reg = fs.readFileSync(regPath, 'utf8'); +if (!new RegExp(`import \\{${look}\\} from '\\.\\./${look}';`).test(reg)) { + reg = reg.replace(/(import type \{SceneDef\} from '\.\.\/core\/look';\n)/, `$1import {${look}} from '../${look}';\n`); +} +const sceneImports = [...reg.matchAll(/^import \{\w+\} from '\.\/\w+';$/gm)]; +const lastImport = sceneImports[sceneImports.length - 1]; +const importLine = `import {${Comp}} from './${id}';`; +reg = lastImport ? reg.slice(0, lastImport.index + lastImport[0].length) + `\n${importLine}` + reg.slice(lastImport.index + lastImport[0].length) : `${importLine}\n${reg}`; +reg = reg.replace(/\n};\s*$/, `\n ${id}: {component: ${Comp}, look: ${look}},\n};\n`); +fs.writeFileSync(regPath, reg); + +// 4. mock timings for just this scene +const py = spawnSync('python', ['--version']).status === 0 ? 'python' : 'python3'; +const r = spawnSync(py, ['tts/gen.py', '--mock', id], {cwd: root, stdio: 'inherit'}); +if (r.status !== 0) process.exit(r.status ?? 1); + +console.log(` +created ${id}: + tts/script.json narration (edit the text, then: ${py} tts/gen.py --mock ${id}) + src/scenes/${id}.tsx the scene (one placeholder beat per cue) + src/scenes/index.ts registered with look "${look}" +next: npm run studio, or node tools/strip.mjs ${id}:${cues[0]}`); diff --git a/tools/overview.mjs b/tools/overview.mjs new file mode 100644 index 0000000..b8a500b --- /dev/null +++ b/tools/overview.mjs @@ -0,0 +1,34 @@ +// One frame of every scene in one image — the quickest way to see the whole film, check a brand colour or a format. +// usage: node tools/overview.mjs → out/overview.png, each scene 20 frames before its narration ends +// node tools/overview.mjs --at 0.5 → each scene at 50 % of its narration instead +// node tools/overview.mjs --format 9x16 → the same in a kit.config.json format (out/overview_9x16.png) +import fs from 'node:fs'; +import path from 'node:path'; +import {renderStill, selectComposition} from '@remotion/renderer'; +import {CFG, IDS, T, browserExecutable, leadOf, narrEnd, open, root, starts} from './timeline.mjs'; + +const argv = process.argv.slice(2); +const opt = (k) => { + const i = argv.indexOf(k); + return i >= 0 ? argv[i + 1] : undefined; +}; +const format = opt('--format'); +const at = opt('--at'); +if (format && !CFG.formats?.[format]) throw new Error(`unknown format ${format}`); +const frames = IDS.map((id) => { + const lead = leadOf(id); + const fr = at !== undefined ? lead + (narrEnd(id) - lead) * Number(at) : narrEnd(id) - 20; + return starts[id] + Math.round(fr); +}); +const labels = IDS.map((id) => `${id} ${T[id].chapter.split(' · ').pop()}`); +const portrait = format && CFG.formats[format].height > CFG.formats[format].width; +const cols = portrait ? Math.min(IDS.length, 5) : Math.ceil(Math.sqrt(IDS.length)); +const inputProps = {frames, labels, cols, format}; +const id = `${CFG.id}-strip`; +const {serveUrl} = await open(id, inputProps); +const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable}); +const outDir = path.join(root, 'out'); +fs.mkdirSync(outDir, {recursive: true}); +const output = path.join(outDir, `overview${format ? `_${format}` : ''}.png`); +await renderStill({composition, serveUrl, output, inputProps, browserExecutable}); +console.log(output); diff --git a/tools/pick.mjs b/tools/pick.mjs index 26391ec..c6eece7 100644 --- a/tools/pick.mjs +++ b/tools/pick.mjs @@ -3,7 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {renderStill} from '@remotion/renderer'; -import {cueFrame, open, root, starts} from './timeline.mjs'; +import {browserExecutable, cueFrame, open, root, starts} from './timeline.mjs'; const args = process.argv.slice(2); if (!args.length) { @@ -18,6 +18,6 @@ for (const a of args) { if (!(id in starts)) throw new Error(`unknown scene ${id}`); const frame = starts[id] + cueFrame(id, cue) + Number(off); const output = path.join(outDir, `${id}_${cue}_${off}.png`); - await renderStill({composition, serveUrl, output, frame, scale: 0.5}); + await renderStill({composition, serveUrl, output, frame, scale: 0.5, browserExecutable}); console.log(output); } diff --git a/tools/publish.mjs b/tools/publish.mjs new file mode 100644 index 0000000..4df3e39 --- /dev/null +++ b/tools/publish.mjs @@ -0,0 +1,47 @@ +// The whole publish pipeline in one command: narration → render → score → mix, for every format you ask for. +// usage: node tools/publish.mjs → real edge-tts, main format, out/-mixed.mp4 +// node tools/publish.mjs --formats 9x16,1x1 → also the portrait / square cuts (or --formats all) +// node tools/publish.mjs --skip-tts → keep the current audio + timings (e.g. after a visual-only edit) +// node tools/publish.mjs --mock → offline timings and silent narration (pipeline check) +// Long renders: run it detached and watch the log (see README 踩过的坑), e.g. +// nohup node tools/publish.mjs --formats all > out/publish.log 2>&1 & +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(import.meta.dirname, '..'); +const CFG = JSON.parse(fs.readFileSync(path.join(root, 'kit.config.json'), 'utf8')); +const argv = process.argv.slice(2); +const has = (k) => argv.includes(k); +const opt = (k) => { + const i = argv.indexOf(k); + return i >= 0 ? argv[i + 1] : undefined; +}; +const all = Object.keys(CFG.formats ?? {}); +const fmts = opt('--formats') === 'all' ? all : (opt('--formats') ?? '').split(',').filter(Boolean); +for (const f of fmts) { + if (!all.includes(f)) { + console.error(`unknown format "${f}"; kit.config.json has: ${all.join(', ')}`); + process.exit(1); + } +} +const py = spawnSync('python', ['--version']).status === 0 ? 'python' : 'python3'; +const t0 = Date.now(); +const run = (label, cmd, args) => { + const t = Date.now(); + console.log(`\n▶ ${label}: ${cmd} ${args.join(' ')}`); + const r = spawnSync(cmd, args, {cwd: root, stdio: 'inherit', shell: process.platform === 'win32'}); + if (r.status !== 0) { + console.error(`✖ ${label} failed (exit ${r.status})`); + process.exit(r.status ?? 1); + } + console.log(`✔ ${label} (${((Date.now() - t) / 1000).toFixed(0)} s)`); +}; + +if (!has('--skip-tts')) run('narration', py, ['tts/gen.py', ...(has('--mock') ? ['--mock'] : [])]); +for (const f of [undefined, ...fmts]) run(`render ${f ?? 'main'}`, 'node', ['tools/render.mjs', ...(f ? [f] : [])]); +run('score', py, ['tools/music.py']); +for (const f of [undefined, ...fmts]) run(`mix ${f ?? 'main'}`, py, ['tools/mix.py', ...(f ? [f] : [])]); + +console.log(`\ndone in ${((Date.now() - t0) / 60000).toFixed(1)} min:`); +for (const f of [undefined, ...fmts]) console.log(` out/${CFG.id}${f ? `-${f}` : ''}-mixed.mp4`); diff --git a/tools/render.mjs b/tools/render.mjs new file mode 100644 index 0000000..777b651 --- /dev/null +++ b/tools/render.mjs @@ -0,0 +1,20 @@ +// Render the film: node tools/render.mjs [format] [extra remotion args] +// node tools/render.mjs → out/.mp4 (main width × height) +// node tools/render.mjs 9x16 → out/-9x16.mp4 (a format from kit.config.json "formats") +// Always uses --gl=angle (GPU compositing; software GL is ~5× slower). Extra args go straight to `remotion render`. +import {spawnSync} from 'node:child_process'; +import path from 'node:path'; +import {CFG, browserExecutable, root} from './timeline.mjs'; + +const args = process.argv.slice(2); +const format = args[0] && !args[0].startsWith('-') ? args.shift() : undefined; +if (format && !CFG.formats?.[format]) { + console.error(`unknown format "${format}"; kit.config.json has: ${Object.keys(CFG.formats ?? {}).join(', ') || '(none)'}`); + process.exit(1); +} +const id = format ? `${CFG.id}-${format}` : CFG.id; +const out = path.join('out', `${id}.mp4`); +const cli = ['remotion', 'render', 'src/index.ts', id, out, '--gl=angle', ...(browserExecutable ? [`--browser-executable=${browserExecutable}`] : []), ...args]; +console.log(`npx ${cli.join(' ')}`); +const r = spawnSync('npx', cli, {cwd: root, stdio: 'inherit', shell: process.platform === 'win32'}); +process.exit(r.status ?? 1); diff --git a/tools/sfx.py b/tools/sfx.py new file mode 100644 index 0000000..6164f4e --- /dev/null +++ b/tools/sfx.py @@ -0,0 +1,219 @@ +"""Synthesize the sound-effect library → public/sfx/.wav (numpy only, no samples, so no licensing questions). + + python tools/sfx.py # writes every sound + python tools/sfx.py pop coin # only these + +Scenes place them with (src/core/Sfx.tsx); `Shots` adds a whoosh to moving transitions. +The files are small and committed, so Studio has sound out of the box; rerun this after tweaking a recipe. +""" +import sys +import wave + +import numpy as np + +from kit import ROOT + +SR = 44100 +OUT = ROOT / "public" / "sfx" +rng = np.random.default_rng(7) + + +def t_(dur): + return np.arange(int(dur * SR)) / SR + + +def env(t, attack=0.002, decay=0.1): + """fast attack, exponential decay""" + a = np.clip(t / max(attack, 1e-6), 0, 1) + return a * np.exp(-np.maximum(t - attack, 0) / decay) + + +def lp(x, fc): + """one-pole low-pass; fc may be a scalar or a per-sample array (for sweeps)""" + fc = np.broadcast_to(np.asarray(fc, dtype=float), x.shape) + a = 1 - np.exp(-2 * np.pi * fc / SR) + y = np.empty_like(x) + acc = 0.0 + for i in range(len(x)): + acc += a[i] * (x[i] - acc) + y[i] = acc + return y + + +def hp(x, fc): + return x - lp(x, fc) + + +def noise(dur): + return rng.standard_normal(int(dur * SR)) + + +def sweep(f0, f1, t, curve=3.0): + """phase of a sine sweeping exponentially from f0 to f1 over t""" + k = t / t[-1] + f = f0 * (f1 / f0) ** (k ** (1 / curve)) + return 2 * np.pi * np.cumsum(f) / SR + + +def square(f, t, duty=0.5): + return np.where((t * f) % 1 < duty, 1.0, -1.0) + + +def hz(m): + return 440.0 * 2 ** ((m - 69) / 12) + + +# ───────────────────────── recipes ───────────────────────── + +def click(): + t = t_(0.06) + return 0.6 * hp(noise(0.06), 3000) * env(t, 0.0005, 0.004) + 0.5 * np.sin(2 * np.pi * 2200 * t) * env(t, 0.0005, 0.01) + + +def pop(): + t = t_(0.16) + return np.sin(sweep(900, 260, t, 1.5)) * env(t, 0.001, 0.045) + 0.15 * hp(noise(0.16), 2000) * env(t, 0.0005, 0.006) + + +def _whoosh(dur, f0, f1, peak): + t = t_(dur) + k = t / dur + shape = np.sin(np.pi * np.clip(k / peak, 0, 1) * 0.5) ** 2 * np.exp(-np.maximum(k - peak, 0) * 6) + fc = f0 * (f1 / f0) ** k + return 1.4 * hp(lp(noise(dur), fc), 200) * shape + + +def whoosh(): + return _whoosh(0.5, 400, 3500, 0.55) + + +def whip(): + return _whoosh(0.26, 900, 6000, 0.4) + + +def swish(): + """soft brush / paper swish""" + return 0.8 * _whoosh(0.4, 1500, 5000, 0.35) + + +def thud(): + """stamp, note landing: low body + paper slap""" + t = t_(0.45) + body = np.sin(sweep(110, 45, t, 2)) * env(t, 0.002, 0.09) + slap = lp(noise(0.45), 1800) * env(t, 0.0005, 0.02) + return 0.9 * body + 0.7 * slap + + +def slam(): + """big impact: sub drop, crack and a short noisy tail""" + t = t_(1.0) + sub = np.sin(sweep(150, 38, t, 2.5)) * env(t, 0.002, 0.22) + crack = hp(noise(1.0), 1500) * env(t, 0.0005, 0.03) + tail = lp(noise(1.0), 2500) * env(t, 0.01, 0.25) * 0.25 + return sub + 0.6 * crack + tail + + +def tick(): + t = t_(0.03) + return 0.5 * hp(noise(0.03), 4000) * env(t, 0.0003, 0.003) + 0.3 * np.sin(2 * np.pi * 3200 * t) * env(t, 0.0003, 0.006) + + +def type_(): + """keystroke: click plus a little body""" + t = t_(0.07) + return 0.45 * hp(noise(0.07), 2500) * env(t, 0.0005, 0.006) + 0.35 * np.sin(sweep(500, 220, t)) * env(t, 0.001, 0.02) + + +def coin(): + """the classic two-note pickup""" + a = t_(0.07) + b = t_(0.32) + return 0.35 * np.concatenate([square(hz(83), a) * env(a, 0.001, 0.5), square(hz(88), b) * env(b, 0.001, 0.12)]) + + +def levelup(): + parts = [] + for m in [72, 76, 79, 84, 88]: + t = t_(0.085) + parts.append(square(hz(m), t, 0.25) * env(t, 0.001, 0.2)) + t = t_(0.5) + parts.append(square(hz(91), t, 0.25) * env(t, 0.001, 0.18)) + return 0.3 * np.concatenate(parts) + + +def glitch(): + dur = 0.32 + t = t_(dur) + x = noise(dur) + # sample-and-hold "bitcrush" plus random square blips + hold = np.repeat(x[::40], 40)[: len(x)] + blips = np.zeros_like(t) + for _ in range(6): + i0 = rng.integers(0, len(t) - 2000) + n = rng.integers(600, 2000) + blips[i0 : i0 + n] += square(rng.uniform(200, 1800), t[:n]) * 0.6 + gate = (rng.random(len(t) // 600 + 1) > 0.35).repeat(600)[: len(t)] + return 0.5 * (np.round(hold * 3) / 3 + blips) * gate * env(t, 0.001, 0.15) + + +def chime(): + t = t_(1.6) + out = np.zeros_like(t) + for m, g in [(84, 1.0), (91, 0.5)]: + f = hz(m) + out += g * (np.sin(2 * np.pi * f * t) + 0.3 * np.sin(2 * np.pi * f * 2.76 * t) * np.exp(-t * 4)) + return 0.35 * out * env(t, 0.002, 0.45) + + +def draw(): + """pencil scribble: grainy noise with a stroke rhythm""" + dur = 0.6 + t = t_(dur) + strokes = 0.5 + 0.5 * np.sin(2 * np.pi * 9 * t) ** 2 + shape = np.clip(t / 0.05, 0, 1) * np.clip((dur - t) / 0.12, 0, 1) + return 0.6 * hp(lp(noise(dur), 6000), 1800) * strokes * shape + + +SOUNDS = { + "click": click, + "pop": pop, + "whoosh": whoosh, + "whip": whip, + "swish": swish, + "thud": thud, + "slam": slam, + "tick": tick, + "type": type_, + "coin": coin, + "levelup": levelup, + "glitch": glitch, + "chime": chime, + "draw": draw, +} + + +def write(name, y): + y = y / (np.max(np.abs(y)) + 1e-9) * 0.89 + fade = min(len(y), int(0.01 * SR)) + y[-fade:] *= np.linspace(1, 0, fade) + OUT.mkdir(parents=True, exist_ok=True) + path = OUT / f"{name}.wav" + with wave.open(str(path), "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(SR) + wf.writeframes((y * 32767).astype(np.int16).tobytes()) + return path + + +def main(argv): + names = argv or list(SOUNDS) + for n in names: + if n not in SOUNDS: + sys.exit(f"unknown sound {n}; have: {', '.join(SOUNDS)}") + p = write(n, SOUNDS[n]()) + print(f"{p.relative_to(ROOT)} {p.stat().st_size // 1024} KB") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/tools/stills.mjs b/tools/stills.mjs index cc799e2..f5bb0a6 100644 --- a/tools/stills.mjs +++ b/tools/stills.mjs @@ -4,7 +4,7 @@ import fs from 'node:fs'; import path from 'node:path'; import {renderStill} from '@remotion/renderer'; -import {FPS, IDS, T, cueFrame, open, root, sceneLen, starts} from './timeline.mjs'; +import {FPS, IDS, T, browserExecutable, cueFrame, open, root, sceneLen, starts} from './timeline.mjs'; const OFF = Number(process.env.OFF ?? Math.round(2.5 * FPS)); const only = process.argv.slice(2); @@ -18,7 +18,7 @@ for (const id of IDS) { frames.push(['end', len - 20]); for (const [k, fr] of frames) { const output = path.join(outDir, `${id}_${k}.png`); - await renderStill({composition, serveUrl, output, frame: starts[id] + fr, scale: 0.5}); + await renderStill({composition, serveUrl, output, frame: starts[id] + fr, scale: 0.5, browserExecutable}); console.log(output); } } diff --git a/tools/strip.mjs b/tools/strip.mjs new file mode 100644 index 0000000..cbb87a0 --- /dev/null +++ b/tools/strip.mjs @@ -0,0 +1,38 @@ +// Motion review: tile a run of frames around a cue into one image, so you see how things move, not just where they land. +// usage: node tools/strip.mjs s01:quote → 16 frames, cue-6 … cue+84 every 6 frames +// node tools/strip.mjs s02:formula:-4:56:4 → scene:cue:from:to:step (cue "end" = end of narration) +// FORMAT=9x16 node tools/strip.mjs s03:box → review a portrait / square format from kit.config.json "formats" +// output: out/strips/_[_].png, each tile labelled with its offset from the cue +import fs from 'node:fs'; +import path from 'node:path'; +import {renderStill} from '@remotion/renderer'; +import {CFG, browserExecutable, cueFrame, open, root, sceneLen, starts} from './timeline.mjs'; + +const args = process.argv.slice(2); +if (!args.length) { + console.error('usage: node tools/strip.mjs scene:cue[:from:to:step] ...'); + process.exit(1); +} +const outDir = path.join(root, 'out/strips'); +fs.mkdirSync(outDir, {recursive: true}); +const id = `${CFG.id}-strip`; +const format = process.env.FORMAT || undefined; +if (format && !CFG.formats?.[format]) throw new Error(`unknown format ${format} (kit.config.json "formats")`); +const {serveUrl} = await open(id, {frames: [0], format}); +for (const a of args) { + const [sid, cue, from = '-6', to = '84', step = '6'] = a.split(':'); + if (!(sid in starts)) throw new Error(`unknown scene ${sid}`); + const base = cueFrame(sid, cue); + const offs = []; + for (let o = Number(from); o <= Number(to) && offs.length < 16; o += Math.max(1, Number(step))) { + if (base + o >= 0 && base + o < sceneLen(sid)) offs.push(o); + } + // portrait tiles are narrow, so lay more of them side by side + const cols = format && CFG.formats[format].height > CFG.formats[format].width ? Math.min(offs.length, 8) : offs.length > 9 ? 4 : offs.length > 4 ? 3 : 2; + const inputProps = {frames: offs.map((o) => starts[sid] + base + o), labels: offs.map((o) => `${cue}${o >= 0 ? '+' : ''}${o}`), cols, format}; + const {selectComposition} = await import('@remotion/renderer'); + const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable}); + const output = path.join(outDir, `${sid}_${cue}${format ? `_${format}` : ''}.png`); + await renderStill({composition, serveUrl, output, inputProps, browserExecutable}); + console.log(output); +} diff --git a/tools/timeline.mjs b/tools/timeline.mjs index 89a9727..fa9744c 100644 --- a/tools/timeline.mjs +++ b/tools/timeline.mjs @@ -28,11 +28,49 @@ export const cueFrame = (id, cue) => { return leadOf(id) + Math.round(v * FPS); }; -/** bundle once and select the composition named in kit.config.json */ -export const open = async () => { +/** + * Browser for rendering. REMOTION_BROWSER=/path/to/chrome-headless-shell wins; otherwise a Playwright headless shell + * under /opt/pw-browsers (preinstalled in cloud sandboxes) is used if present; otherwise Remotion downloads its own. + */ +const findLocalShell = () => { + const base = '/opt/pw-browsers'; + if (!fs.existsSync(base)) return null; + const dir = fs.readdirSync(base).filter((d) => d.startsWith('chromium_headless_shell-')).sort().pop(); + const exe = dir && path.join(base, dir, 'chrome-linux', 'headless_shell'); + return exe && fs.existsSync(exe) ? exe : null; +}; +export const browserExecutable = process.env.REMOTION_BROWSER || findLocalShell(); + +/** scene-local frame → film frame */ +export const filmFrame = (id, fr) => starts[id] + fr; + +/** "s01:quote:40" / "s02:end:-10" / "s03" → {id, cue, off, frame (film frame)} */ +export const parseSpot = (spec, defCue = 'end', defOff = 0) => { + const [id, cue = defCue, off = String(defOff)] = spec.split(':'); + if (!(id in starts)) throw new Error(`unknown scene ${id} (have ${IDS.join(' ')})`); + return {id, cue, off: Number(off), frame: starts[id] + cueFrame(id, cue) + Number(off)}; +}; + +/** the closest cue to a scene-local frame, as "cue+N" / "cue-N" — for reporting */ +export const nearestCue = (id, fr) => { + let best = 'start'; + let at = 0; + for (const k of Object.keys(T[id].cues)) { + const c = cueFrame(id, k); + if (Math.abs(c - fr) < Math.abs(at - fr)) { + best = k; + at = c; + } + } + const d = Math.round(fr - at); + return `${best}${d >= 0 ? '+' : ''}${d}`; +}; + +/** bundle once and select the composition named in kit.config.json (or `id`) */ +export const open = async (id = CFG.id, inputProps = {}) => { const {bundle} = await import('@remotion/bundler'); const {selectComposition} = await import('@remotion/renderer'); const serveUrl = await bundle({entryPoint: path.join(root, 'src/index.ts')}); - const composition = await selectComposition({serveUrl, id: CFG.id}); + const composition = await selectComposition({serveUrl, id, inputProps, browserExecutable}); return {serveUrl, composition}; }; diff --git a/tts/script.json b/tts/script.json index 4e7c806..8d4aad9 100644 --- a/tts/script.json +++ b/tts/script.json @@ -9,6 +9,41 @@ "id": "s02", "chapter": "第二场 · 三个函数", "text": "[[api]]场景里只用三个函数。[[c]]c,取一个标记所在的帧。[[w]]w,取某个词出口的那一帧。[[rel]]rel,算从标记到这个词隔了多少帧。[[formula]]换算只有一行:开场留白,加上秒数乘以帧率。[[done]]改完文稿,重跑一次语音,动画自己跟上。" + }, + { + "id": "s03", + "chapter": "第三场 · 手绘笔记", + "text": "[[draw]]换一套衣服:手绘笔记风。[[box]]线条一笔一笔画出来,[[boil]]画完也不会僵住,会像手绘动画那样轻轻抖动。||[[note]]便利贴啪地落下,[[arrow]]箭头把旁白、时间戳和动画串起来。[[sum]]讲流程、讲因果,用它正合适。" + }, + { + "id": "s04", + "chapter": "第四场 · 霓虹终端", + "text": "[[boot]]再换一套:深色科技风。[[term]]终端里敲一行命令,[[net]]节点一个接一个亮起,[[flow]]数据沿着连线流动。||[[glitch]]切换镜头时,来一点故障感。[[fit]]讲系统、讲架构,就用这一套。" + }, + { + "id": "s05", + "chapter": "第五场 · 杂志排版", + "text": "[[big]]最后一套:杂志排版。[[type]]大字跟着旁白,一个一个跳出来;[[block]]色块推开画面,[[num]]数字直接砸在屏幕上:[[hit]]一百二十帧。||[[out]]讲观点、讲金句,就用它。" + }, + { + "id": "s06", + "chapter": "第六场 · 数学推导", + "text": "[[plane]]第六套:数学推导风。[[curve]]坐标轴画出来,函数曲线一笔描上去,[[tan]]切线跟着点在曲线上滑动,斜率的数字实时在变。||[[tex]]公式也能一步一步变形,[[limit]]先写成极限,[[result]]再化简成二倍的x。[[fit]]讲数学、讲物理,用它最顺手。" + }, + { + "id": "s07", + "chapter": "第七场 · 发布会", + "text": "[[logo]]第七套:发布会风。[[btn]]画面里自始至终只有一个形状,[[click]]光标一点,它就变成进度条,[[card]]再长成一张卡片,[[zoom]]镜头跟着推近。||[[fin]]讲产品、做功能演示,就用这一套。" + }, + { + "id": "s08", + "chapter": "第八场 · 像素游戏", + "text": "[[start]]第八套:像素游戏风。[[walk]]小人从左边走进来,[[coin]]每讲一个要点,就吃到一枚金币,[[bar]]经验条一格一格涨满。||[[talk]]对话框里的字,跟着旁白一个一个蹦出来。[[win]]做轻松的科普,就用它。" + }, + { + "id": "s09", + "chapter": "第九场 · 水墨", + "text": "[[ink]]第九套:水墨风。[[hill]]远山一层一层,从雾里晕出来,[[brush]]毛笔一笔扫过。[[poem]]竖排的字,念到哪里,墨就落到哪里。||[[seal]]最后盖上一方红印。[[close]]讲历史、讲诗词,就用它。" } ] }