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.
- package/README.md +11 -0
- package/cli/board.test.ts +6 -2
- package/cli/install.ts +6 -3
- package/cli/state.test.ts +18 -1
- package/cli/state.ts +24 -1
- package/cli/web.ts +5 -1
- package/package.json +1 -1
- package/skills/story-deck/SKILL.md +63 -0
- package/skills/story-deck/examples/build-example.js +29 -0
- package/skills/story-deck/references/rules.md +62 -0
- package/skills/story-deck/references/story-arc.md +35 -0
- package/skills/story-deck/scripts/check.sh +12 -0
- package/skills/story-deck/scripts/install_fonts.py +42 -0
- package/skills/story-deck/scripts/kit.js +174 -0
- package/skills/story-deck/scripts/prep_images.py +70 -0
- package/skills/story-deck/scripts/qa.sh +48 -0
- package/skills/story-video/SKILL.md +108 -0
- package/skills/story-video/engine/package.json +12 -0
- package/skills/story-video/engine/src/Root.tsx +24 -0
- package/skills/story-video/engine/src/engine/Film.tsx +405 -0
- package/skills/story-video/engine/src/engine/presets.json +58 -0
- package/skills/story-video/engine/src/index.ts +3 -0
- package/skills/story-video/engine/tsconfig.json +1 -0
- package/skills/story-video/examples/sample-film/README.md +12 -0
- package/skills/story-video/examples/sample-film/film.json +16 -0
- package/skills/story-video/examples/sample-film/lines.json +7 -0
- package/skills/story-video/references/licensing.md +14 -0
- package/skills/story-video/references/presets.md +21 -0
- package/skills/story-video/references/production.md +29 -0
- package/skills/story-video/references/revisions.md +25 -0
- package/skills/story-video/references/script.md +30 -0
- package/skills/story-video/scripts/check.sh +19 -0
- package/skills/story-video/scripts/intake.py +73 -0
- package/skills/story-video/scripts/master.sh +26 -0
- package/skills/story-video/scripts/music.sh +10 -0
- package/skills/story-video/scripts/setup.sh +25 -0
- package/skills/story-video/scripts/sfx.py +64 -0
- package/skills/story-video/scripts/srt.py +30 -0
- package/skills/story-video/scripts/still.sh +10 -0
- package/skills/story-video/scripts/vo.py +99 -0
- 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.
|