From b20684de0b1b82bf0227bbec77ded2dbbe4708ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 18:13:55 +0000 Subject: [PATCH 1/3] Add motion toolkit, voice-synced text and three new style packs Motion (src/core): - motion.ts: easing presets, life()/move() enter+exit, stagger, spring presets, idle drift/pulse, punch and shake, deterministic noise - Spoken: on-screen text revealed per character exactly when the narrator says it (same word timings as the subtitles) - Shots moved to core with new transitions: push, up, zoom, wipe, whip - subtitles: line rise-in, boxColor/dim for light looks, per-scene overrides (hideQuotes) Style packs: - paper: hand-drawn strokes that draw on and boil, sticky notes, marker - neon: scrolling grid floor, scanlines, glow type, terminal, node graph with flowing pulses, glitch, HUD brackets - editorial: masked kinetic headlines, slammed numbers, block wipes, rules, ticker Demo: s03-s05 show the new packs; s01/s02 fixed for dead air, quote typed out of sync with the voice, crossfade ghosting and static frames. Review: tools/strip.mjs tiles frames around a cue into one image (via a Strip composition) to judge motion; tools honour REMOTION_BROWSER. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hf6CivwFE1rx9Hf8pbNvaS --- CLAUDE.md | 2 +- README.md | 78 ++- package.json | 1 + public/audio/s03.wav | Bin 0 -> 961756 bytes public/audio/s04.wav | Bin 0 -> 790328 bytes public/audio/s05.wav | Bin 0 -> 721756 bytes src/Root.tsx | 15 +- src/core/Spoken.tsx | 141 ++++++ src/core/Strip.tsx | 28 ++ src/core/Video.tsx | 11 +- src/core/index.ts | 3 + src/core/look.ts | 13 +- src/core/motion.ts | 139 ++++++ src/core/shots.tsx | 89 ++++ src/editorial/index.tsx | 205 ++++++++ src/film/film.tsx | 104 ++-- src/film/props.tsx | 11 +- src/neon/index.tsx | 310 ++++++++++++ src/paper/index.tsx | 193 ++++++++ src/paper/palette.ts | 15 + src/paper/rough.tsx | 202 ++++++++ src/scenes/index.ts | 12 +- src/scenes/s01.tsx | 65 +-- src/scenes/s02.tsx | 69 ++- src/scenes/s03.tsx | 73 +++ src/scenes/s04.tsx | 83 ++++ src/scenes/s05.tsx | 57 +++ src/timings.json | 1043 +++++++++++++++++++++++++++++++++++++++ tools/pick.mjs | 4 +- tools/stills.mjs | 4 +- tools/strip.mjs | 34 ++ tools/timeline.mjs | 9 +- tts/script.json | 15 + 33 files changed, 2876 insertions(+), 152 deletions(-) create mode 100644 public/audio/s03.wav create mode 100644 public/audio/s04.wav create mode 100644 public/audio/s05.wav create mode 100644 src/core/Spoken.tsx create mode 100644 src/core/Strip.tsx create mode 100644 src/core/motion.ts create mode 100644 src/core/shots.tsx create mode 100644 src/editorial/index.tsx create mode 100644 src/neon/index.tsx create mode 100644 src/paper/index.tsx create mode 100644 src/paper/palette.ts create mode 100644 src/paper/rough.tsx create mode 100644 src/scenes/s03.tsx create mode 100644 src/scenes/s04.tsx create mode 100644 src/scenes/s05.tsx create mode 100644 tools/strip.mjs diff --git a/CLAUDE.md b/CLAUDE.md index b9be65b..9b98f1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,5 +3,5 @@ Remotion 讲解视频模板:旁白里写 `[[cue]]` 标记,edge-tts 的逐词时间戳驱动字幕和动画。用法见 `README.md`。 - 面向用户的文档用简体中文;代码、命令、标识符用英文。 -- 本机是 Windows(渲染、真 TTS 验证在那里做);云端沙箱只做重构、类型检查、`--mock` 时间轴和单测。 +- 本机是 Windows(渲染、真 TTS 验证在那里做);云端沙箱只做重构、类型检查、`--mock` 时间轴和单测。沙箱里要看画面,用预装的 headless shell:`REMOTION_BROWSER=$(ls -d /opt/pw-browsers/chromium_headless_shell-*/chrome-linux/headless_shell | head -1) node tools/strip.mjs s01:quote`(只看,不提交 `out/`)。 - 不在仓库里提交字体文件和渲染产物(`out/`、`public/fonts/`)。 diff --git a/README.md b/README.md index a5828c8..b413028 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` 生成的静音占位,克隆下来不用联网合成就能预览。 +仓库自带一支约 2 分钟的演示片(5 场),每场换一套风格包:`film`(电影)、`slides`(幻灯片 + KaTeX 公式)、`paper`(手绘笔记)、`neon`(霓虹终端)、`editorial`(杂志排版)。演示片的时间轴和音频是用 `--mock` 生成的静音占位,克隆下来不用联网合成就能预览。 ## 快速开始 @@ -28,9 +28,12 @@ python tts/gen.py # 真 edge-tts,需要联网和 ffmpeg; | `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/scenes/` | 你的场景;`index.ts` 是登记表 | | `tools/` | 审图、拼图、配乐、混音、下载字体 | | `public/fx/` | 胶片颗粒和纸张纹理 | @@ -76,7 +79,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,12 +91,58 @@ 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` | 快速甩镜,带运动模糊 | + +`dur` 可以按镜头覆盖转场时长。`Shots` 原来在 film 包里,现在在 core,film 继续从原位置导出。 + +**审动态**:静帧只能看到元素落定后的样子,看不到它怎么动。`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`。 +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`。 5. **渲染**:`npm run render`(带 `--gl=angle`)→ `out/.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`。 @@ -102,10 +151,21 @@ const stampAt = w('重新对一遍'); // 说到这个词时盖 外壳 `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 角框。适合讲系统、架构。 +- **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`)。 + +字幕样式由风格包的 `look.subtitles` 决定(浅色底的包用 `boxColor` 换成深色底条),单场可以在登记表里覆盖,例如 `s01: {component: S01, look: film, subtitles: {hideQuotes: true}}`:这一场已经把引语大字放在画面上,就不再给引语配字幕。 + +**选哪套**:叙事、历史、人物用 film;概念、公式、API 用 slides;流程、因果、拆解步骤用 paper;系统、架构、数据流用 neon;观点、金句、关键数字用 editorial。同一支片子可以按场混用。 + +### 写一个新风格包 -两个包共用 `src/core` 的 `At`(`center` 时居中位移会和你传入的 `transform` 叠加)、`Full`、`Svg`、`DrawLine`、`Tex`,配色统一在 `src/core/theme.ts`。 +照着 `src/paper/index.tsx` 的结构:一个调色板、一个背景组件(建议带一点不停的小动作:滚动、闪烁、纸纹跳动)、一个章节卡、几个道具组件,最后导出一个 `Look`。道具只用 `useCurrentFrame()` 和传进来的帧号,不要用 `Math.random()`。然后在 `src/scenes/index.ts` 里给场景挂上这个 look。 ## 加一个场景 @@ -134,6 +194,10 @@ 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 自带浏览器的环境**(比如云端沙箱)可以设 `REMOTION_BROWSER=/path/to/chrome-headless-shell`,`tools/` 里的渲染脚本都会用它。 - **`zh-CN-YunyangNeural` 在 +6% 时只有约 3.3 字/秒**,听着拖。用它的话语速调到 +15% 左右。 - **`--mock` 的时间只是估算**(每字 1/4.2 秒),用来搭画面足够,但换成真配音后每个 cue 的位置都会变;按 cue 和词取帧的写法不受影响,写死的帧数会错位。 - **只重做某几场时,其他场沿用旧 timings**:`python tts/gen.py s02` 不会动 s01 的音频和时间。 @@ -145,7 +209,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 ~2 min, five-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`. Five 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) and `editorial` (masked kinetic headlines, slammed numbers, colour-block wipes, ticker). `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`/`black`/`flash` transitions. Review with `tools/stills.mjs`, `tools/pick.mjs`, `tools/sheet.py`, and `tools/strip.mjs` (a filmstrip of frames around a cue, to judge motion rather than end states); 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/package.json b/package.json index a22f4a0..1f9af05 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "fonts": "node tools/fonts.mjs", "stills": "node tools/stills.mjs", "pick": "node tools/pick.mjs", + "strip": "node tools/strip.mjs", "sheet": "python tools/sheet.py", "music": "python tools/music.py", "mix": "python tools/mix.py", diff --git a/public/audio/s03.wav b/public/audio/s03.wav new file mode 100644 index 0000000000000000000000000000000000000000..d6a9e941caea677c830bc6724064905b032d738c GIT binary patch literal 961756 zcmeIup$&sz0EE$l0SF_o3W7huB8ozgG<7%zU?4<&O^4_}Wemi-&z^eISjz8gGWU<+ zwe4M-q`LYp$?-|*w0W-k`o2C!fB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5)$ GPv8%EoeIzZ literal 0 HcmV?d00001 diff --git a/public/audio/s04.wav b/public/audio/s04.wav new file mode 100644 index 0000000000000000000000000000000000000000..7f52e40f6cd074a9440822035de54440cdc2dbec GIT binary patch literal 790328 zcmeIuu?<830EOXqY6&A4!3qizt%6D+xm>3+fW0&~8WWh{dw=_?ZyJZ8t;#Ij-2e8Y zsgr!3{%dkxNs&6=?byoaV+05gAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly kK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfWW)J4P5OAN&o-= literal 0 HcmV?d00001 diff --git a/public/audio/s05.wav b/public/audio/s05.wav new file mode 100644 index 0000000000000000000000000000000000000000..47146158f5eba3da854228d3131552d6c403e0df GIT binary patch literal 721756 zcmeIup$&sz0EE#4B!&?fffW!XsU=OTD*lAva17ABFpO4~z`gtIsW(5T>D9FPjPJ*~ z?R}S|x;nqf??~!&S;}69>th565FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ y009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNA}kH8;6mk4+O literal 0 HcmV?d00001 diff --git a/src/Root.tsx b/src/Root.tsx index da15653..efc362e 100644 --- a/src/Root.tsx +++ b/src/Root.tsx @@ -1,9 +1,22 @@ import React from 'react'; import {Composition} from 'remotion'; import {CFG} from './config'; +import {Strip} from './core/Strip'; import {totalFrames} from './core/timeline'; import {Video} from './core/Video'; export const Root: React.FC = () => ( - + <> + + {/* review tool (tools/strip.mjs); as long as the film so the frozen s inside aren't clipped */} + + ); 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..7ef79be --- /dev/null +++ b/src/core/Strip.tsx @@ -0,0 +1,28 @@ +/** 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 {HEIGHT, WIDTH} from '../config'; +import {Video} from './Video'; + +export type StripProps = {frames: number[]; labels?: string[]; cols?: number}; + +export const Strip: React.FC = ({frames, labels = [], cols = 4}) => { + const w = WIDTH / cols; + const h = HEIGHT / cols; + return ( + + {frames.slice(0, cols * cols).map((fr, i) => ( +
+
+ + +
+
+ {labels[i] ?? fr} +
+
+ ))} +
+ ); +}; diff --git a/src/core/Video.tsx b/src/core/Video.tsx index 3a76117..d2c50fa 100644 --- a/src/core/Video.tsx +++ b/src/core/Video.tsx @@ -51,12 +51,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 +70,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} )); @@ -95,7 +97,8 @@ const Subtitles: React.FC<{id: string; s: SubtitleStyle}> = ({id, s}) => { letterSpacing: 2, whiteSpace: 'pre', 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} @@ -136,7 +139,7 @@ const SceneShell: React.FC<{id: string}> = ({id}) => {