kaizen-agent 1.48.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +2 -2
  2. package/cli/build-web.ts +4 -2
  3. package/cli/hermes.ts +65 -0
  4. package/cli/install.ts +18 -5
  5. package/cli/localsend.test.ts +84 -0
  6. package/cli/localsend.ts +131 -0
  7. package/cli/notes.test.ts +6 -4
  8. package/cli/settings.ts +1 -0
  9. package/cli/state.test.ts +37 -0
  10. package/cli/state.ts +35 -20
  11. package/cli/video.test.ts +479 -0
  12. package/cli/video.ts +507 -0
  13. package/cli/web.ts +127 -1
  14. package/package.json +1 -1
  15. package/skills/kaizen/config.default.yml +4 -0
  16. package/skills/media/kaizen-book/SKILL.md +107 -0
  17. package/skills/media/kaizen-book/assets/reader.html +440 -0
  18. package/skills/media/kaizen-book/assets/styles.json +45 -0
  19. package/skills/media/kaizen-book/examples/book.json +14 -0
  20. package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
  21. package/skills/media/kaizen-book/examples/manuscript.md +127 -0
  22. package/skills/media/kaizen-book/references/interview.md +85 -0
  23. package/skills/media/kaizen-book/references/proofread.md +47 -0
  24. package/skills/media/kaizen-book/references/writing.md +53 -0
  25. package/skills/media/kaizen-book/scripts/build_book.py +279 -0
  26. package/skills/media/kaizen-book/scripts/check.sh +10 -0
  27. package/skills/media/kaizen-book/scripts/check_book.py +128 -0
  28. package/skills/media/kaizen-book/scripts/deploy.py +58 -0
  29. package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
  30. package/skills/media/kaizen-book/scripts/setup.sh +9 -0
  31. package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
  32. package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
  33. package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
  34. package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
  35. package/skills/media/kaizen-video/SKILL.md +112 -0
  36. package/skills/{kaizen-video → media/kaizen-video}/assets/CREDITS.txt +5 -0
  37. package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
  38. package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
  39. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
  40. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
  41. package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
  42. package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
  43. package/skills/media/kaizen-video/film.html +246 -0
  44. package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
  45. package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
  46. package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
  47. package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
  48. package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
  49. package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
  50. package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
  51. package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
  52. package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
  53. package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
  54. package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
  55. package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
  56. package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
  57. package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
  58. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
  59. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
  60. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
  61. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
  62. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
  63. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
  64. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
  65. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
  66. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
  67. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
  68. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
  69. package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
  70. package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
  71. package/skills/{kaizen-video → media/kaizen-video}/references/presets.md +22 -0
  72. package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
  73. package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
  74. package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
  75. package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
  76. package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
  77. package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
  78. package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
  79. package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
  80. package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
  81. package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
  82. package/skills/{kaizen-video → media/kaizen-video}/scripts/check.sh +8 -0
  83. package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
  84. package/skills/media/kaizen-video/scripts/make_film.py +263 -0
  85. package/skills/{kaizen-video → media/kaizen-video}/scripts/make_tiktok.py +8 -3
  86. package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
  87. package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
  88. package/skills/media/kaizen-video/scripts/srt.py +9 -0
  89. package/skills/media/kaizen-video/scripts/still.sh +7 -0
  90. package/web/board/views.ts +1 -1
  91. package/web/board.html +2 -0
  92. package/web/board.js +2 -2
  93. package/web/notebook.js +1 -1
  94. package/web/notes.html +2 -0
  95. package/web/term.js +1 -1
  96. package/web/video.html +298 -0
  97. package/web/video.js +81 -0
  98. package/web/video.ts +610 -0
  99. package/skills/kaizen-video/SKILL.md +0 -73
  100. package/skills/story-video/SKILL.md +0 -108
  101. package/skills/story-video/engine/package.json +0 -12
  102. package/skills/story-video/engine/src/Root.tsx +0 -24
  103. package/skills/story-video/engine/src/engine/Film.tsx +0 -405
  104. package/skills/story-video/engine/src/engine/presets.json +0 -58
  105. package/skills/story-video/engine/src/index.ts +0 -3
  106. package/skills/story-video/engine/tsconfig.json +0 -1
  107. package/skills/story-video/examples/sample-film/README.md +0 -12
  108. package/skills/story-video/examples/sample-film/film.json +0 -16
  109. package/skills/story-video/references/presets.md +0 -21
  110. package/skills/story-video/scripts/check.sh +0 -19
  111. package/skills/story-video/scripts/setup.sh +0 -25
  112. package/skills/story-video/scripts/srt.py +0 -30
  113. package/skills/story-video/scripts/still.sh +0 -10
  114. /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
  115. /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
  116. /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
  117. /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
  118. /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
  119. /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
  120. /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Fredoka.ttf +0 -0
  121. /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Montserrat.ttf +0 -0
  122. /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/SpecialElite.ttf +0 -0
  123. /package/skills/{kaizen-video → media/kaizen-video}/assets/music/investigations.mp3 +0 -0
  124. /package/skills/{kaizen-video → media/kaizen-video}/assets/music/lights.mp3 +0 -0
  125. /package/skills/{kaizen-video → media/kaizen-video}/assets/music/sunny.mp3 +0 -0
  126. /package/skills/{kaizen-video → media/kaizen-video}/examples/config.json +0 -0
  127. /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
  128. /package/skills/{kaizen-video → media/kaizen-video}/hyperframes.json +0 -0
  129. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/CREDITS.txt +0 -0
  130. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/README.md +0 -0
  131. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/ending.wav +0 -0
  132. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/reveal.wav +0 -0
  133. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/shutter.wav +0 -0
  134. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/stamp.wav +0 -0
  135. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/index.html +0 -0
  136. /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/package.json +0 -0
  137. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/CREDITS.txt +0 -0
  138. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/README.md +0 -0
  139. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
  140. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
  141. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
  142. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
  143. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
  144. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
  145. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
  146. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
  147. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
  148. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/index.html +0 -0
  149. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/package.json +0 -0
  150. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/CREDITS.txt +0 -0
  151. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/README.md +0 -0
  152. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
  153. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
  154. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
  155. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
  156. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
  157. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
  158. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
  159. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
  160. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
  161. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/index.html +0 -0
  162. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/package.json +0 -0
  163. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/CREDITS.txt +0 -0
  164. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/README.md +0 -0
  165. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
  166. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
  167. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
  168. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
  169. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
  170. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
  171. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
  172. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
  173. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/index.html +0 -0
  174. /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/package.json +0 -0
  175. /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
  176. /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
  177. /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
  178. /package/skills/{kaizen-video → media/kaizen-video}/scripts/render_cut.py +0 -0
  179. /package/skills/{kaizen-video → media/kaizen-video}/scripts/setup.sh +0 -0
  180. /package/skills/{kaizen-video → media/kaizen-video}/scripts/transcribe.py +0 -0
  181. /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
  182. /package/skills/{kaizen-video → media/kaizen-video}/tools/carve.mjs +0 -0
  183. /package/skills/{kaizen-video → media/kaizen-video}/tools/lib/main-module.mjs +0 -0
@@ -1,73 +0,0 @@
1
- ---
2
- name: kaizen-video
3
- description: Edit the user's own footage into a publish-ready vertical video — talking-head explainers for TikTok, Reels or Shorts. Transcribe with WhisperX, cut dead air, retakes and false starts, then build it in a preset style (tiktok-mystery, tiktok-cheerful, tiktok-scam-alert, educational-warning, or a preset the user adds) with word-synced karaoke captions, cards, images, sound effects and a music bed carved around the voice, rendered with HyperFrames. Use when the user has recorded footage and asks to cut it, caption it, style it or turn it into a TikTok, or wants a new video preset. Not for narrated product films made from scratch (that is story-video).
4
- ---
5
-
6
- # Kaizen video
7
-
8
- Footage is data: `videos/<name>/` in the user's project folder (the workspace) holds `raw/` (the footage),
9
- `transcript/` (WhisperX words, cut plans), `work/` (the cut), `images/`, `config.json` and `CREDITS.txt`.
10
- Finished videos go to `output/<name>/<preset>/<name>.mp4`, one file per video and preset, overwritten on revision.
11
- Run every script from the project folder. `S=<this skill>/scripts`; every script takes the video folder.
12
- Worked example config: [`examples/config.json`](examples/config.json).
13
-
14
- Read before styling: [`references/presets.md`](references/presets.md) — the preset contract and how to add one.
15
-
16
- ## 0. Setup — once per machine
17
- `$S/check.sh` lists what is missing (ffmpeg, Node, uv). `$S/setup.sh` installs WhisperX 3.8.6 into
18
- `~/.kaizen/kaizen-video/whisperx-venv` (override `KAIZEN_VIDEO_HOME`); it is about 2 GB, and the first
19
- transcription downloads a 3 GB model. Tell the user before running it.
20
- `W="HF_HOME=$HOME/.kaizen/kaizen-video/huggingface $HOME/.kaizen/kaizen-video/whisperx-venv/bin/python"`.
21
-
22
- ## 1. Transcribe
23
- The user creates `videos/<name>/raw/` with the footage in it. Run `$W $S/transcribe.py videos/<name>`.
24
- It skips work when `transcript/words.json` exists, uses CUDA when torch sees a GPU and CPU int8 otherwise
25
- (`KAIZEN_VIDEO_DEVICE=cpu|cuda` overrides). Report the detected language.
26
-
27
- ## 2. Cut — unless the user wants the footage untouched
28
- Propose the segments to keep (drop dead air, retakes, false starts) from the transcript, with times, and
29
- get approval. Write `transcript/cuts.json` (`{"segments": [{"start", "end", "text"}]}`), then
30
- `python3 $S/render_cut.py videos/<name>`: it writes `work/cut.mp4` and `transcript/render-plan.json` and
31
- asserts the decoded frame count. Phone footage is often variable frame rate; never compute frames from the
32
- nominal fps. Footage with someone else's account or comments in it: blur those areas into
33
- `work/censored.mp4`, which `render_cut.py` uses in place of `raw/`.
34
-
35
- ## 3. Style
36
- - **Existing preset:** pick from the list `make_tiktok.py` prints, or the bundled ones: `tiktok-mystery`
37
- (dark, investigative), `tiktok-cheerful` (bright, playful), `tiktok-scam-alert` (fraud warnings),
38
- `educational-warning` (bright, for health and consumer warnings). Each preset's `README.md` has its look,
39
- card types and config keys.
40
- - **New style:** copy the closest preset to the project's `presets/<style>/` and change its `index.html`
41
- (grade, fonts, card designs, transitions) and assets. See `references/presets.md`.
42
- - **Reference video:** analyze it first and keep it in the project's `references/`: specs with ffprobe, cuts
43
- with ffmpeg scene detection (`select='gt(scene,0.3)',showinfo`), and a contact sheet of frames every 2–3 s.
44
-
45
- ## 4. Config
46
- Write `videos/<name>/config.json` modeled on `examples/config.json`. Hook, beats and reactions anchor to
47
- spoken words: the first word starting with `word`, at or after `after` seconds of the cut. Set `"preset"`
48
- to the style's folder name and `"music"` to a file name in `assets/music/` without `.mp3`.
49
-
50
- ## 5. Media
51
- Pictures go in `videos/<name>/images/`. Use Wikimedia Commons; prefer CC0 or public domain, and record every
52
- source, author and license in `videos/<name>/CREDITS.txt`. Music and sound effects come from Wikimedia
53
- Commons or incompetech (CC BY needs credit), or are synthesized. Never use copyrighted film posters, stills
54
- or commercial music. The preset's own `CREDITS.txt` (music, fonts, sound effects) is appended to the
55
- output's credits automatically.
56
-
57
- ## 6. Build and review
58
- `python3 $S/make_tiktok.py videos/<name>` builds `videos/<name>/tiktok/`, carves the music around the voice
59
- and runs `hyperframes check`. Look at frames with `npx --yes hyperframes@0.8.176 snapshot --at <s>` from
60
- `videos/<name>/tiktok` before rendering: clipped titles, overlaps, caption flashes. Always pass `--yes` to
61
- `npx`, or it hangs waiting for a prompt.
62
-
63
- ## 7. Render
64
- `python3 $S/make_tiktok.py videos/<name> --render` writes `output/<name>/<preset>/<name>.mp4` with
65
- `CREDITS.txt` beside it. Measure loudness (`ffmpeg -i <mp4> -af ebur128=peak=true -f null -`): target about
66
- −14 to −16 LUFS integrated, true peak under −1 dBTP. Tell the user what still needs a human: listening to
67
- the whole video and spot-checking the cut edges.
68
-
69
- ## Lessons
70
- - HyperFrames' final mixdown lowers the whole mix when voice plus sound effects peak above −1 dBTP. Keep
71
- sound-effect WAV peaks around −6.5 to −8.5 dBFS, and post-speech ending stings around −4 dBFS.
72
- - `tiktok/source.mp4` is always written as a new file. Never hard-link it to the input: overwriting it
73
- would wipe `work/cut.mp4`.
@@ -1,108 +0,0 @@
1
- ---
2
- name: story-video
3
- description: Turn whatever a user brings for any product — a prompt, a story, product photos, app screenshots, a deck (.pptx/.pdf), footage — into a publish-ready video (MP4 + SRT + thumbnail + credits) in a chosen style (cinematic-drama, promo-booth, fun-energetic, viral-social 9:16) with the product's own brand, narration in the user's language with emotional direction, word-synced captions, sound design, music, logo reveal and loudness mastering; then revise it on request. Use for promo / launch / opener / social / viral / explainer videos or "turn this deck into a video". Not for hand-editing an existing video timeline.
4
- ---
5
-
6
- # Story video
7
-
8
- Product-agnostic. A film is data: `~/.kaizen/videos/<slug>/film.json` (storyboard + brand) + `lines.json`
9
- (narration with emotion tags) + `words.json` (word timings, generated) + a preset.
10
- Worked example: [`examples/sample-film/`](examples/sample-film/) (fictional product, no images needed).
11
- Run `scripts/check.sh` first on a new machine.
12
-
13
- Read before producing:
14
- - [`references/production.md`](references/production.md) — what makes a video publish-grade; QA list.
15
- - [`references/presets.md`](references/presets.md) — styles, when to use each, adding one.
16
- - [`references/script.md`](references/script.md) — **how to write the narration** (a told story, not slide text).
17
- - [`references/revisions.md`](references/revisions.md) — how user revisions are applied and versioned.
18
- - [`references/licensing.md`](references/licensing.md) — **before anything is published**.
19
-
20
- ## 0. Voice — ask first
21
- Ask: "Do you have an ElevenLabs account? Which plan?" Then:
22
- - **Paid ElevenLabs** (Starter+): best and commercially licensed. Use a **native speaker of the
23
- narration language** from the voice library (e.g. Indonesian: search `shared-voices?language=id`,
24
- add it, let the user pick from 3 samples). Non-native voices sound synthetic — this is the
25
- biggest single factor in how natural the video sounds.
26
- - **Free ElevenLabs**: premade voices only (non-native for most languages) and **no commercial use**;
27
- fine for drafts and internal review. Offer samples (v3 with `stability: 0` sounds most natural).
28
- - **No ElevenLabs**: free Microsoft neural voices via edge-tts (`pip install edge-tts`), set
29
- `"voiceProvider": "edge"` (automatic when no key). No emotion control; usable for drafts.
30
- - Or the user records the narration: write the script with timings, cut the film to their audio.
31
- Set `voiceProvider` / `voice` / `edgeVoice` in film.json accordingly.
32
-
33
- ## Where things live
34
- - **Film workspace:** `~/.kaizen/videos/<slug>/` (one home for every video, whatever the product; override `STORY_VIDEO_HOME`) — `inputs/` (the user's material), generated
35
- assets (photos, voice `vNN.mp3`, `music.mp3`), `lines.json`, `film.json`, `words.json`,
36
- `REVISIONS.md`, `out/` (deliverables) and `out/previous/` (earlier versions). Nothing is written to the user's project folders.
37
- - **Engine:** `~/.kaizen/story-video/engine/` (Remotion + node_modules + sfx), installed once per machine
38
- and shared by every project. `scripts/setup.sh <slug>` (run from the project root) installs or updates
39
- it and creates the film workspace (from any directory). Override with `STORY_VIDEO_ENGINE`.
40
- - Scripts run from the skill: `S=<this skill>/scripts`, every script takes the film dir.
41
-
42
- ## 1. Intake — whatever the user brings
43
- `$S/setup.sh <slug>`, put the user's material in `~/.kaizen/videos/<slug>/inputs/` sorted as
44
- `photos/`, `screens/`, `footage/`, `logo*.png`, a deck (`*.pptx` / `*.pdf`) and/or `brief.md` (their
45
- prompt or story, verbatim), then `python3 $S/intake.py ~/.kaizen/videos/<slug>`. It crops photos for
46
- 16:9 and 9:16, keeps screenshots untouched (records ratios), transcodes footage, renders deck slides +
47
- text to `intake/`, and suggests a brand palette from the logo. Read the deck text, the brief and the
48
- slide images before writing anything.
49
-
50
- Missing material is fine: no photos → source candid ones (story-deck rules); no screenshots →
51
- ask for them (product beats need the real product); no story → propose one.
52
-
53
- ## 2. Brief — ask with options (one round)
54
- Purpose and where it plays · style preset · length · formats · language · voice (from step 0;
55
- ask gender/tone) · music mood · brand (logo, colours, fonts — propose from the
56
- logo palette) · CTA (URL / QR target / contact / event details — never invent them) · claims the
57
- user can back up.
58
-
59
- ## 3. Treatment — approve before generating
60
- A beat table: time · picture · narration line + emotion tag · on-screen text · SFX/ambience.
61
- Write the narration as one told story (references/script.md) — the user's text is source, not script.
62
- Show the full script as continuous prose too, so the user can judge how it flows. Only claims the material supports. The user approves or edits it; that is revision 0.
63
-
64
- ## 4. Produce (F = ~/.kaizen/videos/<slug>)
65
- 1. `F/film.json`: `preset`, `language` (`id`, `en`, …), `voiceProvider`, `voice`, `stability`,
66
- `brand` (`palette`, `fonts`), `labels` (on-screen words in that language: `source`, `next`),
67
- `logo` (light, for dark backgrounds), `logoOnLight`, `qr`, `cta`, `beats` — table below.
68
- File names in beats are relative to `F`.
69
- 2. `F/lines.json` (approved script) → `python3 $S/vo.py F` (or only some lines: `vo.py F "" v04,v07`).
70
- 3. Music: `$S/music.sh F "Track"`; offer 3–5 previews when the mood is not settled.
71
- 4. QR: `python3 -c "import qrcode; qrcode.make('<url>').save('F/qr.png')"`.
72
- 5. Check frames: `$S/still.sh F 16x9 60 400 900`, contact-sheet, look at it (or send it to the user), fix. Delete the check images after.
73
- 6. Master each format: `$S/master.sh F <16x9|9x16> <name> <-14|-16> <thumb-second>` → `F/out/`.
74
-
75
- ## 5. Deliver, then revise
76
- Report files, lengths, loudness, licence status, placeholders, claims to verify, and ask the user
77
- to listen once (the agent cannot hear). Revisions follow `references/revisions.md`: change only
78
- the touched beats/lines, re-render, keep earlier versions in `out/previous/`, log in `REVISIONS.md`.
79
-
80
- ## Beat kinds
81
-
82
- | kind | use | key fields |
83
- |---|---|---|
84
- | `photo` | story beat on a photo | `img, side, kick, title, sub, ambience, sfx` |
85
- | `list` | items arrive one by one with the line | `img, kick, title, items[]` |
86
- | `stat` | numbers count up, source on screen | `stat[{value,suffix,label,source}], title` |
87
- | `black` | silence / pause | `title, sub, hold` |
88
- | `triptych` | three viewpoints | `panels[{img,title,sub}]` |
89
- | `question` | the turn ("what if") | `title` |
90
- | `reveal` | logo wipe-in + tagline | `sub, line` |
91
- | `shot` | real product screenshot, framed | `img, shotRatio, kick, title, sub` |
92
- | `footage` | video clip | `video, kick, title` |
93
- | `cta` | the ask: QR, contacts, URL | `img, hold` + `film.cta` |
94
-
95
- Any beat: `vo`, `sfx[]` (whoosh, hit, riser, tick, pop, chime, ping, pings, bell, keyboard) and
96
- `ambience` (amb-morning, amb-classroom, amb-office, amb-night). Add sounds with `scripts/sfx.py` (writes to the shared engine).
97
- Fonts available: Playfair Display, Inter, Fraunces, Montserrat, Poppins, DM Sans, Lora,
98
- Plus Jakarta Sans, Space Grotesk (add more in `engine/Film.tsx` FONTS).
99
-
100
- ## Distribution
101
- Shipped with kaizen (`skills/story-video` in the kaizen repo).
102
-
103
- ## Agent notes (Claude Code, Codex, Gemini CLI, OpenCode, Cursor…)
104
- Steps name capabilities, not tools. Where your agent lacks one:
105
- - **web fetch** (finding stock photos, sources): ask the user for photos/links instead.
106
- - **viewing images** (checking contact sheets / frames): send the image paths to the user and ask them to look.
107
- - **sub-agents** (reading several repos at once): read them one after another.
108
- - **structured questions**: ask in plain text with numbered options.
@@ -1,12 +0,0 @@
1
- {
2
- "name": "story-video-films",
3
- "private": true,
4
- "scripts": { "studio": "remotion studio src/index.ts" },
5
- "dependencies": {
6
- "@remotion/cli": "4.0.529",
7
- "@remotion/google-fonts": "4.0.529",
8
- "react": "^19.0.0",
9
- "react-dom": "^19.0.0",
10
- "remotion": "4.0.529"
11
- }
12
- }
@@ -1,24 +0,0 @@
1
- import React from 'react';
2
- import { Composition, getInputProps } from 'remotion';
3
- import { FilmView, FPS, timeline, type Film, type Words } from './engine/Film';
4
-
5
- // One film per render: scripts/master.sh passes { film, words } as props and the film folder as public dir.
6
- const SIZE = { '16x9': [1920, 1080], '9x16': [1080, 1920] } as const;
7
- type Props = { film: Film; words: Words };
8
- const props = getInputProps() as Partial<Props>;
9
-
10
- export const Root: React.FC = () => (
11
- <>
12
- {(Object.keys(SIZE) as (keyof typeof SIZE)[]).map((fmt) => (
13
- <Composition
14
- key={fmt} id={`Film-${fmt}`} component={FilmView as unknown as React.FC<Record<string, unknown>>}
15
- fps={FPS} width={SIZE[fmt][0]} height={SIZE[fmt][1]} durationInFrames={1}
16
- defaultProps={props as Record<string, unknown>}
17
- calculateMetadata={({ props: pr }) => {
18
- const { film, words } = pr as unknown as Props;
19
- return { durationInFrames: film && words ? timeline(film, words).total : 1 };
20
- }}
21
- />
22
- ))}
23
- </>
24
- );
@@ -1,405 +0,0 @@
1
- // Story-video engine. One film = public/<folder>/film.json (beats) + words.json (VO word timings)
2
- // + a preset from presets.json. Renders 16:9 or 9:16 from the same film (layout follows the frame).
3
- import React from 'react';
4
- import {
5
- AbsoluteFill, Audio, Img, OffthreadVideo, Sequence, interpolate, random, spring, staticFile,
6
- useCurrentFrame, useVideoConfig,
7
- } from 'remotion';
8
- import { loadFont as loadPlayfair } from '@remotion/google-fonts/PlayfairDisplay';
9
- import { loadFont as loadInter } from '@remotion/google-fonts/Inter';
10
- import { loadFont as loadFraunces } from '@remotion/google-fonts/Fraunces';
11
- import { loadFont as loadMontserrat } from '@remotion/google-fonts/Montserrat';
12
- import { loadFont as loadPoppins } from '@remotion/google-fonts/Poppins';
13
- import { loadFont as loadDMSans } from '@remotion/google-fonts/DMSans';
14
- import { loadFont as loadLora } from '@remotion/google-fonts/Lora';
15
- import { loadFont as loadJakarta } from '@remotion/google-fonts/PlusJakartaSans';
16
- import { loadFont as loadSpace } from '@remotion/google-fonts/SpaceGrotesk';
17
- import PRESETS from './presets.json';
18
-
19
- const FONTS: Record<string, string> = {
20
- 'Playfair Display': loadPlayfair('normal', { weights: ['400'], subsets: ['latin'] }).fontFamily,
21
- Inter: loadInter('normal', { weights: ['400', '700', '900'], subsets: ['latin'] }).fontFamily,
22
- Fraunces: loadFraunces('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
23
- Montserrat: loadMontserrat('normal', { weights: ['400', '700', '900'], subsets: ['latin'] }).fontFamily,
24
- Poppins: loadPoppins('normal', { weights: ['400', '700', '900'], subsets: ['latin'] }).fontFamily,
25
- 'DM Sans': loadDMSans('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
26
- Lora: loadLora('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
27
- 'Plus Jakarta Sans': loadJakarta('normal', { weights: ['400', '700', '800'], subsets: ['latin'] }).fontFamily,
28
- 'Space Grotesk': loadSpace('normal', { weights: ['400', '700'], subsets: ['latin'] }).fontFamily,
29
- };
30
- loadPlayfair('italic', { weights: ['400'], subsets: ['latin'] });
31
-
32
- // Files live in the public dir (the film folder when rendered by master.sh); `folder` is an optional prefix.
33
- const asset = (film: Film, n: string) => staticFile(film.folder ? `${film.folder}/${n}` : n);
34
-
35
- export type PresetName = keyof typeof PRESETS;
36
- type Preset = (typeof PRESETS)[PresetName];
37
- // A film's brand overrides the preset's palette/fonts; any font in FONTS may be named.
38
- const resolve = (film: Film): Preset => {
39
- const base = PRESETS[film.preset];
40
- return { ...base, palette: { ...base.palette, ...(film.brand?.palette ?? {}) }, fonts: { ...base.fonts, ...(film.brand?.fonts ?? {}) } } as Preset;
41
- };
42
- export const FPS = 30;
43
-
44
- export type Beat = {
45
- vo?: string; // key in words.json
46
- kind: 'photo' | 'black' | 'question' | 'reveal' | 'stat' | 'list' | 'triptych' | 'shot' | 'footage' | 'cta';
47
- img?: string; // file in the folder (photo/shot/cta/stat/list background)
48
- video?: string; // footage: mp4 in the folder
49
- shotRatio?: number;
50
- side?: 'bottom' | 'left';
51
- kick?: string; title?: string; sub?: string; line?: string;
52
- items?: string[]; // list: staged one by one across the line
53
- stat?: { value: number; suffix?: string; label: string; source?: string }[];
54
- panels?: { img: string; title: string; sub: string }[];
55
- sfx?: string[]; // extra sound effects at the beat start (files in /sfx)
56
- ambience?: string; // looped room tone under the beat (file in /sfx)
57
- hold?: number; // seconds added after the VO
58
- };
59
- export type Film = {
60
- preset: PresetName; folder?: string; logo?: string; logoOnLight?: string; qr?: string;
61
- language?: string; voice?: string; // used by scripts/vo.py (default id / Matilda)
62
- brand?: { palette?: Partial<Preset['palette']>; fonts?: Partial<Preset['fonts']> };
63
- labels?: { source?: string; next?: string }; // on-screen words, in the film's language
64
- cta?: { kick?: string; title: string; sub?: string; lines?: string[]; url?: string };
65
- beats: Beat[];
66
- };
67
- export type Words = Record<string, { dur: number; offset: number; text: string; words: { w: string; s: number; e: number }[] }>;
68
-
69
- export const timeline = (film: Film, words: Words) => {
70
- const p = resolve(film);
71
- const len = (b: Beat) => Math.round(((b.vo ? p.pace.lead + words[b.vo].dur + p.pace.tail : 0) + (b.hold ?? 0)) * FPS);
72
- const starts: number[] = [];
73
- film.beats.forEach((b, i) => starts.push(i === 0 ? 0 : starts[i - 1] + len(film.beats[i - 1])));
74
- const total = starts[starts.length - 1] + len(film.beats[film.beats.length - 1]);
75
- const reveal = film.beats.findIndex((b) => b.kind === 'reveal');
76
- return { len, starts, total, revealAt: reveal >= 0 ? starts[reveal] : Math.round(total / 2) };
77
- };
78
-
79
- // ---------- helpers ----------
80
- const useUnit = () => {
81
- const { width, height } = useVideoConfig();
82
- return { u: Math.min(width, height) / 1080, portrait: height > width, width, height };
83
- };
84
-
85
- const Anim: React.FC<{ p: Preset; delay: number; children: React.ReactNode; style?: React.CSSProperties }> = ({ p, delay, children, style }) => {
86
- const f = useCurrentFrame();
87
- const { fps } = useVideoConfig();
88
- const bouncy = p.text === 'pop';
89
- const k = spring({ frame: f - delay, fps, config: bouncy ? { damping: 9, stiffness: 160 } : { damping: 200 }, durationInFrames: bouncy ? 20 : 28 });
90
- const t = p.text === 'rise' ? `translateY(${(1 - k) * 26}px)` : p.text === 'slide' ? `translateX(${(1 - k) * -50}px)` : `scale(${0.6 + 0.4 * k})`;
91
- return <div style={{ opacity: Math.min(1, k * 1.4), transform: t, transformOrigin: 'left center', ...style }}>{children}</div>;
92
- };
93
-
94
- const Kick: React.FC<{ p: Preset; text: string; u: number }> = ({ p, text, u }) => (
95
- <div style={{ fontFamily: FONTS[p.fonts.body], fontWeight: 700, fontSize: 22 * u, letterSpacing: 9 * u, color: p.palette.accent, textTransform: 'uppercase' }}>{text}</div>
96
- );
97
-
98
- const Title: React.FC<{ p: Preset; text: string; u: number; size?: number; italic?: boolean; color?: string }> = ({ p, text, u, size = 88, italic, color }) => (
99
- <div style={{
100
- fontFamily: FONTS[p.fonts.display], fontWeight: p.fonts.display === 'Inter' ? 900 : 400, fontStyle: italic ? 'italic' : 'normal',
101
- fontSize: size * u, lineHeight: 1.08, color: color ?? p.palette.fg, letterSpacing: p.fonts.display === 'Inter' ? -1 * u : 0,
102
- }}>{text}</div>
103
- );
104
-
105
- const Photo: React.FC<{ src: string; d: number; amount: number; style?: React.CSSProperties }> = ({ src, d, amount, style }) => {
106
- const f = useCurrentFrame();
107
- const z = interpolate(f, [0, d], [1, 1 + amount]);
108
- const x = interpolate(f, [0, d], [0, -24]);
109
- return <Img src={src} style={{ width: '100%', height: '100%', objectFit: 'cover', transform: `scale(${z}) translateX(${x}px)`, ...style }} />;
110
- };
111
-
112
- // ---------- beat ----------
113
- const BeatView: React.FC<{ film: Film; b: Beat; d: number; words: Words }> = ({ film, b, d, words }) => {
114
- const p = resolve(film);
115
- const f = useCurrentFrame();
116
- const { u, portrait } = useUnit();
117
- const src = (n: string) => asset(film, n);
118
- const logo = (p.palette.bg.toUpperCase() > '#C' && film.logoOnLight) || film.logo;
119
- const lead = Math.round(p.pace.lead * FPS);
120
- const line = b.vo ? words[b.vo].dur * FPS : d;
121
-
122
- // transition in/out
123
- const fadeIn = interpolate(f, [0, 10], [0, 1], { extrapolateRight: 'clamp' });
124
- const fadeOut = interpolate(f, [d - 10, d], [1, 0], { extrapolateLeft: 'clamp' });
125
- let wrap: React.CSSProperties = { opacity: Math.min(fadeIn, fadeOut) };
126
- if (p.transition === 'wipe') wrap = { clipPath: `inset(0 ${interpolate(f, [0, 14], [100, 0], { extrapolateRight: 'clamp' })}% 0 0)`, opacity: fadeOut };
127
- if (p.transition === 'punch') wrap = { transform: `scale(${interpolate(f, [0, 8], [1.12, 1], { extrapolateRight: 'clamp' })})`, opacity: Math.min(1, fadeOut + 0.0) };
128
-
129
- const pad = portrait ? 80 * u : 140 * u;
130
- const bgFill = (b.kind === 'question') ? p.palette.accentBg : p.palette.bg;
131
-
132
- const textBlock = (maxW: number, size = 88) => (
133
- <div style={{ maxWidth: maxW * u }}>
134
- {b.kick && <Anim p={p} delay={lead}><Kick p={p} text={b.kick} u={u} /></Anim>}
135
- {b.title && <Anim p={p} delay={lead + 6} style={{ marginTop: 22 * u }}><Title p={p} text={b.title} u={u} size={size} /></Anim>}
136
- {b.sub && <Anim p={p} delay={lead + 30} style={{ marginTop: 28 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 30 * u, color: p.palette.muted, lineHeight: 1.4 }}>{b.sub}</div></Anim>}
137
- </div>
138
- );
139
-
140
- let content: React.ReactNode = null;
141
-
142
- if (b.kind === 'black') {
143
- content = (
144
- <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: pad }}>
145
- <Anim p={p} delay={lead}><Title p={p} text={b.title!} u={u} size={portrait ? 96 : 84} italic={p.fonts.display !== 'Inter'} /></Anim>
146
- {b.sub && <Anim p={p} delay={lead + Math.round(line * 0.55)} style={{ marginTop: 30 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 34 * u, color: p.palette.muted }}>{b.sub}</div></Anim>}
147
- </AbsoluteFill>
148
- );
149
- } else if (b.kind === 'question') {
150
- content = (
151
- <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: pad * 1.5 }}>
152
- <Anim p={p} delay={lead}><Title p={p} text={b.title!} u={u} size={portrait ? 92 : 88} italic={p.fonts.display !== 'Inter'} color={p.palette.bg} /></Anim>
153
- </AbsoluteFill>
154
- );
155
- } else if (b.kind === 'reveal') {
156
- const wipe = interpolate(f, [lead, lead + 24], [100, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
157
- const s = interpolate(f, [lead, d], [1.04, 1]);
158
- content = (
159
- <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center', textAlign: 'center', padding: pad }}>
160
- {logo
161
- ? <Img src={src(logo)} style={{ width: (portrait ? 760 : 820) * u, clipPath: `inset(0 ${wipe}% 0 0)`, transform: `scale(${s})` }} />
162
- : <Title p={p} text={b.title!} u={u} size={170} />}
163
- {b.sub && <Anim p={p} delay={lead + 30} style={{ marginTop: 40 * u }}><Title p={p} text={b.sub} u={u} size={46} italic={p.fonts.display !== 'Inter'} /></Anim>}
164
- {b.line && <Anim p={p} delay={lead + 50} style={{ marginTop: 34 * u }}><Kick p={p} text={b.line} u={u} /></Anim>}
165
- </AbsoluteFill>
166
- );
167
- } else if (b.kind === 'stat') {
168
- const n = b.stat!.length;
169
- content = (
170
- <AbsoluteFill style={{ padding: pad, justifyContent: 'center' }}>
171
- {b.kick && <Anim p={p} delay={lead}><Kick p={p} text={b.kick} u={u} /></Anim>}
172
- <div style={{ display: 'flex', flexDirection: portrait ? 'column' : 'row', gap: 70 * u, marginTop: 40 * u }}>
173
- {b.stat!.map((st, i) => {
174
- const at = lead + Math.round((line * i) / n);
175
- const v = interpolate(f, [at, at + 40], [0, st.value], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
176
- return (
177
- <Anim key={i} p={p} delay={at}>
178
- <div style={{ fontFamily: FONTS[p.fonts.display], fontSize: 150 * u, color: p.palette.accent, lineHeight: 1 }}>
179
- {Math.round(v).toLocaleString('id-ID')}{st.suffix ?? ''}
180
- </div>
181
- <div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 30 * u, color: p.palette.muted, marginTop: 12 * u, maxWidth: 560 * u }}>{st.label}</div>
182
- </Anim>
183
- );
184
- })}
185
- </div>
186
- {b.title && <Anim p={p} delay={lead + Math.round(line * 0.7)} style={{ marginTop: 50 * u }}><Title p={p} text={b.title} u={u} size={52} italic={p.fonts.display !== 'Inter'} /></Anim>}
187
- {b.stat!.some((s) => s.source) && (
188
- <div style={{ position: 'absolute', left: pad, bottom: (portrait ? 330 : 70) * u, fontFamily: FONTS[p.fonts.body], fontSize: 18 * u, color: p.palette.muted, opacity: 0.8 }}>
189
- {film.labels?.source ?? 'Sumber'}: {b.stat!.map((s) => s.source).filter(Boolean).join(' · ')}
190
- </div>
191
- )}
192
- </AbsoluteFill>
193
- );
194
- } else if (b.kind === 'triptych') {
195
- const cw = portrait ? 300 : 420, ch = portrait ? 420 : 520;
196
- content = (
197
- <AbsoluteFill style={{ padding: pad, justifyContent: 'center' }}>
198
- {b.kick && <Anim p={p} delay={lead}><Kick p={p} text={b.kick} u={u} /></Anim>}
199
- {b.title && <Anim p={p} delay={lead + 4} style={{ marginTop: 18 * u }}><Title p={p} text={b.title} u={u} size={60} /></Anim>}
200
- <div style={{ display: 'flex', gap: 36 * u, marginTop: 50 * u }}>
201
- {b.panels!.map((pn, i) => (
202
- <Anim key={i} p={p} delay={lead + Math.round((line * (i + 0.3)) / 3)}>
203
- <div style={{ width: cw * u, height: ch * u, overflow: 'hidden', border: `2px solid ${p.palette.accent}` }}><Img src={src(pn.img)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
204
- <div style={{ fontFamily: FONTS[p.fonts.display], fontSize: 34 * u, color: p.palette.fg, marginTop: 16 * u }}>{pn.title}</div>
205
- <div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 22 * u, color: p.palette.muted, marginTop: 6 * u, maxWidth: cw * u }}>{pn.sub}</div>
206
- </Anim>
207
- ))}
208
- </div>
209
- </AbsoluteFill>
210
- );
211
- } else if (b.kind === 'shot') {
212
- const sw = (portrait ? 920 : 1040) * u, sh = sw / (b.shotRatio ?? 1.6);
213
- content = (
214
- <AbsoluteFill style={{ padding: pad, flexDirection: portrait ? 'column' : 'row', alignItems: 'center', justifyContent: 'space-between', gap: 50 * u }}>
215
- {textBlock(portrait ? 900 : 560, 72)}
216
- <Anim p={p} delay={lead + 10}>
217
- <div style={{ padding: 10 * u, background: '#F3EDE3', border: `2px solid ${p.palette.accent}`, transform: `scale(${interpolate(f, [0, d], [1, 1.04])})` }}>
218
- <Img src={src(b.img!)} style={{ width: sw, height: sh, display: 'block' }} />
219
- </div>
220
- </Anim>
221
- </AbsoluteFill>
222
- );
223
- } else if (b.kind === 'cta') {
224
- const c = film.cta!;
225
- content = (
226
- <AbsoluteFill>
227
- {b.img && <AbsoluteFill style={{ opacity: 0.35 }}><Photo src={src(b.img)} d={d} amount={p.kenburns} /></AbsoluteFill>}
228
- <AbsoluteFill style={{ padding: pad, justifyContent: 'center', alignItems: portrait ? 'center' : 'flex-start', textAlign: portrait ? 'center' : 'left' }}>
229
- <Anim p={p} delay={lead}><Kick p={p} text={c.kick ?? film.labels?.next ?? 'Langkah berikutnya'} u={u} /></Anim>
230
- <Anim p={p} delay={lead + 6} style={{ marginTop: 24 * u }}><Title p={p} text={c.title} u={u} size={portrait ? 96 : 86} /></Anim>
231
- {c.sub && <Anim p={p} delay={lead + 30} style={{ marginTop: 40 * u }}><Kick p={p} text={c.sub} u={u} /></Anim>}
232
- {(c.lines ?? []).map((l, i) => (
233
- <Anim key={i} p={p} delay={lead + 36 + i * 6} style={{ marginTop: 12 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 38 * u, color: p.palette.fg }}>{l}</div></Anim>
234
- ))}
235
- {c.url && <Anim p={p} delay={lead + 48} style={{ marginTop: 12 * u }}><div style={{ fontFamily: FONTS[p.fonts.body], fontSize: 34 * u, color: p.palette.muted }}>{c.url}</div></Anim>}
236
- {portrait && film.qr && <Anim p={p} delay={lead + 54} style={{ marginTop: 50 * u }}><Img src={src(film.qr)} style={{ width: 360 * u, height: 360 * u, borderRadius: 8 }} /></Anim>}
237
- {logo && <Anim p={p} delay={lead + 64} style={{ marginTop: 60 * u }}><Img src={src(logo)} style={{ width: 360 * u }} /></Anim>}
238
- </AbsoluteFill>
239
- {!portrait && film.qr && (
240
- <Anim p={p} delay={lead + 54} style={{ position: 'absolute', right: pad, bottom: 150 * u }}>
241
- <Img src={src(film.qr)} style={{ width: 330 * u, height: 330 * u, borderRadius: 6 }} />
242
- </Anim>
243
- )}
244
- </AbsoluteFill>
245
- );
246
- } else {
247
- // photo | footage | list — the layout depends on the preset
248
- const media = b.kind === 'footage' && b.video
249
- ? <OffthreadVideo src={src(b.video)} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
250
- : b.img ? <Photo src={src(b.img)} d={d} amount={p.kenburns} /> : null;
251
- const items = b.items && (
252
- <div style={{ marginTop: 34 * u }}>
253
- {b.items.map((it, i) => (
254
- <Anim key={i} p={p} delay={lead + 20 + Math.round(((line - 20) * i) / b.items!.length)} style={{ marginTop: 10 * u }}>
255
- <div style={{ fontFamily: FONTS[p.fonts.display], fontStyle: p.fonts.display === 'Inter' ? 'normal' : 'italic', fontSize: 40 * u, color: p.palette.accent }}>{it}</div>
256
- </Anim>
257
- ))}
258
- </div>
259
- );
260
- if (p.layout === 'window' && !portrait) {
261
- const open = interpolate(f, [lead, lead + 18], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
262
- content = (
263
- <AbsoluteFill style={{ flexDirection: 'row' }}>
264
- <div style={{ width: '52%', padding: `0 ${pad}px`, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>{textBlock(820, 76)}{items}</div>
265
- <div style={{ width: '40%', margin: `${110 * u}px 0`, overflow: 'hidden', border: `2px solid ${p.palette.accent}`, clipPath: `inset(${(1 - open) * 50}% 0 ${(1 - open) * 50}% 0)` }}>{media}</div>
266
- </AbsoluteFill>
267
- );
268
- } else if (p.layout === 'card' && !portrait) {
269
- const k = spring({ frame: f - lead, fps: FPS, config: { damping: 10 } });
270
- content = (
271
- <AbsoluteFill style={{ flexDirection: 'row', alignItems: 'center' }}>
272
- <div style={{ width: '50%', padding: `0 ${pad}px` }}>{textBlock(780, 80)}{items}</div>
273
- <div style={{ width: 760 * u, height: 760 * u, borderRadius: 40 * u, overflow: 'hidden', transform: `rotate(${(1 - k) * 8 + 2}deg) scale(${0.8 + 0.2 * k})`, boxShadow: '0 30px 80px rgba(0,0,0,0.25)' }}>{media}</div>
274
- </AbsoluteFill>
275
- );
276
- } else {
277
- const top = portrait && p.captions === 'kinetic';
278
- const bottom = !top && (portrait || b.side !== 'left');
279
- content = (
280
- <AbsoluteFill>
281
- <AbsoluteFill style={{ overflow: 'hidden' }}>{media}</AbsoluteFill>
282
- <AbsoluteFill style={{ background: top
283
- ? `linear-gradient(to bottom, ${p.palette.bg}E6 0%, transparent 40%, transparent 55%, ${p.palette.bg}CC 100%)`
284
- : bottom
285
- ? `linear-gradient(to top, ${p.palette.bg}F0 0%, ${p.palette.bg}99 35%, transparent 65%)`
286
- : `linear-gradient(to right, ${p.palette.bg}F2 0%, ${p.palette.bg}AA 40%, transparent 70%)` }} />
287
- <AbsoluteFill style={{ padding: top ? `${200 * u}px ${pad}px 0` : bottom ? `0 ${pad}px ${(portrait ? 420 : 150) * u}px` : `0 ${pad}px`, justifyContent: top ? 'flex-start' : bottom ? 'flex-end' : 'center' }}>
288
- {textBlock(bottom ? 1400 : 900, portrait ? 92 : bottom ? 92 : 80)}{items}
289
- </AbsoluteFill>
290
- </AbsoluteFill>
291
- );
292
- }
293
- }
294
-
295
- return <AbsoluteFill style={{ background: bgFill, ...wrap }}>{content}</AbsoluteFill>;
296
- };
297
-
298
- // ---------- captions ----------
299
- const Captions: React.FC<{ film: Film; words: Words; starts: number[] }> = ({ film, words, starts }) => {
300
- const p = resolve(film);
301
- const f = useCurrentFrame();
302
- const { u, portrait } = useUnit();
303
- if (p.captions === 'none') return null;
304
- const i = starts.findLastIndex((s) => s <= f);
305
- const b = film.beats[i];
306
- if (!b?.vo || b.kind === 'cta') return null;
307
- const t = (f - starts[i]) / FPS - p.pace.lead;
308
- const ws = words[b.vo].words;
309
- if (t < -0.1 || t > words[b.vo].dur + 0.4) return null;
310
- const cur = Math.max(0, ws.findLastIndex((w) => w.s <= t));
311
-
312
- if (p.captions === 'kinetic') {
313
- const per = ws.slice(Math.floor(cur / 3) * 3, Math.floor(cur / 3) * 3 + 3).join('').length > 16 ? 2 : 3;
314
- const g0 = Math.floor(cur / per) * per;
315
- const group = ws.slice(g0, g0 + per);
316
- const k = spring({ frame: Math.round((t - ws[g0].s) * FPS), fps: FPS, config: { damping: 10, stiffness: 180 }, durationInFrames: 10 });
317
- return (
318
- <AbsoluteFill style={{ justifyContent: 'flex-end', alignItems: 'center', paddingBottom: (portrait ? 300 : 120) * u }}>
319
- <div style={{ transform: `scale(${0.85 + 0.15 * k})`, display: 'flex', flexWrap: 'wrap', justifyContent: 'center', maxWidth: (portrait ? 940 : 1500) * u }}>
320
- {group.map((w, j) => (
321
- <span key={j} style={{
322
- fontFamily: FONTS.Inter, fontWeight: 900, fontSize: (portrait ? 84 : 72) * u, lineHeight: 1.15, textTransform: 'uppercase',
323
- color: g0 + j === cur ? p.palette.accent : '#FFFFFF', margin: `0 ${10 * u}px`,
324
- WebkitTextStroke: `${3 * u}px #000`, paintOrder: 'stroke', textShadow: `0 ${6 * u}px ${18 * u}px rgba(0,0,0,0.6)`,
325
- }}>{w.w}</span>
326
- ))}
327
- </div>
328
- </AbsoluteFill>
329
- );
330
- }
331
- // subtitle: chunks of up to 7 words, broken at sentence punctuation
332
- const chunks: number[][] = [];
333
- let c: number[] = [];
334
- ws.forEach((w, j) => { c.push(j); if (c.length >= 7 || /[.,?!]$/.test(w.w)) { chunks.push(c); c = []; } });
335
- if (c.length) chunks.push(c);
336
- const chunk = chunks.find((ch) => ch.includes(cur)) ?? chunks[0];
337
- const light = p.palette.bg.toUpperCase() > '#C';
338
- return (
339
- <AbsoluteFill style={{ justifyContent: 'flex-end', alignItems: 'center', paddingBottom: (portrait ? 220 : 56) * u }}>
340
- <div style={{
341
- fontFamily: FONTS[p.fonts.body], fontSize: (portrait ? 40 : 32) * u, color: light ? p.palette.fg : '#FFFFFF', textAlign: 'center',
342
- background: light ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.45)', padding: `${8 * u}px ${18 * u}px`, borderRadius: 6 * u, maxWidth: 1500 * u,
343
- }}>{chunk.map((j) => ws[j].w).join(' ')}</div>
344
- </AbsoluteFill>
345
- );
346
- };
347
-
348
- // ---------- texture ----------
349
- const Texture: React.FC<{ p: Preset; starts: number[] }> = ({ p, starts }) => {
350
- const f = useCurrentFrame();
351
- const { width, height } = useVideoConfig();
352
- const sinceCut = f - (starts.findLast((s) => s <= f) ?? 0);
353
- const leak = p.grade.leak ? interpolate(sinceCut, [0, 8, 26], [0, 0.55, 0], { extrapolateRight: 'clamp' }) : 0;
354
- return (
355
- <>
356
- {p.grade.vignette > 0 && <AbsoluteFill style={{ background: `radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,${p.grade.vignette}) 100%)` }} />}
357
- {leak > 0 && <AbsoluteFill style={{ background: `radial-gradient(circle at ${20 + random(Math.floor(f / 40)) * 60}% 30%, rgba(255,190,120,${leak}) 0%, transparent 55%)`, mixBlendMode: 'screen' }} />}
358
- {p.grade.grain > 0 && (
359
- <AbsoluteFill style={{ opacity: p.grade.grain, mixBlendMode: 'overlay' }}>
360
- <svg width={width} height={height}>
361
- <filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed={f % 50} /></filter>
362
- <rect width="100%" height="100%" filter="url(#g)" />
363
- </svg>
364
- </AbsoluteFill>
365
- )}
366
- </>
367
- );
368
- };
369
-
370
- // ---------- film ----------
371
- // Props-driven: the render passes { film, words } (scripts/master.sh), the public dir is the film folder.
372
- export const FilmView: React.FC<{ film: Film; words: Words }> = ({ film, words }) => {
373
- const p = resolve(film);
374
- const { len, starts, total, revealAt } = timeline(film, words);
375
- const music = (fr: number) => interpolate(
376
- fr, [0, 30, revealAt - 15, revealAt + 10, revealAt + 90, total - 60, total],
377
- [0, p.music.base, p.music.base, p.music.lift, p.music.base + 0.04, p.music.base + 0.04, 0],
378
- { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
379
- const sfx = (name: string, vol = 0.6) => name ? <Audio src={staticFile(`sfx/${name}.mp3`)} volume={vol} /> : null;
380
- return (
381
- <AbsoluteFill style={{ background: p.palette.bg }}>
382
- <Audio src={asset(film, p.music.track)} volume={music} />
383
- {film.beats.map((b, i) => {
384
- const lead = Math.round(p.pace.lead * FPS);
385
- const fx = b.kind === 'reveal' ? p.sfx.reveal : b.kind === 'question' ? p.sfx.question : i > 0 ? p.sfx.cut : '';
386
- return (
387
- <Sequence key={i} from={starts[i]} durationInFrames={len(b)}>
388
- <BeatView film={film} b={b} d={len(b)} words={words} />
389
- {fx && sfx(fx, b.kind === 'reveal' ? 0.7 : 0.35)}
390
- {(b.sfx ?? []).map((s, j) => <Sequence key={j} from={lead}>{sfx(s, 0.45)}</Sequence>)}
391
- {b.ambience && <Audio src={staticFile(`sfx/${b.ambience}.mp3`)} volume={0.18} loop />}
392
- {p.sfx.text && b.title && i > 0 && <Sequence from={lead + 6}>{sfx(p.sfx.text, 0.25)}</Sequence>}
393
- {b.vo && (
394
- <Sequence from={lead}>
395
- <Audio src={asset(film, `${b.vo}.mp3`)} trimBefore={Math.round(words[b.vo].offset * FPS)} />
396
- </Sequence>
397
- )}
398
- </Sequence>
399
- );
400
- })}
401
- <Texture p={p} starts={starts} />
402
- <Captions film={film} words={words} starts={starts} />
403
- </AbsoluteFill>
404
- );
405
- };