kaizen-agent 1.19.4 → 1.21.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 (41) hide show
  1. package/README.md +11 -0
  2. package/cli/board.test.ts +6 -2
  3. package/cli/install.ts +6 -3
  4. package/cli/state.test.ts +18 -1
  5. package/cli/state.ts +24 -1
  6. package/cli/web.ts +5 -1
  7. package/package.json +1 -1
  8. package/skills/story-deck/SKILL.md +63 -0
  9. package/skills/story-deck/examples/build-example.js +29 -0
  10. package/skills/story-deck/references/rules.md +62 -0
  11. package/skills/story-deck/references/story-arc.md +35 -0
  12. package/skills/story-deck/scripts/check.sh +12 -0
  13. package/skills/story-deck/scripts/install_fonts.py +42 -0
  14. package/skills/story-deck/scripts/kit.js +174 -0
  15. package/skills/story-deck/scripts/prep_images.py +70 -0
  16. package/skills/story-deck/scripts/qa.sh +48 -0
  17. package/skills/story-video/SKILL.md +108 -0
  18. package/skills/story-video/engine/package.json +12 -0
  19. package/skills/story-video/engine/src/Root.tsx +24 -0
  20. package/skills/story-video/engine/src/engine/Film.tsx +405 -0
  21. package/skills/story-video/engine/src/engine/presets.json +58 -0
  22. package/skills/story-video/engine/src/index.ts +3 -0
  23. package/skills/story-video/engine/tsconfig.json +1 -0
  24. package/skills/story-video/examples/sample-film/README.md +12 -0
  25. package/skills/story-video/examples/sample-film/film.json +16 -0
  26. package/skills/story-video/examples/sample-film/lines.json +7 -0
  27. package/skills/story-video/references/licensing.md +14 -0
  28. package/skills/story-video/references/presets.md +21 -0
  29. package/skills/story-video/references/production.md +29 -0
  30. package/skills/story-video/references/revisions.md +25 -0
  31. package/skills/story-video/references/script.md +30 -0
  32. package/skills/story-video/scripts/check.sh +19 -0
  33. package/skills/story-video/scripts/intake.py +73 -0
  34. package/skills/story-video/scripts/master.sh +26 -0
  35. package/skills/story-video/scripts/music.sh +10 -0
  36. package/skills/story-video/scripts/setup.sh +25 -0
  37. package/skills/story-video/scripts/sfx.py +64 -0
  38. package/skills/story-video/scripts/srt.py +30 -0
  39. package/skills/story-video/scripts/still.sh +10 -0
  40. package/skills/story-video/scripts/vo.py +99 -0
  41. package/web/board.html +2 -1
@@ -0,0 +1,30 @@
1
+ # Writing the narration
2
+
3
+ The user's text (deck, brochure, feature list, brief) is **source material, not the script**.
4
+ Keep its facts and its promise; rewrite everything else for the ear and for the film's theme.
5
+
6
+ ## A narrator tells, a slide lists
7
+ Slide text is fragments ("Tagihan SPP dicatat tangan."). Narration is a person talking to a person:
8
+ full sentences that lead into each other. Read the whole script aloud in one go — if it sounds like
9
+ someone reading bullet points, rewrite it.
10
+
11
+ | slide-style (don't) | narration (do) |
12
+ |---|---|
13
+ | "Pukul 06.45. Seorang ibu melepas tangan anaknya." | "Seperti ibu ini. Pukul enam empat puluh lima, ia melepas tangan anaknya di depan gerbang, melambaikan tangan, lalu berbalik pulang." |
14
+ | "Tagihan dicatat tangan. Nilai diketik ulang." | "Di ruang tata usaha, tagihan masih ditulis tangan… Sore harinya, para guru mengetik ulang nilai ke Excel." |
15
+
16
+ ## Rules
17
+ 1. **One throughline.** Pick one character or situation and return to it ("ibu tadi", "surat izin yang ia kirim pagi tadi"). Callbacks make separate scenes one story.
18
+ 2. **Connective tissue.** Open lines with links that carry time and logic: *Dan, Padahal, Sementara itu, Sore harinya, Malam itu, Lalu, Di sini, Bahkan* (en: *And, Meanwhile, That evening, But, So*).
19
+ 3. **Vary the rhythm.** Mostly flowing sentences; one short line where it lands ("Lalu… bagaimana jika gerbang itu tetap terbuka?"). Fragments only as deliberate emphasis, at most once or twice per film.
20
+ 4. **Narration ≠ on-screen text.** The screen carries the short headline; the voice carries the story around it. Never read the slide.
21
+ 5. **Numbers as story, not data dumps.** "Di balik gerbang itu ada lima puluh tiga juta anak…" rather than "Lima puluh tiga juta siswa." Keep the source on screen.
22
+ 6. **Match the preset:** drama = documentary storyteller, unhurried; booth = warm presenter, clear; fun = friendly, a smile in the voice, talks to "kamu/Anda"; viral = one sharp hook sentence, then quick, direct.
23
+ 7. **Write for the voice:** numbers and acronyms spelled as spoken; avoid words the voice mis-stresses; one emotion tag per line at most, at the start. Commas and "…" make natural pauses.
24
+ 8. **Product only after the turn**, introduced like a character ("Inilah <Produk> — …" / "Meet <Product> — …"), then what changes for each person in the story, ending back on the opening image when possible.
25
+ 9. **Only claims the source supports** — rewording must not upgrade a feature.
26
+
27
+ ## Check before voicing
28
+ Read it aloud start to finish (or `edge-tts` a quick draft): does each line follow from the last?
29
+ Could a listener retell the story after one hearing? Show the full script to the user for approval —
30
+ voice credits are spent only on an approved script.
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env bash
2
+ # Requirements for story-video. Prints what is missing and how to install it; exit 1 if anything required is missing.
3
+ miss=0
4
+ need(){ command -v "$1" >/dev/null 2>&1 && echo "ok $1" || { echo "MISSING $1 — $2"; miss=1; }; }
5
+ opt(){ command -v "$1" >/dev/null 2>&1 && echo "ok $1" || echo "opt $1 — $2"; }
6
+ need node "Node.js 18+ (https://nodejs.org or your package manager)"
7
+ need npm "comes with Node.js"
8
+ need python3 "Python 3.9+"
9
+ need ffmpeg "ffmpeg (brew install ffmpeg / apt install ffmpeg / pacman -S ffmpeg / winget install ffmpeg)"
10
+ need ffprobe "comes with ffmpeg"
11
+ opt soffice "LibreOffice — only for importing .pptx decks"
12
+ opt pdftoppm "poppler-utils — only for importing decks"
13
+ opt markitdown "pip install 'markitdown[pptx]' — only for reading .pptx text"
14
+ python3 -c "import PIL" 2>/dev/null && echo "ok Pillow" || { echo "MISSING Pillow — pip install pillow"; miss=1; }
15
+ python3 -c "import edge_tts" 2>/dev/null && echo "ok edge-tts (free voices)" || echo "opt edge-tts — pip install edge-tts (free voice when there is no ElevenLabs key)"
16
+ python3 -c "import qrcode" 2>/dev/null && echo "ok qrcode" || echo "opt qrcode — pip install 'qrcode[pil]' (QR on the CTA)"
17
+ [ -n "${ELEVENLABS_API_KEY:-}" ] && echo "ok ELEVENLABS_API_KEY set" || echo "info no ELEVENLABS_API_KEY — free voice (edge-tts) and synthesized sound effects will be used"
18
+ echo "note first setup downloads the Remotion engine (~750 MB) into ~/.kaizen/story-video/engine"
19
+ exit $miss
@@ -0,0 +1,73 @@
1
+ """Turn whatever the user brought into film-ready assets.
2
+ Usage: python3 scripts/intake.py <film-dir> [input_dir] (default input_dir: <film-dir>/inputs)
3
+ <input_dir> may contain, in any mix:
4
+ photos/ story photos -> <name>-16x9.jpg + <name>-9x16.jpg (cropped, light grade)
5
+ screens/ app/web screenshots -> copied untouched; ratio recorded (use kind "shot")
6
+ footage/ video clips (.mp4/.mov) -> <name>.mp4 (H.264, no audio, 1080p max)
7
+ logo*.png logo(s) -> copied; dominant colours suggested for brand.palette
8
+ *.pptx/*.pdf deck -> slide images in <film-dir>/intake/slides/, text in intake/deck.md
9
+ brief.md the user's story/prompt -> copied to <film-dir>/intake/ (read it when writing the treatment)
10
+ Writes <film-dir>/intake.json (asset list) and prints a summary."""
11
+ import json, os, shutil, subprocess, sys
12
+ from pathlib import Path
13
+ from PIL import Image, ImageEnhance
14
+
15
+ out = Path(sys.argv[1]); src = Path(sys.argv[2]) if len(sys.argv) > 2 else out / "inputs"
16
+ out.mkdir(parents=True, exist_ok=True)
17
+ work = out / "intake"; work.mkdir(parents=True, exist_ok=True)
18
+ manifest = {"photos": [], "screens": [], "footage": [], "logos": [], "slides": [], "palette_suggestion": []}
19
+ IMG = {".jpg", ".jpeg", ".png", ".webp"}
20
+
21
+
22
+ def crop(im, w, h, fy=0.5):
23
+ r = w / h
24
+ if im.width / im.height > r:
25
+ nw = int(im.height * r); x = (im.width - nw) // 2; im = im.crop((x, 0, x + nw, im.height))
26
+ else:
27
+ nh = int(im.width / r); y = int((im.height - nh) * fy); im = im.crop((0, y, im.width, y + nh))
28
+ return im.resize((w, h), Image.LANCZOS)
29
+
30
+
31
+ def grade(im):
32
+ im = ImageEnhance.Color(im).enhance(0.85)
33
+ return Image.blend(im, Image.new("RGB", im.size, (60, 40, 20)), 0.06)
34
+
35
+
36
+ for f in sorted((src / "photos").glob("*")) if (src / "photos").exists() else []:
37
+ if f.suffix.lower() not in IMG: continue
38
+ im = Image.open(f).convert("RGB")
39
+ for tag, size in (("16x9", (1920, 1080)), ("9x16", (1080, 1920))):
40
+ grade(crop(im, *size)).save(out / f"{f.stem}-{tag}.jpg", quality=88)
41
+ manifest["photos"].append(f.stem)
42
+ for f in sorted((src / "screens").glob("*")) if (src / "screens").exists() else []:
43
+ if f.suffix.lower() not in IMG: continue
44
+ im = Image.open(f); shutil.copy(f, out / f.name)
45
+ manifest["screens"].append({"file": f.name, "ratio": round(im.width / im.height, 3)})
46
+ for f in sorted((src / "footage").glob("*")) if (src / "footage").exists() else []:
47
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(f), "-an", "-vf", "scale='min(1920,iw)':-2", "-c:v", "libx264", "-crf", "20", str(out / (f.stem + ".mp4"))], check=True)
48
+ manifest["footage"].append(f.stem + ".mp4")
49
+ for f in sorted(src.glob("logo*")):
50
+ shutil.copy(f, out / f.name); manifest["logos"].append(f.name)
51
+ im = Image.open(f).convert("RGBA"); im.thumbnail((200, 200))
52
+ px = [p[:3] for p in (im.get_flattened_data() if hasattr(im, "get_flattened_data") else im.getdata()) if p[3] > 200 and not (max(p[:3]) > 235 and min(p[:3]) > 235) and max(p[:3]) > 30]
53
+ if px:
54
+ q = Image.new("RGB", (len(px), 1)); q.putdata(px)
55
+ cols = q.quantize(colors=4).convert("RGB").getcolors(4) or []
56
+ manifest["palette_suggestion"] += ["#%02X%02X%02X" % c for _, c in sorted(cols, reverse=True)]
57
+ for f in list(src.glob("*.pptx")) + list(src.glob("*.pdf")):
58
+ pdf = f
59
+ if f.suffix == ".pptx":
60
+ subprocess.run(["soffice", "--headless", "--convert-to", "pdf", "--outdir", str(work), str(f)], check=True, capture_output=True)
61
+ pdf = work / (f.stem + ".pdf")
62
+ subprocess.run(f"markitdown '{f}' > '{work}/deck.md'", shell=True)
63
+ else:
64
+ subprocess.run(["pdftotext", "-layout", str(f), str(work / "deck.md")])
65
+ (work / "slides").mkdir(exist_ok=True)
66
+ subprocess.run(["pdftoppm", "-jpeg", "-r", "80", str(pdf), str(work / "slides" / "s")], check=True)
67
+ manifest["slides"] = sorted(p.name for p in (work / "slides").glob("*.jpg"))
68
+ if (src / "brief.md").exists():
69
+ shutil.copy(src / "brief.md", work / "brief.md")
70
+ json.dump(manifest, open(out / "intake.json", "w"), indent=1)
71
+ print(json.dumps({k: (len(v) if isinstance(v, list) else v) for k, v in manifest.items()}))
72
+ print("palette suggestion:", manifest["palette_suggestion"][:4])
73
+ print("read:", work)
@@ -0,0 +1,26 @@
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]
5
+ set -euo pipefail
6
+ DIR=$(cd "${1:?film dir}" && pwd); FMT=${2:?format}; NAME=${3:?name}; LUFS=${4:--16}; THUMB=${5:-3}
7
+ SKILL=$(cd "$(dirname "$0")/.." && pwd)
8
+ ENGINE=${STORY_VIDEO_ENGINE:-$HOME/.kaizen/story-video/engine}
9
+ OUT="$DIR/out"; mkdir -p "$OUT/previous"
10
+ if [ -f "$OUT/$NAME.mp4" ]; then
11
+ stamp=$(date +%Y%m%d-%H%M%S)
12
+ 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
+ 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)
16
+ 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
+ g(){ echo "$J" | python3 -c "import sys,json;print(json.load(sys.stdin)['$1'])"; }
18
+ ffmpeg -v error -y -i "$OUT/$NAME.raw.mp4" -c:v copy -c:a aac -b:a 192k \
19
+ -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
+ "$OUT/$NAME.mp4"
21
+ rm "$OUT/$NAME.raw.mp4" "$DIR/.props.json"
22
+ python3 "$SKILL/scripts/srt.py" "$DIR" "$OUT/$NAME.srt"
23
+ 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
+ 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,10 @@
1
+ #!/usr/bin/env bash
2
+ # Fetch a Kevin MacLeod track (incompetech.com, CC BY 4.0 — credit required) into <film-dir>/music.mp3,
3
+ # loudness-normalised and cut to 3 minutes. Usage: scripts/music.sh <film-dir> "Track Name"
4
+ set -euo pipefail
5
+ f=$1; name=$2
6
+ url="https://incompetech.com/music/royalty-free/mp3-royaltyfree/$(python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.argv[1]))' "$name").mp3"
7
+ curl -sfL -o /tmp/sv-music.mp3 "$url"
8
+ ffmpeg -v error -y -t 180 -i /tmp/sv-music.mp3 -af loudnorm=I=-16:TP=-1.5:LRA=11 -ar 44100 "$f/music.mp3"
9
+ echo "Music: $name — Kevin MacLeod (incompetech.com), CC BY 4.0" > "$f/CREDITS-music.txt"
10
+ rm -f /tmp/sv-music.mp3; echo "music ok: $name"
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Prepare a video workspace inside the project's .kaizen and the shared engine (installed once).
3
+ # Usage: scripts/setup.sh <slug>
4
+ # -> ~/.kaizen/videos/<slug>/{inputs,out,intake} (all film data and deliverables; override STORY_VIDEO_HOME)
5
+ # -> ~/.kaizen/story-video/engine (Remotion engine + node_modules, shared by all projects)
6
+ set -euo pipefail
7
+ "$(dirname "$0")/check.sh" >/dev/null || { "$(dirname "$0")/check.sh"; exit 1; }
8
+ SLUG=${1:?usage: setup.sh <slug>}
9
+ SKILL=$(cd "$(dirname "$0")/.." && pwd)
10
+ ENGINE=${STORY_VIDEO_ENGINE:-$HOME/.kaizen/story-video/engine}
11
+ if [ ! -d "$ENGINE/node_modules/remotion" ]; then
12
+ mkdir -p "$ENGINE"
13
+ cp -r "$SKILL/engine/." "$ENGINE/"
14
+ (cd "$ENGINE" && npm install --silent --no-audit --no-fund)
15
+ fi
16
+ # keep engine code current with the skill (node_modules untouched)
17
+ cp -r "$SKILL/engine/src" "$SKILL/engine/package.json" "$SKILL/engine/tsconfig.json" "$ENGINE/"
18
+ # sound library: made on this machine (never shipped) — ElevenLabs with a key, otherwise synthesized
19
+ [ "$(ls "$ENGINE/sfx" 2>/dev/null | wc -l)" -ge 14 ] || STORY_VIDEO_ENGINE="$ENGINE" python3 "$SKILL/scripts/sfx.py" ${SFX_PROVIDER:+--provider "$SFX_PROVIDER"}
20
+ DIR="${STORY_VIDEO_HOME:-$HOME/.kaizen/videos}/$SLUG"
21
+ mkdir -p "$DIR/inputs" "$DIR/out/previous" "$DIR/intake"
22
+ [ -e "$DIR/sfx" ] || ln -s "$ENGINE/sfx" "$DIR/sfx"
23
+ [ -f "$DIR/REVISIONS.md" ] || printf '# Revisions — %s\n\n' "$SLUG" > "$DIR/REVISIONS.md"
24
+ echo "engine: $ENGINE"
25
+ echo "film: $DIR (put the user's material in $DIR/inputs)"
@@ -0,0 +1,64 @@
1
+ """Build the sound-effect / room-tone library into the shared engine (~/.kaizen/story-video/engine/sfx/).
2
+ Usage: python3 scripts/sfx.py [--provider synth|elevenlabs] [--force]
3
+ synth (default without ELEVENLABS_API_KEY) — synthesized locally with ffmpeg; free, no licence limits.
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.
6
+ No sound files ship with the skill: every machine makes its own."""
7
+ import json, os, subprocess, sys, urllib.request
8
+
9
+ LIB = {
10
+ "whoosh": ("soft airy cinematic whoosh transition, short, clean", 1.2),
11
+ "hit": ("deep warm cinematic impact boom with soft tail, for a logo reveal", 2.5),
12
+ "riser": ("gentle airy riser building tension, soft, ends cleanly", 2.5),
13
+ "tick": ("very soft subtle UI tick, minimal click", 0.5),
14
+ "pop": ("playful bubbly pop sound, bright, short", 0.5),
15
+ "chime": ("bright happy chime sparkle for a reveal", 1.8),
16
+ "ping": ("single smartphone chat message notification ping", 0.8),
17
+ "pings": ("many chat message notification pings stacking up quickly, overwhelming", 3.0),
18
+ "bell": ("school bell ringing in the distance, Indonesian school morning", 3.0),
19
+ "keyboard": ("typing on a laptop keyboard in a quiet office", 4.0),
20
+ "amb-morning": ("quiet morning outdoor ambience with birds and distant children near a school gate", 10.0),
21
+ "amb-classroom": ("soft classroom room tone with murmuring students and pencil sounds", 10.0),
22
+ "amb-office": ("quiet office room tone, air conditioner hum, occasional paper rustle", 10.0),
23
+ "amb-night": ("quiet night ambience indoors at home, crickets outside", 10.0),
24
+ }
25
+ # ffmpeg recipes for the free synthesized library (lavfi sources + filters)
26
+ SYNTH = {
27
+ "whoosh": "anoisesrc=d=1.2:c=pink:a=0.6,highpass=f=300,lowpass=f=5000,afade=t=in:d=0.5,afade=t=out:st=0.6:d=0.6",
28
+ "hit": "sine=f=55:d=2.5,volume=1.5,afade=t=out:st=0.05:d=2.4,lowpass=f=200",
29
+ "riser": "anoisesrc=d=2.5:c=pink:a=0.5,highpass=f=200,afade=t=in:d=2.3,afade=t=out:st=2.35:d=0.15",
30
+ "tick": "sine=f=1800:d=0.05,afade=t=out:d=0.05,volume=0.6",
31
+ "pop": "sine=f=620:d=0.12,afade=t=out:d=0.12,volume=0.9",
32
+ "chime": "sine=f=1318:d=1.8,afade=t=out:d=1.8,volume=0.5",
33
+ "ping": "sine=f=1046:d=0.4,afade=t=out:d=0.4,volume=0.7",
34
+ "pings": "sine=f=1046:d=0.18,afade=t=out:d=0.18,apad=pad_dur=0.12,aloop=loop=9:size=13230,volume=0.6",
35
+ "bell": "sine=f=880:d=3,afade=t=out:d=3,volume=0.5",
36
+ "keyboard": "anoisesrc=d=4:c=white:a=0.3,highpass=f=2000,volume=0.4,apulsator=hz=9:amount=1",
37
+ "amb-morning": "anoisesrc=d=10:c=brown:a=0.15,lowpass=f=800,afade=t=in:d=1,afade=t=out:st=9:d=1",
38
+ "amb-classroom": "anoisesrc=d=10:c=pink:a=0.08,lowpass=f=1200,afade=t=in:d=1,afade=t=out:st=9:d=1",
39
+ "amb-office": "anoisesrc=d=10:c=brown:a=0.12,lowpass=f=400,afade=t=in:d=1,afade=t=out:st=9:d=1",
40
+ "amb-night": "anoisesrc=d=10:c=brown:a=0.08,lowpass=f=300,afade=t=in:d=1,afade=t=out:st=9:d=1",
41
+ }
42
+
43
+ args = sys.argv[1:]
44
+ force = "--force" in args
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"
47
+ os.makedirs(SFX, exist_ok=True)
48
+ H = {"xi-api-key": os.environ.get("ELEVENLABS_API_KEY", ""), "Content-Type": "application/json"}
49
+ print("sfx provider:", provider)
50
+ for name, (prompt, sec) in LIB.items():
51
+ path = f"{SFX}/{name}.mp3"
52
+ if os.path.exists(path) and not force:
53
+ continue
54
+ if provider == "elevenlabs":
55
+ req = urllib.request.Request("https://api.elevenlabs.io/v1/sound-generation",
56
+ data=json.dumps({"text": prompt, "duration_seconds": sec, "prompt_influence": 0.5}).encode(), headers=H)
57
+ open(path + ".raw", "wb").write(urllib.request.urlopen(req).read())
58
+ src = ["-i", path + ".raw"]
59
+ else:
60
+ src = ["-f", "lavfi", "-i", SYNTH[name]]
61
+ subprocess.run(["ffmpeg", "-v", "error", "-y", *src, "-af", "loudnorm=I=-18:TP=-2", "-ar", "44100", "-ac", "2", path], check=True)
62
+ if os.path.exists(path + ".raw"):
63
+ os.remove(path + ".raw")
64
+ print("made", name)
@@ -0,0 +1,30 @@
1
+ """Write an .srt from film.json + words.json using the same timeline as the engine.
2
+ Usage: python3 scripts/srt.py <film-dir> <out.srt>"""
3
+ import json, sys
4
+ folder, out = sys.argv[1], sys.argv[2]
5
+ film = json.load(open(f"{folder}/film.json")); words = json.load(open(f"{folder}/words.json"))
6
+ import os
7
+ ENGINE = os.path.expanduser(os.environ.get("STORY_VIDEO_ENGINE", "~/.kaizen/story-video/engine"))
8
+ p = json.load(open(f"{ENGINE}/src/engine/presets.json"))[film["preset"]]
9
+ FPS = 30
10
+ def blen(b):
11
+ v = (p["pace"]["lead"] + words[b["vo"]]["dur"] + p["pace"]["tail"]) if b.get("vo") else 0
12
+ return round((v + b.get("hold", 0)) * FPS)
13
+ def ts(sec):
14
+ ms = int(round(sec * 1000)); h, ms = divmod(ms, 3600000); m, ms = divmod(ms, 60000); s, ms = divmod(ms, 1000)
15
+ return f"{h:02}:{m:02}:{s:02},{ms:03}"
16
+ start, cues = 0, []
17
+ for b in film["beats"]:
18
+ if b.get("vo"):
19
+ ws = words[b["vo"]]["words"]; t0 = start / FPS + p["pace"]["lead"]
20
+ chunk = []
21
+ for w in ws:
22
+ chunk.append(w)
23
+ if len(chunk) >= 7 or w["w"][-1] in ".,?!" or w is ws[-1]:
24
+ cues.append([t0 + chunk[0]['s'], t0 + chunk[-1]['e'] + 0.2, ' '.join(x['w'] for x in chunk)])
25
+ chunk = []
26
+ start += blen(b)
27
+ for a_, b_ in zip(cues, cues[1:]):
28
+ a_[1] = min(a_[1], b_[0] - 0.02) # no overlapping cues
29
+ lines = [f"{i}\n{ts(s)} --> {ts(e)}\n{t}\n" for i, (s, e, t) in enumerate(cues, 1)]
30
+ open(out, "w").write("\n".join(lines)); print("srt", len(cues), "cues")
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ # Quick check frames: scripts/still.sh <film-dir> <16x9|9x16> <frame> [<frame>...] -> <film-dir>/out/check-<frame>.jpg
3
+ set -euo pipefail
4
+ DIR=$(cd "$1" && pwd); FMT=$2; shift 2
5
+ ENGINE=${STORY_VIDEO_ENGINE:-$HOME/.kaizen/story-video/engine}
6
+ python3 -c "import json; json.dump({'film': json.load(open('$DIR/film.json')), 'words': json.load(open('$DIR/words.json'))}, open('$DIR/.props.json','w'))"
7
+ for fr in "$@"; do
8
+ (cd "$ENGINE" && npx remotion still src/index.ts "Film-$FMT" "$DIR/out/check-$fr.jpg" --frame="$fr" --props="$DIR/.props.json" --public-dir="$DIR" --image-format=jpeg --scale=0.33 --log=error 2>&1 | grep -iv network | tail -1 || true)
9
+ done
10
+ rm "$DIR/.props.json"; ls "$DIR"/out/check-*.jpg
@@ -0,0 +1,99 @@
1
+ """Voice-over with word timings (ElevenLabs eleven_v3, with-timestamps).
2
+ Usage: python3 scripts/vo.py <film-dir> [voice_id] [only,keys]
3
+ Reads <film-dir>/lines.json {"v01": "[softly] Text...", ...}; writes vNN.mp3 + words.json.
4
+ Emotion tags in [brackets] direct the delivery and are removed from the captions.
5
+ Voice and language come from film.json ("voice", "language"); defaults: Matilda, "id"."""
6
+ import base64, json, os, re, subprocess, sys, urllib.request
7
+
8
+ film_dir = sys.argv[1].rstrip("/")
9
+ out = film_dir + "/"
10
+ film = json.load(open(out + "film.json")) if os.path.exists(out + "film.json") else {}
11
+ voice = (sys.argv[2] if len(sys.argv) > 2 and sys.argv[2] else None) or film.get("voice") or "XrExE9yKIg1WjnnlVkGX"
12
+ lang = film.get("language", "id")
13
+ H = {"xi-api-key": os.environ.get("ELEVENLABS_API_KEY", ""), "Content-Type": "application/json"}
14
+ lines = json.load(open(out + "lines.json"))
15
+ prev = json.load(open(out + "words.json")) if os.path.exists(out + "words.json") else {}
16
+ only = set(sys.argv[3].split(",")) if len(sys.argv) > 3 else None # regenerate only these keys
17
+
18
+
19
+ def words_from(alignment):
20
+ chars, starts, ends = alignment["characters"], alignment["character_start_times_seconds"], alignment["character_end_times_seconds"]
21
+ words, cur, s, e, depth = [], "", None, None, 0
22
+ for ch, cs, ce in zip(chars, starts, ends):
23
+ if ch == "[": depth += 1; continue
24
+ if ch == "]": depth -= 1; continue
25
+ if depth: continue
26
+ if ch.isspace():
27
+ if cur.strip(): words.append({"w": cur.strip(), "s": s, "e": e})
28
+ cur, s = "", None
29
+ continue
30
+ if s is None: s = cs
31
+ cur += ch; e = ce
32
+ if cur.strip(): words.append({"w": cur.strip(), "s": s, "e": e})
33
+ return [w for w in words if re.search(r"\w", w["w"])]
34
+
35
+
36
+ EDGE_DEFAULT = {"id": "id-ID-GadisNeural", "en": "en-US-AriaNeural", "ms": "ms-MY-YasminNeural"}
37
+
38
+
39
+ def edge_line(key, text):
40
+ """Free fallback (Microsoft Edge neural voices via edge-tts, `pip install edge-tts`). No emotion
41
+ control: tags are removed. Word timings come from the WordBoundary events."""
42
+ import asyncio, edge_tts
43
+ clean = re.sub(r"\[[^\]]*\]\s*", "", text)
44
+ v = film.get("edgeVoice") or EDGE_DEFAULT.get(lang, "en-US-AriaNeural")
45
+ words, audio = [], bytearray()
46
+ async def run():
47
+ async for ch in edge_tts.Communicate(clean, v, rate="-5%", boundary="WordBoundary").stream():
48
+ if ch["type"] == "audio": audio.extend(ch["data"])
49
+ elif ch["type"] == "WordBoundary":
50
+ st = ch["offset"] / 1e7; words.append({"w": ch["text"], "s": st, "e": st + ch["duration"] / 1e7})
51
+ asyncio.run(run())
52
+ return bytes(audio), words, clean
53
+
54
+
55
+ provider = film.get("voiceProvider") or ("elevenlabs" if os.environ.get("ELEVENLABS_API_KEY") else "edge")
56
+ print("voice provider:", provider)
57
+ result = dict(prev)
58
+ for key, text in lines.items():
59
+ if only and key not in only:
60
+ continue
61
+ if provider == "edge":
62
+ audio, ws, clean = edge_line(key, text)
63
+ raw = out + key + ".raw.mp3"; open(raw, "wb").write(audio)
64
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", raw, "-af", "loudnorm=I=-16:TP=-1.5", "-ar", "44100", out + key + ".mp3"], check=True)
65
+ os.remove(raw)
66
+ ws = [w for w in ws if re.search(r"\w", w["w"])]
67
+ # edge-tts drops punctuation; take each word's spelling from the script so captions keep it
68
+ toks = clean.split(); ti = 0
69
+ for w in ws:
70
+ core = re.sub(r"\W", "", w["w"]).lower()
71
+ for j in range(ti, min(ti + 4, len(toks))):
72
+ if re.sub(r"\W", "", toks[j]).lower() == core:
73
+ w["w"] = toks[j]; ti = j + 1; break
74
+ offset = max(0.0, ws[0]["s"] - 0.05)
75
+ for w in ws:
76
+ w["s"] = round(w["s"] - offset, 3); w["e"] = round(w["e"] - offset, 3)
77
+ result[key] = {"dur": round(ws[-1]["e"] + 0.15, 3), "offset": round(offset, 3), "text": clean, "words": ws}
78
+ print(key, result[key]["dur"], "s")
79
+ json.dump(result, open(out + "words.json", "w"), ensure_ascii=False, indent=1); continue
80
+ body = {"text": text, "model_id": "eleven_v3", "language_code": lang, "voice_settings": {"stability": film.get("stability", 0.0)}}
81
+ req = urllib.request.Request(f"https://api.elevenlabs.io/v1/text-to-speech/{voice}/with-timestamps",
82
+ data=json.dumps(body).encode(), headers=H)
83
+ try:
84
+ d = json.load(urllib.request.urlopen(req))
85
+ except urllib.error.HTTPError as e:
86
+ sys.exit(f"{key}: ElevenLabs refused ({e.code}): {e.read()[:300].decode(errors='ignore')} — lines before {key} are saved.")
87
+ raw = out + key + ".raw.mp3"
88
+ open(raw, "wb").write(base64.b64decode(d["audio_base64"]))
89
+ # loudness only — never trim, so the timings stay valid
90
+ subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", raw, "-af", "loudnorm=I=-16:TP=-1.5", "-ar", "44100", out + key + ".mp3"], check=True)
91
+ os.remove(raw)
92
+ ws = words_from(d["alignment"])
93
+ offset = max(0.0, ws[0]["s"] - 0.05)
94
+ for w in ws:
95
+ w["s"] = round(w["s"] - offset, 3); w["e"] = round(w["e"] - offset, 3)
96
+ clean = re.sub(r"\[[^\]]*\]\s*", "", text)
97
+ result[key] = {"dur": round(ws[-1]["e"] + 0.15, 3), "offset": round(offset, 3), "text": clean, "words": ws}
98
+ print(key, result[key]["dur"], "s")
99
+ json.dump(result, open(out + "words.json", "w"), ensure_ascii=False, indent=1) # after every line: a failure never leaves audio and timings out of step
package/web/board.html CHANGED
@@ -518,7 +518,7 @@ function card(c){
518
518
  items.push(["open","Open",ico("open")]);
519
519
  if(c.kind==="idea")items.push(null,["delete","Delete",ico("trash"),"danger"]);
520
520
  }else if(c.kind==="idea"&&c.status==="idea"){
521
- items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],["archive","Archive",ico("archive")],null,["reject","Reject",ico("x")],["delete","Delete",ico("trash"),"danger"]);
521
+ items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],...(S.projects.some(p=>p.dir!==c.state)?[["move","Move to project",ico("ext")]]:[]),["archive","Archive",ico("archive")],null,["reject","Reject",ico("x")],["delete","Delete",ico("trash"),"danger"]);
522
522
  }else if(c.kind==="idea"){
523
523
  items.push(["open","Open",ico("open")],["run","Run again",ico("play")],["archive","Archive",ico("archive")],null,["delete","Remove",ico("trash"),"danger"]);
524
524
  }else{
@@ -542,6 +542,7 @@ function inlineForm(c){
542
542
  if(mode==="edit"){li.innerHTML=`<input value="${esc(c.text)}"><textarea placeholder="Notes: links, paths, background">${esc(c.notes||"")}</textarea><div class="q dim" id="clong" hidden>The first line is over 72 characters; logs will cut it short.</div><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Save</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const next=li.querySelector("input").value.trim(),notes=li.querySelector("textarea").value.trim();mode=null;if(next&&(next!==c.text||notes!==(c.notes||"")))await post("/inbox",{op:"edit",dir,text:c.text,next,notes});load()}}
543
543
  if(mode==="reject"){const idea=c.kind==="idea";li.innerHTML=`<div class="q">${idea?"Reject this idea?":"Abandon this run?"} The reason is recorded.</div><input placeholder="Reason"><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn warn" data-a="s">${idea?"Reject":"Abandon"}</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const why=li.querySelector("input").value.trim();if(!why){toast("A reason is required");return}mode=null;const r=idea?await post("/inbox",{op:"reject",dir,text:c.text,reason:why}):await post("/abort",{dir,id:c.id,why});if(r.ok)toast(idea?"Rejected":"Abandoned, reason recorded");load()}}
544
544
  if(mode==="delete"){li.innerHTML=`<div class="q">Delete "${esc(c.text)}"?</div><div class="q dim">Not recorded anywhere.</div><div class="row"><button class="btn ghost" data-a="c">Keep it</button><button class="btn danger" data-a="s">Delete</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{mode=null;const r=await post("/inbox",{op:"delete",dir,text:c.text});if(r.ok)toast("Deleted");load()}}
545
+ if(mode==="move"){const ps=S.projects.filter(p=>p.dir!==dir);li.innerHTML=`<div class="q">Move "${esc(c.text)}" to</div><select class="sel" aria-label="Project">${ps.map(p=>`<option value="${esc(p.dir)}">${esc(p.label)}</option>`).join("")}</select><div class="row"><button class="btn ghost" data-a="c">Cancel</button><button class="btn pri" data-a="s">Move</button></div>`;li.querySelector('[data-a=s]').onclick=async()=>{const sel=li.querySelector("select"),to=sel.value,name=sel.selectedOptions[0]?.textContent;mode=null;const r=await post("/inbox",{op:"move",dir,to,text:c.text});if(r.ok)toast(`Moved to ${name}`);load()}}
545
546
  if(mode==="run"){li.innerHTML=`<div class="q">Start a run for "${esc(c.text)}"</div><div class="q dim">Opens your agent in a new terminal, or a herdr tab or tmux window when one is running, with the request typed in.</div><div class="q dim">Full: planned, built, then checked by a separate reviewer. Lite: one session does it all, faster and cheaper.</div><div id="run-pick" style="display:grid;gap:6px"><div id="run-agent" class="agents" role="radiogroup" aria-label="Agent"></div></div><div class="row"><label class="yolo" id="run-yolo" title="Yolo: the agent runs every command and edit without asking" hidden><button type="button" class="chk" role="switch" aria-checked="false"></button>Yolo</label><button class="btn ghost" data-a="c">Cancel</button><button class="btn" data-a="l">Lite</button><button class="btn pri" data-a="s">Full run</button></div>`;li.querySelector('[data-a=s]').onclick=()=>startRun(c,"full");li.querySelector('[data-a=l]').onclick=()=>startRun(c,"lite");runForm(li,c)}
546
547
  li.querySelector('[data-a=c]').onclick=()=>{mode=null;render()};
547
548
  // Edit and reject take dictation; the run prompt has no field.