kaizen-agent 1.48.0 → 1.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/cli/build-web.ts +4 -2
- package/cli/hermes.ts +65 -0
- package/cli/install.ts +18 -5
- package/cli/localsend.test.ts +84 -0
- package/cli/localsend.ts +131 -0
- package/cli/notes.test.ts +6 -4
- package/cli/settings.ts +1 -0
- package/cli/state.test.ts +37 -0
- package/cli/state.ts +35 -20
- package/cli/video.test.ts +479 -0
- package/cli/video.ts +507 -0
- package/cli/web.ts +127 -1
- package/package.json +1 -1
- package/skills/kaizen/config.default.yml +4 -0
- package/skills/media/kaizen-book/SKILL.md +107 -0
- package/skills/media/kaizen-book/assets/reader.html +440 -0
- package/skills/media/kaizen-book/assets/styles.json +45 -0
- package/skills/media/kaizen-book/examples/book.json +14 -0
- package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
- package/skills/media/kaizen-book/examples/manuscript.md +127 -0
- package/skills/media/kaizen-book/references/interview.md +85 -0
- package/skills/media/kaizen-book/references/proofread.md +47 -0
- package/skills/media/kaizen-book/references/writing.md +53 -0
- package/skills/media/kaizen-book/scripts/build_book.py +279 -0
- package/skills/media/kaizen-book/scripts/check.sh +10 -0
- package/skills/media/kaizen-book/scripts/check_book.py +128 -0
- package/skills/media/kaizen-book/scripts/deploy.py +58 -0
- package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
- package/skills/media/kaizen-book/scripts/setup.sh +9 -0
- package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
- package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
- package/skills/media/kaizen-video/SKILL.md +112 -0
- package/skills/{kaizen-video → media/kaizen-video}/assets/CREDITS.txt +5 -0
- package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
- package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
- package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
- package/skills/media/kaizen-video/film.html +246 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
- package/skills/{kaizen-video → media/kaizen-video}/references/presets.md +22 -0
- package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
- package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
- package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
- package/skills/{kaizen-video → media/kaizen-video}/scripts/check.sh +8 -0
- package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
- package/skills/media/kaizen-video/scripts/make_film.py +263 -0
- package/skills/{kaizen-video → media/kaizen-video}/scripts/make_tiktok.py +8 -3
- package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
- package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
- package/skills/media/kaizen-video/scripts/srt.py +9 -0
- package/skills/media/kaizen-video/scripts/still.sh +7 -0
- package/web/board/views.ts +1 -1
- package/web/board.html +2 -0
- package/web/board.js +2 -2
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +1 -1
- package/web/video.html +298 -0
- package/web/video.js +81 -0
- package/web/video.ts +610 -0
- package/skills/kaizen-video/SKILL.md +0 -73
- package/skills/story-video/SKILL.md +0 -108
- package/skills/story-video/engine/package.json +0 -12
- package/skills/story-video/engine/src/Root.tsx +0 -24
- package/skills/story-video/engine/src/engine/Film.tsx +0 -405
- package/skills/story-video/engine/src/engine/presets.json +0 -58
- package/skills/story-video/engine/src/index.ts +0 -3
- package/skills/story-video/engine/tsconfig.json +0 -1
- package/skills/story-video/examples/sample-film/README.md +0 -12
- package/skills/story-video/examples/sample-film/film.json +0 -16
- package/skills/story-video/references/presets.md +0 -21
- package/skills/story-video/scripts/check.sh +0 -19
- package/skills/story-video/scripts/setup.sh +0 -25
- package/skills/story-video/scripts/srt.py +0 -30
- package/skills/story-video/scripts/still.sh +0 -10
- /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Fredoka.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/Montserrat.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/fonts/SpecialElite.ttf +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/investigations.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/lights.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/assets/music/sunny.mp3 +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/examples/config.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/hyperframes.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/educational-warning/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-cheerful/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-mystery/package.json +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/CREDITS.txt +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/README.md +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/index.html +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/presets/tiktok-scam-alert/package.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/render_cut.py +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/setup.sh +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/scripts/transcribe.py +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/tools/carve.mjs +0 -0
- /package/skills/{kaizen-video → media/kaizen-video}/tools/lib/main-module.mjs +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Build (and optionally render) a film made from nothing: narration, pictures, music, sound effects.
|
|
2
|
+
|
|
3
|
+
python3 scripts/make_film.py videos/<name> [--render] [--format 9x16|16x9|4x5]
|
|
4
|
+
|
|
5
|
+
Run from the project folder. videos/<name>/ holds film.json (storyboard + brand), lines.json (the script),
|
|
6
|
+
words.json (word timings from vo.py), the voice files vNN.mp3 and the pictures. Writes the HyperFrames project
|
|
7
|
+
into videos/<name>/film/; with --render, scripts/master.sh renders it, masters the loudness and writes
|
|
8
|
+
output/<name>/<preset>/<name>.mp4 with the .srt, thumbnail and credits.
|
|
9
|
+
|
|
10
|
+
film.json: "preset" (a folder under presets/ with a style.json, e.g. film-cinematic-drama), "format"
|
|
11
|
+
("9x16" default, "16x9", or "4x5" for an Instagram feed post), "beats", "brand", "labels", "cta", "logo", "qr", "music" (a bundled track name;
|
|
12
|
+
without it videos/<name>/music.mp3 is used). The preset is found in ./presets/, $KAIZEN_VIDEO_PRESETS
|
|
13
|
+
(default ~/.kaizen/video-presets/), then the skill's presets/, like make_tiktok.py.
|
|
14
|
+
--format overrides film.json's format for one build and adds -<format> to the output name, so one film can be
|
|
15
|
+
rendered for several shapes (the Video page in kaizen web does this) without one render replacing another.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SKILL = Path(__file__).resolve().parent.parent
|
|
26
|
+
WORK = Path.cwd()
|
|
27
|
+
HF = "hyperframes@0.8.176"
|
|
28
|
+
FPS = 30
|
|
29
|
+
HOME = Path(os.environ.get("KAIZEN_VIDEO_HOME", Path.home() / ".kaizen/kaizen-video"))
|
|
30
|
+
SIZES = {"9x16": (1080, 1920, "portrait"), "16x9": (1920, 1080, "landscape"), "4x5": (1080, 1350, "portrait")}
|
|
31
|
+
|
|
32
|
+
vdir = Path(sys.argv[1]).resolve()
|
|
33
|
+
proj = vdir / "film"
|
|
34
|
+
film = json.loads((vdir / "film.json").read_text())
|
|
35
|
+
words = json.loads((vdir / "words.json").read_text())
|
|
36
|
+
forced = sys.argv[sys.argv.index("--format") + 1] if "--format" in sys.argv[:-1] else None
|
|
37
|
+
fmt = forced or film.get("format", "9x16")
|
|
38
|
+
if fmt not in SIZES:
|
|
39
|
+
sys.exit(f"film.json format {fmt!r} must be one of {', '.join(SIZES)}")
|
|
40
|
+
W, H, RES = SIZES[fmt]
|
|
41
|
+
LANG = film.get("language", "id")
|
|
42
|
+
|
|
43
|
+
# 1. Preset: a folder with style.json.
|
|
44
|
+
dirs = [WORK / "presets", Path(os.environ.get("KAIZEN_VIDEO_PRESETS", Path.home() / ".kaizen/video-presets")), SKILL / "presets"]
|
|
45
|
+
want = film["preset"]
|
|
46
|
+
preset = next((d / n for n in (want, "film-" + want) for d in dirs if (d / n / "style.json").exists()), None)
|
|
47
|
+
if not preset:
|
|
48
|
+
names = sorted({p.parent.name for d in dirs if d.exists() for p in d.glob("*/style.json")})
|
|
49
|
+
sys.exit(f"preset {want!r} not found in {', '.join(map(str, dirs))}; available: {', '.join(names)}")
|
|
50
|
+
print(f"preset {preset}")
|
|
51
|
+
style = json.loads((preset / "style.json").read_text())
|
|
52
|
+
brand = film.get("brand", {})
|
|
53
|
+
style["palette"] = {**style["palette"], **brand.get("palette", {})}
|
|
54
|
+
style["fonts"] = {**style["fonts"], **brand.get("fonts", {})}
|
|
55
|
+
pace = style["pace"]
|
|
56
|
+
shutil.rmtree(proj / "assets", ignore_errors=True)
|
|
57
|
+
for d in ("img", "video", "sfx", "fonts"):
|
|
58
|
+
(proj / "assets" / d).mkdir(parents=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def probe(f, entry, kind="format"):
|
|
62
|
+
out = subprocess.check_output(["ffprobe", "-v", "error", "-show_entries", f"{kind}={entry}", "-of", "csv=p=0", str(f)])
|
|
63
|
+
return out.decode().strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run(*cmd, cwd=proj):
|
|
67
|
+
subprocess.run(cmd, check=True, cwd=cwd)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# 2. Timeline: every beat is lead + its voice line + tail (+ hold), in whole frames, one after another.
|
|
71
|
+
t, beats = 0, []
|
|
72
|
+
for n, b in enumerate(film["beats"]):
|
|
73
|
+
v = b.get("vo")
|
|
74
|
+
if v and v not in words:
|
|
75
|
+
sys.exit(f"beat {n + 1}: voice {v!r} is not in words.json; run vo.py first")
|
|
76
|
+
frames = round(((pace["lead"] + words[v]["dur"] + pace["tail"]) if v else 0) * FPS + b.get("hold", 0) * FPS)
|
|
77
|
+
if frames:
|
|
78
|
+
beats.append({**b, "n": n, "start": t / FPS, "len": frames / FPS, "dur": words[v]["dur"] if v else 0})
|
|
79
|
+
else:
|
|
80
|
+
print(f"warning: beat {n + 1} ({b['kind']}) has no voice and no hold, so it is skipped")
|
|
81
|
+
t += frames
|
|
82
|
+
total = t / FPS
|
|
83
|
+
reveal = next((b["start"] for b in beats if b["kind"] == "reveal"), total / 2)
|
|
84
|
+
|
|
85
|
+
# 3. Pictures, footage, logo, QR into the project (a photo name may be a stem: intake writes <stem>-9x16.jpg).
|
|
86
|
+
def stage(name, sub):
|
|
87
|
+
if not name:
|
|
88
|
+
return name
|
|
89
|
+
src = vdir / name
|
|
90
|
+
for tag in (fmt, "9x16") if fmt == "4x5" else (fmt,): # a film intaken before 4x5 existed uses its 9x16 crops
|
|
91
|
+
if not src.exists():
|
|
92
|
+
src = vdir / f"{Path(name).stem}-{tag}.jpg"
|
|
93
|
+
if not src.exists():
|
|
94
|
+
sys.exit(f"file {name!r} not found in {vdir}")
|
|
95
|
+
flat = re.sub(r"[\\/]", "_", str(src.relative_to(vdir)))
|
|
96
|
+
shutil.copy(src, proj / "assets" / sub / flat)
|
|
97
|
+
return f"assets/{sub}/{flat}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
for b in beats:
|
|
101
|
+
b["img"], b["video"] = stage(b.get("img"), "img"), stage(b.get("video"), "video")
|
|
102
|
+
if b.get("panels"):
|
|
103
|
+
b["panels"] = [{**p, "img": stage(p["img"], "img")} for p in b["panels"]]
|
|
104
|
+
if b["kind"] == "shot" and b["img"] and not b.get("shotRatio"):
|
|
105
|
+
b["shotRatio"] = round(int(probe(proj / b["img"], "width", "stream")) / int(probe(proj / b["img"], "height", "stream")), 3)
|
|
106
|
+
labels = {"source": "Sumber" if LANG == "id" else "Source", "next": "Langkah berikutnya" if LANG == "id" else "Next step", **film.get("labels", {})}
|
|
107
|
+
|
|
108
|
+
# 4. Captions: subtitle chunks of up to 7 words (also the .srt cues), or kinetic groups of 2-3 words.
|
|
109
|
+
def chunks(ws):
|
|
110
|
+
out, cur = [], []
|
|
111
|
+
for w in ws:
|
|
112
|
+
cur.append(w)
|
|
113
|
+
if len(cur) >= 7 or re.search(r"[.,?!]$", w["w"]):
|
|
114
|
+
out.append(cur)
|
|
115
|
+
cur = []
|
|
116
|
+
return out + ([cur] if cur else [])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def groups(ws):
|
|
120
|
+
out, i = [], 0
|
|
121
|
+
while i < len(ws):
|
|
122
|
+
k = 2 if len("".join(w["w"] for w in ws[i:i + 3])) > 16 else 3
|
|
123
|
+
out.append(ws[i:i + k])
|
|
124
|
+
i += k
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
captions, cues = [], []
|
|
129
|
+
for b in beats:
|
|
130
|
+
if not b.get("vo"):
|
|
131
|
+
continue
|
|
132
|
+
t0 = b["start"] + pace["lead"]
|
|
133
|
+
ws = [{"w": w["w"], "s": round(t0 + w["s"], 3), "e": round(t0 + w["e"], 3)} for w in words[b["vo"]]["words"]]
|
|
134
|
+
cues += [[c[0]["s"], c[-1]["e"] + 0.2, " ".join(w["w"] for w in c)] for c in chunks(ws)]
|
|
135
|
+
if b["kind"] == "cta" or style["captions"] == "none":
|
|
136
|
+
continue
|
|
137
|
+
gs = chunks(ws) if style["captions"] == "subtitle" else groups(ws)
|
|
138
|
+
for i, g in enumerate(gs):
|
|
139
|
+
nxt = gs[i + 1][0]["s"] if i + 1 < len(gs) else min(g[-1]["e"] + 0.4, b["start"] + b["len"])
|
|
140
|
+
captions.append({"s": round(max(b["start"], g[0]["s"] - (0.05 if i == 0 else 0)), 3), "e": round(nxt, 3), "words": g})
|
|
141
|
+
for a, c in zip(cues, cues[1:]):
|
|
142
|
+
a[1] = round(min(a[1], c[0] - 0.02), 3) # no overlapping cues
|
|
143
|
+
proj.mkdir(exist_ok=True)
|
|
144
|
+
(proj / "cues.json").write_text(json.dumps(cues, ensure_ascii=False))
|
|
145
|
+
|
|
146
|
+
film_data = {"fmt": fmt, "W": W, "H": H, "language": LANG, "style": style, "labels": labels, "total": total, "beats": beats, "captions": captions,
|
|
147
|
+
"cta": film.get("cta"), "logo": stage(film.get("logo"), "img"), "logoOnLight": stage(film.get("logoOnLight"), "img"), "qr": stage(film.get("qr"), "img")}
|
|
148
|
+
(proj / "film-data.js").write_text("window.FILM = " + json.dumps(film_data, ensure_ascii=False) + ";\n")
|
|
149
|
+
|
|
150
|
+
# 5. Audio: the voice lines on one track, the music bed with the style's levels, sound effects and room tone.
|
|
151
|
+
def ffmpeg(*args):
|
|
152
|
+
run("ffmpeg", "-v", "error", "-y", *args)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
ins, graph = [], []
|
|
156
|
+
for i, b in enumerate(x for x in beats if x.get("vo")):
|
|
157
|
+
ins += ["-i", str(vdir / f"{b['vo']}.mp3")]
|
|
158
|
+
ms = round((b["start"] + pace["lead"]) * 1000)
|
|
159
|
+
graph.append(f"[{i}:a]aformat=sample_rates=48000:channel_layouts=stereo,atrim=start={words[b['vo']]['offset']},asetpts=PTS-STARTPTS,adelay={ms}|{ms}[a{i}]")
|
|
160
|
+
if not graph:
|
|
161
|
+
sys.exit("no beat has a voice line (vo); a film needs narration")
|
|
162
|
+
mix = "".join(f"[a{i}]" for i in range(len(graph)))
|
|
163
|
+
ffmpeg(*ins, "-filter_complex", ";".join(graph) + f";{mix}amix=inputs={len(graph)}:normalize=0:duration=longest,apad[o]",
|
|
164
|
+
"-map", "[o]", "-t", str(total), "-ar", "48000", "assets/vo.wav")
|
|
165
|
+
|
|
166
|
+
music = style["music"]
|
|
167
|
+
track = vdir / music["track"]
|
|
168
|
+
if film.get("music"):
|
|
169
|
+
track = SKILL / "assets/music" / f"{film['music']}.mp3"
|
|
170
|
+
if not track.exists():
|
|
171
|
+
sys.exit(f"no music: put {music['track']} in {vdir} (scripts/music.sh) or set film.json \"music\" to one of: "
|
|
172
|
+
+ ", ".join(sorted(p.stem for p in (SKILL / "assets/music").glob("*.mp3"))))
|
|
173
|
+
pts = [(0, 0), (1, music["base"]), (max(1, reveal - 0.5), music["base"]), (reveal + 1 / 3, music["lift"]), (reveal + 3, music["base"] + 0.04),
|
|
174
|
+
(total - 2, music["base"] + 0.04), (total, 0)]
|
|
175
|
+
pts = [p for i, p in enumerate(pts) if i == 0 or p[0] > pts[i - 1][0]] # a short film can fold the points back on themselves
|
|
176
|
+
expr = f"{pts[-1][1]}"
|
|
177
|
+
for (t0, a0), (t1, a1) in reversed(list(zip(pts, pts[1:]))):
|
|
178
|
+
expr = f"if(lt(t,{t1}),{a0}+({a1}-{a0})*(t-{t0})/({t1}-{t0}),{expr})"
|
|
179
|
+
ffmpeg("-i", str(track), "-af", f"aloop=loop=-1:size=2e9,atrim=0:{total},loudnorm=I=-16:TP=-1.5,volume='{expr}':eval=frame,aformat=sample_rates=48000:channel_layouts=stereo", "assets/music.wav")
|
|
180
|
+
|
|
181
|
+
LIB = HOME / "sfx"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def sound(name):
|
|
185
|
+
def find():
|
|
186
|
+
return next((f for d in (vdir / "sfx", preset / "assets/sfx", LIB) for e in (".wav", ".mp3") if (f := d / (name + e)).exists()), None)
|
|
187
|
+
if not find() and not (LIB / "whoosh.mp3").exists(): # make the shared library once, synthesized (no ElevenLabs credits spent unless SFX_PROVIDER says so)
|
|
188
|
+
run("python3", str(SKILL / "scripts/sfx.py"), "--provider", os.environ.get("SFX_PROVIDER", "synth"))
|
|
189
|
+
f = find()
|
|
190
|
+
if not f:
|
|
191
|
+
sys.exit(f"sound {name!r} not found in {vdir / 'sfx'}, {preset / 'assets/sfx'} or {LIB}")
|
|
192
|
+
shutil.copy(f, proj / "assets/sfx" / f.name)
|
|
193
|
+
return f.name
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
fx = style["sfx"]
|
|
197
|
+
sfx, amb = [], [] # (file, start, volume)
|
|
198
|
+
for i, b in enumerate(beats):
|
|
199
|
+
cut = fx["reveal"] if b["kind"] == "reveal" else fx["question"] if b["kind"] == "question" else fx["cut"] if i else ""
|
|
200
|
+
if cut:
|
|
201
|
+
sfx.append((sound(cut), b["start"], 0.7 if b["kind"] == "reveal" else 0.35))
|
|
202
|
+
sfx += [(sound(s), b["start"] + pace["lead"], 0.45) for s in b.get("sfx", [])]
|
|
203
|
+
if fx["text"] and b.get("title") and i:
|
|
204
|
+
sfx.append((sound(fx["text"]), b["start"] + pace["lead"] + 0.2, 0.25))
|
|
205
|
+
if b.get("ambience"):
|
|
206
|
+
a = sound(b["ambience"])
|
|
207
|
+
ffmpeg("-stream_loop", "-1", "-i", f"assets/sfx/{a}", "-t", str(b["len"]), "-af", f"afade=t=in:d=0.3,afade=t=out:st={max(0, b['len'] - 0.3)}:d=0.3,aformat=sample_rates=48000:channel_layouts=stereo", f"assets/amb-{i}.wav")
|
|
208
|
+
amb.append((f"amb-{i}.wav", b["start"], b["len"]))
|
|
209
|
+
tracks, tags = {}, [] # greedy track numbers from 3, so no two sounds overlap on one track (HyperFrames lint)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def place(tag, start, dur):
|
|
213
|
+
k = next(k for k in range(3, 99) if tracks.get(k, -1) <= start)
|
|
214
|
+
tracks[k] = start + dur
|
|
215
|
+
tags.append(tag.replace("TRACK", str(k)))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
for i, (name, start, vol) in enumerate(sorted(sfx, key=lambda x: x[1])):
|
|
219
|
+
d = round(float(probe(proj / "assets/sfx" / name, "duration")), 2)
|
|
220
|
+
place(f'<audio id="sfx-{i}" src="assets/sfx/{name}" data-start="{start:.2f}" data-duration="{d}" data-track-index="TRACK" data-volume="{vol}"></audio>', start, d)
|
|
221
|
+
for i, (name, start, dur) in enumerate(amb):
|
|
222
|
+
place(f'<audio id="amb-{i}" src="assets/{name}" data-start="{start:.2f}" data-duration="{dur}" data-track-index="TRACK" data-volume="0.18"></audio>', start, dur)
|
|
223
|
+
audio = "\n ".join([f'<audio id="vo" src="assets/vo.wav" data-start="0" data-duration="{total}" data-track-index="1"></audio>',
|
|
224
|
+
f'<audio id="music" src="assets/music.wav" data-start="0" data-duration="{total}" data-track-index="2"></audio>'] + tags)
|
|
225
|
+
|
|
226
|
+
# 6. Fonts (the style's, plus Inter for kinetic captions), the page, carve the music around the voice, check.
|
|
227
|
+
faces = []
|
|
228
|
+
fonts = set(style["fonts"].values()) | ({"Inter"} if style["captions"] == "kinetic" else set())
|
|
229
|
+
for fam in sorted(fonts):
|
|
230
|
+
stem = fam.replace(" ", "")
|
|
231
|
+
for suffix, slant in (("", "normal"), ("-Italic", "italic")):
|
|
232
|
+
src = next((f for d in (preset / "assets/fonts", SKILL / "assets/film-fonts", SKILL / "assets/fonts") if (f := d / f"{stem}{suffix}.ttf").exists()), None)
|
|
233
|
+
if src:
|
|
234
|
+
shutil.copy(src, proj / "assets/fonts" / src.name)
|
|
235
|
+
faces.append(f'@font-face {{ font-family: "{fam}"; src: url("assets/fonts/{src.name}"); font-weight: 100 900; font-style: {slant}; }}')
|
|
236
|
+
elif not suffix:
|
|
237
|
+
sys.exit(f"font {fam!r} not found: add {stem}.ttf to the preset's assets/fonts or the skill's assets/film-fonts")
|
|
238
|
+
page = (SKILL / "film.html").read_text()
|
|
239
|
+
for k, v in {"W": W, "H": H, "RES": RES, "DURATION": total, "LANG": LANG}.items():
|
|
240
|
+
page = page.replace("{{" + k + "}}", str(v))
|
|
241
|
+
page = page.replace("/*FONTS*/", "\n ".join(faces)).replace("<!-- AUDIO -->", audio)
|
|
242
|
+
(proj / "index.html").write_text(page)
|
|
243
|
+
shutil.copy(SKILL / "hyperframes.json", proj / "hyperframes.json")
|
|
244
|
+
(proj / "package.json").write_text(json.dumps({"name": "kaizen-film", "private": True, "type": "module", "devDependencies": {"@hyperframes/core": HF.split("@")[1]}}, indent=2))
|
|
245
|
+
if not (proj / "node_modules/@hyperframes/core").exists():
|
|
246
|
+
run("npm", "install", "--silent")
|
|
247
|
+
run("node", str(SKILL / "tools/carve.mjs"), "--comp", "index.html", "--bed", "music", "--voice", "vo")
|
|
248
|
+
run("npx", "--yes", HF, "check")
|
|
249
|
+
print(f"{len(beats)} beats, {len(captions)} caption groups, {len(sfx)} sfx, {fmt}, {total}s")
|
|
250
|
+
|
|
251
|
+
# 7. Credits and master data, then render + master with --render.
|
|
252
|
+
name = film.get("name", vdir.name) + (f"-{fmt}" if forced else "")
|
|
253
|
+
line = re.compile(rf"^- {re.escape(music['track'] if not film.get('music') else film['music'])}\.mp3: (.+)$", re.M)
|
|
254
|
+
bundled = (SKILL / "assets/CREDITS.txt").read_text() if (SKILL / "assets/CREDITS.txt").exists() else ""
|
|
255
|
+
credits = [f.read_text().strip() for f in [vdir / "CREDITS.txt", *sorted(vdir.glob("CREDITS-*.txt"))] if f.exists()]
|
|
256
|
+
if film.get("music") and (m := line.search(bundled)):
|
|
257
|
+
credits.append("Music: " + m.group(1))
|
|
258
|
+
credits += [f.read_text().strip() for f in (preset / "CREDITS.txt",) if f.exists()]
|
|
259
|
+
credits.append("Voice and sound effects: see the voice provider in film.json (ElevenLabs commercial use requires a paid plan).")
|
|
260
|
+
(proj / "CREDITS.txt").write_text("\n\n".join(credits) + "\n")
|
|
261
|
+
(proj / "meta.json").write_text(json.dumps({"name": name, "folder": film.get("name", vdir.name), "preset": preset.name, "loudness": style["loudness"]}))
|
|
262
|
+
if "--render" in sys.argv:
|
|
263
|
+
run("bash", str(SKILL / "scripts/master.sh"), str(vdir), cwd=WORK)
|
|
@@ -158,7 +158,7 @@ tags = "\n".join(f' <audio id="sfx-{i}" src="assets/sfx/{n}.wav" data-start
|
|
|
158
158
|
src, key = rel(cfg["video"]), proj / "source.key"
|
|
159
159
|
vf = "scale=1080:1920:flags=lanczos,unsharp=5:5:0.6:5:5:0,fps=30,format=yuv420p"
|
|
160
160
|
af = "afftdn=nf=-28,deesser=i=0.35,aformat=sample_rates=48000:channel_layouts=stereo"
|
|
161
|
-
k = json.dumps([str(src), src.stat().st_mtime, co_a, co_b, vf, af])
|
|
161
|
+
k = json.dumps([str(src), src.stat().st_mtime, co_a, co_b, vf, af, "g30"])
|
|
162
162
|
if not (proj / "source.mp4").exists() or not key.exists() or key.read_text() != k:
|
|
163
163
|
if co:
|
|
164
164
|
graph = (f"[0:v]split[v1][v2];[0:a]asplit[a1][a2];"
|
|
@@ -167,7 +167,7 @@ if not (proj / "source.mp4").exists() or not key.exists() or key.read_text() !=
|
|
|
167
167
|
else:
|
|
168
168
|
graph = f"[0:v]{vf}[v];[0:a]{af}[a]"
|
|
169
169
|
run("ffmpeg", "-v", "error", "-y", "-i", str(src), "-filter_complex", graph, "-map", "[v]", "-map", "[a]",
|
|
170
|
-
"-c:v", "libx264", "-crf", "14", "-preset", "medium", "-c:a", "aac", "-b:a", "256k", "source.mp4")
|
|
170
|
+
"-c:v", "libx264", "-crf", "14", "-preset", "medium", "-g", "30", "-c:a", "aac", "-b:a", "256k", "source.mp4")
|
|
171
171
|
key.write_text(k)
|
|
172
172
|
|
|
173
173
|
# 5. Music bed trimmed to length, then the composition from the template.
|
|
@@ -191,7 +191,12 @@ print(f"{len(chunks)} caption chunks, {len(beats)} beats, {len(sfx)} sfx, {durat
|
|
|
191
191
|
if "--render" in sys.argv:
|
|
192
192
|
out = WORK / "output" / cfg["name"] / cfg["preset"] / f"{cfg['name']}.mp4"
|
|
193
193
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
194
|
-
|
|
194
|
+
# Frame extraction writes gigabytes of temp files; /tmp is often a small tmpfs, which fails as VIDEO_SOURCE_UNRENDERABLE.
|
|
195
|
+
tmp = proj / ".render-tmp"
|
|
196
|
+
tmp.mkdir(exist_ok=True)
|
|
197
|
+
run("npx", "--yes", HF, "render", "--crf", str(cfg.get("crf", 23)), "-o", str(out), # CRF 23: ~1/3 the size of the default 16, same resolution
|
|
198
|
+
env={**os.environ, "TMPDIR": str(tmp)})
|
|
199
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
195
200
|
shutil.rmtree(proj / "renders", ignore_errors=True)
|
|
196
201
|
# The video's own credits (images, footage), the music bed actually used, then the preset's (fonts, sound effects).
|
|
197
202
|
# A music credit is the line "- <music>.mp3: ..." in the preset's CREDITS.txt, else in the shared assets/CREDITS.txt.
|
|
@@ -1,26 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Render
|
|
3
|
-
# Earlier deliveries move to
|
|
4
|
-
# Usage: scripts/master.sh <film-dir>
|
|
2
|
+
# Render a built film (make_film.py), master loudness (two-pass), write .srt + thumbnail + credits.
|
|
3
|
+
# Run from the project folder. Earlier deliveries move to output/<name>/<preset>/previous/ (never overwritten).
|
|
4
|
+
# Usage: scripts/master.sh <film-dir> [lufs: default from the style, -14 social | -16 presentation] [thumb-second]
|
|
5
|
+
# Writes output/<film>/<preset>/<name>.mp4; <name> is <film>-<shape> when make_film.py ran with --format.
|
|
5
6
|
set -euo pipefail
|
|
6
|
-
DIR=$(cd "${1:?film dir}" && pwd);
|
|
7
|
+
DIR=$(cd "${1:?film dir}" && pwd); THUMB=${3:-3}
|
|
7
8
|
SKILL=$(cd "$(dirname "$0")/.." && pwd)
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
m(){ python3 -c "import json;print(json.load(open('$DIR/film/meta.json')).get('$1',''))"; }
|
|
10
|
+
# folder: the film's name; name: the file's, which make_film.py --format suffixes (gerbang-4x5) so shapes sit side by side.
|
|
11
|
+
NAME=$(m name); FOLDER=$(m folder); PRESET=$(m preset); LUFS=${2:-$(m loudness)}
|
|
12
|
+
OUT="$PWD/output/${FOLDER:-$NAME}/$PRESET"; mkdir -p "$OUT/previous"
|
|
10
13
|
if [ -f "$OUT/$NAME.mp4" ]; then
|
|
11
14
|
stamp=$(date +%Y%m%d-%H%M%S)
|
|
12
15
|
for x in "$OUT/$NAME".mp4 "$OUT/$NAME".srt "$OUT/$NAME"-thumb.jpg; do [ -f "$x" ] && mv "$x" "$OUT/previous/$(basename "${x%.*}")-$stamp.${x##*.}"; done
|
|
13
16
|
fi
|
|
14
|
-
|
|
15
|
-
(cd "$ENGINE" && npx remotion render src/index.ts "Film-$FMT" "$OUT/$NAME.raw.mp4" --props="$DIR/.props.json" --public-dir="$DIR" --log=error 2>&1 | grep -v "network requests" || true)
|
|
17
|
+
(cd "$DIR/film" && npx --yes hyperframes@0.8.176 render --crf 20 -o "$OUT/$NAME.raw.mp4" >/dev/null)
|
|
16
18
|
J=$(ffmpeg -hide_banner -i "$OUT/$NAME.raw.mp4" -af loudnorm=I=$LUFS:TP=-1.5:LRA=11:print_format=json -f null - 2>&1 | sed -n '/{/,/}/p')
|
|
17
19
|
g(){ echo "$J" | python3 -c "import sys,json;print(json.load(sys.stdin)['$1'])"; }
|
|
18
20
|
ffmpeg -v error -y -i "$OUT/$NAME.raw.mp4" -c:v copy -c:a aac -b:a 192k \
|
|
19
21
|
-af "loudnorm=I=$LUFS:TP=-1.5:LRA=11:measured_I=$(g input_i):measured_TP=$(g input_tp):measured_LRA=$(g input_lra):measured_thresh=$(g input_thresh):offset=$(g target_offset):linear=true" \
|
|
20
22
|
"$OUT/$NAME.mp4"
|
|
21
|
-
rm "$OUT/$NAME.raw.mp4" "$DIR
|
|
23
|
+
rm "$OUT/$NAME.raw.mp4"; rm -rf "$DIR/film/renders"
|
|
22
24
|
python3 "$SKILL/scripts/srt.py" "$DIR" "$OUT/$NAME.srt"
|
|
23
25
|
ffmpeg -v error -y -ss "$THUMB" -i "$OUT/$NAME.mp4" -frames:v 1 -q:v 2 "$OUT/$NAME-thumb.jpg"
|
|
24
|
-
|
|
25
|
-
echo "Voice & sound effects: see the voice provider in film.json (ElevenLabs commercial use requires a paid plan)." >> "$OUT/$NAME-credits.txt"
|
|
26
|
+
cp "$DIR/film/CREDITS.txt" "$OUT/CREDITS.txt"
|
|
26
27
|
ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUT/$NAME.mp4" | xargs -I{} echo "done: $OUT/$NAME.mp4 ({} s, $LUFS LUFS)"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""Build the sound-effect / room-tone library
|
|
1
|
+
"""Build the sound-effect / room-tone library on this machine (~/.kaizen/kaizen-video/sfx/; override KAIZEN_VIDEO_HOME).
|
|
2
2
|
Usage: python3 scripts/sfx.py [--provider synth|elevenlabs] [--force]
|
|
3
3
|
synth (default without ELEVENLABS_API_KEY) — synthesized locally with ffmpeg; free, no licence limits.
|
|
4
4
|
elevenlabs (default with a key) — generated sound effects; commercial use needs a paid ElevenLabs plan.
|
|
5
|
-
Only missing files are made unless --force. Names are what
|
|
5
|
+
Only missing files are made unless --force. Names are what a film preset's style.json and beats (sfx/ambience) use.
|
|
6
6
|
No sound files ship with the skill: every machine makes its own."""
|
|
7
7
|
import json, os, subprocess, sys, urllib.request
|
|
8
8
|
|
|
@@ -43,7 +43,7 @@ SYNTH = {
|
|
|
43
43
|
args = sys.argv[1:]
|
|
44
44
|
force = "--force" in args
|
|
45
45
|
provider = args[args.index("--provider") + 1] if "--provider" in args else ("elevenlabs" if os.environ.get("ELEVENLABS_API_KEY") else "synth")
|
|
46
|
-
SFX = os.path.expanduser(os.environ.get("
|
|
46
|
+
SFX = os.path.expanduser(os.environ.get("KAIZEN_VIDEO_HOME", "~/.kaizen/kaizen-video")) + "/sfx"
|
|
47
47
|
os.makedirs(SFX, exist_ok=True)
|
|
48
48
|
H = {"xi-api-key": os.environ.get("ELEVENLABS_API_KEY", ""), "Content-Type": "application/json"}
|
|
49
49
|
print("sfx provider:", provider)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Write an .srt from the cues make_film.py computed (<film-dir>/film/cues.json: [[start, end, text], ...]).
|
|
2
|
+
Usage: python3 scripts/srt.py <film-dir> <out.srt>"""
|
|
3
|
+
import json, sys
|
|
4
|
+
cues = json.load(open(f"{sys.argv[1]}/film/cues.json"))
|
|
5
|
+
def ts(sec):
|
|
6
|
+
ms = int(round(sec * 1000)); h, ms = divmod(ms, 3600000); m, ms = divmod(ms, 60000); s, ms = divmod(ms, 1000)
|
|
7
|
+
return f"{h:02}:{m:02}:{s:02},{ms:03}"
|
|
8
|
+
open(sys.argv[2], "w").write("\n".join(f"{i}\n{ts(s)} --> {ts(e)}\n{t}\n" for i, (s, e, t) in enumerate(cues, 1)))
|
|
9
|
+
print("srt", len(cues), "cues")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Quick check frames of a built film: scripts/still.sh <film-dir> <second> [<second>...]
|
|
3
|
+
# -> <film-dir>/film/check/at-<second>s.png (run make_film.py first; delete the folder after looking)
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
DIR=$(cd "$1" && pwd); shift
|
|
6
|
+
IFS=,; (cd "$DIR/film" && npx --yes hyperframes@0.8.176 snapshot --at "$*" --no-end -o check >/dev/null)
|
|
7
|
+
ls "$DIR"/film/check/*.png
|
package/web/board/views.ts
CHANGED
|
@@ -58,7 +58,7 @@ export function go(v, push) {
|
|
|
58
58
|
|
|
59
59
|
export function initViews() {
|
|
60
60
|
document.querySelectorAll(".rail a[data-view]").forEach((a) => {
|
|
61
|
-
if (a.dataset.view === "notes") return;
|
|
61
|
+
if (a.dataset.view === "notes" || a.dataset.view === "video") return;
|
|
62
62
|
a.onclick = (e) => {
|
|
63
63
|
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button) return;
|
|
64
64
|
e.preventDefault();
|
package/web/board.html
CHANGED
|
@@ -539,6 +539,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
|
|
|
539
539
|
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
|
|
540
540
|
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M4 5h16l-6 8v6l-4-2v-4z"/></symbol>
|
|
541
541
|
<symbol id="i-note" viewBox="0 0 24 24"><path d="M6 3h9l4 4v14H6z"/><path d="M14 3v5h5M9 13h6M9 17h6"/></symbol>
|
|
542
|
+
<symbol id="i-video" viewBox="0 0 24 24"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="M16 10.5l5-3v9l-5-3z"/></symbol>
|
|
542
543
|
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14M10 11v6M14 11v6"/></symbol>
|
|
543
544
|
<symbol id="i-ask" viewBox="0 0 24 24"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z"/><path d="M9.8 9.5a2.3 2.3 0 0 1 4.4.8c0 1.5-2.2 2-2.2 3.2M12 16.5h.01"/></symbol>
|
|
544
545
|
<symbol id="i-term" viewBox="0 0 24 24"><path d="M4 17l6-5-6-5M12 19h8"/></symbol>
|
|
@@ -590,6 +591,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
|
|
|
590
591
|
<a class="it" href="/?view=icebox" data-view="sources" title="Icebox: requests gathered from your sources"><svg><use href="#i-inbox"/></svg><span class="lbl">Icebox</span></a>
|
|
591
592
|
<a class="it" href="/" data-view="board" title="Board"><svg><use href="#i-board"/></svg><span class="lbl">Board</span></a>
|
|
592
593
|
<a class="it" href="/notebook" data-view="notes" title="Notes"><svg><use href="#i-note"/></svg><span class="lbl">Notes</span></a>
|
|
594
|
+
<a class="it" href="/video" data-view="video" title="Video: make and edit videos"><svg><use href="#i-video"/></svg><span class="lbl">Video</span></a>
|
|
593
595
|
<a class="it" href="/?view=terminal" data-view="terminal" title="Terminal: every agent running in its card"><svg><use href="#i-term"/></svg><span class="lbl">Terminal</span></a>
|
|
594
596
|
<a class="it" href="/?view=settings" data-view="settings" title="Settings"><svg><use href="#i-gear"/></svg><span class="lbl">Settings</span></a>
|
|
595
597
|
<span class="end"></span>
|