explainer-studio 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. explainer_studio-0.1.5.dist-info/METADATA +149 -0
  2. explainer_studio-0.1.5.dist-info/RECORD +37 -0
  3. explainer_studio-0.1.5.dist-info/WHEEL +4 -0
  4. explainer_studio-0.1.5.dist-info/entry_points.txt +2 -0
  5. explainer_studio-0.1.5.dist-info/licenses/LICENSE +41 -0
  6. studio/__init__.py +0 -0
  7. studio/build.py +196 -0
  8. studio/chatterbox_worker.py +96 -0
  9. studio/cli.py +265 -0
  10. studio/common.py +160 -0
  11. studio/data/brand/brand.css +145 -0
  12. studio/data/brand/fonts/JetBrainsMono.ttf +0 -0
  13. studio/data/brand/fonts/OFL-JetBrainsMono.txt +93 -0
  14. studio/data/brand/fonts/OFL-PTSerif.txt +93 -0
  15. studio/data/brand/fonts/OFL-SourceSans3.txt +93 -0
  16. studio/data/brand/fonts/PTSerif-Bold.ttf +0 -0
  17. studio/data/brand/fonts/PTSerif-BoldItalic.ttf +0 -0
  18. studio/data/brand/fonts/PTSerif-Italic.ttf +0 -0
  19. studio/data/brand/fonts/PTSerif-Regular.ttf +0 -0
  20. studio/data/brand/fonts/SourceSans3-Bold.ttf +0 -0
  21. studio/data/brand/fonts/SourceSans3-Italic.ttf +0 -0
  22. studio/data/brand/fonts/SourceSans3.ttf +0 -0
  23. studio/data/brand/frame.md +108 -0
  24. studio/data/brand/pronunciations.yaml +8 -0
  25. studio/data/brand/vendor/gsap.min.js +11 -0
  26. studio/data/templates/scenes/blank.html +46 -0
  27. studio/data/templates/scenes/outro.html +50 -0
  28. studio/data/templates/scenes/points.html +55 -0
  29. studio/data/templates/scenes/quote.html +41 -0
  30. studio/data/templates/scenes/stat.html +57 -0
  31. studio/data/templates/scenes/title.html +46 -0
  32. studio/data/templates/script.md +51 -0
  33. studio/data/templates/thumbnail.html +28 -0
  34. studio/music.py +66 -0
  35. studio/render.py +271 -0
  36. studio/script.py +158 -0
  37. studio/voice.py +633 -0
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: explainer-studio
3
+ Version: 0.1.5
4
+ Summary: Command-line studio for narrated explainer videos (HyperFrames + Kokoro TTS + ffmpeg)
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: <3.14,>=3.12
8
+ Requires-Dist: kokoro-onnx>=0.6.1
9
+ Requires-Dist: misaki>=0.9
10
+ Requires-Dist: num2words>=0.5
11
+ Requires-Dist: numpy>=2.0
12
+ Requires-Dist: pyyaml>=6.0
13
+ Requires-Dist: soundfile>=0.12
14
+ Requires-Dist: spacy<3.9,>=3.8
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Explainer Studio
18
+
19
+ [![CI](https://github.com/JoeMountWSWS/explainer-studio/actions/workflows/ci.yml/badge.svg)](https://github.com/JoeMountWSWS/explainer-studio/actions/workflows/ci.yml)
20
+
21
+ A command-line studio for narrated explainer videos in the style of [wsws.org](https://www.wsws.org).
22
+ You write a script in Markdown. The studio narrates it with local TTS, aligns every word to the audio,
23
+ and turns each scene into a [HyperFrames](https://www.npmjs.com/package/hyperframes) HTML composition
24
+ that is timed to the narration. It then renders a mastered 1920×1080 MP4 for YouTube, with captions
25
+ and chapter markers.
26
+
27
+ ```
28
+ script.md ──► narration (Kokoro, local; or ElevenLabs) ──► word timings (Whisper)
29
+ ──► scenes/<id>.html (HyperFrames + GSAP, timed to cues) ──► cached per-scene renders
30
+ ──► out/<slug>.mp4 (−14 LUFS) + out/<slug>.srt + out/<slug>.chapters.txt
31
+ ```
32
+
33
+ # Installation
34
+
35
+ ## Requirements
36
+
37
+ - Linux (developed on Arch with an AMD GPU; the render uses Chromium's GPU capture)
38
+ - [uv](https://docs.astral.sh/uv/), with Python 3.12 or 3.13 (`kokoro-onnx` doesn't support 3.14 yet)
39
+ - Node.js with `npx`. The HyperFrames CLI is fetched on demand at the version pinned in `tools/studio/common.py`.
40
+ - ffmpeg and Chromium
41
+ - Optional: an ElevenLabs API key in `.env` (`ELEVENLABS_API_KEY=…`) for cloud narration
42
+
43
+ The first `sync` downloads the Kokoro-82M model to `~/.cache/hyperframes/tts` and spaCy's
44
+ `en_core_web_sm` to `~/.cache/hyperframes/spacy`.
45
+
46
+ # Usage
47
+
48
+ Making a video with the studio is a loop of writing and listening. You write the script as plain
49
+ Markdown, and the studio reads it aloud, works out exactly when each word is spoken, and hands those
50
+ timings to your scenes so the visuals move in step with the voice. Change a sentence and only that
51
+ sentence is re-recorded and only the affected scenes are re-rendered, so you can refine a five-minute
52
+ video in seconds rather than starting over. Everything runs from one command, `uv run studio`, and
53
+ nothing leaves your machine unless you choose cloud narration.
54
+
55
+ ## Install
56
+
57
+ With the [requirements](#requirements) in place, either install the `studio` command from PyPI or
58
+ clone the repository.
59
+
60
+ **From PyPI**, to make videos in a folder of your own:
61
+
62
+ ```bash
63
+ uv tool install explainer-studio # or run it without installing: uvx --from explainer-studio studio …
64
+ mkdir my-videos && cd my-videos
65
+ studio new my-video "My Video"
66
+ ```
67
+
68
+ The folder you run `studio` in becomes the workspace. It holds `episodes/`, `out/` and `.env`, and
69
+ `studio` finds it from any subdirectory (or set `STUDIO_ROOT`). The house style (`brand/`) and scene
70
+ templates ship with the package. To customise them, copy `brand/` or `templates/` from this repository
71
+ into the workspace, and the studio uses your copy instead. Music beds and voice clips go in the
72
+ workspace's `brand/music/` and `brand/voices/`. Use `studio` wherever this README says `uv run studio`.
73
+
74
+ **From a clone**, to work on the studio itself or to use the example episodes:
75
+
76
+ ```bash
77
+ git clone git@github.com:JoeMountWSWS/explainer-studio.git
78
+ cd explainer-studio
79
+ uv sync
80
+ ```
81
+
82
+ Run commands from the repository root (or any folder below it). Confirm the install with
83
+ `uv run studio --help`. The first `sync` of an episode downloads the Kokoro voice model and the
84
+ spaCy English model, so it takes a little longer than later runs.
85
+
86
+ ## Quick start
87
+
88
+ ```bash
89
+ uv run studio new my-video "My Video" # scaffold episodes/my-video/
90
+ $EDITOR episodes/my-video/script.md # write the script
91
+ uv run studio sync my-video # narrate, align, stub scene files, write index.html
92
+ uv run studio status my-video # words, estimated vs actual length, cues per scene
93
+ # design each episodes/my-video/scenes/<id>.html
94
+ uv run studio preview my-video # HyperFrames Studio in the browser, with narration
95
+ uv run studio check my-video # lint, layout and contrast audits (must pass)
96
+ uv run studio render my-video --draft # quick review copy -> out/my-video-draft.mp4
97
+ uv run studio render my-video # final -> out/my-video.mp4 + .srt + .chapters.txt
98
+ uv run studio thumbnail my-video # out/my-video-thumbnail.jpg
99
+ uv run studio voices my-video # audition Kokoro voice blends -> out/voice-samples/
100
+ ```
101
+
102
+ Only scenes that have changed are re-rendered. Changing a paragraph of narration re-synthesises
103
+ that paragraph and nothing else, and re-times the visuals that depend on it.
104
+
105
+ ## Writing a script
106
+
107
+ ```markdown
108
+ ---
109
+ title: The Paris Commune
110
+ voice: bf_emma*0.6 + bf_isabella*0.4
111
+ speed: 0.85
112
+ ---
113
+ ## hook: Seventy-two days
114
+ > template: title
115
+
116
+ In the spring of 1871, the working class took power in a major city. {sub} This is the story of the Paris Commune.
117
+ ```
118
+
119
+ Each `## id: Chapter title` heading is a scene and a YouTube chapter. `> template:` chooses the
120
+ starting pattern for the scene file (`title`, `stat`, `quote`, `points`, `outro`, `blank`). Other `>`
121
+ lines are visual notes and are never spoken. `{name}` marks a cue that the scene can animate on,
122
+ and `{pause 1.5}` inserts a pause.
123
+
124
+ The full documentation is at <https://joemountwsws.github.io/explainer-studio/>. [AGENTS.md](AGENTS.md) is the reference for coding agents: every front-matter setting, voice choice, the rules for
125
+ authoring scenes, the render pipeline and known problems. Read [brand/frame.md](brand/frame.md)
126
+ before designing scenes.
127
+
128
+ ## Layout
129
+
130
+ | Path | What it is |
131
+ | --- | --- |
132
+ | `tools/studio/` | The Python CLI (`uv run studio …`) |
133
+ | `brand/` | House style shared by every episode: design spec, CSS, fonts, GSAP, pronunciations |
134
+ | `templates/` | Starter script, scene patterns and thumbnail |
135
+ | `episodes/<slug>/` | One HyperFrames project per video. `script.md` is the source of truth |
136
+ | `docs/` | The documentation site (MkDocs), including notes such as British ElevenLabs voices |
137
+ | `out/` | Rendered deliverables (gitignored) |
138
+
139
+ Worked examples: `episodes/demo-paris-commune` (Kokoro, 38s) and `episodes/demo-elevenlabs`
140
+ (the same script narrated by ElevenLabs).
141
+
142
+ ## Working with coding agents
143
+
144
+ The studio is designed to be driven by Claude Code or another coding agent. `AGENTS.md` (loaded
145
+ through `CLAUDE.md`) gives the agent the conventions it needs, the HyperFrames skills cover the
146
+ composition format and motion, and the `make-explainer-video` skill
147
+ (`.claude/skills/make-explainer-video/`) walks Claude through the whole workflow. Start `claude` in
148
+ the repository root and ask for a video, or run `/make-explainer-video`. See
149
+ [Working with a coding agent](https://joemountwsws.github.io/explainer-studio/coding-agents/).
@@ -0,0 +1,37 @@
1
+ studio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ studio/build.py,sha256=TslXn0NCOPXE8YiedKuSoQ8KSBi_96Z1xSPIno8wt3E,8272
3
+ studio/chatterbox_worker.py,sha256=uH2PcWBmP3Ku9019deLTEfPxclILWgukJqB-gYyOGMg,3725
4
+ studio/cli.py,sha256=4EqdOkoKxURH0H8uFm91QvO-RX0Lx2ntk3tymq6aGy4,12504
5
+ studio/common.py,sha256=BFBDD9uamIOVKgAfFBCRoIuzn-p7SS51xwoB9LgqBmo,6796
6
+ studio/music.py,sha256=InOhFZp0jW-v9dMVzTqFfJxEnHreyIrR7cP2LheGhJI,2986
7
+ studio/render.py,sha256=MZq3X9BkofIpKOTN9yJjBXVADULddG-STcWwmGMnyLw,12888
8
+ studio/script.py,sha256=XFDvKpvfcnnIGDwxae1NRM5haeYNnrTZ1QVtx2rO7hQ,5137
9
+ studio/voice.py,sha256=xAN8vbr5NVZ1A4AomKYSHhismpuw1m7UtvzKOzOY4Qc,27223
10
+ studio/data/templates/script.md,sha256=ctxlYiVCUodAkc7tO_BiBvPoooOF2fGmrbb878oypoQ,2236
11
+ studio/data/templates/thumbnail.html,sha256=aDwi-sxGCoIiKc7Xogu2PpbANrr5D0P1pZFap7y657Q,1317
12
+ studio/data/templates/scenes/blank.html,sha256=SEmzt9jLiIkUo7JqnXGpsjEZFp8GQp-W2UMzMhjhCJQ,2071
13
+ studio/data/templates/scenes/outro.html,sha256=h5FSTDl6czgwQJ6YAWDmM_O_etUp7LdaUT6NxaTljSE,2386
14
+ studio/data/templates/scenes/points.html,sha256=HRaVC28g_uQilSs1Z2RdoNxOGUMFGDk2QKW9EB85iSU,2795
15
+ studio/data/templates/scenes/quote.html,sha256=MYhwmmNopGVEY08QEpzbHVPDz_wj3bMq-RS6nsRdk8w,1953
16
+ studio/data/templates/scenes/stat.html,sha256=JbV25BGypTDotK7mjOHRYzl89-xFp-SwJ-tqC5_JVYk,3001
17
+ studio/data/templates/scenes/title.html,sha256=9wAI9GjJL7YzLpQQl9JYFJNNkEJ5eUGCPfHMrfkj8CE,2191
18
+ studio/data/brand/brand.css,sha256=PogQXVQihSt443q6l3egFKkdaTbHW2adl3J62vIvCEw,4632
19
+ studio/data/brand/fonts/JetBrainsMono.ttf,sha256=SHFaQuwkLCHp8CaSiR4UfQIimaUuSNXkE-GpQhk__to,187208
20
+ studio/data/brand/fonts/OFL-JetBrainsMono.txt,sha256=sv5eiYdZTp_9HSylKi9dc-uDNSQ4k8XWJUta1pJpWR0,4399
21
+ studio/data/brand/fonts/OFL-PTSerif.txt,sha256=3fMRwo3fWlrZdHZJg3NGtnvtnTVnicMHK7J9vOSeUU0,4434
22
+ studio/data/brand/fonts/OFL-SourceSans3.txt,sha256=CXRnhyh6KJMjsOw8_00aSoATMbgrcgfB4Yb10mYZo5I,4579
23
+ studio/data/brand/fonts/PTSerif-Bold.ttf,sha256=A4unM2vX6hTxKtFVvtUaQ0XKxRUyddUh3sO6BAIcUm4,339996
24
+ studio/data/brand/fonts/PTSerif-BoldItalic.ttf,sha256=8AN4i6CJgesJiLNVem8iSlPatJwg4oPot01a88Rm-L4,337580
25
+ studio/data/brand/fonts/PTSerif-Italic.ttf,sha256=9X6V_53IVpGjsuGT8gKNs29mY5OaRsD8TyhtYYuAt84,375356
26
+ studio/data/brand/fonts/PTSerif-Regular.ttf,sha256=pJUfreBv-PCbdnOqgf-2WozUCeJNMomm3GcLxN2iVXo,359048
27
+ studio/data/brand/fonts/SourceSans3-Bold.ttf,sha256=jRaSttx_uzPM1nqY_md_YdGsx_QfcAAgeiSi9-CJOYg,379420
28
+ studio/data/brand/fonts/SourceSans3-Italic.ttf,sha256=OeOrBczXy5SQfDEAW7W-wdVDLwsJait4KXbiF6VA62w,395372
29
+ studio/data/brand/fonts/SourceSans3.ttf,sha256=BC_izAuTPjKEENesvQqmoYc9ylrvgYdfS8IUsIglx7k,646340
30
+ studio/data/brand/frame.md,sha256=zfa_Ry2vYkB_Tr0FN6XQOl2_uw59yWbKVIzpkx8kkOo,5626
31
+ studio/data/brand/pronunciations.yaml,sha256=Zd7uMM7N1I0PxuxEBZneCCtQ8aCtc7ppC6mYZuqscms,418
32
+ studio/data/brand/vendor/gsap.min.js,sha256=wXS_zlOnKUGNV6ithiXnJHx5OiL--OKFHjz6PenNgoA,72779
33
+ explainer_studio-0.1.5.dist-info/METADATA,sha256=hCwJL2Za7FBRNS-DTHKpZx4-UnXHn93gYWPI2MkfGeI,7090
34
+ explainer_studio-0.1.5.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
35
+ explainer_studio-0.1.5.dist-info/entry_points.txt,sha256=gyRQqzO1R9OGry73U8LLCD6Yl1eh2kxLWzHXyVTQwvk,43
36
+ explainer_studio-0.1.5.dist-info/licenses/LICENSE,sha256=W3y-R_D5tZ6ip9FM-tw-4wc9hbTv0yps-B_UUH_0CD4,2110
37
+ explainer_studio-0.1.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ studio = studio.cli:main
@@ -0,0 +1,41 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 World Socialist Web Site
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ -------------------------------------------------------------------------------
24
+ Scope
25
+
26
+ The MIT licence above covers the studio software and its documentation:
27
+ tools/, templates/, brand/brand.css, brand/frame.md, brand/pronunciations.yaml,
28
+ docs/, the .github/ workflows, pyproject.toml, README.md, AGENTS.md and CLAUDE.md.
29
+
30
+ It does NOT cover editorial content. Everything under episodes/ and scripts/
31
+ (scripts, narration audio, timings, scene designs, thumbnails and the videos
32
+ made from them) is Copyright (c) 2026 World Socialist Web Site, all rights
33
+ reserved.
34
+
35
+ Third-party files keep their own licences:
36
+ - brand/fonts/: PT Serif, Source Sans 3 and JetBrains Mono, SIL Open Font
37
+ License 1.1 (see the OFL-*.txt files alongside them).
38
+ - brand/vendor/gsap.min.js: GSAP, GreenSock Standard License
39
+ (https://gsap.com/standard-license), as stated in the file header.
40
+ - episodes/*/assets/media/: photographs, maps and footage remain under the
41
+ terms of their original sources, credited on screen where they are used.
studio/__init__.py ADDED
File without changes
studio/build.py ADDED
@@ -0,0 +1,196 @@
1
+ """Turn script + narration into a HyperFrames project: timing, scene stubs, index.html."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import re
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ from .common import BRAND, FORMATS, HF_VERSION, ROOT, TEMPLATES, die, info
12
+ from .script import Script
13
+
14
+ TIMING_RE = re.compile(r"(/\* <studio:timing> \*/)(.*?)(/\* </studio:timing> \*/)", re.S)
15
+
16
+
17
+ def frames(seconds: float, fps: int) -> int:
18
+ return int(round(seconds * fps))
19
+
20
+
21
+ def compute_timing(script: Script, vo: dict[str, dict]) -> dict:
22
+ """Lay scenes end to end on whole frames. Scene-local times throughout, except `start`."""
23
+ fps = int(script.setting("fps"))
24
+ fmt = str(script.setting("format"))
25
+ if fmt not in FORMATS:
26
+ die(f"unknown format '{fmt}' (have: {', '.join(FORMATS)})")
27
+ width, height = FORMATS[fmt]
28
+ pad_in, pad_out = float(script.setting("pad_in")), float(script.setting("pad_out"))
29
+ t_frames, scenes = 0, []
30
+ for sc in script.scenes:
31
+ rec = vo[sc.id]
32
+ has_vo = rec["duration"] > 0
33
+ lead = pad_in if has_vo else 0.0
34
+ need = lead + rec["duration"] + (pad_out if has_vo else 0.0)
35
+ dur_f = max(frames(need, fps), frames(sc.min_duration, fps))
36
+ scenes.append({
37
+ "id": sc.id,
38
+ "title": sc.title,
39
+ "template": sc.template,
40
+ "start": t_frames / fps,
41
+ "duration": dur_f / fps,
42
+ "voStart": lead,
43
+ "voEnd": round(lead + rec["duration"], 3),
44
+ "cues": {k: round(v + lead, 3) for k, v in rec["cues"].items()},
45
+ "paragraphs": [round(p + lead, 3) for p in rec["paragraphs"]],
46
+ })
47
+ t_frames += dur_f
48
+ return {"fps": fps, "format": fmt, "width": width, "height": height,
49
+ "duration": t_frames / fps, "scenes": scenes}
50
+
51
+
52
+ def sync_brand(ep: Path) -> None:
53
+ """Copy the studio's shared look into the episode (edit brand/, not these copies)."""
54
+ dst = ep / "assets" / "brand"
55
+ dst.mkdir(parents=True, exist_ok=True)
56
+ shutil.copy2(BRAND / "brand.css", dst / "brand.css")
57
+ shutil.copy2(BRAND / "vendor" / "gsap.min.js", dst / "gsap.min.js")
58
+ shutil.copytree(BRAND / "fonts", dst / "fonts", dirs_exist_ok=True)
59
+ shutil.copy2(BRAND / "frame.md", ep / "frame.md")
60
+
61
+
62
+ def stub_scene(ep: Path, script: Script, scene: dict, size: tuple[int, int]) -> bool:
63
+ path = ep / "scenes" / f"{scene['id']}.html"
64
+ if path.exists():
65
+ return False
66
+ tpl = TEMPLATES / "scenes" / f"{scene['template']}.html"
67
+ if not tpl.exists():
68
+ choices = ", ".join(p.stem for p in (TEMPLATES / "scenes").glob("*.html"))
69
+ die(f"scene '{scene['id']}': unknown template '{scene['template']}' (have: {choices})")
70
+ text = tpl.read_text()
71
+ text = text.replace("__ID__", scene["id"])
72
+ text = text.replace("__TITLE__", html.escape(scene["title"]))
73
+ text = text.replace("__EPISODE__", html.escape(str(script.meta.get("title", "Untitled"))))
74
+ text = text.replace('data-width="1920" data-height="1080"', f'data-width="{size[0]}" data-height="{size[1]}"')
75
+ path.parent.mkdir(parents=True, exist_ok=True)
76
+ path.write_text(text)
77
+ info(f" new scene file scenes/{scene['id']}.html (from {scene['template']} template)")
78
+ return True
79
+
80
+
81
+ def inject_timing(ep: Path, scene: dict) -> None:
82
+ path = ep / "scenes" / f"{scene['id']}.html"
83
+ text = path.read_text()
84
+ data = {k: scene[k] for k in ("duration", "voStart", "voEnd", "cues", "paragraphs")}
85
+ data["duration"] = round(data["duration"], 4)
86
+ block = f"\n const T = {json.dumps(data)};\n "
87
+ new, n = TIMING_RE.subn(lambda m: m.group(1) + block + m.group(3), text, count=1)
88
+ if n == 0:
89
+ info(f" warning: scenes/{scene['id']}.html has no /* <studio:timing> */ block; cues not updated")
90
+ return
91
+ if new != text:
92
+ path.write_text(new)
93
+
94
+
95
+ def _host(scene: dict, start: float, size: tuple[int, int]) -> str:
96
+ sid = scene["id"]
97
+ return (
98
+ f' <div id="scene-{sid}" data-composition-id="scene-{sid}" data-composition-src="scenes/{sid}.html"\n'
99
+ f' data-start="{start:.4f}" data-duration="{scene["duration"]:.4f}" data-track-index="1"\n'
100
+ f' data-track-kind="graphics" data-width="{size[0]}" data-height="{size[1]}"></div>'
101
+ )
102
+
103
+
104
+ def page(title: str, duration: float, body: str, size: tuple[int, int]) -> str:
105
+ w, h = size
106
+ return f"""<!doctype html>
107
+ <!-- GENERATED by `studio sync` from script.md. Do not edit: changes are overwritten. -->
108
+ <html lang="en" data-resolution="{'portrait' if h > w else 'landscape'}">
109
+ <head>
110
+ <meta charset="UTF-8" />
111
+ <meta name="viewport" content="width={w}, height={h}" />
112
+ <title>{html.escape(title)}</title>
113
+ <script src="assets/brand/gsap.min.js"></script>
114
+ <link rel="stylesheet" href="assets/brand/brand.css" />
115
+ <style>
116
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
117
+ html, body {{ width: {w}px; height: {h}px; overflow: hidden; background: #ffffff; }}
118
+ #root {{ position: relative; width: 100%; height: 100%; overflow: hidden; }}
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <div id="root" data-composition-id="main" data-start="0" data-duration="{duration:.4f}" data-width="{w}" data-height="{h}">
123
+ {body}
124
+ </div>
125
+ <script>
126
+ window.__timelines["main"] = gsap.timeline({{ paused: true }});
127
+ </script>
128
+ </body>
129
+ </html>
130
+ """
131
+
132
+
133
+ def write_index(ep: Path, script: Script, timing: dict) -> None:
134
+ size = (timing["width"], timing["height"])
135
+ parts = [_host(s, s["start"], size) for s in timing["scenes"]]
136
+ for s in timing["scenes"]:
137
+ if s["voEnd"] > s["voStart"]:
138
+ parts.append(
139
+ f' <audio id="vo-{s["id"]}" src="audio/vo/{s["id"]}.wav" data-start="{s["start"] + s["voStart"]:.4f}" '
140
+ f'data-duration="{s["voEnd"] - s["voStart"]:.4f}" data-track-index="10" data-volume="1"></audio>'
141
+ )
142
+ music = script.setting("music")
143
+ if music:
144
+ ext = Path(music).suffix
145
+ dst = ep / "assets" / f"music{ext}"
146
+ src = next((p for p in (ep / music, ROOT / music) if p.exists()), ROOT / music)
147
+ if not src.exists():
148
+ die(f"music file not found: {src}")
149
+ shutil.copy2(src, dst)
150
+ vol = round(10 ** (float(script.setting("music_db")) / 20), 4)
151
+ parts.append(
152
+ f' <audio id="music" src="assets/music{ext}" data-start="0" data-duration="{timing["duration"]:.4f}" '
153
+ f'data-track-index="11" data-volume="{vol}"></audio>'
154
+ )
155
+ (ep / "index.html").write_text(page(str(script.meta.get("title", "Untitled")), timing["duration"], "\n".join(parts), size))
156
+
157
+
158
+ def render_shell(ep: Path, scene: dict, timing: dict) -> Path:
159
+ """A temporary root page hosting one scene at t=0 (silent), so scenes render independently."""
160
+ shell = ep / f"_render_{scene['id']}.html"
161
+ size = (timing["width"], timing["height"])
162
+ shell.write_text(page(scene["id"], scene["duration"], _host(scene, 0.0, size), size))
163
+ return shell
164
+
165
+
166
+ def ensure_project_files(ep: Path) -> None:
167
+ pkg = ep / "package.json"
168
+ pkg.write_text(json.dumps({
169
+ "name": ep.name,
170
+ "private": True,
171
+ "type": "module",
172
+ "scripts": {
173
+ "dev": f"npx --yes hyperframes@{HF_VERSION} preview",
174
+ "check": f"npx --yes hyperframes@{HF_VERSION} check",
175
+ },
176
+ }, indent=2) + "\n")
177
+ hfj = ep / "hyperframes.json"
178
+ if not hfj.exists():
179
+ hfj.write_text(json.dumps({
180
+ "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
181
+ "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
182
+ "paths": {"blocks": "compositions", "components": "compositions/components", "assets": "assets"},
183
+ "media": {"autoProxy": True},
184
+ }, indent=2) + "\n")
185
+
186
+
187
+ def build(ep: Path, script: Script, vo: dict[str, dict]) -> dict:
188
+ ensure_project_files(ep)
189
+ sync_brand(ep)
190
+ timing = compute_timing(script, vo)
191
+ for s in timing["scenes"]:
192
+ stub_scene(ep, script, s, (timing["width"], timing["height"]))
193
+ inject_timing(ep, s)
194
+ write_index(ep, script, timing)
195
+ (ep / "timing.json").write_text(json.dumps(timing, indent=1))
196
+ return timing
@@ -0,0 +1,96 @@
1
+ """Chatterbox TTS worker, run by voice.Chatterbox in its own uv environment (torch, numpy<2).
2
+
3
+ Not part of the studio package's imports: Chatterbox pins torch and numpy<2, which can't share
4
+ the studio venv. Usage:
5
+
6
+ python chatterbox_worker.py turbo|standard
7
+
8
+ Then one JSON request per stdin line:
9
+ {"text", "out", "ref" (path or null for the built-in voice), "exaggeration", "cfg_weight", "temperature", "seed"}
10
+ and one JSON reply per stdout line: {"ok": true, "sr": 24000} or {"error": "..."}.
11
+ Everything else the libraries print goes to stderr.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ import traceback
18
+
19
+ # Keep library chatter off the reply channel: replies go to a private copy of fd 1, and fd 1 itself
20
+ # (what native code and child processes write to) is pointed at stderr.
21
+ replies = os.fdopen(os.dup(1), "w", buffering=1)
22
+ os.dup2(2, 1)
23
+ sys.stdout = sys.stderr
24
+
25
+
26
+ def reply(obj: dict) -> None:
27
+ replies.write(json.dumps(obj) + "\n")
28
+ replies.flush()
29
+
30
+
31
+ def quiet(bar: type) -> type:
32
+ """A tqdm subclass that never draws."""
33
+
34
+ class Quiet(bar):
35
+ def __init__(self, *args, **kwargs):
36
+ super().__init__(*args, **{**kwargs, "disable": True})
37
+
38
+ return Quiet
39
+
40
+
41
+ def main() -> None:
42
+ kind = sys.argv[1] if len(sys.argv) > 1 else "turbo"
43
+ import soundfile as sf
44
+ import torch
45
+
46
+ torch.set_num_threads(os.cpu_count() or 4)
47
+ if kind == "turbo":
48
+ from chatterbox.tts_turbo import ChatterboxTurboTTS as Model
49
+ elif kind == "standard":
50
+ from chatterbox.tts import ChatterboxTTS as Model
51
+ else:
52
+ reply({"error": f"unknown chatterbox_model '{kind}' (choose: turbo, standard)"})
53
+ return
54
+ model = Model.from_pretrained(device="cpu")
55
+ # Mute per-step progress bars during generation (left on above for the first-run download).
56
+ # A subclass, not a partial, so class attributes (tqdm.write, isinstance) keep working.
57
+ for mod in list(sys.modules.values()):
58
+ bar = getattr(mod, "tqdm", None) if (getattr(mod, "__name__", "") or "").startswith("chatterbox") else None
59
+ if isinstance(bar, type):
60
+ mod.tqdm = quiet(bar)
61
+ builtin, current = model.conds, None
62
+ reply({"ok": True, "ready": kind, "sr": model.sr})
63
+
64
+ for line in sys.stdin:
65
+ if not line.strip():
66
+ continue
67
+ try:
68
+ req = json.loads(line)
69
+ ref = req.get("ref")
70
+ exaggeration = float(req.get("exaggeration", 0.5))
71
+ if ref != current:
72
+ # Conditioning on a clip is the slow part; do it once per voice, not per sentence.
73
+ # Keyed on the clip alone: standard's generate() re-applies exaggeration on every call,
74
+ # and Turbo has no emotion input (hp.emotion_adv is False), so it takes no exaggeration.
75
+ if ref and kind == "standard":
76
+ model.prepare_conditionals(ref, exaggeration=exaggeration)
77
+ elif ref:
78
+ model.prepare_conditionals(ref)
79
+ else:
80
+ model.conds = builtin
81
+ current = ref
82
+ torch.manual_seed(int(req.get("seed", 0)))
83
+ kwargs = {"temperature": float(req.get("temperature", 0.8))}
84
+ if kind == "standard":
85
+ kwargs.update(exaggeration=exaggeration, cfg_weight=float(req.get("cfg_weight", 0.5)))
86
+ with torch.inference_mode():
87
+ wav = model.generate(req["text"], **kwargs)
88
+ sf.write(req["out"], wav.squeeze(0).cpu().numpy(), model.sr)
89
+ reply({"ok": True, "sr": model.sr})
90
+ except Exception as e:
91
+ traceback.print_exc()
92
+ reply({"error": f"{type(e).__name__}: {e}"})
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()