explainer-studio 0.1.5__tar.gz → 0.1.6__tar.gz
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.6/.claude/skills/make-explainer-video/SKILL.md +54 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/.gitignore +4 -0
- explainer_studio-0.1.6/AGENTS.md +117 -0
- explainer_studio-0.1.5/README.md → explainer_studio-0.1.6/PKG-INFO +58 -14
- explainer_studio-0.1.5/PKG-INFO → explainer_studio-0.1.6/README.md +37 -30
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/brand.css +8 -2
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/frame.md +9 -10
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/pyproject.toml +13 -1
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/build.py +15 -1
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/cli.py +12 -1
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/common.py +2 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/render.py +29 -5
- explainer_studio-0.1.6/tools/studio/skill.py +49 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/LICENSE +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/JetBrainsMono.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/OFL-JetBrainsMono.txt +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/OFL-PTSerif.txt +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/OFL-SourceSans3.txt +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/PTSerif-Bold.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/PTSerif-BoldItalic.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/PTSerif-Italic.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/PTSerif-Regular.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/SourceSans3-Bold.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/SourceSans3-Italic.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/fonts/SourceSans3.ttf +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/pronunciations.yaml +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/brand/vendor/gsap.min.js +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/scenes/blank.html +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/scenes/outro.html +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/scenes/points.html +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/scenes/quote.html +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/scenes/stat.html +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/scenes/title.html +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/script.md +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/templates/thumbnail.html +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/__init__.py +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/chatterbox_worker.py +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/music.py +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/script.py +0 -0
- {explainer_studio-0.1.5 → explainer_studio-0.1.6}/tools/studio/voice.py +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: make-explainer-video
|
|
3
|
+
description: Make a narrated explainer video (YouTube landscape or 9:16 reel) with this studio. Use when asked to create, script, narrate, design scenes for, preview, check, render or thumbnail an episode under episodes/, or to fix a failing `studio check` or render.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Make an explainer video with the studio
|
|
7
|
+
|
|
8
|
+
Pipeline: `script.md` → local TTS → word timings → one HyperFrames scene per script section → cached renders → mastered MP4, `.srt` and chapters in `out/`. All commands run from the workspace (the folder holding `episodes/`) as `studio <cmd> <slug>`; in a clone of the repository, run it as `uv run studio <cmd> <slug>`.
|
|
9
|
+
|
|
10
|
+
The full reference (script settings, TTS engines, gotchas) is `AGENTS.md` in a clone, or `references/agents.md` beside this file when the skill was installed with `studio install-skill`. The design spec is `brand/frame.md` or `references/frame.md`, and the starter script is `templates/script.md` or `references/script-template.md`. (In a pip-installed workspace there is no `brand/` or `templates/` folder unless you copied one in; the house style then ships with the package.) Read the design spec before designing any scene, and load the `hyperframes-core` and `hyperframes-animation` skills for the composition contract and motion. Don't restate them from memory.
|
|
11
|
+
|
|
12
|
+
## Workflow
|
|
13
|
+
|
|
14
|
+
1. **Scaffold**: `studio new <slug> "Title"` creates `episodes/<slug>/`. Read the starter script and, if present, the worked example `episodes/demo-paris-commune/`.
|
|
15
|
+
2. **Write `script.md`** (the only source of truth for words, scene order and chapters):
|
|
16
|
+
- Front matter: `title`, `voice`, `speed`; `format: reel` and `burn_captions: true` for 9:16.
|
|
17
|
+
- One `## <id>: <chapter title>` per scene, then `> template: title|blank|stat|quote|points|outro`, optional `> duration: n` (required if a scene has no narration), then narration. Other `>` lines are visual notes and are never spoken.
|
|
18
|
+
- `{name}` marks a cue the visuals can hit; `{pause n}` adds a beat.
|
|
19
|
+
- Open on a question, contrast or striking fact in the first 5–10s. About 150–165 wpm: 5 min ≈ 700–750 words. Three main sections beat seven, and each needs a re-hook. Aim for 30–60s for a reel.
|
|
20
|
+
- Write numbers and abbreviations as they should be spoken, or add a respelling to `pronounce:`.
|
|
21
|
+
- YouTube chapters need at least 3, each 10s or longer. Consecutive scenes with the same title merge.
|
|
22
|
+
3. **Sync**: `studio sync <slug>` narrates, aligns, stubs `scenes/<id>.html` from the templates and writes `index.html` and `timing.json`. `studio status <slug>` shows words, estimated and actual length, and cues per scene. Fix length and pacing in the script, then re-sync. Only changed paragraphs are re-narrated.
|
|
23
|
+
4. **Design each scene** in `episodes/<slug>/scenes/<id>.html`. Rules:
|
|
24
|
+
- Keep the `/* <studio:timing> */` block; `sync` rewrites it. Time animation with `cue("name", fallback)` and `T.*`, never hard-coded seconds.
|
|
25
|
+
- Prefix every id and class with the scene id. The root is `#root`. Keep the script in its IIFE.
|
|
26
|
+
- Use `gsap.fromTo` for entrances, and no CSS `transform` on anything GSAP moves.
|
|
27
|
+
- Start with `<div class="paper">`, put content in `.stage` (or `.stage-reel`), and keep the final 0.5s exit fade (not on the outro).
|
|
28
|
+
- Put images in `assets/media/` and reference them as `assets/media/x.jpg`. Every figure, quote and photo gets a `.byline` source line.
|
|
29
|
+
- One idea on screen at a time, triggered by the narration. Use brand classes from `brand/brand.css`, with red on a single focal element.
|
|
30
|
+
- Changing `> template:` on an existing scene does nothing. Delete the scene file to re-stub it.
|
|
31
|
+
- Edit `brand/` for house-style changes, never `episodes/<slug>/assets/brand/` (a synced copy).
|
|
32
|
+
5. **Review**: `studio preview <slug>` opens HyperFrames Studio with narration. For a look without a browser, run `npx hyperframes snapshot episodes/<slug> --at 12.5,40` and view the frames.
|
|
33
|
+
6. **Check**: `studio check <slug>` must pass with 0 errors (lint, layout, contrast) before any render.
|
|
34
|
+
7. **Render**: `studio render <slug> --draft` for a review copy, then `studio render <slug>` for `out/<slug>.mp4`, `.srt` and `.chapters.txt`. Only changed scenes re-render, and `--force [ids]` forces them. A full render takes about real time. Optionally run `studio thumbnail <slug>` after editing `thumbnail/thumbnail.html`.
|
|
35
|
+
|
|
36
|
+
## Voice
|
|
37
|
+
|
|
38
|
+
Default is Kokoro (local, free). `studio voices <slug>` writes loudness-matched samples to `out/voice-samples/` for choosing a voice or blend. Kokoro ignores SSML, so punctuation is the only delivery control. `tts: chatterbox` (voice cloning) and `tts: elevenlabs` (billed per character, key in `.env`; never read or print `.env`) are described in `AGENTS.md`. Ask before switching to a paid engine. Changing any voice setting re-narrates everything.
|
|
39
|
+
|
|
40
|
+
## Gotchas
|
|
41
|
+
|
|
42
|
+
- `index.html` and `timing.json` are generated; never hand-edit them.
|
|
43
|
+
- Add `audio/recorded/<id>.wav|flac|m4a|mp3` to replace TTS for one scene.
|
|
44
|
+
- A crashed render can leave `_render_*.html` in the episode; delete it.
|
|
45
|
+
- DNS errors on downloads: `resolvectl flush-caches`.
|
|
46
|
+
- Commit `audio/vo/`, `timing.json` and `index.html` after `sync`, because CI never narrates. Commit messages use `episode:` for episode content and `brand:` for the house style.
|
|
47
|
+
|
|
48
|
+
## Music
|
|
49
|
+
|
|
50
|
+
After `sync`, generate the bed yourself: `studio music <slug> --prompt "<genre, mood, tempo, instruments>" --variants 2`. It makes an instrumental ElevenLabs Music track as long as the episode (about $0.0083 a second, so tell the user the cost for long episodes) and prints the `music:` line. Match the prompt to the episode's tone, name no artists or songs, then set `music:` in `script.md`, listen or spot-check with a draft render, and log the track in `brand/music/CREDITS.md`. If it fails with a 401 naming `music_generation`, the API key lacks that permission: tell the user to enable it in ElevenLabs. If music is unavailable, fall back to writing a Suno-style song description (genre, mood, tempo, instrumentation, "instrumental, no vocals") for the user.
|
|
51
|
+
|
|
52
|
+
## Done means
|
|
53
|
+
|
|
54
|
+
`studio check` passes, a draft render has been made and its frames spot-checked, and the final render and `out/` files exist. Report the paths.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Explainer video studio
|
|
2
|
+
|
|
3
|
+
Command-line studio for narrated explainer videos (~5 min, 1920x1080 30fps, for YouTube),
|
|
4
|
+
with a clean editorial house style. Pipeline: `script.md` → local TTS narration → word-aligned timing →
|
|
5
|
+
one HyperFrames sub-composition per scene → cached per-scene renders → mastered MP4, captions
|
|
6
|
+
and chapters in `out/`.
|
|
7
|
+
|
|
8
|
+
## Quick start
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
uv run studio new <slug> "Title" # scaffold episodes/<slug>/
|
|
12
|
+
$EDITOR episodes/<slug>/script.md # write the script (format below)
|
|
13
|
+
uv run studio sync <slug> # narrate + align + stub scenes + write index.html
|
|
14
|
+
uv run studio status <slug> # word counts, est vs actual length, cues per scene
|
|
15
|
+
# design each scenes/<id>.html (see "Authoring scenes")
|
|
16
|
+
uv run studio preview <slug> # HyperFrames Studio in the browser (with narration)
|
|
17
|
+
uv run studio check <slug> # lint + layout + contrast audits; must pass
|
|
18
|
+
uv run studio render <slug> --draft # fast review copy -> out/<slug>-draft.mp4
|
|
19
|
+
uv run studio render <slug> # final -> out/<slug>.mp4 + .srt + .chapters.txt
|
|
20
|
+
uv run studio music <slug> --prompt "..." # ElevenLabs Music bed sized to the synced episode (after sync)
|
|
21
|
+
uv run studio thumbnail <slug> # out/<slug>-thumbnail.jpg (edit thumbnail/thumbnail.html)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`episodes/demo-paris-commune/` is a complete worked example (38s, 4 scenes).
|
|
25
|
+
|
|
26
|
+
## Layout
|
|
27
|
+
|
|
28
|
+
- `brand/`: the house style, shared by every episode. **Edit here, never in an episode's copy.**
|
|
29
|
+
- `frame.md`: design spec (tokens in front matter, rules in prose). Read before designing a scene.
|
|
30
|
+
- `brand.css`: fonts, colour tokens and page furniture classes (`.section-head`, `.tag`, `.headline`, `.standfirst`, `.quote`, `.byline`, `.hl`…).
|
|
31
|
+
- `fonts/`: PT Serif + Source Sans 3 and JetBrains Mono, all OFL. `vendor/gsap.min.js` (no CDN at render time).
|
|
32
|
+
- `pronunciations.yaml`: studio-wide TTS respellings (acronyms, names).
|
|
33
|
+
- `music/`: optional music beds (`studio music --out brand/music/<name>.mp3` generates one with ElevenLabs Music).
|
|
34
|
+
- `templates/scenes/*.html`: scene patterns: `title`, `blank`, `stat`, `quote`, `points`, `outro`. `templates/script.md` is the starter script and `templates/thumbnail.html` the thumbnail.
|
|
35
|
+
- `tools/studio/`: the Python CLI (`uv run studio …`).
|
|
36
|
+
- `episodes/<slug>/`: one HyperFrames project per video.
|
|
37
|
+
- `script.md`: **source of truth** for words, scene order and chapter titles.
|
|
38
|
+
- `scenes/<id>.html`: one sub-composition per scene. Created from a template on first sync, then yours.
|
|
39
|
+
- `index.html`, `timing.json`: **generated**. Never hand-edit.
|
|
40
|
+
- `audio/vo/`: generated narration plus `.words.json` timings. `audio/tts/`: TTS cache.
|
|
41
|
+
- `audio/recorded/<id>.wav|flac|m4a|mp3`: optional real recording that replaces TTS for that scene.
|
|
42
|
+
- `assets/brand/`: synced copy of `brand/`. `assets/media/`: episode images and footage.
|
|
43
|
+
- `build/`: render cache (safe to delete).
|
|
44
|
+
- `out/`: deliverables.
|
|
45
|
+
- **Workspace.** `ROOT` (`tools/studio/common.py`) is `$STUDIO_ROOT`, else the nearest folder at or above the cwd that has `episodes/`, else the cwd. `BRAND` and `TEMPLATES` are the workspace's `brand/` and `templates/` when present (as in this repo), else the copies bundled in the package (`PKG_DATA`, `studio/data/` in the wheel). `brand/music/` and `brand/voices/` are always workspace paths.
|
|
46
|
+
|
|
47
|
+
## Script format (`episodes/<slug>/script.md`)
|
|
48
|
+
|
|
49
|
+
```markdown
|
|
50
|
+
---
|
|
51
|
+
title: The Paris Commune
|
|
52
|
+
voice: bf_emma*0.6 + bf_isabella*0.4 # Kokoro voice or weighted blend (UK: bf_*, bm_*; US: af_*, am_*)
|
|
53
|
+
speed: 0.85
|
|
54
|
+
music: brand/music/bed.mp3 # optional, ducked under the voice
|
|
55
|
+
pronounce: { Engels: Engles }
|
|
56
|
+
---
|
|
57
|
+
## hook: Seventy-two days <- "## <id>: <chapter title>"
|
|
58
|
+
> template: title <- pattern for a NEW scene file
|
|
59
|
+
> duration: 4 <- optional minimum length (required if no narration)
|
|
60
|
+
> Any other quoted line is a visual note, never spoken.
|
|
61
|
+
|
|
62
|
+
Narration paragraph. {sub} A cue: the scene can act exactly when the next word is spoken.
|
|
63
|
+
Next paragraph (blank line = short pause). {pause 1.5} An explicit pause.
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
- Scene ids: lowercase, start with a letter, `a-z0-9-`. Consecutive scenes with the same title merge into one YouTube chapter. YouTube needs at least 3 chapters, each 10s or longer; `render` warns when they aren't.
|
|
67
|
+
- Write numbers and abbreviations as they should be **spoken** when the voice gets them wrong, or add a respelling to `pronounce:`. Captions always use the script's spelling.
|
|
68
|
+
- **Kokoro voice.** `voice:` takes one voice or a weighted blend (`a*0.6 + b*0.4`) of their style vectors. Blends sound less synthetic than single voices. By Kokoro's own grades, `bf_emma` (B-) is the best UK voice and the UK male voices are C or lower. Audition with `uv run studio voices <slug> [--scene id] [--speed 0.85] [--voices "spec" ...]`, which writes loudness-matched samples to `out/voice-samples/`. Speed is relative to each voice: aim for ~150–170 wpm.
|
|
69
|
+
- **Kokoro delivery.** Phonemes come from **misaki** (`g2p: misaki`, the G2P Kokoro was trained on: proper RP vowels, "1871" read as a year) rather than espeak (`g2p: espeak`). Each sentence is a separate utterance, so each one gets its own falling intonation; they're joined by `sentence_pause` (0.38s). Kokoro ignores SSML and emotion tags, so punctuation is the only lever: commas for breaths, full stops for resets, `?` for questions, and `{pause n}` for a beat. `voice_fx: true` adds a gentle EQ, de-ess, compressor and faint room (`VOICE_FX` in `voice.py`). Any of these settings change re-synthesises the narration.
|
|
70
|
+
- **ElevenLabs** instead of Kokoro: set `tts: elevenlabs` and `voice: <ElevenLabs voice ID>`. Optional: `tts_model` (default `eleven_multilingual_v2`) and `voice_settings: {stability, similarity_boost, style}`; `speed` is passed through (0.7–1.2). The key is read from `ELEVENLABS_API_KEY`, in the environment or in `.env` at the studio root (never commit it). It costs about 1 credit per character. Chunks are cached like Kokoro's, so only new or changed paragraphs are billed, and `sync` prints the characters it used. British voice picks: `docs/elevenlabs-voices.md`.
|
|
71
|
+
- **Chatterbox** (Resemble AI, local, voice cloning): set `tts: chatterbox` and `voice: brand/voices/<name>.wav`, a reference clip (path relative to the studio root) or `default` for the model's built-in American voice. The clip must be **over 5s** (10–15s is best), clean, dry, mono speech with no music, in the accent and pace you want: pacing comes from the clip, and `speed` is ignored. `chatterbox_model: turbo` (default, 350M, English) or `standard` (500M), which also honours `exaggeration` (0.5; higher is more animated) and `cfg_weight` (0.5; lower is slower and more deliberate). Both use `temperature` (0.8) and `seed` (0, so a re-synthesis repeats). Text goes to the model in sentence groups of up to ~280 characters, joined by `sentence_pause`; a longer sentence is split at clause breaks (`, ; : —`), joined by `clause_pause`, and `voice_fx` applies as with Kokoro. The first run builds its environment and downloads the model (~1–2 GB). It runs on the CPU (~4–5× slower than real time here), but cached chunks are reused, so only changed paragraphs are re-synthesised. Re-recording the clip re-synthesises everything. Output carries Resemble's inaudible Perth watermark. `studio voices <slug>` with `tts: chatterbox` auditions every `brand/voices/*.wav` plus `default`, or the clips given with `--voices`.
|
|
72
|
+
- **Music** (`uv run studio music <slug> --prompt "…"`): an instrumental bed from the ElevenLabs Music API (`POST /v1/music`, `force_instrumental`), sized to `timing.json` (run `sync` first) and cached by prompt, length and model in `audio/music/`. Needs `ELEVENLABS_API_KEY` with the `music_generation` permission (a 401 naming it means the key lacks it) and a paid plan for commercial use. About $0.0083 per second (third-party figure). `--variants N` makes takes to audition, `--out brand/music/x.mp3` shares one across episodes, `--model` picks `music_v1|v2|v2_5`. Describe genre, mood, tempo and instruments; never name artists or songs. Then set `music:` and log the track in `brand/music/CREDITS.md`. Code: `tools/studio/music.py`; the HTTP/retry helper `elevenlabs_post` is shared with narration in `voice.py`.
|
|
73
|
+
- **Reels** (9:16 for Instagram, TikTok and Shorts): `format: reel` renders at 1080x1920, and `burn_captions: true` burns word-highlighted captions into the video (most reels autoplay muted). Scenes use `.stage-reel`; see the "Reel" section of `brand/frame.md` for safe zones. Aim for 30–60s.
|
|
74
|
+
- Settings that can go in the front matter, with their defaults (`tools/studio/common.py`): `format landscape` (or `reel`), `burn_captions false`, `tts kokoro` (or `chatterbox`, `elevenlabs`), `g2p misaki`, `sentence_pause 0.38`, `clause_pause 0.12`, `voice_fx true`, `chatterbox_model turbo`, `exaggeration 0.5`, `cfg_weight 0.5`, `temperature 0.8`, `seed 0`, `fps 30`, `pad_in 0.6`, `pad_out 0.9`, `paragraph_gap 0.45`, `music_prompt` (default prompt for `studio music`), `music_db -24`, `music_fade_in 2`, `loudness -14`, `true_peak -1.5`.
|
|
75
|
+
|
|
76
|
+
## Authoring scenes (`scenes/<id>.html`)
|
|
77
|
+
|
|
78
|
+
Read `brand/frame.md` first, and use the `hyperframes-core` and `hyperframes-animation` skills for the composition contract and motion. Rules specific to this studio:
|
|
79
|
+
|
|
80
|
+
- Keep the `/* <studio:timing> */ … /* </studio:timing> */` block. `sync` rewrites it with `T = {duration, voStart, voEnd, cues, paragraphs}` (scene-local seconds). Time animation with `cue("name", fallback)` and `T.*`, never hard-coded guesses, so re-recording narration re-times the visuals.
|
|
81
|
+
- Prefix every id and scene-local class with the scene id (`#lesson-quote`) because all scenes share one page in preview. Style the root as `#root`. Keep the script in its IIFE.
|
|
82
|
+
- Use `gsap.fromTo` for entrances, and no CSS `transform` on anything GSAP moves. Start each scene with `<div class="paper">` and content in `.stage` (title-safe). Keep the final 0.5s exit fade (except the outro).
|
|
83
|
+
- Images and footage go in `assets/media/` and are referenced as `assets/media/x.jpg`. Show a source/credit line (`.byline`) for every figure, quote and photo.
|
|
84
|
+
- Changing `> template:` after a scene file exists does nothing. Delete the scene file to re-stub it.
|
|
85
|
+
- Before rendering, `uv run studio check <slug>` must pass with 0 errors. Spot-check frames with
|
|
86
|
+
`npx hyperframes snapshot episodes/<slug> --at 12.5,40` and look at them.
|
|
87
|
+
|
|
88
|
+
## Rendering and output
|
|
89
|
+
|
|
90
|
+
- Each scene renders separately, keyed on its HTML, assets, duration and quality, so only changed scenes re-render. `--force [ids]` re-renders explicitly. Scenes are concatenated losslessly. The voice track is built from `audio/vo/` and the music bed is ducked under it (sidechain). Audio is mastered to **−14 LUFS / −1.5 dBTP** (two-pass loudnorm), AAC 48 kHz.
|
|
91
|
+
- `out/<slug>.srt`: upload as YouTube subtitles; captions are not burned in. `out/<slug>.chapters.txt`: paste into the description.
|
|
92
|
+
- Speed on this machine (RX 580, GPU beginframe capture): a full render runs at about real time, and a single-scene change takes seconds.
|
|
93
|
+
|
|
94
|
+
## Writing for this format (research notes, Oct 2026)
|
|
95
|
+
|
|
96
|
+
- The first 5–10 seconds decide retention. Open on a question, contrast or striking fact, never "welcome to the channel". Then say what the video will explain.
|
|
97
|
+
- ~150–165 words a minute (the default Kokoro blend at speed 0.85 measures ~165; `bm_george` at 1.0 ~155). 5 minutes ≈ 700–750 words. Leave visual breathing room.
|
|
98
|
+
- Three main sections beat seven. Re-hook at each section change, and keep a payoff for the end.
|
|
99
|
+
- One idea on screen at a time, triggered by the narration. Diagrams, documents and figures carry the argument; bullets are a last resort.
|
|
100
|
+
- Keep the right ~45% of the last 5–20s clear for YouTube end-screen elements (the `outro` template does this).
|
|
101
|
+
|
|
102
|
+
## Tools
|
|
103
|
+
|
|
104
|
+
- **HyperFrames** CLI pinned at `HF_VERSION` in `tools/studio/common.py` (override with `STUDIO_HF_VERSION`). To upgrade, bump it, run `studio check` on an episode, and compare a draft render.
|
|
105
|
+
- **Kokoro-82M** TTS runs in-process from the studio venv; the model is cached in `~/.cache/hyperframes/tts`. English phonemes come from misaki plus spaCy `en_core_web_sm`, which PyPI won't accept as a direct-URL dependency, so `voice.ensure_spacy_model()` unpacks the pinned wheel into `~/.cache/hyperframes/spacy` on first use (misaki is installed without `misaki[en]`, whose extra pulls in ~5 GB of torch for the unused transformer model). **Whisper** (`hyperframes transcribe`, small.en) gives word timings, which are aligned back onto the script text. `npx hyperframes models install parakeet` is faster and more accurate, and is used automatically once installed.
|
|
106
|
+
- **Chatterbox** (optional local TTS, `tts: chatterbox`): pins torch and numpy<2, so it can't share the studio venv. On first use, `voice.Chatterbox` builds a separate venv at `~/.cache/hyperframes/chatterbox/<version>` (`uv pip install --torch-backend cpu`, plus `setuptools<81` for the watermarker's `pkg_resources` import) and drives `tools/studio/chatterbox_worker.py` over JSON lines on stdin/stdout. The version is pinned at `CHATTERBOX_VERSION` in `common.py` (override with `STUDIO_CHATTERBOX_VERSION`), and the weights are cached in `~/.cache/huggingface`. Delete the venv folder to rebuild it. No GPU: the RX 580 has no CUDA, and ROCm doesn't support it.
|
|
107
|
+
- **ElevenLabs** (optional cloud TTS, `tts: elevenlabs`): stdlib HTTPS calls from `tools/studio/voice.py`, sending each paragraph with its neighbours' text so the delivery stays continuous.
|
|
108
|
+
- **CI** (`.github/workflows/ci.yml`): `uv sync --locked`, ruff (`[tool.ruff]` in `pyproject.toml`), a CLI smoke test, and `hyperframes check` on every episode. It never narrates or renders, so commit `audio/vo/`, `timing.json` and `index.html` after `sync`.
|
|
109
|
+
- **Releases** (`.github/workflows/release-please.yml`, `release-please-config.json`): release-please turns Conventional Commit messages into a release PR (version in `pyproject.toml` + `uv.lock`, `CHANGELOG.md`); merging it tags, publishes the GitHub release and uploads to PyPI (trusted publishing; see `RELEASING.md`). Only `brand/` files listed under `force-include` in `pyproject.toml` ship in the wheel. Write commit messages as `feat:`, `fix:`, `perf:`, `episode:` (episode content), `brand:` (house style), `docs:`, `deps:`, or `refactor:`/`test:`/`ci:`/`chore:` (not in the changelog). Never hand-edit the version or `CHANGELOG.md`.
|
|
110
|
+
- ffmpeg 9, Chromium (thumbnails), uv (Python 3.12–3.13; `kokoro-onnx` needs <3.14).
|
|
111
|
+
|
|
112
|
+
## Gotchas
|
|
113
|
+
|
|
114
|
+
- Only one root-level HTML file may carry `data-composition-id`. `render` creates `_render_<id>.html` shells temporarily and deletes them; if a crash leaves one behind, delete it.
|
|
115
|
+
- If package or model downloads fail with DNS errors (NordVPN + DNSSEC "signature-expired"), run `resolvectl flush-caches`.
|
|
116
|
+
- If a render hangs or reports empty frames under GPU capture, add `--no-browser-gpu` in `render_scenes()` (`tools/studio/render.py`).
|
|
117
|
+
- **DINPro** (the sans: labels, tags, bylines, captions) is a licensed FontFont, not OFL. Its TTFs live in gitignored `brand/fonts-licensed/` and must never be committed or moved into `brand/fonts/` (the repo is public and `brand/fonts/` ships in the wheel). Without them everything falls back to Source Sans 3. This build's `fi`/`fl` ligatures are broken, so `brand.css` turns common ligatures off and burned-in captions insert a zero-width non-joiner.
|
|
@@ -1,8 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: explainer-studio
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: Command-line studio for narrated explainer videos (HyperFrames + Kokoro TTS + ffmpeg)
|
|
5
|
+
Project-URL: Homepage, https://joemountwsws.github.io/explainer-studio/
|
|
6
|
+
Project-URL: Documentation, https://joemountwsws.github.io/explainer-studio/
|
|
7
|
+
Project-URL: Repository, https://github.com/JoeMountWSWS/explainer-studio
|
|
8
|
+
Project-URL: Issues, https://github.com/JoeMountWSWS/explainer-studio/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/JoeMountWSWS/explainer-studio/blob/main/CHANGELOG.md
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Python: <3.14,>=3.12
|
|
13
|
+
Requires-Dist: kokoro-onnx>=0.6.1
|
|
14
|
+
Requires-Dist: misaki>=0.9
|
|
15
|
+
Requires-Dist: num2words>=0.5
|
|
16
|
+
Requires-Dist: numpy>=2.0
|
|
17
|
+
Requires-Dist: pyyaml>=6.0
|
|
18
|
+
Requires-Dist: soundfile>=0.12
|
|
19
|
+
Requires-Dist: spacy<3.9,>=3.8
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
1
22
|
# Explainer Studio
|
|
2
23
|
|
|
3
24
|
[](https://github.com/JoeMountWSWS/explainer-studio/actions/workflows/ci.yml)
|
|
25
|
+
[](https://pypi.org/project/explainer-studio/)
|
|
4
26
|
|
|
5
|
-
A command-line studio for narrated explainer videos
|
|
27
|
+
A command-line studio for narrated explainer videos with a clean, editorial house style.
|
|
6
28
|
You write a script in Markdown. The studio narrates it with local TTS, aligns every word to the audio,
|
|
7
29
|
and turns each scene into a [HyperFrames](https://www.npmjs.com/package/hyperframes) HTML composition
|
|
8
30
|
that is timed to the narration. It then renders a mastered 1920×1080 MP4 for YouTube, with captions
|
|
@@ -27,16 +49,7 @@ script.md ──► narration (Kokoro, local; or ElevenLabs) ──► word timi
|
|
|
27
49
|
The first `sync` downloads the Kokoro-82M model to `~/.cache/hyperframes/tts` and spaCy's
|
|
28
50
|
`en_core_web_sm` to `~/.cache/hyperframes/spacy`.
|
|
29
51
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
Making a video with the studio is a loop of writing and listening. You write the script as plain
|
|
33
|
-
Markdown, and the studio reads it aloud, works out exactly when each word is spoken, and hands those
|
|
34
|
-
timings to your scenes so the visuals move in step with the voice. Change a sentence and only that
|
|
35
|
-
sentence is re-recorded and only the affected scenes are re-rendered, so you can refine a five-minute
|
|
36
|
-
video in seconds rather than starting over. Everything runs from one command, `uv run studio`, and
|
|
37
|
-
nothing leaves your machine unless you choose cloud narration.
|
|
38
|
-
|
|
39
|
-
## Install
|
|
52
|
+
## Install the studio
|
|
40
53
|
|
|
41
54
|
With the [requirements](#requirements) in place, either install the `studio` command from PyPI or
|
|
42
55
|
clone the repository.
|
|
@@ -44,11 +57,16 @@ clone the repository.
|
|
|
44
57
|
**From PyPI**, to make videos in a folder of your own:
|
|
45
58
|
|
|
46
59
|
```bash
|
|
47
|
-
|
|
60
|
+
pip install explainer-studio # or: uv tool install explainer-studio
|
|
48
61
|
mkdir my-videos && cd my-videos
|
|
49
62
|
studio new my-video "My Video"
|
|
50
63
|
```
|
|
51
64
|
|
|
65
|
+
The package is on [PyPI](https://pypi.org/project/explainer-studio/). `uv tool install` puts
|
|
66
|
+
`studio` on your PATH in its own isolated environment, and `uvx --from explainer-studio studio …`
|
|
67
|
+
runs it without installing. With `pip`, install into a virtual environment (or use `pipx install
|
|
68
|
+
explainer-studio`) so its dependencies stay separate from your system Python.
|
|
69
|
+
|
|
52
70
|
The folder you run `studio` in becomes the workspace. It holds `episodes/`, `out/` and `.env`, and
|
|
53
71
|
`studio` finds it from any subdirectory (or set `STUDIO_ROOT`). The house style (`brand/`) and scene
|
|
54
72
|
templates ship with the package. To customise them, copy `brand/` or `templates/` from this repository
|
|
@@ -67,6 +85,31 @@ Run commands from the repository root (or any folder below it). Confirm the inst
|
|
|
67
85
|
`uv run studio --help`. The first `sync` of an episode downloads the Kokoro voice model and the
|
|
68
86
|
spaCy English model, so it takes a little longer than later runs.
|
|
69
87
|
|
|
88
|
+
### Install the agent skill
|
|
89
|
+
|
|
90
|
+
To have Claude Code drive the studio, install the `make-explainer-video` skill into your workspace:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cd my-videos
|
|
94
|
+
studio install-skill # -> .claude/skills/make-explainer-video/
|
|
95
|
+
studio install-skill --user # or for every project: ~/.claude/skills/
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
It installs the skill plus the references it reads (the studio guide, house-style spec and starter
|
|
99
|
+
script). Start `claude` in the workspace and ask for a video, or run `/make-explainer-video`. Re-run
|
|
100
|
+
the command with `--force` after upgrading the package to refresh it. From a clone the skill is
|
|
101
|
+
already available with no installation. See
|
|
102
|
+
[Working with a coding agent](https://joemountwsws.github.io/explainer-studio/coding-agents/).
|
|
103
|
+
|
|
104
|
+
# Usage
|
|
105
|
+
|
|
106
|
+
Making a video with the studio is a loop of writing and listening. You write the script as plain
|
|
107
|
+
Markdown, and the studio reads it aloud, works out exactly when each word is spoken, and hands those
|
|
108
|
+
timings to your scenes so the visuals move in step with the voice. Change a sentence and only that
|
|
109
|
+
sentence is re-recorded and only the affected scenes are re-rendered, so you can refine a five-minute
|
|
110
|
+
video in seconds rather than starting over. Everything runs from one command, `uv run studio`, and
|
|
111
|
+
nothing leaves your machine unless you choose cloud narration.
|
|
112
|
+
|
|
70
113
|
## Quick start
|
|
71
114
|
|
|
72
115
|
```bash
|
|
@@ -128,6 +171,7 @@ Worked examples: `episodes/demo-paris-commune` (Kokoro, 38s) and `episodes/demo-
|
|
|
128
171
|
The studio is designed to be driven by Claude Code or another coding agent. `AGENTS.md` (loaded
|
|
129
172
|
through `CLAUDE.md`) gives the agent the conventions it needs, the HyperFrames skills cover the
|
|
130
173
|
composition format and motion, and the `make-explainer-video` skill
|
|
131
|
-
(`.claude/skills/make-explainer-video
|
|
132
|
-
the repository root and ask for a
|
|
174
|
+
(`.claude/skills/make-explainer-video/`, or `studio install-skill` for a pip install) walks Claude
|
|
175
|
+
through the whole workflow. Start `claude` in the repository root or your workspace and ask for a
|
|
176
|
+
video, or run `/make-explainer-video`. See
|
|
133
177
|
[Working with a coding agent](https://joemountwsws.github.io/explainer-studio/coding-agents/).
|
|
@@ -1,24 +1,9 @@
|
|
|
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
1
|
# Explainer Studio
|
|
18
2
|
|
|
19
3
|
[](https://github.com/JoeMountWSWS/explainer-studio/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/explainer-studio/)
|
|
20
5
|
|
|
21
|
-
A command-line studio for narrated explainer videos
|
|
6
|
+
A command-line studio for narrated explainer videos with a clean, editorial house style.
|
|
22
7
|
You write a script in Markdown. The studio narrates it with local TTS, aligns every word to the audio,
|
|
23
8
|
and turns each scene into a [HyperFrames](https://www.npmjs.com/package/hyperframes) HTML composition
|
|
24
9
|
that is timed to the narration. It then renders a mastered 1920×1080 MP4 for YouTube, with captions
|
|
@@ -43,16 +28,7 @@ script.md ──► narration (Kokoro, local; or ElevenLabs) ──► word timi
|
|
|
43
28
|
The first `sync` downloads the Kokoro-82M model to `~/.cache/hyperframes/tts` and spaCy's
|
|
44
29
|
`en_core_web_sm` to `~/.cache/hyperframes/spacy`.
|
|
45
30
|
|
|
46
|
-
|
|
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
|
|
31
|
+
## Install the studio
|
|
56
32
|
|
|
57
33
|
With the [requirements](#requirements) in place, either install the `studio` command from PyPI or
|
|
58
34
|
clone the repository.
|
|
@@ -60,11 +36,16 @@ clone the repository.
|
|
|
60
36
|
**From PyPI**, to make videos in a folder of your own:
|
|
61
37
|
|
|
62
38
|
```bash
|
|
63
|
-
|
|
39
|
+
pip install explainer-studio # or: uv tool install explainer-studio
|
|
64
40
|
mkdir my-videos && cd my-videos
|
|
65
41
|
studio new my-video "My Video"
|
|
66
42
|
```
|
|
67
43
|
|
|
44
|
+
The package is on [PyPI](https://pypi.org/project/explainer-studio/). `uv tool install` puts
|
|
45
|
+
`studio` on your PATH in its own isolated environment, and `uvx --from explainer-studio studio …`
|
|
46
|
+
runs it without installing. With `pip`, install into a virtual environment (or use `pipx install
|
|
47
|
+
explainer-studio`) so its dependencies stay separate from your system Python.
|
|
48
|
+
|
|
68
49
|
The folder you run `studio` in becomes the workspace. It holds `episodes/`, `out/` and `.env`, and
|
|
69
50
|
`studio` finds it from any subdirectory (or set `STUDIO_ROOT`). The house style (`brand/`) and scene
|
|
70
51
|
templates ship with the package. To customise them, copy `brand/` or `templates/` from this repository
|
|
@@ -83,6 +64,31 @@ Run commands from the repository root (or any folder below it). Confirm the inst
|
|
|
83
64
|
`uv run studio --help`. The first `sync` of an episode downloads the Kokoro voice model and the
|
|
84
65
|
spaCy English model, so it takes a little longer than later runs.
|
|
85
66
|
|
|
67
|
+
### Install the agent skill
|
|
68
|
+
|
|
69
|
+
To have Claude Code drive the studio, install the `make-explainer-video` skill into your workspace:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
cd my-videos
|
|
73
|
+
studio install-skill # -> .claude/skills/make-explainer-video/
|
|
74
|
+
studio install-skill --user # or for every project: ~/.claude/skills/
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It installs the skill plus the references it reads (the studio guide, house-style spec and starter
|
|
78
|
+
script). Start `claude` in the workspace and ask for a video, or run `/make-explainer-video`. Re-run
|
|
79
|
+
the command with `--force` after upgrading the package to refresh it. From a clone the skill is
|
|
80
|
+
already available with no installation. See
|
|
81
|
+
[Working with a coding agent](https://joemountwsws.github.io/explainer-studio/coding-agents/).
|
|
82
|
+
|
|
83
|
+
# Usage
|
|
84
|
+
|
|
85
|
+
Making a video with the studio is a loop of writing and listening. You write the script as plain
|
|
86
|
+
Markdown, and the studio reads it aloud, works out exactly when each word is spoken, and hands those
|
|
87
|
+
timings to your scenes so the visuals move in step with the voice. Change a sentence and only that
|
|
88
|
+
sentence is re-recorded and only the affected scenes are re-rendered, so you can refine a five-minute
|
|
89
|
+
video in seconds rather than starting over. Everything runs from one command, `uv run studio`, and
|
|
90
|
+
nothing leaves your machine unless you choose cloud narration.
|
|
91
|
+
|
|
86
92
|
## Quick start
|
|
87
93
|
|
|
88
94
|
```bash
|
|
@@ -144,6 +150,7 @@ Worked examples: `episodes/demo-paris-commune` (Kokoro, 38s) and `episodes/demo-
|
|
|
144
150
|
The studio is designed to be driven by Claude Code or another coding agent. `AGENTS.md` (loaded
|
|
145
151
|
through `CLAUDE.md`) gives the agent the conventions it needs, the HyperFrames skills cover the
|
|
146
152
|
composition format and motion, and the `make-explainer-video` skill
|
|
147
|
-
(`.claude/skills/make-explainer-video
|
|
148
|
-
the repository root and ask for a
|
|
153
|
+
(`.claude/skills/make-explainer-video/`, or `studio install-skill` for a pip install) walks Claude
|
|
154
|
+
through the whole workflow. Start `claude` in the repository root or your workspace and ask for a
|
|
155
|
+
video, or run `/make-explainer-video`. See
|
|
149
156
|
[Working with a coding agent](https://joemountwsws.github.io/explainer-studio/coding-agents/).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* Explainer house style
|
|
1
|
+
/* Explainer house style. Tokens mirror brand/frame.md.
|
|
2
2
|
`studio sync` copies this file (with fonts/ and gsap) into each episode's assets/brand/. */
|
|
3
3
|
|
|
4
4
|
@font-face { font-family: "PT Serif"; src: url("fonts/PTSerif-Regular.ttf") format("truetype"); font-weight: 400; font-style: normal; }
|
|
@@ -8,6 +8,12 @@
|
|
|
8
8
|
@font-face { font-family: "Source Sans 3"; src: url("fonts/SourceSans3.ttf") format("truetype"); font-weight: 200 900; font-style: normal; }
|
|
9
9
|
@font-face { font-family: "Source Sans 3"; src: url("fonts/SourceSans3-Italic.ttf") format("truetype"); font-weight: 200 900; font-style: italic; }
|
|
10
10
|
@font-face { font-family: "JetBrains Mono"; src: url("fonts/JetBrainsMono.ttf") format("truetype"); font-weight: 100 800; }
|
|
11
|
+
/* DINPro is licensed, not OFL: it lives in brand/fonts-licensed/ (gitignored). `sync` copies it into the
|
|
12
|
+
episode and appends its @font-face rules to this file's copy, so without it there are no 404s and the sans
|
|
13
|
+
stack falls back to Source Sans 3. No 400/600 cuts: those map to Medium/Bold.
|
|
14
|
+
This DINPro build's fi/fl ligatures point at the wrong glyphs ("five" renders "(ve"), and Chromium ignores
|
|
15
|
+
the @font-face font-feature-settings descriptor, so ligatures are off everywhere. */
|
|
16
|
+
* { font-variant-ligatures: no-common-ligatures; }
|
|
11
17
|
|
|
12
18
|
:root {
|
|
13
19
|
--paper: #ffffff;
|
|
@@ -21,7 +27,7 @@
|
|
|
21
27
|
--tag: #1b2129;
|
|
22
28
|
|
|
23
29
|
--font-serif: "PT Serif", Georgia, serif;
|
|
24
|
-
--font-sans: "Source Sans 3", "Helvetica Neue", Arial, sans-serif;
|
|
30
|
+
--font-sans: "DINPro", "Source Sans 3", "Helvetica Neue", Arial, sans-serif;
|
|
25
31
|
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
|
26
32
|
|
|
27
33
|
--safe-x: 192px; /* title-safe inset (10%) */
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
name: Explainer house style
|
|
3
|
-
reference: https://www.wsws.org — typography, palette and page furniture are taken from the site
|
|
2
|
+
name: Explainer house style
|
|
4
3
|
canvas:
|
|
5
4
|
width: 1920
|
|
6
5
|
height: 1080
|
|
@@ -19,8 +18,8 @@ typography:
|
|
|
19
18
|
headline: { family: "PT Serif", weight: 700, line-height: 1.12, min-size: 72px }
|
|
20
19
|
standfirst: { family: "PT Serif", style: italic, weight: 400, color: ink-soft }
|
|
21
20
|
body: { family: "PT Serif", weight: 400, size: 46px, line-height: 1.4 }
|
|
22
|
-
label: { family: "Source Sans 3", weight: 700, size: 26px, tracking: "0.12em", case: uppercase }
|
|
23
|
-
ui: { family: "Source Sans 3", weight: 600 }
|
|
21
|
+
label: { family: "DINPro", fallback: "Source Sans 3", weight: 700, size: 26px, tracking: "0.12em", case: uppercase }
|
|
22
|
+
ui: { family: "DINPro", fallback: "Source Sans 3", weight: 600 } # DINPro cuts: 300, 500, 700, 900 (600 renders Bold)
|
|
24
23
|
mono: { family: "JetBrains Mono", weight: 500 } # formulae, data, code
|
|
25
24
|
spacing:
|
|
26
25
|
safe-title: 192px 108px # 10% inset: key text stays inside this box
|
|
@@ -29,7 +28,7 @@ spacing:
|
|
|
29
28
|
components:
|
|
30
29
|
section-rule: "6px solid ink above an uppercase label (the site's 'TOP STORIES' head)"
|
|
31
30
|
hairline: "2px solid rule"
|
|
32
|
-
tag: "tag-coloured box, white uppercase
|
|
31
|
+
tag: "tag-coloured box, white uppercase DINPro label"
|
|
33
32
|
blockquote: "PT Serif, indented, 6px red rule on the left"
|
|
34
33
|
corners: 0
|
|
35
34
|
shadow: none
|
|
@@ -40,11 +39,11 @@ motion:
|
|
|
40
39
|
stagger: 0.12s
|
|
41
40
|
---
|
|
42
41
|
|
|
43
|
-
# Explainer house style
|
|
42
|
+
# Explainer house style
|
|
44
43
|
|
|
45
44
|
## Overview
|
|
46
45
|
|
|
47
|
-
The videos should look like a
|
|
46
|
+
The videos should look like a serious news article set in motion: a white page, serious
|
|
48
47
|
serif headlines, small uppercase section labels, thin rules, and one red. Plain
|
|
49
48
|
and authoritative, not "content-marketing". The narrator carries the argument. The
|
|
50
49
|
frame shows its structure (claims, evidence, quotations, dates, figures) with the
|
|
@@ -61,7 +60,7 @@ same typographic furniture a reader knows from the site.
|
|
|
61
60
|
- **Standfirst**: PT Serif Italic in `ink-soft` under a headline.
|
|
62
61
|
- **Blockquote**: PT Serif with a red left rule (`.quote`), attribution in `label`
|
|
63
62
|
style. Used for documents, speeches and historical sources.
|
|
64
|
-
- **Byline/date line**:
|
|
63
|
+
- **Byline/date line**: DINPro, `ink-soft`, small. Use it for sources and
|
|
65
64
|
citations on screen ("Source: Bureau of Labor Statistics, 2025").
|
|
66
65
|
|
|
67
66
|
## The frame
|
|
@@ -80,7 +79,7 @@ Episodes with `format: reel` render at 1080x1920 for Instagram Reels, TikTok and
|
|
|
80
79
|
- Content box: `.stage-reel` (x 72-1008, y 220-1180). The platform's buttons sit on the right
|
|
81
80
|
edge and its caption, handle and audio line take the bottom ~480px, so keep both clear.
|
|
82
81
|
- Caption band: y 1220-1440. `burn_captions: true` puts word-highlighted captions here (white
|
|
83
|
-
|
|
82
|
+
DINPro on a `tag` box, the spoken word in red). Don't put content in this band.
|
|
84
83
|
- Photos may run full-bleed behind everything, under an ink gradient so type stays legible.
|
|
85
84
|
- Minimum sizes: headline 88px, body 52px, label 30px, byline 26px.
|
|
86
85
|
- One statement per screen. Cut every 3-6s, on the narration's cues. Get the striking fact on screen in the first second.
|
|
@@ -93,7 +92,7 @@ Episodes with `format: reel` render at 1080x1920 for Instagram Reels, TikTok and
|
|
|
93
92
|
and charts carry the argument. Always show a source line for a figure or quote.
|
|
94
93
|
- **Charts**: flat, ink and grey with the key series in red. Hairline axes, Source
|
|
95
94
|
Sans labels, no gridline clutter, no 3D.
|
|
96
|
-
- **Lists**: at most 4 items, numbered in red
|
|
95
|
+
- **Lists**: at most 4 items, numbered in red DINPro, with hairlines between.
|
|
97
96
|
- No stock photos used as decoration, emoji, drop shadows, rounded "app" cards,
|
|
98
97
|
gradients or glow. Photos are allowed only as evidence (archival images, news
|
|
99
98
|
photos with credit), set full-width with a caption, as on the site.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "explainer-studio"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.6"
|
|
4
4
|
description = "Command-line studio for narrated explainer videos (HyperFrames + Kokoro TTS + ffmpeg)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12,<3.14"
|
|
@@ -16,6 +16,13 @@ dependencies = [
|
|
|
16
16
|
"num2words>=0.5",
|
|
17
17
|
]
|
|
18
18
|
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://joemountwsws.github.io/explainer-studio/"
|
|
21
|
+
Documentation = "https://joemountwsws.github.io/explainer-studio/"
|
|
22
|
+
Repository = "https://github.com/JoeMountWSWS/explainer-studio"
|
|
23
|
+
Issues = "https://github.com/JoeMountWSWS/explainer-studio/issues"
|
|
24
|
+
Changelog = "https://github.com/JoeMountWSWS/explainer-studio/blob/main/CHANGELOG.md"
|
|
25
|
+
|
|
19
26
|
[project.scripts]
|
|
20
27
|
studio = "studio.cli:main"
|
|
21
28
|
|
|
@@ -34,6 +41,9 @@ packages = ["tools/studio"]
|
|
|
34
41
|
"brand/fonts" = "studio/data/brand/fonts"
|
|
35
42
|
"brand/vendor" = "studio/data/brand/vendor"
|
|
36
43
|
"templates" = "studio/data/templates"
|
|
44
|
+
# The agent skill (`studio install-skill`); it also installs brand/frame.md and templates/script.md as references.
|
|
45
|
+
".claude/skills/make-explainer-video/SKILL.md" = "studio/data/skills/make-explainer-video/SKILL.md"
|
|
46
|
+
"AGENTS.md" = "studio/data/skills/make-explainer-video/references/agents.md"
|
|
37
47
|
|
|
38
48
|
[tool.hatch.build.targets.sdist]
|
|
39
49
|
only-include = [
|
|
@@ -44,6 +54,8 @@ only-include = [
|
|
|
44
54
|
"brand/fonts",
|
|
45
55
|
"brand/vendor",
|
|
46
56
|
"templates",
|
|
57
|
+
".claude/skills/make-explainer-video/SKILL.md",
|
|
58
|
+
"AGENTS.md",
|
|
47
59
|
"README.md",
|
|
48
60
|
"LICENSE",
|
|
49
61
|
]
|
|
@@ -8,7 +8,7 @@ import re
|
|
|
8
8
|
import shutil
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
-
from .common import BRAND, FORMATS, HF_VERSION, ROOT, TEMPLATES, die, info
|
|
11
|
+
from .common import BRAND, FORMATS, HF_VERSION, LICENSED_FONTS, ROOT, TEMPLATES, die, info
|
|
12
12
|
from .script import Script
|
|
13
13
|
|
|
14
14
|
TIMING_RE = re.compile(r"(/\* <studio:timing> \*/)(.*?)(/\* </studio:timing> \*/)", re.S)
|
|
@@ -49,6 +49,9 @@ def compute_timing(script: Script, vo: dict[str, dict]) -> dict:
|
|
|
49
49
|
"duration": t_frames / fps, "scenes": scenes}
|
|
50
50
|
|
|
51
51
|
|
|
52
|
+
DINPRO_WEIGHTS = {"Light": 300, "Regular": 400, "Medium": 500, "Bold": 700, "Black": 900}
|
|
53
|
+
|
|
54
|
+
|
|
52
55
|
def sync_brand(ep: Path) -> None:
|
|
53
56
|
"""Copy the studio's shared look into the episode (edit brand/, not these copies)."""
|
|
54
57
|
dst = ep / "assets" / "brand"
|
|
@@ -56,6 +59,17 @@ def sync_brand(ep: Path) -> None:
|
|
|
56
59
|
shutil.copy2(BRAND / "brand.css", dst / "brand.css")
|
|
57
60
|
shutil.copy2(BRAND / "vendor" / "gsap.min.js", dst / "gsap.min.js")
|
|
58
61
|
shutil.copytree(BRAND / "fonts", dst / "fonts", dirs_exist_ok=True)
|
|
62
|
+
# Licensed fonts are optional, so their @font-face rules are only written when the files exist.
|
|
63
|
+
faces = []
|
|
64
|
+
for font in sorted(LICENSED_FONTS.glob("DINPro-*.ttf")):
|
|
65
|
+
weight = DINPRO_WEIGHTS.get(font.stem.removeprefix("DINPro-"))
|
|
66
|
+
if weight:
|
|
67
|
+
shutil.copy2(font, dst / "fonts" / font.name)
|
|
68
|
+
faces.append(f'@font-face {{ font-family: "DINPro"; src: url("fonts/{font.name}") format("truetype"); '
|
|
69
|
+
f"font-weight: {weight}; font-style: normal; }}")
|
|
70
|
+
if faces:
|
|
71
|
+
with (dst / "brand.css").open("a") as f:
|
|
72
|
+
f.write("\n/* Added by `studio sync` from brand/fonts-licensed/ */\n" + "\n".join(faces) + "\n")
|
|
59
73
|
shutil.copy2(BRAND / "frame.md", ep / "frame.md")
|
|
60
74
|
|
|
61
75
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
uv run studio thumbnail <slug> screenshot thumbnail/thumbnail.html -> out/
|
|
10
10
|
uv run studio voices <slug> audition Kokoro blends or Chatterbox clips on one scene -> out/voice-samples/
|
|
11
11
|
uv run studio music <slug> --prompt generate an instrumental music bed (ElevenLabs Music) sized to the episode
|
|
12
|
+
uv run studio install-skill install the make-explainer-video agent skill for Claude Code
|
|
12
13
|
"""
|
|
13
14
|
|
|
14
15
|
from __future__ import annotations
|
|
@@ -26,7 +27,7 @@ import numpy as np
|
|
|
26
27
|
import soundfile as sf
|
|
27
28
|
|
|
28
29
|
from . import build as builder
|
|
29
|
-
from . import music, voice
|
|
30
|
+
from . import music, skill, voice
|
|
30
31
|
from . import render as renderer
|
|
31
32
|
from .common import EPISODES, OUT, ROOT, TEMPLATES, die, episode_dir, hf, info, require_tools
|
|
32
33
|
from .script import Script, parse
|
|
@@ -202,6 +203,10 @@ def audition(tts, scene, table: dict, out: Path) -> None:
|
|
|
202
203
|
info(f" {out.relative_to(ROOT)}")
|
|
203
204
|
|
|
204
205
|
|
|
206
|
+
def cmd_install_skill(a):
|
|
207
|
+
skill.install(user=a.user, dest=a.dir, force=a.force)
|
|
208
|
+
|
|
209
|
+
|
|
205
210
|
def main():
|
|
206
211
|
p = argparse.ArgumentParser(prog="studio", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
207
212
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
@@ -257,6 +262,12 @@ def main():
|
|
|
257
262
|
s.add_argument("--out", metavar="PATH", help="write here instead, e.g. brand/music/my-bed.mp3 to share across episodes")
|
|
258
263
|
s.set_defaults(fn=cmd_music)
|
|
259
264
|
|
|
265
|
+
s = sub.add_parser("install-skill", help="install the make-explainer-video agent skill (Claude Code) into this workspace")
|
|
266
|
+
s.add_argument("--user", action="store_true", help="install for every project (~/.claude/skills) instead of this workspace")
|
|
267
|
+
s.add_argument("--dir", metavar="SKILLS_DIR", help="install into this skills folder instead, e.g. for another agent")
|
|
268
|
+
s.add_argument("--force", action="store_true", help="replace an existing, different copy")
|
|
269
|
+
s.set_defaults(fn=cmd_install_skill)
|
|
270
|
+
|
|
260
271
|
a = p.parse_args()
|
|
261
272
|
a.fn(a)
|
|
262
273
|
|
|
@@ -28,6 +28,8 @@ PKG_DATA = _HERE / "data" if (_HERE / "data").is_dir() else _HERE.parents[1]
|
|
|
28
28
|
ROOT = _workspace()
|
|
29
29
|
BRAND = ROOT / "brand" if (ROOT / "brand" / "brand.css").exists() else PKG_DATA / "brand"
|
|
30
30
|
TEMPLATES = ROOT / "templates" if (ROOT / "templates" / "script.md").exists() else PKG_DATA / "templates"
|
|
31
|
+
# Commercial fonts (e.g. DINPro) the user holds a licence for: gitignored, never shipped in the wheel.
|
|
32
|
+
LICENSED_FONTS = ROOT / "brand" / "fonts-licensed"
|
|
31
33
|
EPISODES = ROOT / "episodes"
|
|
32
34
|
OUT = ROOT / "out"
|
|
33
35
|
|
|
@@ -11,7 +11,20 @@ import numpy as np
|
|
|
11
11
|
import soundfile as sf
|
|
12
12
|
|
|
13
13
|
from .build import render_shell
|
|
14
|
-
from .common import
|
|
14
|
+
from .common import (
|
|
15
|
+
BRAND,
|
|
16
|
+
HF_VERSION,
|
|
17
|
+
LICENSED_FONTS,
|
|
18
|
+
OUT,
|
|
19
|
+
SAMPLE_RATE,
|
|
20
|
+
die,
|
|
21
|
+
ffprobe_duration,
|
|
22
|
+
hf,
|
|
23
|
+
info,
|
|
24
|
+
run,
|
|
25
|
+
sha,
|
|
26
|
+
tree_hash,
|
|
27
|
+
)
|
|
15
28
|
from .script import Script
|
|
16
29
|
|
|
17
30
|
# ---------------------------------------------------------------- video
|
|
@@ -191,14 +204,23 @@ def _ass_color(hexcolor: str) -> str:
|
|
|
191
204
|
return f"&H00{b}{g}{r}".upper()
|
|
192
205
|
|
|
193
206
|
|
|
207
|
+
def caption_font() -> tuple[str, Path]:
|
|
208
|
+
"""(libass font name, fonts dir) for burned-in captions."""
|
|
209
|
+
if (LICENSED_FONTS / "DINPro-Bold.ttf").exists():
|
|
210
|
+
return "DINPro-Bold", LICENSED_FONTS
|
|
211
|
+
return "Source Sans 3 Bold", BRAND / "fonts"
|
|
212
|
+
|
|
213
|
+
|
|
194
214
|
def ass_captions(ep: Path, timing: dict, dst: Path) -> int:
|
|
195
|
-
"""Burn-in captions in house style: white
|
|
215
|
+
"""Burn-in captions in house style: white sans on a dark tag box, the spoken word in red.
|
|
196
216
|
Short single-line cues (a few words), one event per word so the highlight follows the voice.
|
|
197
|
-
libass can't pick weights from the variable font, so this relies on
|
|
217
|
+
libass can't pick weights from the variable font, so this relies on a static bold: DINPro-Bold from
|
|
218
|
+
brand/fonts-licensed/ when present, else brand/fonts/SourceSans3-Bold.ttf (see caption_font())."""
|
|
198
219
|
w, h = timing["width"], timing["height"]
|
|
199
220
|
portrait = h > w
|
|
200
221
|
size, y = (72, int(h * 0.69)) if portrait else (52, int(h * 0.86)) # reel caption band / lower third
|
|
201
222
|
ink, red, white = _ass_color("#1b2129"), _ass_color("#e71e25"), _ass_color("#ffffff")
|
|
223
|
+
font = caption_font()[0]
|
|
202
224
|
head = [
|
|
203
225
|
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {w}", f"PlayResY: {h}", "WrapStyle: 2",
|
|
204
226
|
"ScaledBorderAndShadow: yes", "",
|
|
@@ -206,7 +228,7 @@ def ass_captions(ep: Path, timing: dict, dst: Path) -> int:
|
|
|
206
228
|
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, "
|
|
207
229
|
"Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
|
|
208
230
|
"MarginL, MarginR, MarginV, Encoding",
|
|
209
|
-
f"Style: Cap,
|
|
231
|
+
f"Style: Cap,{font},{size},{white},{white},{ink},{ink},-1,0,0,0,100,100,0,0,3,14,0,5,0,0,0,1",
|
|
210
232
|
"", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
211
233
|
]
|
|
212
234
|
cues = _caption_cues(ep, timing, max_chars=13 if portrait else 21, max_secs=2.5, max_words=4, min_sentence=0)
|
|
@@ -219,6 +241,8 @@ def ass_captions(ep: Path, timing: dict, dst: Path) -> int:
|
|
|
219
241
|
b = cue[k + 1]["start"] if k + 1 < len(cue) else cue_end
|
|
220
242
|
text = " ".join(("{\\c" + red + "&}" + x["text"] + "{\\c" + white + "&}") if j == k else x["text"]
|
|
221
243
|
for j, x in enumerate(cue))
|
|
244
|
+
if font == "DINPro-Bold": # its fi/fl ligatures are broken; a zero-width non-joiner stops HarfBuzz forming them
|
|
245
|
+
text = re.sub(r"f(?=[il])", "f", text)
|
|
222
246
|
events.append(f"Dialogue: 0,{_ts(a, '.')[1:-1]},{_ts(b, '.')[1:-1]},Cap,,0,0,0,,{{\\pos({w // 2},{y})}}{text}")
|
|
223
247
|
dst.write_text("\n".join(head + events) + "\n")
|
|
224
248
|
return len(cues)
|
|
@@ -251,7 +275,7 @@ def render_episode(ep: Path, script: Script, timing: dict, draft: bool, only: se
|
|
|
251
275
|
ass = build / "captions.ass"
|
|
252
276
|
ass_captions(ep, timing, ass)
|
|
253
277
|
crf = "26" if draft else "16"
|
|
254
|
-
vcodec = ["-vf", f"ass={ass.name}:fontsdir={
|
|
278
|
+
vcodec = ["-vf", f"ass={ass.name}:fontsdir={caption_font()[1]}", "-c:v", "libx264", "-preset", "medium",
|
|
255
279
|
"-crf", crf, "-pix_fmt", "yuv420p"]
|
|
256
280
|
run(["ffmpeg", "-v", "error", "-y", "-i", str(video.resolve()), "-i", str(master.resolve()), "-map", "0:v", "-map", "1:a",
|
|
257
281
|
*vcodec, "-c:a", "aac", "-b:a", "384k", "-ar", str(SAMPLE_RATE),
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Install the `make-explainer-video` agent skill into a workspace or the user's skills folder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import filecmp
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .common import PKG_DATA, ROOT, die, info
|
|
10
|
+
|
|
11
|
+
NAME = "make-explainer-video"
|
|
12
|
+
|
|
13
|
+
# Where each file of the skill comes from, relative to PKG_DATA (the wheel's studio/data/, or the
|
|
14
|
+
# repository root in a checkout). The house-style spec and starter script sit at the same path in both.
|
|
15
|
+
_WHEEL = {"SKILL.md": f"skills/{NAME}/SKILL.md", "references/agents.md": f"skills/{NAME}/references/agents.md"}
|
|
16
|
+
_CHECKOUT = {"SKILL.md": f".claude/skills/{NAME}/SKILL.md", "references/agents.md": "AGENTS.md"}
|
|
17
|
+
_SHARED = {"references/frame.md": "brand/frame.md", "references/script-template.md": "templates/script.md"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sources() -> dict[str, Path]:
|
|
21
|
+
own = _WHEEL if (PKG_DATA / _WHEEL["SKILL.md"]).is_file() else _CHECKOUT
|
|
22
|
+
paths = {rel: PKG_DATA / src for rel, src in {**own, **_SHARED}.items()}
|
|
23
|
+
missing = [str(p) for p in paths.values() if not p.is_file()]
|
|
24
|
+
if missing:
|
|
25
|
+
die(f"skill files missing from this install: {', '.join(missing)}")
|
|
26
|
+
return paths
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def target_dir(user: bool, dest: str | None) -> Path:
|
|
30
|
+
if dest:
|
|
31
|
+
return Path(dest).expanduser().resolve() / NAME
|
|
32
|
+
return (Path.home() if user else ROOT) / ".claude" / "skills" / NAME
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def install(user: bool = False, dest: str | None = None, force: bool = False) -> None:
|
|
36
|
+
src = sources()
|
|
37
|
+
target = target_dir(user, dest)
|
|
38
|
+
if (target / "SKILL.md").exists() and (target / "SKILL.md").samefile(src["SKILL.md"]):
|
|
39
|
+
info(f"{target} is this repository's own copy: the skill is already available here")
|
|
40
|
+
return
|
|
41
|
+
stale = [rel for rel, s in src.items() if (target / rel).exists() and not filecmp.cmp(s, target / rel, shallow=False)]
|
|
42
|
+
if stale and not force:
|
|
43
|
+
die(f"{target} already has a different copy ({', '.join(stale)}). Re-run with --force to replace it")
|
|
44
|
+
for rel, s in src.items():
|
|
45
|
+
out = target / rel
|
|
46
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
shutil.copyfile(s, out)
|
|
48
|
+
info(f"installed the {NAME} skill to {target}")
|
|
49
|
+
info(f"start Claude Code in {ROOT if not user else 'your workspace'} and ask for a video, or run /{NAME}")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|