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.
- explainer_studio-0.1.5.dist-info/METADATA +149 -0
- explainer_studio-0.1.5.dist-info/RECORD +37 -0
- explainer_studio-0.1.5.dist-info/WHEEL +4 -0
- explainer_studio-0.1.5.dist-info/entry_points.txt +2 -0
- explainer_studio-0.1.5.dist-info/licenses/LICENSE +41 -0
- studio/__init__.py +0 -0
- studio/build.py +196 -0
- studio/chatterbox_worker.py +96 -0
- studio/cli.py +265 -0
- studio/common.py +160 -0
- studio/data/brand/brand.css +145 -0
- studio/data/brand/fonts/JetBrainsMono.ttf +0 -0
- studio/data/brand/fonts/OFL-JetBrainsMono.txt +93 -0
- studio/data/brand/fonts/OFL-PTSerif.txt +93 -0
- studio/data/brand/fonts/OFL-SourceSans3.txt +93 -0
- studio/data/brand/fonts/PTSerif-Bold.ttf +0 -0
- studio/data/brand/fonts/PTSerif-BoldItalic.ttf +0 -0
- studio/data/brand/fonts/PTSerif-Italic.ttf +0 -0
- studio/data/brand/fonts/PTSerif-Regular.ttf +0 -0
- studio/data/brand/fonts/SourceSans3-Bold.ttf +0 -0
- studio/data/brand/fonts/SourceSans3-Italic.ttf +0 -0
- studio/data/brand/fonts/SourceSans3.ttf +0 -0
- studio/data/brand/frame.md +108 -0
- studio/data/brand/pronunciations.yaml +8 -0
- studio/data/brand/vendor/gsap.min.js +11 -0
- studio/data/templates/scenes/blank.html +46 -0
- studio/data/templates/scenes/outro.html +50 -0
- studio/data/templates/scenes/points.html +55 -0
- studio/data/templates/scenes/quote.html +41 -0
- studio/data/templates/scenes/stat.html +57 -0
- studio/data/templates/scenes/title.html +46 -0
- studio/data/templates/script.md +51 -0
- studio/data/templates/thumbnail.html +28 -0
- studio/music.py +66 -0
- studio/render.py +271 -0
- studio/script.py +158 -0
- 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
|
+
[](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,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()
|