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,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
|
|
3
|
-
# Earlier deliveries move to
|
|
4
|
-
# Usage: scripts/master.sh <film-dir>
|
|
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);
|
|
7
|
+
DIR=$(cd "${1:?film dir}" && pwd); THUMB=${3:-3}
|
|
7
8
|
SKILL=$(cd "$(dirname "$0")/.." && pwd)
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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("
|
|
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}")
|