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.
Files changed (184) hide show
  1. package/README.md +111 -84
  2. package/cli/build-web.ts +4 -2
  3. package/cli/hermes.ts +65 -0
  4. package/cli/install.ts +24 -10
  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/cli/worktree.test.ts +3 -1
  15. package/package.json +1 -1
  16. package/skills/kaizen/config.default.yml +4 -0
  17. package/skills/media/kaizen-book/SKILL.md +107 -0
  18. package/skills/media/kaizen-book/assets/reader.html +440 -0
  19. package/skills/media/kaizen-book/assets/styles.json +45 -0
  20. package/skills/media/kaizen-book/examples/book.json +14 -0
  21. package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
  22. package/skills/media/kaizen-book/examples/manuscript.md +127 -0
  23. package/skills/media/kaizen-book/references/interview.md +85 -0
  24. package/skills/media/kaizen-book/references/proofread.md +47 -0
  25. package/skills/media/kaizen-book/references/writing.md +53 -0
  26. package/skills/media/kaizen-book/scripts/build_book.py +279 -0
  27. package/skills/media/kaizen-book/scripts/check.sh +10 -0
  28. package/skills/media/kaizen-book/scripts/check_book.py +128 -0
  29. package/skills/media/kaizen-book/scripts/deploy.py +58 -0
  30. package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
  31. package/skills/media/kaizen-book/scripts/setup.sh +9 -0
  32. package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
  33. package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
  34. package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
  35. package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
  36. package/skills/media/kaizen-video/SKILL.md +112 -0
  37. package/skills/media/kaizen-video/assets/CREDITS.txt +16 -0
  38. package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
  39. package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
  40. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
  41. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
  42. package/skills/media/kaizen-video/assets/fonts/Fredoka.ttf +0 -0
  43. package/skills/media/kaizen-video/assets/fonts/Montserrat.ttf +0 -0
  44. package/skills/media/kaizen-video/assets/fonts/SpecialElite.ttf +0 -0
  45. package/skills/media/kaizen-video/assets/music/investigations.mp3 +0 -0
  46. package/skills/media/kaizen-video/assets/music/lights.mp3 +0 -0
  47. package/skills/media/kaizen-video/assets/music/sunny.mp3 +0 -0
  48. package/skills/media/kaizen-video/examples/config.json +152 -0
  49. package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
  50. package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
  51. package/skills/media/kaizen-video/film.html +246 -0
  52. package/skills/media/kaizen-video/hyperframes.json +12 -0
  53. package/skills/media/kaizen-video/presets/educational-warning/CREDITS.txt +3 -0
  54. package/skills/media/kaizen-video/presets/educational-warning/README.md +26 -0
  55. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/ending.wav +0 -0
  56. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/reveal.wav +0 -0
  57. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/shutter.wav +0 -0
  58. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/stamp.wav +0 -0
  59. package/skills/media/kaizen-video/presets/educational-warning/index.html +236 -0
  60. package/skills/media/kaizen-video/presets/educational-warning/package.json +10 -0
  61. package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
  62. package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
  63. package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
  64. package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
  65. package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
  66. package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
  67. package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
  68. package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
  69. package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
  70. package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
  71. package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
  72. package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
  73. package/skills/media/kaizen-video/presets/tiktok-cheerful/CREDITS.txt +3 -0
  74. package/skills/media/kaizen-video/presets/tiktok-cheerful/README.md +35 -0
  75. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
  76. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
  77. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
  78. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
  79. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
  80. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
  81. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
  82. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
  83. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
  84. package/skills/media/kaizen-video/presets/tiktok-cheerful/index.html +260 -0
  85. package/skills/media/kaizen-video/presets/tiktok-cheerful/package.json +10 -0
  86. package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
  87. package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
  88. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
  89. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
  90. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
  91. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
  92. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
  93. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
  94. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
  95. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
  96. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
  97. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
  98. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
  99. package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
  100. package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
  101. package/skills/media/kaizen-video/presets/tiktok-mystery/CREDITS.txt +12 -0
  102. package/skills/media/kaizen-video/presets/tiktok-mystery/README.md +51 -0
  103. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
  104. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
  105. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
  106. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
  107. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
  108. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
  109. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
  110. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
  111. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
  112. package/skills/media/kaizen-video/presets/tiktok-mystery/index.html +260 -0
  113. package/skills/media/kaizen-video/presets/tiktok-mystery/package.json +10 -0
  114. package/skills/media/kaizen-video/presets/tiktok-scam-alert/CREDITS.txt +8 -0
  115. package/skills/media/kaizen-video/presets/tiktok-scam-alert/README.md +24 -0
  116. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
  117. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
  118. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
  119. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
  120. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
  121. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
  122. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
  123. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
  124. package/skills/media/kaizen-video/presets/tiktok-scam-alert/index.html +266 -0
  125. package/skills/media/kaizen-video/presets/tiktok-scam-alert/package.json +10 -0
  126. package/skills/media/kaizen-video/references/presets.md +76 -0
  127. package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
  128. package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
  129. package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
  130. package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
  131. package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
  132. package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
  133. package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
  134. package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
  135. package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
  136. package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
  137. package/skills/media/kaizen-video/scripts/check.sh +22 -0
  138. package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
  139. package/skills/media/kaizen-video/scripts/make_film.py +263 -0
  140. package/skills/media/kaizen-video/scripts/make_tiktok.py +213 -0
  141. package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
  142. package/skills/media/kaizen-video/scripts/render_cut.py +88 -0
  143. package/skills/media/kaizen-video/scripts/setup.sh +14 -0
  144. package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
  145. package/skills/media/kaizen-video/scripts/srt.py +9 -0
  146. package/skills/media/kaizen-video/scripts/still.sh +7 -0
  147. package/skills/media/kaizen-video/scripts/transcribe.py +45 -0
  148. package/skills/media/kaizen-video/tools/carve.mjs +560 -0
  149. package/skills/media/kaizen-video/tools/lib/main-module.mjs +13 -0
  150. package/web/board/views.ts +1 -1
  151. package/web/board.html +3 -1
  152. package/web/board.js +2 -2
  153. package/web/notebook.js +1 -1
  154. package/web/notes.html +2 -0
  155. package/web/term.js +38 -36
  156. package/web/term.ts +43 -10
  157. package/web/video.html +298 -0
  158. package/web/video.js +81 -0
  159. package/web/video.ts +610 -0
  160. package/skills/story-video/SKILL.md +0 -108
  161. package/skills/story-video/engine/package.json +0 -12
  162. package/skills/story-video/engine/src/Root.tsx +0 -24
  163. package/skills/story-video/engine/src/engine/Film.tsx +0 -405
  164. package/skills/story-video/engine/src/engine/presets.json +0 -58
  165. package/skills/story-video/engine/src/index.ts +0 -3
  166. package/skills/story-video/engine/tsconfig.json +0 -1
  167. package/skills/story-video/examples/sample-film/README.md +0 -12
  168. package/skills/story-video/examples/sample-film/film.json +0 -16
  169. package/skills/story-video/references/presets.md +0 -21
  170. package/skills/story-video/scripts/check.sh +0 -19
  171. package/skills/story-video/scripts/setup.sh +0 -25
  172. package/skills/story-video/scripts/srt.py +0 -30
  173. package/skills/story-video/scripts/still.sh +0 -10
  174. /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
  175. /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
  176. /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
  177. /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
  178. /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
  179. /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
  180. /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
  181. /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
  182. /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
  183. /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
  184. /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
@@ -0,0 +1,213 @@
1
+ """Build (and optionally render) a TikTok video from a preset + a per-video config.
2
+
3
+ python3 scripts/make_tiktok.py videos/<name> [--render]
4
+
5
+ Run from the project folder. Writes the HyperFrames project into videos/<name>/tiktok/
6
+ and, with --render, overwrites output/<name>/<preset>/<name>.mp4 so revisions of the
7
+ same preset never pile up while different presets stay separate.
8
+
9
+ The preset is found by folder name in ./presets/, then $KAIZEN_VIDEO_PRESETS
10
+ (default ~/.kaizen/video-presets/), then the skill's bundled presets/. A preset file
11
+ missing from its folder falls back to the skill's shared assets/, tools/ and
12
+ hyperframes.json.
13
+ """
14
+ import html
15
+ import os
16
+ import json
17
+ import shutil
18
+ import subprocess
19
+ import re
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ SKILL = Path(__file__).resolve().parent.parent
24
+ WORK = Path.cwd()
25
+ HF = "hyperframes@0.8.176"
26
+ FPS = 30
27
+ SFX_TRACK = {"reveal": 4, "ending": 6, "shutter": 3, "stamp": 4, "ding": 4, "whoosh": 6} # any other sound: track 5
28
+ CARD_DUR = {"title": 1.8, "case": 3.2, "photo": 2.8}
29
+
30
+ vdir = Path(sys.argv[1]).resolve()
31
+ proj = vdir / "tiktok"
32
+ proj.mkdir(exist_ok=True)
33
+ cfg = json.loads((vdir / "config.json").read_text())
34
+ dirs = [WORK / "presets", Path(os.environ.get("KAIZEN_VIDEO_PRESETS", Path.home() / ".kaizen/video-presets")), SKILL / "presets"]
35
+ preset = next((d / cfg["preset"] for d in dirs if (d / cfg["preset"] / "index.html").exists()), None)
36
+ if not preset:
37
+ names = sorted({p.parent.name for d in dirs if d.exists() for p in d.glob("*/index.html")})
38
+ sys.exit(f"preset {cfg['preset']!r} not found in {', '.join(map(str, dirs))}; available: {', '.join(names)}")
39
+ print(f"preset {preset}")
40
+ src_of = lambda p: next(f for f in (preset / p, SKILL / p) if f.exists()) # preset file, else the shared one
41
+ rel = lambda p: (vdir / p).resolve()
42
+
43
+
44
+ def run(*cmd, **kw):
45
+ subprocess.run(cmd, check=True, cwd=proj, **kw)
46
+
47
+
48
+ # 1. Preset files and this video's images into the project.
49
+ for f in ["package.json", "hyperframes.json"]:
50
+ shutil.copy(src_of(f), proj / f)
51
+ for d in ["fonts", "sfx"]:
52
+ shutil.rmtree(proj / "assets" / d, ignore_errors=True)
53
+ for base in (SKILL, preset): # shared assets first, the preset's own on top
54
+ if (base / "assets" / d).exists():
55
+ shutil.copytree(base / "assets" / d, proj / "assets" / d, dirs_exist_ok=True)
56
+ if (vdir / "images").exists():
57
+ shutil.copytree(vdir / "images", proj / "assets/img", dirs_exist_ok=True)
58
+
59
+ # 2. Captions: WhisperX words, remapped onto the cut when a render plan is given.
60
+ words = [w for s in json.loads(rel(cfg["words"]).read_text())["segments"] for w in s["words"] if "start" in w]
61
+ if cfg.get("plan"):
62
+ # Segment length is its time span: phone footage is often VFR, so frames / nominal fps is wrong.
63
+ segs = [(s["start_time"], s["end_time"], s["end_time"] - s["start_time"]) for s in json.loads(rel(cfg["plan"]).read_text())["segments"]]
64
+ else:
65
+ segs = [(0.0, 1e9, None)]
66
+ chunks, seg_starts, offset = [], [], 0.0
67
+ for s0, s1, length in segs:
68
+ seg_starts.append(round(offset, 3))
69
+ end_cut = offset + length if length else words[-1]["end"]
70
+ cur = []
71
+ for w in words:
72
+ # Skip words the cut slices to a few frames (they flash as glitches).
73
+ if not (s0 - 0.05 <= w["start"] < s1) or (w["end"] > s1 and s1 - w["start"] < 0.12):
74
+ continue
75
+ word = w["word"].strip()
76
+ core = word.rstrip(",.?!")
77
+ word = cfg.get("fix", {}).get(core, core) + word[len(core):] # caption spelling corrections
78
+ cur.append({"w": word, "s": round(offset + w["start"] - s0, 3),
79
+ "e": round(min(offset + w["end"] - s0, end_cut), 3)})
80
+ # ponytail: fixed 3-word chunks, break early on punctuation
81
+ if len(cur) == 3 or cur[-1]["w"][-1] in ",.?!":
82
+ chunks.append(cur)
83
+ cur = []
84
+ if cur:
85
+ chunks.append(cur)
86
+ offset = end_cut
87
+ cut_duration = offset
88
+ flat = [w for c in chunks for w in c]
89
+
90
+
91
+ def at(key, after=0):
92
+ try:
93
+ return next(w["s"] for w in flat if w["s"] >= after and w["w"].lower().strip(",.?!").startswith(key))
94
+ except StopIteration:
95
+ sys.exit(f"config error: no word starting with {key!r} after {after}s")
96
+
97
+
98
+ # Cold open: replay one strong line before the intro (TikTok hook). Everything else shifts by its length.
99
+ co = cfg.get("cold_open")
100
+ if co:
101
+ co_a = at(co["from"], co.get("after", 0)) - 0.08
102
+ co_b = next(w["e"] for w in flat if w["s"] >= co_a and w["w"].lower().strip(",.?!").startswith(co["to"])) + 0.25
103
+ else:
104
+ co_a = co_b = 0.0
105
+ L = round(co_b - co_a, 3)
106
+ beats = sorted(({**{k: v for k, v in b_.items() if k not in ("word", "after")}, "t": round(at(b_["word"], b_.get("after", 0)) + L, 3)}
107
+ for b_ in cfg["beats"]), key=lambda x: x["t"])
108
+ shift = lambda w, d: {**w, "s": round(w["s"] + d, 3), "e": round(w["e"] + d, 3)}
109
+ cold = [[shift(w, -co_a) for w in c if co_a <= w["s"] and w["e"] <= co_b] for c in chunks] if co else []
110
+ chunks = [c for c in cold if c] + [[shift(w, L) for w in c] for c in chunks]
111
+ seg_starts = ([0.0] if co else []) + [round(x + L, 3) for x in seg_starts]
112
+ video_duration = round(cut_duration + L, 3)
113
+ duration = round(video_duration + cfg.get("tail", 0), 3) # tail: ending card holds after the footage ends
114
+ for i, c in enumerate(chunks): # hold each chunk until the next one starts
115
+ c[-1].pop("hold", None)
116
+ nxt = chunks[i + 1][0]["s"] if i + 1 < len(chunks) else video_duration
117
+ c[-1]["hold"] = round(max(c[-1]["e"], min(nxt, c[-1]["e"] + 0.6)), 3)
118
+ cards = [(b["t"], b["t"] + b.get("dur", CARD_DUR.get(b["kind"], 0))) for b in beats if b["kind"] in CARD_DUR]
119
+ for (a0, a1), (b0, _) in zip(cards, cards[1:]):
120
+ if b0 < a1:
121
+ print(f"warning: cards overlap at {a0:.2f}s and {b0:.2f}s")
122
+ (proj / "captions.js").write_text(
123
+ f"window.CAPTIONS = {json.dumps(chunks, ensure_ascii=False)};\n"
124
+ f"window.SEG_STARTS = {json.dumps(seg_starts)};\n"
125
+ f"window.BEATS = {json.dumps(beats, ensure_ascii=False)};\n")
126
+
127
+ # 3. Sound effects: shutter on every card, stamp/ding on their beats, reactions from the config.
128
+ sfx = [("shutter", 0.3, 0.7)]
129
+ for b in beats:
130
+ if b["kind"] in ("title", "case", "photo"):
131
+ sfx.append(("shutter", b["t"], 0.7))
132
+ if b["kind"] == "end": # reversed gong swells in, hit lands 1.2 s in on the last word slam
133
+ sfx.append(("ending", max(0, b["t"] + 0.54 - 1.2), 0.9))
134
+ if b.get("stamp"):
135
+ sfx.append(("stamp", b["t"] + 1.12, 0.9))
136
+ if b["kind"] == "item":
137
+ sfx.append(("reveal", b["t"], 0.6))
138
+ for r in cfg.get("reactions", []):
139
+ sfx.append((r["sfx"], max(0, at(r["word"], r.get("after", 0)) + L + r.get("offset", 0)), r.get("volume", 0.5)))
140
+
141
+
142
+ def sfx_dur(n):
143
+ f = proj / "assets/sfx" / f"{n}.wav"
144
+ if not f.exists():
145
+ sys.exit(f"config error: sound {n!r} is not in preset {cfg['preset']!r}; available: "
146
+ + ", ".join(sorted(p.stem for p in (proj / "assets/sfx").glob("*.wav"))))
147
+ # ffprobe, not the wave module: user-added effects are often float or extensible WAVs
148
+ return round(float(subprocess.check_output(["ffprobe", "-v", "error", "-show_entries", "format=duration",
149
+ "-of", "csv=p=0", str(f)])), 2)
150
+
151
+
152
+ tags = "\n".join(f' <audio id="sfx-{i}" src="assets/sfx/{n}.wav" data-start="{t:.2f}" data-duration="{sfx_dur(n)}" '
153
+ f'data-track-index="{SFX_TRACK.get(n, 5)}" data-volume="{v}"></audio>'
154
+ for i, (n, t, v) in enumerate(sorted(sfx, key=lambda x: x[1])))
155
+
156
+ # 4. Source video: cold open + cut, lanczos upscale to 1080x1920 with light sharpening,
157
+ # dialogue cleanup (denoise, de-ess). Cached until the inputs or settings change.
158
+ src, key = rel(cfg["video"]), proj / "source.key"
159
+ vf = "scale=1080:1920:flags=lanczos,unsharp=5:5:0.6:5:5:0,fps=30,format=yuv420p"
160
+ af = "afftdn=nf=-28,deesser=i=0.35,aformat=sample_rates=48000:channel_layouts=stereo"
161
+ k = json.dumps([str(src), src.stat().st_mtime, co_a, co_b, vf, af, "g30"])
162
+ if not (proj / "source.mp4").exists() or not key.exists() or key.read_text() != k:
163
+ if co:
164
+ graph = (f"[0:v]split[v1][v2];[0:a]asplit[a1][a2];"
165
+ f"[v1]trim={co_a}:{co_b},setpts=PTS-STARTPTS[cv];[a1]atrim={co_a}:{co_b},asetpts=PTS-STARTPTS[ca];"
166
+ f"[cv][ca][v2][a2]concat=n=2:v=1:a=1[v0][a0];[v0]{vf}[v];[a0]{af}[a]")
167
+ else:
168
+ graph = f"[0:v]{vf}[v];[0:a]{af}[a]"
169
+ run("ffmpeg", "-v", "error", "-y", "-i", str(src), "-filter_complex", graph, "-map", "[v]", "-map", "[a]",
170
+ "-c:v", "libx264", "-crf", "14", "-preset", "medium", "-g", "30", "-c:a", "aac", "-b:a", "256k", "source.mp4")
171
+ key.write_text(k)
172
+
173
+ # 5. Music bed trimmed to length, then the composition from the template.
174
+ run("ffmpeg", "-v", "error", "-y", "-i", str(src_of(f"assets/music/{cfg['music']}.mp3")), "-af",
175
+ f"aloop=loop=-1:size=2e9,atrim=0:{duration},afade=t=in:d=1,afade=t=out:st={duration - 3.5}:d=3.5,"
176
+ "aformat=sample_rates=48000:channel_layouts=stereo", "assets/music.wav") # fades over the tail too
177
+ h = cfg["hook"]
178
+ page = (preset / "index.html").read_text()
179
+ for k, v in {"DURATION": duration, "VIDEO_DURATION": video_duration, "VIDEO": "source.mp4", "MUSIC_VOLUME": cfg.get("music_volume", 0.32),
180
+ "HOOK_1": html.escape(h["line1"]), "HOOK_2": html.escape(h["line2"]), "HOOK_SUB": html.escape(h["sub"])}.items():
181
+ page = page.replace("{{" + k + "}}", str(v))
182
+ page = page.replace("<!-- SFX:BEGIN -->", "<!-- SFX:BEGIN -->\n" + tags + "\n ")
183
+ (proj / "index.html").write_text(page)
184
+
185
+ # 6. Carve the music around the voice (hyperframes-audio), validate, render.
186
+ if not (proj / "node_modules/@hyperframes/core").exists():
187
+ run("npm", "install", "--silent")
188
+ run("node", str(src_of("tools/carve.mjs")), "--comp", "index.html", "--bed", "music", "--voice", "a-roll")
189
+ run("npx", "--yes", HF, "check")
190
+ print(f"{len(chunks)} caption chunks, {len(beats)} beats, {len(sfx)} sfx, {duration}s")
191
+ if "--render" in sys.argv:
192
+ out = WORK / "output" / cfg["name"] / cfg["preset"] / f"{cfg['name']}.mp4"
193
+ out.parent.mkdir(parents=True, exist_ok=True)
194
+ # Frame extraction writes gigabytes of temp files; /tmp is often a small tmpfs, which fails as VIDEO_SOURCE_UNRENDERABLE.
195
+ tmp = proj / ".render-tmp"
196
+ tmp.mkdir(exist_ok=True)
197
+ run("npx", "--yes", HF, "render", "--crf", str(cfg.get("crf", 23)), "-o", str(out), # CRF 23: ~1/3 the size of the default 16, same resolution
198
+ env={**os.environ, "TMPDIR": str(tmp)})
199
+ shutil.rmtree(tmp, ignore_errors=True)
200
+ shutil.rmtree(proj / "renders", ignore_errors=True)
201
+ # The video's own credits (images, footage), the music bed actually used, then the preset's (fonts, sound effects).
202
+ # A music credit is the line "- <music>.mp3: ..." in the preset's CREDITS.txt, else in the shared assets/CREDITS.txt.
203
+ line = re.compile(rf"^- {re.escape(cfg['music'])}\.mp3: (.+)$", re.M)
204
+ music = next((m.group(1) for f in (preset / "CREDITS.txt", SKILL / "assets/CREDITS.txt")
205
+ if f.exists() and (m := line.search(f.read_text()))), None)
206
+ if not music:
207
+ print(f"warning: no credit for music {cfg['music']!r}; add '- {cfg['music']}.mp3: ...' to the preset's CREDITS.txt")
208
+ credits = [f.read_text().strip() for f in (vdir / "CREDITS.txt",) if f.exists()]
209
+ credits += [f"Music: {music}"] if music else []
210
+ credits += [f.read_text().strip() for f in (preset / "CREDITS.txt",) if f.exists()]
211
+ if credits:
212
+ (out.parent / "CREDITS.txt").write_text("\n\n".join(credits) + "\n")
213
+ print(f"rendered {out}")
@@ -1,26 +1,27 @@
1
1
  #!/usr/bin/env bash
2
- # Render one format of a film, master loudness (two-pass), write .srt + thumbnail + credits.
3
- # Earlier deliveries move to out/previous/ (never overwritten).
4
- # Usage: scripts/master.sh <film-dir> <16x9|9x16> <out-name> [lufs: -14 social | -16 presentation] [thumb-second]
2
+ # Render a built film (make_film.py), master loudness (two-pass), write .srt + thumbnail + credits.
3
+ # Run from the project folder. Earlier deliveries move to output/<name>/<preset>/previous/ (never overwritten).
4
+ # Usage: scripts/master.sh <film-dir> [lufs: default from the style, -14 social | -16 presentation] [thumb-second]
5
+ # Writes output/<film>/<preset>/<name>.mp4; <name> is <film>-<shape> when make_film.py ran with --format.
5
6
  set -euo pipefail
6
- DIR=$(cd "${1:?film dir}" && pwd); FMT=${2:?format}; NAME=${3:?name}; LUFS=${4:--16}; THUMB=${5:-3}
7
+ DIR=$(cd "${1:?film dir}" && pwd); THUMB=${3:-3}
7
8
  SKILL=$(cd "$(dirname "$0")/.." && pwd)
8
- ENGINE=${STORY_VIDEO_ENGINE:-$HOME/.kaizen/story-video/engine}
9
- OUT="$DIR/out"; mkdir -p "$OUT/previous"
9
+ m(){ python3 -c "import json;print(json.load(open('$DIR/film/meta.json')).get('$1',''))"; }
10
+ # folder: the film's name; name: the file's, which make_film.py --format suffixes (gerbang-4x5) so shapes sit side by side.
11
+ NAME=$(m name); FOLDER=$(m folder); PRESET=$(m preset); LUFS=${2:-$(m loudness)}
12
+ OUT="$PWD/output/${FOLDER:-$NAME}/$PRESET"; mkdir -p "$OUT/previous"
10
13
  if [ -f "$OUT/$NAME.mp4" ]; then
11
14
  stamp=$(date +%Y%m%d-%H%M%S)
12
15
  for x in "$OUT/$NAME".mp4 "$OUT/$NAME".srt "$OUT/$NAME"-thumb.jpg; do [ -f "$x" ] && mv "$x" "$OUT/previous/$(basename "${x%.*}")-$stamp.${x##*.}"; done
13
16
  fi
14
- python3 -c "import json,sys; json.dump({'film': json.load(open('$DIR/film.json')), 'words': json.load(open('$DIR/words.json'))}, open('$DIR/.props.json','w'))"
15
- (cd "$ENGINE" && npx remotion render src/index.ts "Film-$FMT" "$OUT/$NAME.raw.mp4" --props="$DIR/.props.json" --public-dir="$DIR" --log=error 2>&1 | grep -v "network requests" || true)
17
+ (cd "$DIR/film" && npx --yes hyperframes@0.8.176 render --crf 20 -o "$OUT/$NAME.raw.mp4" >/dev/null)
16
18
  J=$(ffmpeg -hide_banner -i "$OUT/$NAME.raw.mp4" -af loudnorm=I=$LUFS:TP=-1.5:LRA=11:print_format=json -f null - 2>&1 | sed -n '/{/,/}/p')
17
19
  g(){ echo "$J" | python3 -c "import sys,json;print(json.load(sys.stdin)['$1'])"; }
18
20
  ffmpeg -v error -y -i "$OUT/$NAME.raw.mp4" -c:v copy -c:a aac -b:a 192k \
19
21
  -af "loudnorm=I=$LUFS:TP=-1.5:LRA=11:measured_I=$(g input_i):measured_TP=$(g input_tp):measured_LRA=$(g input_lra):measured_thresh=$(g input_thresh):offset=$(g target_offset):linear=true" \
20
22
  "$OUT/$NAME.mp4"
21
- rm "$OUT/$NAME.raw.mp4" "$DIR/.props.json"
23
+ rm "$OUT/$NAME.raw.mp4"; rm -rf "$DIR/film/renders"
22
24
  python3 "$SKILL/scripts/srt.py" "$DIR" "$OUT/$NAME.srt"
23
25
  ffmpeg -v error -y -ss "$THUMB" -i "$OUT/$NAME.mp4" -frames:v 1 -q:v 2 "$OUT/$NAME-thumb.jpg"
24
- cat "$DIR"/CREDITS-*.txt > "$OUT/$NAME-credits.txt" 2>/dev/null || true
25
- echo "Voice & sound effects: see the voice provider in film.json (ElevenLabs commercial use requires a paid plan)." >> "$OUT/$NAME-credits.txt"
26
+ cp "$DIR/film/CREDITS.txt" "$OUT/CREDITS.txt"
26
27
  ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUT/$NAME.mp4" | xargs -I{} echo "done: $OUT/$NAME.mp4 ({} s, $LUFS LUFS)"
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Render the approved word-timed cut and a Resolve Free import copy."""
3
+
4
+ import bisect
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ from fractions import Fraction
9
+ from pathlib import Path
10
+
11
+
12
+ ROOT = Path(sys.argv[1]).resolve() # videos/<name>
13
+ RAW = ROOT / "work/censored.mp4" # censored copy of raw/, when a video needs one
14
+ RAW = RAW if RAW.exists() else next(f for f in sorted((ROOT / "raw").iterdir())
15
+ if f.suffix.lower() in (".mp4", ".mov", ".m4v", ".mkv", ".webm"))
16
+ CUTS = ROOT / "transcript/cuts.json"
17
+ MP4 = ROOT / "work/cut.mp4"
18
+ RESOLVE = ROOT / "work/cut-resolve.mov"
19
+
20
+
21
+ def probe(*args):
22
+ return json.loads(subprocess.check_output(["ffprobe", "-v", "error", *args, str(RAW)]))
23
+
24
+
25
+ def count_frames(path):
26
+ data = json.loads(subprocess.check_output([
27
+ "ffprobe", "-v", "error", "-count_frames", "-select_streams", "v:0",
28
+ "-show_entries", "stream=nb_read_frames:format=duration", "-of", "json", str(path)
29
+ ]))
30
+ return int(data["streams"][0]["nb_read_frames"]), float(data["format"]["duration"])
31
+
32
+
33
+ def main():
34
+ stream = probe("-select_streams", "v:0", "-show_entries", "stream=r_frame_rate,width,height", "-of", "json")["streams"][0]
35
+ fps = Fraction(stream["r_frame_rate"])
36
+ frames = probe("-select_streams", "v:0", "-show_frames", "-show_entries", "frame=best_effort_timestamp_time", "-of", "json")["frames"]
37
+ pts = [float(frame["best_effort_timestamp_time"]) for frame in frames]
38
+ segments = json.loads(CUTS.read_text())["segments"]
39
+ assert pts == sorted(pts) and len(segments) > 0
40
+
41
+ def nearest(time):
42
+ i = bisect.bisect_left(pts, time)
43
+ return min((j for j in (i - 1, i) if 0 <= j < len(pts)), key=lambda j: abs(pts[j] - time))
44
+
45
+ plan = []
46
+ for segment in segments:
47
+ start, end = nearest(segment["start"]), nearest(segment["end"])
48
+ assert 0 <= start < end < len(pts)
49
+ if plan:
50
+ assert start >= plan[-1]["end_frame"]
51
+ plan.append({"start_frame": start, "end_frame": end, "start_time": pts[start],
52
+ "end_time": pts[end], "frames": end - start, "text": segment["text"]})
53
+ (ROOT / "transcript/render-plan.json").write_text(json.dumps({
54
+ "fps": str(fps), "width": stream["width"], "height": stream["height"],
55
+ "segments": plan
56
+ }, indent=2) + "\n")
57
+
58
+ MP4.parent.mkdir(exist_ok=True)
59
+ n = len(plan)
60
+ filters = [f"[0:v]split={n}" + "".join(f"[v{i}]" for i in range(n)),
61
+ f"[0:a]asplit={n}" + "".join(f"[a{i}]" for i in range(n))]
62
+ for i, part in enumerate(plan):
63
+ filters += [f"[v{i}]trim=start_frame={part['start_frame']}:end_frame={part['end_frame']},setpts=PTS-STARTPTS[v{i}o]",
64
+ f"[a{i}]atrim=start={part['start_time']:.6f}:end={part['end_time']:.6f},asetpts=PTS-STARTPTS[a{i}o]"]
65
+ filters.append("".join(f"[v{i}o][a{i}o]" for i in range(n)) + f"concat=n={n}:v=1:a=1[vout][aout]")
66
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-stats", "-i", str(RAW),
67
+ "-filter_complex", ";".join(filters), "-map", "[vout]", "-map", "[aout]",
68
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
69
+ "-c:a", "aac", "-b:a", "192k", "-fps_mode", "passthrough",
70
+ "-movflags", "+faststart", "-y", str(MP4)], check=True)
71
+
72
+ expected_frames = sum(part["frames"] for part in plan)
73
+ expected_duration = sum(part["end_time"] - part["start_time"] for part in plan)
74
+ actual_frames, actual_duration = count_frames(MP4)
75
+ assert actual_frames == expected_frames, (actual_frames, expected_frames)
76
+ assert abs(actual_duration - expected_duration) < 2 / float(fps), (actual_duration, expected_duration)
77
+
78
+ subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-stats", "-i", str(MP4),
79
+ "-c:v", "dnxhd", "-profile:v", "dnxhr_lb", "-pix_fmt", "yuv422p",
80
+ "-c:a", "pcm_s16le", "-fps_mode", "passthrough", "-y", str(RESOLVE)], check=True)
81
+ resolve_frames, resolve_duration = count_frames(RESOLVE)
82
+ assert resolve_frames == expected_frames, (resolve_frames, expected_frames)
83
+ assert abs(resolve_duration - expected_duration) < 2 / float(fps), (resolve_duration, expected_duration)
84
+ print(f"{n} cuts; {expected_frames} frames at nominal {fps} fps; MP4 {actual_duration:.3f}s; Resolve MOV {resolve_duration:.3f}s")
85
+
86
+
87
+ if __name__ == "__main__":
88
+ main()
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env bash
2
+ # Install WhisperX once per machine, shared by every project.
3
+ # Usage: scripts/setup.sh -> ~/.kaizen/kaizen-video/{whisperx-venv,huggingface} (override KAIZEN_VIDEO_HOME)
4
+ set -euo pipefail
5
+ "$(dirname "$0")/check.sh" >/dev/null || { "$(dirname "$0")/check.sh"; exit 1; }
6
+ HOME_DIR=${KAIZEN_VIDEO_HOME:-$HOME/.kaizen/kaizen-video}
7
+ VENV="$HOME_DIR/whisperx-venv"
8
+ if ! "$VENV/bin/python" -c "import whisperx" 2>/dev/null; then
9
+ mkdir -p "$HOME_DIR"
10
+ uv venv -q -p 3.11 "$VENV"
11
+ uv pip install -q -p "$VENV/bin/python" "whisperx==3.8.6"
12
+ fi
13
+ mkdir -p "$HOME_DIR/huggingface"
14
+ echo "whisperx: HF_HOME=$HOME_DIR/huggingface $VENV/bin/python <skill>/scripts/transcribe.py videos/<name>"
@@ -1,8 +1,8 @@
1
- """Build the sound-effect / room-tone library into the shared engine (~/.kaizen/story-video/engine/sfx/).
1
+ """Build the sound-effect / room-tone library on this machine (~/.kaizen/kaizen-video/sfx/; override KAIZEN_VIDEO_HOME).
2
2
  Usage: python3 scripts/sfx.py [--provider synth|elevenlabs] [--force]
3
3
  synth (default without ELEVENLABS_API_KEY) — synthesized locally with ffmpeg; free, no licence limits.
4
4
  elevenlabs (default with a key) — generated sound effects; commercial use needs a paid ElevenLabs plan.
5
- Only missing files are made unless --force. Names are what presets.json and beats (sfx/ambience) use.
5
+ Only missing files are made unless --force. Names are what a film preset's style.json and beats (sfx/ambience) use.
6
6
  No sound files ship with the skill: every machine makes its own."""
7
7
  import json, os, subprocess, sys, urllib.request
8
8
 
@@ -43,7 +43,7 @@ SYNTH = {
43
43
  args = sys.argv[1:]
44
44
  force = "--force" in args
45
45
  provider = args[args.index("--provider") + 1] if "--provider" in args else ("elevenlabs" if os.environ.get("ELEVENLABS_API_KEY") else "synth")
46
- SFX = os.path.expanduser(os.environ.get("STORY_VIDEO_ENGINE", "~/.kaizen/story-video/engine")) + "/sfx"
46
+ SFX = os.path.expanduser(os.environ.get("KAIZEN_VIDEO_HOME", "~/.kaizen/kaizen-video")) + "/sfx"
47
47
  os.makedirs(SFX, exist_ok=True)
48
48
  H = {"xi-api-key": os.environ.get("ELEVENLABS_API_KEY", ""), "Content-Type": "application/json"}
49
49
  print("sfx provider:", provider)
@@ -0,0 +1,9 @@
1
+ """Write an .srt from the cues make_film.py computed (<film-dir>/film/cues.json: [[start, end, text], ...]).
2
+ Usage: python3 scripts/srt.py <film-dir> <out.srt>"""
3
+ import json, sys
4
+ cues = json.load(open(f"{sys.argv[1]}/film/cues.json"))
5
+ def ts(sec):
6
+ ms = int(round(sec * 1000)); h, ms = divmod(ms, 3600000); m, ms = divmod(ms, 60000); s, ms = divmod(ms, 1000)
7
+ return f"{h:02}:{m:02}:{s:02},{ms:03}"
8
+ open(sys.argv[2], "w").write("\n".join(f"{i}\n{ts(s)} --> {ts(e)}\n{t}\n" for i, (s, e, t) in enumerate(cues, 1)))
9
+ print("srt", len(cues), "cues")
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ # Quick check frames of a built film: scripts/still.sh <film-dir> <second> [<second>...]
3
+ # -> <film-dir>/film/check/at-<second>s.png (run make_film.py first; delete the folder after looking)
4
+ set -euo pipefail
5
+ DIR=$(cd "$1" && pwd); shift
6
+ IFS=,; (cd "$DIR/film" && npx --yes hyperframes@0.8.176 snapshot --at "$*" --no-end -o check >/dev/null)
7
+ ls "$DIR"/film/check/*.png
@@ -0,0 +1,45 @@
1
+ """Transcribe the footage in videos/<name>/raw/ with WhisperX: <whisperx python> transcribe.py videos/<name>
2
+
3
+ Uses CUDA (float16) when torch sees a GPU, otherwise CPU (int8); KAIZEN_VIDEO_DEVICE=cpu|cuda overrides.
4
+ """
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ import torch
12
+ import whisperx
13
+
14
+ VIDEO = (".mp4", ".mov", ".m4v", ".mkv", ".webm")
15
+
16
+
17
+ root = Path(sys.argv[1]).resolve()
18
+ audio_path = root / "transcript/audio.wav"
19
+ output_path = root / "transcript/words.json"
20
+
21
+ if output_path.exists():
22
+ print(f"Using existing {output_path}")
23
+ raise SystemExit(0)
24
+
25
+ if not audio_path.exists():
26
+ audio_path.parent.mkdir(exist_ok=True)
27
+ raw = next(f for f in sorted((root / "raw").iterdir()) if f.suffix.lower() in VIDEO)
28
+ subprocess.run(["ffmpeg", "-v", "error", "-i", str(raw), "-vn", "-ac", "1", "-ar", "16000", str(audio_path)], check=True)
29
+
30
+ audio = whisperx.load_audio(str(audio_path))
31
+ device = os.environ.get("KAIZEN_VIDEO_DEVICE") or ("cuda" if torch.cuda.is_available() else "cpu")
32
+ print(f"device {device}, compute {'float16' if device == 'cuda' else 'int8'}")
33
+ model = whisperx.load_model("large-v3", device, compute_type="float16" if device == "cuda" else "int8")
34
+ result = model.transcribe(audio, batch_size=8)
35
+ del model
36
+
37
+ print(f"language {result['language']}")
38
+ align_model, metadata = whisperx.load_align_model(language_code=result["language"], device=device)
39
+ result = whisperx.align(result["segments"], align_model, metadata, audio, device)
40
+ assert result["word_segments"], "Alignment returned no words"
41
+
42
+ temporary_path = output_path.with_suffix(".json.tmp")
43
+ temporary_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
44
+ temporary_path.replace(output_path)
45
+ print(f"Saved {len(result['word_segments'])} words to {output_path}")