kaizen-agent 1.47.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.
- package/README.md +111 -84
- package/cli/build-web.ts +4 -2
- package/cli/hermes.ts +65 -0
- package/cli/install.ts +24 -10
- package/cli/localsend.test.ts +84 -0
- package/cli/localsend.ts +131 -0
- package/cli/notes.test.ts +6 -4
- package/cli/settings.ts +1 -0
- package/cli/state.test.ts +37 -0
- package/cli/state.ts +35 -20
- package/cli/video.test.ts +479 -0
- package/cli/video.ts +507 -0
- package/cli/web.ts +127 -1
- package/cli/worktree.test.ts +3 -1
- package/package.json +1 -1
- package/skills/kaizen/config.default.yml +4 -0
- package/skills/media/kaizen-book/SKILL.md +107 -0
- package/skills/media/kaizen-book/assets/reader.html +440 -0
- package/skills/media/kaizen-book/assets/styles.json +45 -0
- package/skills/media/kaizen-book/examples/book.json +14 -0
- package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
- package/skills/media/kaizen-book/examples/manuscript.md +127 -0
- package/skills/media/kaizen-book/references/interview.md +85 -0
- package/skills/media/kaizen-book/references/proofread.md +47 -0
- package/skills/media/kaizen-book/references/writing.md +53 -0
- package/skills/media/kaizen-book/scripts/build_book.py +279 -0
- package/skills/media/kaizen-book/scripts/check.sh +10 -0
- package/skills/media/kaizen-book/scripts/check_book.py +128 -0
- package/skills/media/kaizen-book/scripts/deploy.py +58 -0
- package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
- package/skills/media/kaizen-book/scripts/setup.sh +9 -0
- package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
- package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
- package/skills/media/kaizen-video/SKILL.md +112 -0
- package/skills/media/kaizen-video/assets/CREDITS.txt +16 -0
- package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/Fredoka.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/Montserrat.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/SpecialElite.ttf +0 -0
- package/skills/media/kaizen-video/assets/music/investigations.mp3 +0 -0
- package/skills/media/kaizen-video/assets/music/lights.mp3 +0 -0
- package/skills/media/kaizen-video/assets/music/sunny.mp3 +0 -0
- package/skills/media/kaizen-video/examples/config.json +152 -0
- package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
- package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
- package/skills/media/kaizen-video/film.html +246 -0
- package/skills/media/kaizen-video/hyperframes.json +12 -0
- package/skills/media/kaizen-video/presets/educational-warning/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/educational-warning/README.md +26 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/index.html +236 -0
- package/skills/media/kaizen-video/presets/educational-warning/package.json +10 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/README.md +35 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/index.html +260 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/CREDITS.txt +12 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/README.md +51 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/index.html +260 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/CREDITS.txt +8 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/README.md +24 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/index.html +266 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/package.json +10 -0
- package/skills/media/kaizen-video/references/presets.md +76 -0
- package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
- package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
- package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/check.sh +22 -0
- package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
- package/skills/media/kaizen-video/scripts/make_film.py +263 -0
- package/skills/media/kaizen-video/scripts/make_tiktok.py +213 -0
- package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
- package/skills/media/kaizen-video/scripts/render_cut.py +88 -0
- package/skills/media/kaizen-video/scripts/setup.sh +14 -0
- package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
- package/skills/media/kaizen-video/scripts/srt.py +9 -0
- package/skills/media/kaizen-video/scripts/still.sh +7 -0
- package/skills/media/kaizen-video/scripts/transcribe.py +45 -0
- package/skills/media/kaizen-video/tools/carve.mjs +560 -0
- package/skills/media/kaizen-video/tools/lib/main-module.mjs +13 -0
- package/web/board/views.ts +1 -1
- package/web/board.html +3 -1
- package/web/board.js +2 -2
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +38 -36
- package/web/term.ts +43 -10
- package/web/video.html +298 -0
- package/web/video.js +81 -0
- package/web/video.ts +610 -0
- package/skills/story-video/SKILL.md +0 -108
- package/skills/story-video/engine/package.json +0 -12
- package/skills/story-video/engine/src/Root.tsx +0 -24
- package/skills/story-video/engine/src/engine/Film.tsx +0 -405
- package/skills/story-video/engine/src/engine/presets.json +0 -58
- package/skills/story-video/engine/src/index.ts +0 -3
- package/skills/story-video/engine/tsconfig.json +0 -1
- package/skills/story-video/examples/sample-film/README.md +0 -12
- package/skills/story-video/examples/sample-film/film.json +0 -16
- package/skills/story-video/references/presets.md +0 -21
- package/skills/story-video/scripts/check.sh +0 -19
- package/skills/story-video/scripts/setup.sh +0 -25
- package/skills/story-video/scripts/srt.py +0 -30
- package/skills/story-video/scripts/still.sh +0 -10
- /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
- /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Presets
|
|
2
|
+
|
|
3
|
+
A preset is a folder. `make_tiktok.py` finds it by the name in the video's `config.json` (`"preset"`),
|
|
4
|
+
looking in this order and taking the first folder that has an `index.html`:
|
|
5
|
+
|
|
6
|
+
1. `presets/<name>/` in the project folder — styles for this project.
|
|
7
|
+
2. `$KAIZEN_VIDEO_PRESETS/<name>/`, default `~/.kaizen/video-presets/<name>/` — your own styles, for every project.
|
|
8
|
+
3. `<skill>/presets/<name>/` — the bundled ones.
|
|
9
|
+
|
|
10
|
+
A local preset with a bundled name overrides the bundled one. An unknown name stops the build and lists
|
|
11
|
+
every preset it can see. Nothing is registered anywhere: adding a folder is adding a preset.
|
|
12
|
+
|
|
13
|
+
## What goes in the folder
|
|
14
|
+
|
|
15
|
+
| File | Required | Notes |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `index.html` | yes | The HyperFrames composition template. |
|
|
18
|
+
| `package.json` | yes | Pins `@hyperframes/core` (copy one from a bundled preset). |
|
|
19
|
+
| `README.md` | yes | Look, motion, card types, sounds and config keys, for whoever picks a style. |
|
|
20
|
+
| `CREDITS.txt` | yes | Source, author and license of every font and sound file the preset uses, copied into every render's credits. Credit a preset's own music as a line `- <name>.mp3: <title, author, license>`; the render credits only the track the video uses, looked up here and then in the skill's `assets/CREDITS.txt`. |
|
|
21
|
+
| `assets/sfx/*.wav` | yes | Sound effects, 48 kHz, any WAV encoding. Durations are read from the files with ffprobe. |
|
|
22
|
+
| `assets/fonts/*.ttf` | no | Falls back to the skill's `assets/fonts/` (Montserrat, Fredoka, Special Elite). |
|
|
23
|
+
| `assets/music/*.mp3` | no | Falls back to the skill's `assets/music/` (`lights`, `investigations`, `sunny`). |
|
|
24
|
+
| `tools/carve.mjs`, `hyperframes.json` | no | Fall back to the skill's copies. |
|
|
25
|
+
|
|
26
|
+
A file in the preset always wins over the shared one with the same name, so a preset can replace a font or
|
|
27
|
+
a track without touching the others.
|
|
28
|
+
|
|
29
|
+
## Template contract
|
|
30
|
+
|
|
31
|
+
`make_tiktok.py` fills these in `index.html`; keep them:
|
|
32
|
+
|
|
33
|
+
- `{{DURATION}}`, `{{VIDEO_DURATION}}`, `{{VIDEO}}`, `{{MUSIC_VOLUME}}`
|
|
34
|
+
- `{{HOOK_1}}`, `{{HOOK_2}}`, `{{HOOK_SUB}}`
|
|
35
|
+
- `<!-- SFX:BEGIN -->` — where the sound-effect `<audio>` tags are inserted.
|
|
36
|
+
|
|
37
|
+
The composition also reads `captions.js` (`window.CAPTIONS`, `window.SEG_STARTS`, `window.BEATS`), and its
|
|
38
|
+
voice element must have the id `a-roll` and the music bed the id `music`, because the music is carved around
|
|
39
|
+
the voice by those ids.
|
|
40
|
+
|
|
41
|
+
Sounds the script places itself: `shutter` (every card), `stamp` (a beat with `"stamp"`), `reveal` (each
|
|
42
|
+
`item`) and `ending` (the `end` beat). Any other wav in `assets/sfx/` can be used as a reaction in the
|
|
43
|
+
config (`{"sfx": "<file name>", "word", "after", "offset", "volume"}`). Keep peaks around −6.5 to −8.5 dBFS.
|
|
44
|
+
|
|
45
|
+
## Adding one
|
|
46
|
+
|
|
47
|
+
1. Copy the closest preset to the project's `presets/<style>/` (or `~/.kaizen/video-presets/<style>/`).
|
|
48
|
+
2. Change `index.html` and the assets, update `README.md`, and write `CREDITS.txt` for every file you add.
|
|
49
|
+
3. Set `"preset": "<style>"` in a video's config and build it.
|
|
50
|
+
|
|
51
|
+
To ship it to everyone who installs kaizen, copy the folder into this skill's `presets/` in the kaizen
|
|
52
|
+
repository. Move any music or font another preset could use into the shared `assets/`, credit it in
|
|
53
|
+
`assets/CREDITS.txt`, and keep only the preset's own files in its folder: every byte here goes into the
|
|
54
|
+
kaizen package.
|
|
55
|
+
|
|
56
|
+
# Film presets (from scratch)
|
|
57
|
+
|
|
58
|
+
`make_film.py` finds a film preset by the folder holding `style.json` (same three places as above), so the two kinds never collide. Bundled: `film-cinematic-drama`, `film-promo-booth`, `film-fun-energetic`, `film-viral-social`. Each works in 9:16 and 16:9; the `window` and `card` layouts fall back to full-bleed in 9:16.
|
|
59
|
+
| preset | use for | look & motion | audio | captions | loudness |
|
|
60
|
+
|---|---|---|---|---|---|
|
|
61
|
+
| cinematic-drama | pitch opener, brand film | dark, full-bleed photos, slow push-in, rise text, grain + vignette, fades | quiet music, hit on reveal, riser into the question, room tone | subtitle bar | −16 |
|
|
62
|
+
| promo-booth | exhibition loop, deck-faithful promo (the friend's style) | photo in a framed window beside the text, wipes, slide-in text, staged builds | whoosh on cuts, tick on titles, slightly louder music | subtitle bar | −16 |
|
|
63
|
+
| fun-energetic | social posts, parents, events | cream background, rounded photo cards that bounce in, pop text, light leaks | bright track (Carefree), pops and a chime | subtitle bar | −14 |
|
|
64
|
+
| viral-social | TikTok / Reels / Shorts (9:16) | full-bleed, fast punch-in cuts, bold Inter, strong Ken Burns | Easy Lemon-type track, whooshes | kinetic 2–3 words, accent on the spoken word | −14 |
|
|
65
|
+
|
|
66
|
+
Pace: `lead` / `tail` = silence before and after each line. Drama 0.8/1.1, booth 0.45/0.7, fun 0.2/0.35, viral 0.05/0.2.
|
|
67
|
+
|
|
68
|
+
## Writing for each preset
|
|
69
|
+
- **drama**: full story arc, 70–110 s, long pauses allowed.
|
|
70
|
+
- **booth**: the deck's beats in order, 80–100 s, must make sense when joined mid-loop; real contacts on the CTA.
|
|
71
|
+
- **fun**: 35–50 s, playful lines ("Kenal rasanya?"), one problem montage, three quick benefits.
|
|
72
|
+
- **viral**: ≤ 30 s, line 1 is the hook (a sharp, relatable pain), product by second ~12, one CTA.
|
|
73
|
+
|
|
74
|
+
## Adding a preset
|
|
75
|
+
Copy a `film-*` folder to `presets/<name>/` in the project and edit its `style.json` (no `index.html`: `film.html` is shared); fields: palette, fonts (any font file in `assets/film-fonts/` or the preset's `assets/fonts/`, named like `PlayfairDisplay.ttf`), pace, text (rise|slide|pop), transition (fade|wipe|punch),
|
|
76
|
+
layout (fullbleed|window|card), grade, captions (subtitle|kinetic|none), music levels, sfx names, kenburns, loudness.
|
|
@@ -7,8 +7,8 @@ Learned by comparing deck-to-video renders against richer productions . The exam
|
|
|
7
7
|
"140 pesan", `keyboard`, `bell`), soft whoosh/tick on cuts, a hit on the reveal, a riser into the
|
|
8
8
|
question. Music ducks under speech and only lifts at the reveal — never a dramatic swell unless
|
|
9
9
|
the preset is drama.
|
|
10
|
-
2. **Captions**: always. Projectors and phones are often muted. Subtitle bar
|
|
11
|
-
2–3-word captions
|
|
10
|
+
2. **Captions**: always. Projectors and phones are often muted. Subtitle bar (cinematic, booth, fun) or kinetic
|
|
11
|
+
2–3-word captions (viral), in either shape. Captions come from `words.json`, so they follow the voice exactly.
|
|
12
12
|
3. **Staged builds inside a beat**: numbers count up, list items arrive one by one with the voice,
|
|
13
13
|
triptych panels arrive in turn. A whole slide appearing at once reads as a slideshow.
|
|
14
14
|
4. **Directed voice**: emotion tag per line, low and slow through the problem, lift at the question,
|
|
@@ -5,21 +5,21 @@ Clients revise. Make every revision cheap, traceable and reversible.
|
|
|
5
5
|
## Classify the request, then touch only that layer
|
|
6
6
|
| request | what changes | redo |
|
|
7
7
|
|---|---|---|
|
|
8
|
-
| "change this sentence / say it warmer / mispronounced X" | `lines.json` entry | `vo.py <film-dir> "" v04` (only that key; empty voice = use film.json), then
|
|
9
|
-
| "different text on screen" | beat `kick/title/sub/items` in `film.json` | master |
|
|
10
|
-
| "swap this picture" | beat `img` (+ intake for new photos) |
|
|
11
|
-
| "slower / faster / shorter" | preset `pace`, beat `hold`, drop or merge beats |
|
|
12
|
-
| "music too loud / different mood" | preset `music.base/lift` or `music.sh` new track |
|
|
13
|
-
| "other colours / logo / font" | `film.brand`, `logo` |
|
|
8
|
+
| "change this sentence / say it warmer / mispronounced X" | `lines.json` entry | `vo.py <film-dir> "" v04` (only that key; empty voice = use film.json), then build and render |
|
|
9
|
+
| "different text on screen" | beat `kick/title/sub/items` in `film.json` | build + master |
|
|
10
|
+
| "swap this picture" | beat `img` (+ intake for new photos) | build + render |
|
|
11
|
+
| "slower / faster / shorter" | preset `pace`, beat `hold`, drop or merge beats | build + render |
|
|
12
|
+
| "music too loud / different mood" | preset `music.base/lift` or `music.sh` new track | build + render |
|
|
13
|
+
| "other colours / logo / font" | `film.brand`, `logo` | build + render |
|
|
14
14
|
| "make a vertical / 15 s version" | new film entry with its own (shorter) script | vo + master |
|
|
15
15
|
| "different style" | `preset` (and usually a rewritten script) | vo + master |
|
|
16
16
|
|
|
17
17
|
Never regenerate all narration for a one-line change: voices vary between takes and credits cost money.
|
|
18
18
|
|
|
19
19
|
## Versioning
|
|
20
|
-
- `master.sh` moves the previous delivery to `
|
|
21
|
-
- Append to
|
|
22
|
-
- When a revision is rejected, restore from `
|
|
20
|
+
- `master.sh` moves the previous delivery to `output/<name>/<preset>/previous/<name>-<timestamp>.*` before writing the new one.
|
|
21
|
+
- Append to `videos/<name>/REVISIONS.md`: date, the user's words verbatim, what changed, output file.
|
|
22
|
+
- When a revision is rejected, restore from `previous/` instead of re-rendering.
|
|
23
23
|
|
|
24
24
|
## Before re-delivering
|
|
25
25
|
Re-check stills of the beats that changed, master, and say exactly what changed and what did not.
|
|
Binary file
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Requirements for kaizen-video. Prints what is missing and how to install it; exit 1 if anything required is missing.
|
|
3
|
+
miss=0
|
|
4
|
+
HOME_DIR=${KAIZEN_VIDEO_HOME:-$HOME/.kaizen/kaizen-video}
|
|
5
|
+
need(){ command -v "$1" >/dev/null 2>&1 && echo "ok $1" || { echo "MISSING $1 — $2"; miss=1; }; }
|
|
6
|
+
need ffmpeg "ffmpeg (brew install ffmpeg / apt install ffmpeg / pacman -S ffmpeg / winget install ffmpeg)"
|
|
7
|
+
need ffprobe "comes with ffmpeg"
|
|
8
|
+
need node "Node.js 18+ (https://nodejs.org or your package manager)"
|
|
9
|
+
need npm "comes with Node.js"
|
|
10
|
+
need npx "comes with Node.js"
|
|
11
|
+
need python3 "Python 3.9+"
|
|
12
|
+
command -v uv >/dev/null 2>&1 && echo "ok uv" || { echo "MISSING uv — https://docs.astral.sh/uv/ (installs Python 3.11 for WhisperX)"; miss=1; }
|
|
13
|
+
[ -x "$HOME_DIR/whisperx-venv/bin/python" ] && echo "ok WhisperX ($HOME_DIR/whisperx-venv)" || echo "todo WhisperX — run setup.sh (about 2 GB of packages, plus a 3 GB model on the first transcription)"
|
|
14
|
+
# From-scratch films (make_film.py) need these too; WhisperX is footage-only.
|
|
15
|
+
opt(){ command -v "$1" >/dev/null 2>&1 && echo "ok $1" || echo "opt $1 — $2"; }
|
|
16
|
+
opt soffice "LibreOffice — only for importing .pptx decks"
|
|
17
|
+
opt pdftoppm "poppler-utils — only for importing decks"
|
|
18
|
+
python3 -c "import PIL" 2>/dev/null && echo "ok Pillow" || echo "opt Pillow — pip install pillow (from-scratch: intake.py)"
|
|
19
|
+
python3 -c "import edge_tts" 2>/dev/null && echo "ok edge-tts (free voices)" || echo "opt edge-tts — pip install edge-tts (from-scratch voice without an ElevenLabs key)"
|
|
20
|
+
python3 -c "import qrcode" 2>/dev/null && echo "ok qrcode" || echo "opt qrcode — pip install 'qrcode[pil]' (QR on the CTA)"
|
|
21
|
+
[ -n "${ELEVENLABS_API_KEY:-}" ] && echo "ok ELEVENLABS_API_KEY set" || echo "info no ELEVENLABS_API_KEY — from-scratch uses edge-tts and synthesized sound effects"
|
|
22
|
+
exit $miss
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Turn whatever the user brought into film-ready assets.
|
|
2
2
|
Usage: python3 scripts/intake.py <film-dir> [input_dir] (default input_dir: <film-dir>/inputs)
|
|
3
3
|
<input_dir> may contain, in any mix:
|
|
4
|
-
photos/ story photos -> <name>-16x9.jpg
|
|
4
|
+
photos/ story photos -> <name>-16x9.jpg, -9x16.jpg, -4x5.jpg (cropped, light grade)
|
|
5
5
|
screens/ app/web screenshots -> copied untouched; ratio recorded (use kind "shot")
|
|
6
6
|
footage/ video clips (.mp4/.mov) -> <name>.mp4 (H.264, no audio, 1080p max)
|
|
7
7
|
logo*.png logo(s) -> copied; dominant colours suggested for brand.palette
|
|
@@ -36,7 +36,7 @@ def grade(im):
|
|
|
36
36
|
for f in sorted((src / "photos").glob("*")) if (src / "photos").exists() else []:
|
|
37
37
|
if f.suffix.lower() not in IMG: continue
|
|
38
38
|
im = Image.open(f).convert("RGB")
|
|
39
|
-
for tag, size in (("16x9", (1920, 1080)), ("9x16", (1080, 1920))):
|
|
39
|
+
for tag, size in (("16x9", (1920, 1080)), ("9x16", (1080, 1920)), ("4x5", (1080, 1350))):
|
|
40
40
|
grade(crop(im, *size)).save(out / f"{f.stem}-{tag}.jpg", quality=88)
|
|
41
41
|
manifest["photos"].append(f.stem)
|
|
42
42
|
for f in sorted((src / "screens").glob("*")) if (src / "screens").exists() else []:
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Build (and optionally render) a film made from nothing: narration, pictures, music, sound effects.
|
|
2
|
+
|
|
3
|
+
python3 scripts/make_film.py videos/<name> [--render] [--format 9x16|16x9|4x5]
|
|
4
|
+
|
|
5
|
+
Run from the project folder. videos/<name>/ holds film.json (storyboard + brand), lines.json (the script),
|
|
6
|
+
words.json (word timings from vo.py), the voice files vNN.mp3 and the pictures. Writes the HyperFrames project
|
|
7
|
+
into videos/<name>/film/; with --render, scripts/master.sh renders it, masters the loudness and writes
|
|
8
|
+
output/<name>/<preset>/<name>.mp4 with the .srt, thumbnail and credits.
|
|
9
|
+
|
|
10
|
+
film.json: "preset" (a folder under presets/ with a style.json, e.g. film-cinematic-drama), "format"
|
|
11
|
+
("9x16" default, "16x9", or "4x5" for an Instagram feed post), "beats", "brand", "labels", "cta", "logo", "qr", "music" (a bundled track name;
|
|
12
|
+
without it videos/<name>/music.mp3 is used). The preset is found in ./presets/, $KAIZEN_VIDEO_PRESETS
|
|
13
|
+
(default ~/.kaizen/video-presets/), then the skill's presets/, like make_tiktok.py.
|
|
14
|
+
--format overrides film.json's format for one build and adds -<format> to the output name, so one film can be
|
|
15
|
+
rendered for several shapes (the Video page in kaizen web does this) without one render replacing another.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
26
|
+
WORK = Path.cwd()
|
|
27
|
+
HF = "hyperframes@0.8.176"
|
|
28
|
+
FPS = 30
|
|
29
|
+
HOME = Path(os.environ.get("KAIZEN_VIDEO_HOME", Path.home() / ".kaizen/kaizen-video"))
|
|
30
|
+
SIZES = {"9x16": (1080, 1920, "portrait"), "16x9": (1920, 1080, "landscape"), "4x5": (1080, 1350, "portrait")}
|
|
31
|
+
|
|
32
|
+
vdir = Path(sys.argv[1]).resolve()
|
|
33
|
+
proj = vdir / "film"
|
|
34
|
+
film = json.loads((vdir / "film.json").read_text())
|
|
35
|
+
words = json.loads((vdir / "words.json").read_text())
|
|
36
|
+
forced = sys.argv[sys.argv.index("--format") + 1] if "--format" in sys.argv[:-1] else None
|
|
37
|
+
fmt = forced or film.get("format", "9x16")
|
|
38
|
+
if fmt not in SIZES:
|
|
39
|
+
sys.exit(f"film.json format {fmt!r} must be one of {', '.join(SIZES)}")
|
|
40
|
+
W, H, RES = SIZES[fmt]
|
|
41
|
+
LANG = film.get("language", "id")
|
|
42
|
+
|
|
43
|
+
# 1. Preset: a folder with style.json.
|
|
44
|
+
dirs = [WORK / "presets", Path(os.environ.get("KAIZEN_VIDEO_PRESETS", Path.home() / ".kaizen/video-presets")), SKILL / "presets"]
|
|
45
|
+
want = film["preset"]
|
|
46
|
+
preset = next((d / n for n in (want, "film-" + want) for d in dirs if (d / n / "style.json").exists()), None)
|
|
47
|
+
if not preset:
|
|
48
|
+
names = sorted({p.parent.name for d in dirs if d.exists() for p in d.glob("*/style.json")})
|
|
49
|
+
sys.exit(f"preset {want!r} not found in {', '.join(map(str, dirs))}; available: {', '.join(names)}")
|
|
50
|
+
print(f"preset {preset}")
|
|
51
|
+
style = json.loads((preset / "style.json").read_text())
|
|
52
|
+
brand = film.get("brand", {})
|
|
53
|
+
style["palette"] = {**style["palette"], **brand.get("palette", {})}
|
|
54
|
+
style["fonts"] = {**style["fonts"], **brand.get("fonts", {})}
|
|
55
|
+
pace = style["pace"]
|
|
56
|
+
shutil.rmtree(proj / "assets", ignore_errors=True)
|
|
57
|
+
for d in ("img", "video", "sfx", "fonts"):
|
|
58
|
+
(proj / "assets" / d).mkdir(parents=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def probe(f, entry, kind="format"):
|
|
62
|
+
out = subprocess.check_output(["ffprobe", "-v", "error", "-show_entries", f"{kind}={entry}", "-of", "csv=p=0", str(f)])
|
|
63
|
+
return out.decode().strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(*cmd, cwd=proj):
|
|
67
|
+
subprocess.run(cmd, check=True, cwd=cwd)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# 2. Timeline: every beat is lead + its voice line + tail (+ hold), in whole frames, one after another.
|
|
71
|
+
t, beats = 0, []
|
|
72
|
+
for n, b in enumerate(film["beats"]):
|
|
73
|
+
v = b.get("vo")
|
|
74
|
+
if v and v not in words:
|
|
75
|
+
sys.exit(f"beat {n + 1}: voice {v!r} is not in words.json; run vo.py first")
|
|
76
|
+
frames = round(((pace["lead"] + words[v]["dur"] + pace["tail"]) if v else 0) * FPS + b.get("hold", 0) * FPS)
|
|
77
|
+
if frames:
|
|
78
|
+
beats.append({**b, "n": n, "start": t / FPS, "len": frames / FPS, "dur": words[v]["dur"] if v else 0})
|
|
79
|
+
else:
|
|
80
|
+
print(f"warning: beat {n + 1} ({b['kind']}) has no voice and no hold, so it is skipped")
|
|
81
|
+
t += frames
|
|
82
|
+
total = t / FPS
|
|
83
|
+
reveal = next((b["start"] for b in beats if b["kind"] == "reveal"), total / 2)
|
|
84
|
+
|
|
85
|
+
# 3. Pictures, footage, logo, QR into the project (a photo name may be a stem: intake writes <stem>-9x16.jpg).
|
|
86
|
+
def stage(name, sub):
|
|
87
|
+
if not name:
|
|
88
|
+
return name
|
|
89
|
+
src = vdir / name
|
|
90
|
+
for tag in (fmt, "9x16") if fmt == "4x5" else (fmt,): # a film intaken before 4x5 existed uses its 9x16 crops
|
|
91
|
+
if not src.exists():
|
|
92
|
+
src = vdir / f"{Path(name).stem}-{tag}.jpg"
|
|
93
|
+
if not src.exists():
|
|
94
|
+
sys.exit(f"file {name!r} not found in {vdir}")
|
|
95
|
+
flat = re.sub(r"[\\/]", "_", str(src.relative_to(vdir)))
|
|
96
|
+
shutil.copy(src, proj / "assets" / sub / flat)
|
|
97
|
+
return f"assets/{sub}/{flat}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
for b in beats:
|
|
101
|
+
b["img"], b["video"] = stage(b.get("img"), "img"), stage(b.get("video"), "video")
|
|
102
|
+
if b.get("panels"):
|
|
103
|
+
b["panels"] = [{**p, "img": stage(p["img"], "img")} for p in b["panels"]]
|
|
104
|
+
if b["kind"] == "shot" and b["img"] and not b.get("shotRatio"):
|
|
105
|
+
b["shotRatio"] = round(int(probe(proj / b["img"], "width", "stream")) / int(probe(proj / b["img"], "height", "stream")), 3)
|
|
106
|
+
labels = {"source": "Sumber" if LANG == "id" else "Source", "next": "Langkah berikutnya" if LANG == "id" else "Next step", **film.get("labels", {})}
|
|
107
|
+
|
|
108
|
+
# 4. Captions: subtitle chunks of up to 7 words (also the .srt cues), or kinetic groups of 2-3 words.
|
|
109
|
+
def chunks(ws):
|
|
110
|
+
out, cur = [], []
|
|
111
|
+
for w in ws:
|
|
112
|
+
cur.append(w)
|
|
113
|
+
if len(cur) >= 7 or re.search(r"[.,?!]$", w["w"]):
|
|
114
|
+
out.append(cur)
|
|
115
|
+
cur = []
|
|
116
|
+
return out + ([cur] if cur else [])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def groups(ws):
|
|
120
|
+
out, i = [], 0
|
|
121
|
+
while i < len(ws):
|
|
122
|
+
k = 2 if len("".join(w["w"] for w in ws[i:i + 3])) > 16 else 3
|
|
123
|
+
out.append(ws[i:i + k])
|
|
124
|
+
i += k
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
captions, cues = [], []
|
|
129
|
+
for b in beats:
|
|
130
|
+
if not b.get("vo"):
|
|
131
|
+
continue
|
|
132
|
+
t0 = b["start"] + pace["lead"]
|
|
133
|
+
ws = [{"w": w["w"], "s": round(t0 + w["s"], 3), "e": round(t0 + w["e"], 3)} for w in words[b["vo"]]["words"]]
|
|
134
|
+
cues += [[c[0]["s"], c[-1]["e"] + 0.2, " ".join(w["w"] for w in c)] for c in chunks(ws)]
|
|
135
|
+
if b["kind"] == "cta" or style["captions"] == "none":
|
|
136
|
+
continue
|
|
137
|
+
gs = chunks(ws) if style["captions"] == "subtitle" else groups(ws)
|
|
138
|
+
for i, g in enumerate(gs):
|
|
139
|
+
nxt = gs[i + 1][0]["s"] if i + 1 < len(gs) else min(g[-1]["e"] + 0.4, b["start"] + b["len"])
|
|
140
|
+
captions.append({"s": round(max(b["start"], g[0]["s"] - (0.05 if i == 0 else 0)), 3), "e": round(nxt, 3), "words": g})
|
|
141
|
+
for a, c in zip(cues, cues[1:]):
|
|
142
|
+
a[1] = round(min(a[1], c[0] - 0.02), 3) # no overlapping cues
|
|
143
|
+
proj.mkdir(exist_ok=True)
|
|
144
|
+
(proj / "cues.json").write_text(json.dumps(cues, ensure_ascii=False))
|
|
145
|
+
|
|
146
|
+
film_data = {"fmt": fmt, "W": W, "H": H, "language": LANG, "style": style, "labels": labels, "total": total, "beats": beats, "captions": captions,
|
|
147
|
+
"cta": film.get("cta"), "logo": stage(film.get("logo"), "img"), "logoOnLight": stage(film.get("logoOnLight"), "img"), "qr": stage(film.get("qr"), "img")}
|
|
148
|
+
(proj / "film-data.js").write_text("window.FILM = " + json.dumps(film_data, ensure_ascii=False) + ";\n")
|
|
149
|
+
|
|
150
|
+
# 5. Audio: the voice lines on one track, the music bed with the style's levels, sound effects and room tone.
|
|
151
|
+
def ffmpeg(*args):
|
|
152
|
+
run("ffmpeg", "-v", "error", "-y", *args)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
ins, graph = [], []
|
|
156
|
+
for i, b in enumerate(x for x in beats if x.get("vo")):
|
|
157
|
+
ins += ["-i", str(vdir / f"{b['vo']}.mp3")]
|
|
158
|
+
ms = round((b["start"] + pace["lead"]) * 1000)
|
|
159
|
+
graph.append(f"[{i}:a]aformat=sample_rates=48000:channel_layouts=stereo,atrim=start={words[b['vo']]['offset']},asetpts=PTS-STARTPTS,adelay={ms}|{ms}[a{i}]")
|
|
160
|
+
if not graph:
|
|
161
|
+
sys.exit("no beat has a voice line (vo); a film needs narration")
|
|
162
|
+
mix = "".join(f"[a{i}]" for i in range(len(graph)))
|
|
163
|
+
ffmpeg(*ins, "-filter_complex", ";".join(graph) + f";{mix}amix=inputs={len(graph)}:normalize=0:duration=longest,apad[o]",
|
|
164
|
+
"-map", "[o]", "-t", str(total), "-ar", "48000", "assets/vo.wav")
|
|
165
|
+
|
|
166
|
+
music = style["music"]
|
|
167
|
+
track = vdir / music["track"]
|
|
168
|
+
if film.get("music"):
|
|
169
|
+
track = SKILL / "assets/music" / f"{film['music']}.mp3"
|
|
170
|
+
if not track.exists():
|
|
171
|
+
sys.exit(f"no music: put {music['track']} in {vdir} (scripts/music.sh) or set film.json \"music\" to one of: "
|
|
172
|
+
+ ", ".join(sorted(p.stem for p in (SKILL / "assets/music").glob("*.mp3"))))
|
|
173
|
+
pts = [(0, 0), (1, music["base"]), (max(1, reveal - 0.5), music["base"]), (reveal + 1 / 3, music["lift"]), (reveal + 3, music["base"] + 0.04),
|
|
174
|
+
(total - 2, music["base"] + 0.04), (total, 0)]
|
|
175
|
+
pts = [p for i, p in enumerate(pts) if i == 0 or p[0] > pts[i - 1][0]] # a short film can fold the points back on themselves
|
|
176
|
+
expr = f"{pts[-1][1]}"
|
|
177
|
+
for (t0, a0), (t1, a1) in reversed(list(zip(pts, pts[1:]))):
|
|
178
|
+
expr = f"if(lt(t,{t1}),{a0}+({a1}-{a0})*(t-{t0})/({t1}-{t0}),{expr})"
|
|
179
|
+
ffmpeg("-i", str(track), "-af", f"aloop=loop=-1:size=2e9,atrim=0:{total},loudnorm=I=-16:TP=-1.5,volume='{expr}':eval=frame,aformat=sample_rates=48000:channel_layouts=stereo", "assets/music.wav")
|
|
180
|
+
|
|
181
|
+
LIB = HOME / "sfx"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def sound(name):
|
|
185
|
+
def find():
|
|
186
|
+
return next((f for d in (vdir / "sfx", preset / "assets/sfx", LIB) for e in (".wav", ".mp3") if (f := d / (name + e)).exists()), None)
|
|
187
|
+
if not find() and not (LIB / "whoosh.mp3").exists(): # make the shared library once, synthesized (no ElevenLabs credits spent unless SFX_PROVIDER says so)
|
|
188
|
+
run("python3", str(SKILL / "scripts/sfx.py"), "--provider", os.environ.get("SFX_PROVIDER", "synth"))
|
|
189
|
+
f = find()
|
|
190
|
+
if not f:
|
|
191
|
+
sys.exit(f"sound {name!r} not found in {vdir / 'sfx'}, {preset / 'assets/sfx'} or {LIB}")
|
|
192
|
+
shutil.copy(f, proj / "assets/sfx" / f.name)
|
|
193
|
+
return f.name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
fx = style["sfx"]
|
|
197
|
+
sfx, amb = [], [] # (file, start, volume)
|
|
198
|
+
for i, b in enumerate(beats):
|
|
199
|
+
cut = fx["reveal"] if b["kind"] == "reveal" else fx["question"] if b["kind"] == "question" else fx["cut"] if i else ""
|
|
200
|
+
if cut:
|
|
201
|
+
sfx.append((sound(cut), b["start"], 0.7 if b["kind"] == "reveal" else 0.35))
|
|
202
|
+
sfx += [(sound(s), b["start"] + pace["lead"], 0.45) for s in b.get("sfx", [])]
|
|
203
|
+
if fx["text"] and b.get("title") and i:
|
|
204
|
+
sfx.append((sound(fx["text"]), b["start"] + pace["lead"] + 0.2, 0.25))
|
|
205
|
+
if b.get("ambience"):
|
|
206
|
+
a = sound(b["ambience"])
|
|
207
|
+
ffmpeg("-stream_loop", "-1", "-i", f"assets/sfx/{a}", "-t", str(b["len"]), "-af", f"afade=t=in:d=0.3,afade=t=out:st={max(0, b['len'] - 0.3)}:d=0.3,aformat=sample_rates=48000:channel_layouts=stereo", f"assets/amb-{i}.wav")
|
|
208
|
+
amb.append((f"amb-{i}.wav", b["start"], b["len"]))
|
|
209
|
+
tracks, tags = {}, [] # greedy track numbers from 3, so no two sounds overlap on one track (HyperFrames lint)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def place(tag, start, dur):
|
|
213
|
+
k = next(k for k in range(3, 99) if tracks.get(k, -1) <= start)
|
|
214
|
+
tracks[k] = start + dur
|
|
215
|
+
tags.append(tag.replace("TRACK", str(k)))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
for i, (name, start, vol) in enumerate(sorted(sfx, key=lambda x: x[1])):
|
|
219
|
+
d = round(float(probe(proj / "assets/sfx" / name, "duration")), 2)
|
|
220
|
+
place(f'<audio id="sfx-{i}" src="assets/sfx/{name}" data-start="{start:.2f}" data-duration="{d}" data-track-index="TRACK" data-volume="{vol}"></audio>', start, d)
|
|
221
|
+
for i, (name, start, dur) in enumerate(amb):
|
|
222
|
+
place(f'<audio id="amb-{i}" src="assets/{name}" data-start="{start:.2f}" data-duration="{dur}" data-track-index="TRACK" data-volume="0.18"></audio>', start, dur)
|
|
223
|
+
audio = "\n ".join([f'<audio id="vo" src="assets/vo.wav" data-start="0" data-duration="{total}" data-track-index="1"></audio>',
|
|
224
|
+
f'<audio id="music" src="assets/music.wav" data-start="0" data-duration="{total}" data-track-index="2"></audio>'] + tags)
|
|
225
|
+
|
|
226
|
+
# 6. Fonts (the style's, plus Inter for kinetic captions), the page, carve the music around the voice, check.
|
|
227
|
+
faces = []
|
|
228
|
+
fonts = set(style["fonts"].values()) | ({"Inter"} if style["captions"] == "kinetic" else set())
|
|
229
|
+
for fam in sorted(fonts):
|
|
230
|
+
stem = fam.replace(" ", "")
|
|
231
|
+
for suffix, slant in (("", "normal"), ("-Italic", "italic")):
|
|
232
|
+
src = next((f for d in (preset / "assets/fonts", SKILL / "assets/film-fonts", SKILL / "assets/fonts") if (f := d / f"{stem}{suffix}.ttf").exists()), None)
|
|
233
|
+
if src:
|
|
234
|
+
shutil.copy(src, proj / "assets/fonts" / src.name)
|
|
235
|
+
faces.append(f'@font-face {{ font-family: "{fam}"; src: url("assets/fonts/{src.name}"); font-weight: 100 900; font-style: {slant}; }}')
|
|
236
|
+
elif not suffix:
|
|
237
|
+
sys.exit(f"font {fam!r} not found: add {stem}.ttf to the preset's assets/fonts or the skill's assets/film-fonts")
|
|
238
|
+
page = (SKILL / "film.html").read_text()
|
|
239
|
+
for k, v in {"W": W, "H": H, "RES": RES, "DURATION": total, "LANG": LANG}.items():
|
|
240
|
+
page = page.replace("{{" + k + "}}", str(v))
|
|
241
|
+
page = page.replace("/*FONTS*/", "\n ".join(faces)).replace("<!-- AUDIO -->", audio)
|
|
242
|
+
(proj / "index.html").write_text(page)
|
|
243
|
+
shutil.copy(SKILL / "hyperframes.json", proj / "hyperframes.json")
|
|
244
|
+
(proj / "package.json").write_text(json.dumps({"name": "kaizen-film", "private": True, "type": "module", "devDependencies": {"@hyperframes/core": HF.split("@")[1]}}, indent=2))
|
|
245
|
+
if not (proj / "node_modules/@hyperframes/core").exists():
|
|
246
|
+
run("npm", "install", "--silent")
|
|
247
|
+
run("node", str(SKILL / "tools/carve.mjs"), "--comp", "index.html", "--bed", "music", "--voice", "vo")
|
|
248
|
+
run("npx", "--yes", HF, "check")
|
|
249
|
+
print(f"{len(beats)} beats, {len(captions)} caption groups, {len(sfx)} sfx, {fmt}, {total}s")
|
|
250
|
+
|
|
251
|
+
# 7. Credits and master data, then render + master with --render.
|
|
252
|
+
name = film.get("name", vdir.name) + (f"-{fmt}" if forced else "")
|
|
253
|
+
line = re.compile(rf"^- {re.escape(music['track'] if not film.get('music') else film['music'])}\.mp3: (.+)$", re.M)
|
|
254
|
+
bundled = (SKILL / "assets/CREDITS.txt").read_text() if (SKILL / "assets/CREDITS.txt").exists() else ""
|
|
255
|
+
credits = [f.read_text().strip() for f in [vdir / "CREDITS.txt", *sorted(vdir.glob("CREDITS-*.txt"))] if f.exists()]
|
|
256
|
+
if film.get("music") and (m := line.search(bundled)):
|
|
257
|
+
credits.append("Music: " + m.group(1))
|
|
258
|
+
credits += [f.read_text().strip() for f in (preset / "CREDITS.txt",) if f.exists()]
|
|
259
|
+
credits.append("Voice and sound effects: see the voice provider in film.json (ElevenLabs commercial use requires a paid plan).")
|
|
260
|
+
(proj / "CREDITS.txt").write_text("\n\n".join(credits) + "\n")
|
|
261
|
+
(proj / "meta.json").write_text(json.dumps({"name": name, "folder": film.get("name", vdir.name), "preset": preset.name, "loudness": style["loudness"]}))
|
|
262
|
+
if "--render" in sys.argv:
|
|
263
|
+
run("bash", str(SKILL / "scripts/master.sh"), str(vdir), cwd=WORK)
|