kaizen-agent 1.19.4 → 1.20.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/install.ts +6 -3
- 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
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"preset": "cinematic-drama",
|
|
3
|
+
"language": "en",
|
|
4
|
+
"voiceProvider": "edge",
|
|
5
|
+
"edgeVoice": "en-US-AriaNeural",
|
|
6
|
+
"brand": { "palette": { "accent": "#D9A441", "accentBg": "#D9A441" } },
|
|
7
|
+
"labels": { "source": "Source", "next": "Next step" },
|
|
8
|
+
"cta": { "title": "See your own numbers in 20 minutes.", "sub": "Book a demo", "url": "[website]" },
|
|
9
|
+
"beats": [
|
|
10
|
+
{ "vo": "v01", "kind": "black", "title": "Every café closes at ten.", "sub": "The real work starts after.", "ambience": "amb-night" },
|
|
11
|
+
{ "vo": "v02", "kind": "stat", "kick": "The hidden shift", "stat": [{ "value": 4, "suffix": "+ hours", "label": "a week reconciling receipts (example figure — replace with a sourced one)", "source": "[source]" }] },
|
|
12
|
+
{ "vo": "v03", "kind": "question", "title": "What if the books closed themselves?" },
|
|
13
|
+
{ "vo": "v04", "kind": "reveal", "title": "Ledgerly", "sub": "Bookkeeping that closes with the café.", "line": "Invoices · Cash · Reports" },
|
|
14
|
+
{ "vo": "v05", "kind": "cta", "hold": 2.5 }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v01": "[calm, warm storyteller] Every café in the city closes at ten. But for the owner, that's when the real work begins.",
|
|
3
|
+
"v02": "[thoughtful] Most small cafés spend more than four hours a week just reconciling receipts, invoices and cash.",
|
|
4
|
+
"v03": "[gently curious, rising hope] So what if the books could close themselves, right when the doors do?",
|
|
5
|
+
"v04": "[warm, proud] Meet Ledgerly. Bookkeeping that closes with the café.",
|
|
6
|
+
"v05": "[warm, inviting] See your own numbers in twenty minutes. Book a demo today."
|
|
7
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Licensing — read before publishing
|
|
2
|
+
|
|
3
|
+
- **Voice provider is the user's choice** (SKILL.md step 0). Edge-tts voices: Microsoft service terms apply; treat as draft quality.
|
|
4
|
+
- **ElevenLabs voice and sound effects**: the free plan does NOT allow commercial use and requires
|
|
5
|
+
attribution. Anything published for a business (ads, booth, client) needs a paid plan
|
|
6
|
+
(Starter or above) active when the audio is generated — regenerate after upgrading.
|
|
7
|
+
Library (community) voices also need a paid plan for API use.
|
|
8
|
+
- **Music**: Kevin MacLeod / incompetech tracks are CC BY 4.0 — credit "Music: <Track> — Kevin MacLeod
|
|
9
|
+
(incompetech.com), CC BY 4.0" in the description or end credits. `music.sh` writes the credit file.
|
|
10
|
+
- **Photos**: Unsplash licence (free commercial use, no attribution required; credit anyway in notes).
|
|
11
|
+
Don't use photos with identifiable people in a way that implies endorsement.
|
|
12
|
+
- **Data on screen**: every statistic carries its source on screen (stat `source`).
|
|
13
|
+
- **Contacts on the CTA**: confirm phone numbers, booth numbers and URLs with the user — never reuse
|
|
14
|
+
contacts seen in someone else's material without confirmation.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Presets (src/engine/presets.json)
|
|
2
|
+
|
|
3
|
+
| preset | use for | look & motion | audio | captions | loudness |
|
|
4
|
+
|---|---|---|---|---|---|
|
|
5
|
+
| cinematic-drama | pitch opener, brand film | dark, full-bleed photos, slow push-in, rise text, grain + vignette, fades | quiet music, hit on reveal, riser into the question, room tone | subtitle bar | −16 |
|
|
6
|
+
| promo-booth | exhibition loop, deck-faithful promo (the friend's style) | photo in a framed window beside the text, wipes, slide-in text, staged builds | whoosh on cuts, tick on titles, slightly louder music | subtitle bar | −16 |
|
|
7
|
+
| fun-energetic | social posts, parents, events | cream background, rounded photo cards that bounce in, pop text, light leaks | bright track (Carefree), pops and a chime | subtitle bar | −14 |
|
|
8
|
+
| viral-social | TikTok / Reels / Shorts (9:16) | full-bleed, fast punch-in cuts, bold Inter, strong Ken Burns | Easy Lemon-type track, whooshes | kinetic 2–3 words, accent on the spoken word | −14 |
|
|
9
|
+
|
|
10
|
+
Pace: `lead` / `tail` = silence before and after each line. Drama 0.8/1.1, booth 0.45/0.7, fun 0.2/0.35, viral 0.05/0.2.
|
|
11
|
+
|
|
12
|
+
## Writing for each preset
|
|
13
|
+
- **drama**: full story arc, 70–110 s, long pauses allowed.
|
|
14
|
+
- **booth**: the deck's beats in order, 80–100 s, must make sense when joined mid-loop; real contacts on the CTA.
|
|
15
|
+
- **fun**: 35–50 s, playful lines ("Kenal rasanya?"), one problem montage, three quick benefits.
|
|
16
|
+
- **viral**: ≤ 30 s, line 1 is the hook (a sharp, relatable pain), product by second ~12, one CTA.
|
|
17
|
+
|
|
18
|
+
## Adding a preset
|
|
19
|
+
Copy an entry in presets.json; fields: palette, fonts (Playfair Display / Inter / Fraunces are loaded —
|
|
20
|
+
add others in Film.tsx FONTS), pace, text (rise|slide|pop), transition (fade|wipe|punch),
|
|
21
|
+
layout (fullbleed|window|card), grade, captions (subtitle|kinetic|none), music levels, sfx names, kenburns, loudness.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# What makes a video publish-grade
|
|
2
|
+
|
|
3
|
+
Learned by comparing deck-to-video renders against richer productions . The examples are from an Indonesian school product; apply the principles to any product.
|
|
4
|
+
|
|
5
|
+
## Layers, in order of impact
|
|
6
|
+
1. **Sound design**: room tone under every beat (`ambience`), one-shots on story moments (`pings` for
|
|
7
|
+
"140 pesan", `keyboard`, `bell`), soft whoosh/tick on cuts, a hit on the reveal, a riser into the
|
|
8
|
+
question. Music ducks under speech and only lifts at the reveal — never a dramatic swell unless
|
|
9
|
+
the preset is drama.
|
|
10
|
+
2. **Captions**: always. Projectors and phones are often muted. Subtitle bar for 16:9, kinetic
|
|
11
|
+
2–3-word captions for 9:16. Captions come from `words.json`, so they follow the voice exactly.
|
|
12
|
+
3. **Staged builds inside a beat**: numbers count up, list items arrive one by one with the voice,
|
|
13
|
+
triptych panels arrive in turn. A whole slide appearing at once reads as a slideshow.
|
|
14
|
+
4. **Directed voice**: emotion tag per line, low and slow through the problem, lift at the question,
|
|
15
|
+
warm and bright after the reveal. Fix mispronounced words by rewording, not by hoping.
|
|
16
|
+
5. **Rhythm**: vary beat length; hold on silence (`black` with `hold`); pace by preset.
|
|
17
|
+
6. **Real product**: actual screenshots / screen recordings for product beats. Stock photos of code
|
|
18
|
+
or generic laptops are a last resort.
|
|
19
|
+
7. **Brand**: real logo reveal, consistent palette and type, the same end card every time.
|
|
20
|
+
8. **Texture**: grain + vignette for drama, light leaks for fun; consistent grade on every photo.
|
|
21
|
+
9. **Format-native**: 9:16 has its own script (hook in the first 2 s, ≤ 30 s), not a crop of 16:9.
|
|
22
|
+
10. **Delivery**: master loudness (−14 LUFS social, −16 presentation), SRT, thumbnail, credits.
|
|
23
|
+
|
|
24
|
+
## QA before delivering
|
|
25
|
+
- Stills from every film at 3–4 points: no text overlap, captions inside safe area, CTA not covered.
|
|
26
|
+
- Length matches the brief (tell the user when the story needs more).
|
|
27
|
+
- `master.sh` printed the final duration and LUFS.
|
|
28
|
+
- Credits file present; licence status stated (see licensing.md).
|
|
29
|
+
- Ask the user to listen once for pronunciation and the mix — the agent cannot hear.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Revisions
|
|
2
|
+
|
|
3
|
+
Clients revise. Make every revision cheap, traceable and reversible.
|
|
4
|
+
|
|
5
|
+
## Classify the request, then touch only that layer
|
|
6
|
+
| request | what changes | redo |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| "change this sentence / say it warmer / mispronounced X" | `lines.json` entry | `vo.py <film-dir> "" v04` (only that key; empty voice = use film.json), then master |
|
|
9
|
+
| "different text on screen" | beat `kick/title/sub/items` in `film.json` | master |
|
|
10
|
+
| "swap this picture" | beat `img` (+ intake for new photos) | master |
|
|
11
|
+
| "slower / faster / shorter" | preset `pace`, beat `hold`, drop or merge beats | master |
|
|
12
|
+
| "music too loud / different mood" | preset `music.base/lift` or `music.sh` new track | master |
|
|
13
|
+
| "other colours / logo / font" | `film.brand`, `logo` | master |
|
|
14
|
+
| "make a vertical / 15 s version" | new film entry with its own (shorter) script | vo + master |
|
|
15
|
+
| "different style" | `preset` (and usually a rewritten script) | vo + master |
|
|
16
|
+
|
|
17
|
+
Never regenerate all narration for a one-line change: voices vary between takes and credits cost money.
|
|
18
|
+
|
|
19
|
+
## Versioning
|
|
20
|
+
- `master.sh` moves the previous delivery to `out/previous/<name>-<timestamp>.*` before writing the new one.
|
|
21
|
+
- Append to `~/.kaizen/videos/<slug>/REVISIONS.md`: date, the user's words verbatim, what changed, output file.
|
|
22
|
+
- When a revision is rejected, restore from `out/previous/` instead of re-rendering.
|
|
23
|
+
|
|
24
|
+
## Before re-delivering
|
|
25
|
+
Re-check stills of the beats that changed, master, and say exactly what changed and what did not.
|
|
@@ -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
|