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,55 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><meta charset="UTF-8" /></head>
|
|
4
|
+
<body>
|
|
5
|
+
<template>
|
|
6
|
+
<style>
|
|
7
|
+
#root { position: absolute; inset: 0; overflow: hidden; }
|
|
8
|
+
#__ID__-stage { display: flex; flex-direction: column; justify-content: center; }
|
|
9
|
+
#__ID__-head { width: 560px; }
|
|
10
|
+
#__ID__-headline { font-size: 88px; margin: 36px 0 52px; }
|
|
11
|
+
#__ID__-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
|
|
12
|
+
#__ID__-list li { display: flex; align-items: baseline; gap: 36px; padding: 22px 0; border-top: var(--hairline); }
|
|
13
|
+
#__ID__-list .num { font-family: var(--font-sans); font-weight: 700; font-size: 34px; color: var(--red); min-width: 48px; }
|
|
14
|
+
</style>
|
|
15
|
+
|
|
16
|
+
<div id="root" data-composition-id="scene-__ID__" data-width="1920" data-height="1080">
|
|
17
|
+
<div class="paper"></div>
|
|
18
|
+
<div id="__ID__-stage" class="stage">
|
|
19
|
+
<div id="__ID__-head" class="section-head">
|
|
20
|
+
<div id="__ID__-rule" class="section-rule"></div>
|
|
21
|
+
<div id="__ID__-label" class="label">__TITLE__</div>
|
|
22
|
+
</div>
|
|
23
|
+
<h2 id="__ID__-headline" class="headline">Heading for the list</h2>
|
|
24
|
+
<!-- Up to 4 items. Each appears on cue {p1}..{p4} in the narration. -->
|
|
25
|
+
<ol id="__ID__-list">
|
|
26
|
+
<li id="__ID__-p1" class="body"><span class="num">1</span><span>First point</span></li>
|
|
27
|
+
<li id="__ID__-p2" class="body"><span class="num">2</span><span>Second point</span></li>
|
|
28
|
+
<li id="__ID__-p3" class="body"><span class="num">3</span><span>Third point</span></li>
|
|
29
|
+
</ol>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<script>
|
|
34
|
+
(() => {
|
|
35
|
+
/* <studio:timing> */
|
|
36
|
+
const T = { duration: 6, voStart: 0.5, voEnd: 5.5, cues: {}, paragraphs: [0.5] };
|
|
37
|
+
/* </studio:timing> */
|
|
38
|
+
const cue = (name, fallback = T.voStart) => T.cues[name] ?? fallback;
|
|
39
|
+
|
|
40
|
+
const tl = gsap.timeline({ paused: true });
|
|
41
|
+
tl.fromTo("#__ID__-rule", { scaleX: 0 }, { scaleX: 1, duration: 0.7, ease: "power2.inOut" }, 0.1);
|
|
42
|
+
tl.fromTo("#__ID__-label", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0.4);
|
|
43
|
+
tl.fromTo("#__ID__-headline", { opacity: 0, y: 36 }, { opacity: 1, y: 0, duration: 0.8, ease: "power3.out" }, 0.45);
|
|
44
|
+
["p1", "p2", "p3", "p4"].forEach((p, i) => {
|
|
45
|
+
if (!document.getElementById(`__ID__-${p}`)) return;
|
|
46
|
+
tl.fromTo(`#__ID__-${p}`, { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, cue(p, 1.3 + i * 1.5));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
tl.to("#__ID__-stage", { opacity: 0, duration: 0.45, ease: "power2.in" }, T.duration - 0.5);
|
|
50
|
+
window.__timelines["scene-__ID__"] = tl;
|
|
51
|
+
})();
|
|
52
|
+
</script>
|
|
53
|
+
</template>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><meta charset="UTF-8" /></head>
|
|
4
|
+
<body>
|
|
5
|
+
<template>
|
|
6
|
+
<style>
|
|
7
|
+
#root { position: absolute; inset: 0; overflow: hidden; }
|
|
8
|
+
#__ID__-stage { display: flex; flex-direction: column; justify-content: center; }
|
|
9
|
+
#__ID__-quote { font-size: 68px; line-height: 1.3; max-width: 1420px; }
|
|
10
|
+
#__ID__-who { margin-top: 52px; padding-left: 62px; }
|
|
11
|
+
#__ID__-src { margin-top: 12px; padding-left: 62px; }
|
|
12
|
+
</style>
|
|
13
|
+
|
|
14
|
+
<div id="root" data-composition-id="scene-__ID__" data-width="1920" data-height="1080">
|
|
15
|
+
<div class="paper"></div>
|
|
16
|
+
<div id="__ID__-stage" class="stage">
|
|
17
|
+
<blockquote id="__ID__-quote" class="quote">The quotation goes here, with the <span id="__ID__-wash" class="hl">key phrase</span> washed in red.</blockquote>
|
|
18
|
+
<div id="__ID__-who" class="label">Author name</div>
|
|
19
|
+
<div id="__ID__-src" class="byline"><span class="italic">Work or document</span>, date</div>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<script>
|
|
24
|
+
(() => {
|
|
25
|
+
/* <studio:timing> */
|
|
26
|
+
const T = { duration: 6, voStart: 0.5, voEnd: 5.5, cues: {}, paragraphs: [0.5] };
|
|
27
|
+
/* </studio:timing> */
|
|
28
|
+
const cue = (name, fallback = T.voStart) => T.cues[name] ?? fallback;
|
|
29
|
+
|
|
30
|
+
const tl = gsap.timeline({ paused: true });
|
|
31
|
+
tl.fromTo("#__ID__-quote", { opacity: 0, x: -30 }, { opacity: 1, x: 0, duration: 0.8, ease: "power3.out" }, 0.25);
|
|
32
|
+
tl.fromTo(["#__ID__-who", "#__ID__-src"], { opacity: 0 }, { opacity: 1, duration: 0.6, stagger: 0.12 }, 1.0);
|
|
33
|
+
tl.fromTo("#__ID__-wash", { backgroundSize: "0% 82%" }, { backgroundSize: "100% 82%", duration: 0.6, ease: "power2.out" }, cue("key", 2.0));
|
|
34
|
+
|
|
35
|
+
tl.to("#__ID__-stage", { opacity: 0, duration: 0.45, ease: "power2.in" }, T.duration - 0.5);
|
|
36
|
+
window.__timelines["scene-__ID__"] = tl;
|
|
37
|
+
})();
|
|
38
|
+
</script>
|
|
39
|
+
</template>
|
|
40
|
+
</body>
|
|
41
|
+
</html>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><meta charset="UTF-8" /></head>
|
|
4
|
+
<body>
|
|
5
|
+
<template>
|
|
6
|
+
<style>
|
|
7
|
+
#root { position: absolute; inset: 0; overflow: hidden; }
|
|
8
|
+
#__ID__-stage { display: flex; flex-direction: column; justify-content: center; }
|
|
9
|
+
#__ID__-head { width: 560px; }
|
|
10
|
+
#__ID__-figure { font-family: var(--font-serif); font-weight: 700; font-size: 300px; line-height: 1; color: var(--red); margin-top: 48px; font-variant-numeric: lining-nums tabular-nums; }
|
|
11
|
+
#__ID__-what { max-width: 1300px; margin-top: 28px; color: var(--ink); }
|
|
12
|
+
#__ID__-src { margin-top: 48px; }
|
|
13
|
+
</style>
|
|
14
|
+
|
|
15
|
+
<div id="root" data-composition-id="scene-__ID__" data-width="1920" data-height="1080">
|
|
16
|
+
<div class="paper"></div>
|
|
17
|
+
<div id="__ID__-stage" class="stage">
|
|
18
|
+
<div id="__ID__-head" class="section-head">
|
|
19
|
+
<div id="__ID__-rule" class="section-rule"></div>
|
|
20
|
+
<div id="__ID__-label" class="label">__TITLE__</div>
|
|
21
|
+
</div>
|
|
22
|
+
<!-- data-to is the number the counter lands on; data-suffix/prefix are added around it. -->
|
|
23
|
+
<div id="__ID__-figure" data-to="42" data-prefix="" data-suffix="%">0%</div>
|
|
24
|
+
<p id="__ID__-what" class="standfirst">What the figure measures, in one line.</p>
|
|
25
|
+
<div id="__ID__-src" class="byline">Source: Organisation, report title, year</div>
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<script>
|
|
30
|
+
(() => {
|
|
31
|
+
/* <studio:timing> */
|
|
32
|
+
const T = { duration: 6, voStart: 0.5, voEnd: 5.5, cues: {}, paragraphs: [0.5] };
|
|
33
|
+
/* </studio:timing> */
|
|
34
|
+
const cue = (name, fallback = T.voStart) => T.cues[name] ?? fallback;
|
|
35
|
+
|
|
36
|
+
const fig = document.getElementById("__ID__-figure");
|
|
37
|
+
const to = Number(fig.dataset.to), pre = fig.dataset.prefix || "", suf = fig.dataset.suffix || "";
|
|
38
|
+
const counter = { v: 0 };
|
|
39
|
+
const fmt = (v) => pre + Math.round(v).toLocaleString("en-US") + suf;
|
|
40
|
+
fig.textContent = fmt(0);
|
|
41
|
+
|
|
42
|
+
const tl = gsap.timeline({ paused: true });
|
|
43
|
+
tl.fromTo("#__ID__-rule", { scaleX: 0 }, { scaleX: 1, duration: 0.7, ease: "power2.inOut" }, 0.1);
|
|
44
|
+
tl.fromTo("#__ID__-label", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 0.4);
|
|
45
|
+
const at = cue("figure", 0.7);
|
|
46
|
+
tl.fromTo("#__ID__-figure", { opacity: 0, y: 30 }, { opacity: 1, y: 0, duration: 0.6, ease: "power3.out" }, at);
|
|
47
|
+
tl.fromTo(counter, { v: 0 }, { v: to, duration: 1.4, ease: "power2.out", onUpdate: () => (fig.textContent = fmt(counter.v)) }, at);
|
|
48
|
+
tl.fromTo("#__ID__-what", { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.7, ease: "power3.out" }, at + 0.9);
|
|
49
|
+
tl.fromTo("#__ID__-src", { opacity: 0 }, { opacity: 1, duration: 0.6 }, at + 1.4);
|
|
50
|
+
|
|
51
|
+
tl.to("#__ID__-stage", { opacity: 0, duration: 0.45, ease: "power2.in" }, T.duration - 0.5);
|
|
52
|
+
window.__timelines["scene-__ID__"] = tl;
|
|
53
|
+
})();
|
|
54
|
+
</script>
|
|
55
|
+
</template>
|
|
56
|
+
</body>
|
|
57
|
+
</html>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<head><meta charset="UTF-8" /></head>
|
|
4
|
+
<body>
|
|
5
|
+
<template>
|
|
6
|
+
<style>
|
|
7
|
+
#root { position: absolute; inset: 0; overflow: hidden; }
|
|
8
|
+
#__ID__-stage { display: flex; flex-direction: column; justify-content: center; }
|
|
9
|
+
#__ID__-title { font-size: 132px; max-width: 1450px; margin-top: 44px; }
|
|
10
|
+
#__ID__-sub { margin-top: 40px; max-width: 1300px; }
|
|
11
|
+
#__ID__-rule { width: 100%; margin-top: 64px; transform-origin: left center; }
|
|
12
|
+
#__ID__-byline { margin-top: 28px; }
|
|
13
|
+
</style>
|
|
14
|
+
|
|
15
|
+
<div id="root" data-composition-id="scene-__ID__" data-width="1920" data-height="1080">
|
|
16
|
+
<div class="paper"></div>
|
|
17
|
+
<div id="__ID__-stage" class="stage">
|
|
18
|
+
<div id="__ID__-tag" class="tag">Explainer</div>
|
|
19
|
+
<h1 id="__ID__-title" class="headline">__EPISODE__</h1>
|
|
20
|
+
<p id="__ID__-sub" class="standfirst">__TITLE__</p>
|
|
21
|
+
<div id="__ID__-rule" class="hairline"></div>
|
|
22
|
+
<div id="__ID__-byline" class="byline">Byline or series name</div>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<script>
|
|
27
|
+
(() => {
|
|
28
|
+
/* <studio:timing> */
|
|
29
|
+
const T = { duration: 6, voStart: 0.5, voEnd: 5.5, cues: {}, paragraphs: [0.5] };
|
|
30
|
+
/* </studio:timing> */
|
|
31
|
+
const cue = (name, fallback = T.voStart) => T.cues[name] ?? fallback;
|
|
32
|
+
|
|
33
|
+
const tl = gsap.timeline({ paused: true });
|
|
34
|
+
tl.fromTo("#__ID__-tag", { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.5, ease: "power3.out" }, 0.2);
|
|
35
|
+
tl.fromTo("#__ID__-title", { opacity: 0, y: 50 }, { opacity: 1, y: 0, duration: 0.9, ease: "power3.out" }, 0.35);
|
|
36
|
+
tl.fromTo("#__ID__-sub", { opacity: 0, y: 24 }, { opacity: 1, y: 0, duration: 0.7, ease: "power3.out" }, cue("sub", 1.2));
|
|
37
|
+
tl.fromTo("#__ID__-rule", { scaleX: 0 }, { scaleX: 1, duration: 0.9, ease: "power2.inOut" }, 1.0);
|
|
38
|
+
tl.fromTo("#__ID__-byline", { opacity: 0 }, { opacity: 1, duration: 0.6 }, 1.6);
|
|
39
|
+
|
|
40
|
+
tl.to("#__ID__-stage", { opacity: 0, duration: 0.45, ease: "power2.in" }, T.duration - 0.5);
|
|
41
|
+
window.__timelines["scene-__ID__"] = tl;
|
|
42
|
+
})();
|
|
43
|
+
</script>
|
|
44
|
+
</template>
|
|
45
|
+
</body>
|
|
46
|
+
</html>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: __TITLE__
|
|
3
|
+
voice: bf_emma*0.6 + bf_isabella*0.4 # Kokoro voice or weighted blend; audition with `studio voices`
|
|
4
|
+
speed: 0.85 # ~165 wpm with this voice
|
|
5
|
+
# music: brand/music/your-bed.mp3 # optional; ducked under narration automatically
|
|
6
|
+
# pronounce: # TTS-only respellings for this episode (captions keep the original)
|
|
7
|
+
# Engels: Engles
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<!--
|
|
11
|
+
HOW THIS FILE WORKS (delete this comment when you like)
|
|
12
|
+
- `## id: Chapter title` starts a scene. The id is lowercase letters/digits/hyphens and names
|
|
13
|
+
scenes/<id>.html. Consecutive scenes with the same title share one YouTube chapter.
|
|
14
|
+
- Plain paragraphs are narration. Blank lines between paragraphs become short pauses.
|
|
15
|
+
- `> template: title|blank|quote|points|stat|outro` picks the starting design for a NEW scene.
|
|
16
|
+
- `> duration: 4` sets a minimum length (needed for a scene with no narration).
|
|
17
|
+
- Any other `>` line is a note about the visuals. It is never spoken.
|
|
18
|
+
- `{name}` marks a cue: the scene can animate exactly when the next word is spoken,
|
|
19
|
+
via cue("name") in scenes/<id>.html. `{pause 1.5}` inserts a pause in seconds.
|
|
20
|
+
- Write numbers and abbreviations the way they should be SPOKEN, e.g. "nineteen seventeen"
|
|
21
|
+
rather than "1917" when the voice gets it wrong. The screen can still show "1917".
|
|
22
|
+
- Pacing: about 150 words a minute. A 5-minute video is about 700-750 words.
|
|
23
|
+
-->
|
|
24
|
+
|
|
25
|
+
# __TITLE__
|
|
26
|
+
|
|
27
|
+
## hook: __TITLE__
|
|
28
|
+
> template: title
|
|
29
|
+
> Open on the question the video answers. No "welcome to the channel".
|
|
30
|
+
|
|
31
|
+
Start with a question, a contrast, or a fact the viewer doesn't expect. {sub} Then say, in one sentence, what this video will explain.
|
|
32
|
+
|
|
33
|
+
## context: Background
|
|
34
|
+
> template: blank
|
|
35
|
+
|
|
36
|
+
Set up what the viewer needs to know before the argument starts. {line} Keep it concrete: a date, a place, a figure.
|
|
37
|
+
|
|
38
|
+
## argument: The argument
|
|
39
|
+
> template: points
|
|
40
|
+
|
|
41
|
+
The core of the video goes here, built up point by point. {p1} The first point. {p2} The second point. {p3} And the third.
|
|
42
|
+
|
|
43
|
+
## evidence: The evidence
|
|
44
|
+
> template: quote
|
|
45
|
+
|
|
46
|
+
Quote the source directly, and name it. {key} Put a cue on the phrase that matters most.
|
|
47
|
+
|
|
48
|
+
## outro: Conclusion
|
|
49
|
+
> template: outro
|
|
50
|
+
|
|
51
|
+
Close by restating the main idea in one line, and say what to read or watch next. {next}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<!-- YouTube thumbnail, 1280x720. `studio thumbnail <slug>` screenshots this page.
|
|
3
|
+
Edit freely: it is copied into the episode once and never overwritten.
|
|
4
|
+
Thumbnails are read at ~320px wide, so: 3-6 words, huge type, one red element. -->
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="UTF-8" />
|
|
8
|
+
<link rel="stylesheet" href="../assets/brand/brand.css" />
|
|
9
|
+
<style>
|
|
10
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
11
|
+
html, body { width: 1280px; height: 720px; overflow: hidden; background: #ffffff; }
|
|
12
|
+
.frame { position: absolute; inset: 0; padding: 72px 88px; display: flex; flex-direction: column; justify-content: center; }
|
|
13
|
+
.bar { position: absolute; left: 0; top: 0; bottom: 0; width: 28px; background: var(--red); }
|
|
14
|
+
.tag { font-size: 30px; padding: 12px 22px 11px; }
|
|
15
|
+
h1 { font-family: var(--font-serif); font-weight: 700; font-size: 112px; line-height: 1.04; color: var(--ink); margin-top: 36px; max-width: 1100px; }
|
|
16
|
+
h1 .red { color: var(--red); }
|
|
17
|
+
.rule { height: 8px; background: var(--ink); width: 240px; margin-top: 44px; }
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<div class="bar"></div>
|
|
22
|
+
<div class="frame">
|
|
23
|
+
<div class="tag">__TAG__</div>
|
|
24
|
+
<h1>__HEADLINE__</h1>
|
|
25
|
+
<div class="rule"></div>
|
|
26
|
+
</div>
|
|
27
|
+
</body>
|
|
28
|
+
</html>
|
studio/music.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Music beds: an instrumental track from the ElevenLabs Music API, sized to an episode.
|
|
2
|
+
|
|
3
|
+
audio/music/<hash>.mp3 cached API result (48 kHz stereo); the same prompt, length and model is never re-billed
|
|
4
|
+
audio/music/<name>.mp3 the bed itself (or --out), ready for `music:` in script.md
|
|
5
|
+
|
|
6
|
+
The render loops a bed shorter than the episode and trims a longer one, so the length only has to
|
|
7
|
+
be about right. Uses the same ELEVENLABS_API_KEY as narration.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import shutil
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .common import ROOT, die, ffprobe_duration, info, run, sha
|
|
18
|
+
from .voice import elevenlabs_key, elevenlabs_post
|
|
19
|
+
|
|
20
|
+
URL = "https://api.elevenlabs.io/v1/music?output_format=mp3_48000_192"
|
|
21
|
+
MODEL = "music_v2"
|
|
22
|
+
MIN_S, MAX_S = 3, 600 # API limits on music_length_ms
|
|
23
|
+
USD_PER_SECOND = 0.0083 # third-party figure; check elevenlabs.io/pricing/api
|
|
24
|
+
TAIL_S = 2 # beds run slightly past the narration so the final fade has music under it
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def episode_length(ep: Path) -> float:
|
|
28
|
+
path = ep / "timing.json"
|
|
29
|
+
if not path.exists():
|
|
30
|
+
die(f"{path.relative_to(ROOT)} not found - run: studio sync {ep.name}")
|
|
31
|
+
return float(json.loads(path.read_text())["duration"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate(ep: Path, prompt: str, length_s: int, model: str, variant: int = 0) -> Path:
|
|
35
|
+
"""Return the cached bed for these inputs, calling the API only on a miss. `variant` forces a fresh take."""
|
|
36
|
+
length_s = min(max(length_s, MIN_S), MAX_S)
|
|
37
|
+
cache = ep / "audio" / "music" / f"{sha(prompt, length_s, model, variant)[:20]}.mp3"
|
|
38
|
+
if cache.exists():
|
|
39
|
+
info(f" cached: {cache.relative_to(ROOT)}")
|
|
40
|
+
return cache
|
|
41
|
+
info(f" composing {length_s}s with {model} (about ${length_s * USD_PER_SECOND:.2f})")
|
|
42
|
+
body = {"prompt": prompt, "music_length_ms": length_s * 1000, "model_id": model, "force_instrumental": True}
|
|
43
|
+
audio = elevenlabs_post(URL, elevenlabs_key(), body, timeout=600)
|
|
44
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
raw = cache.with_name(f".{cache.stem}.raw.mp3")
|
|
46
|
+
raw.write_bytes(audio)
|
|
47
|
+
run(["ffmpeg", "-v", "error", "-y", "-i", str(raw), "-ar", "48000", "-ac", "2", "-c:a", "libmp3lame",
|
|
48
|
+
"-b:a", "192k", str(cache)])
|
|
49
|
+
raw.unlink()
|
|
50
|
+
return cache
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_beds(ep: Path, prompt: str, name: str, length_s: int | None, model: str, variants: int,
|
|
54
|
+
out: Path | None) -> list[Path]:
|
|
55
|
+
length = length_s or math.ceil(episode_length(ep)) + TAIL_S
|
|
56
|
+
dst_dir = out.parent if out else ep / "audio" / "music"
|
|
57
|
+
dst_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
stem = out.stem if out else name
|
|
59
|
+
paths = []
|
|
60
|
+
for i in range(variants):
|
|
61
|
+
suffix = f"-{chr(ord('a') + i)}" if variants > 1 else ""
|
|
62
|
+
dst = dst_dir / f"{stem}{suffix}.mp3"
|
|
63
|
+
shutil.copyfile(generate(ep, prompt, length, model, i), dst)
|
|
64
|
+
paths.append(dst)
|
|
65
|
+
info(f" {dst.relative_to(ROOT) if dst.is_relative_to(ROOT) else dst} ({ffprobe_duration(dst):.0f}s)")
|
|
66
|
+
return paths
|
studio/render.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Render scenes (cached), concatenate, mix and master audio, write captions and chapters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import soundfile as sf
|
|
12
|
+
|
|
13
|
+
from .build import render_shell
|
|
14
|
+
from .common import BRAND, HF_VERSION, OUT, SAMPLE_RATE, die, ffprobe_duration, hf, info, run, sha, tree_hash
|
|
15
|
+
from .script import Script
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------- video
|
|
18
|
+
|
|
19
|
+
def render_scenes(ep: Path, timing: dict, quality: str, only: set[str] | None, force: bool) -> list[Path]:
|
|
20
|
+
cache = ep / "build" / "scenes"
|
|
21
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
assets_key = tree_hash(ep / "assets")
|
|
23
|
+
outputs = []
|
|
24
|
+
for s in timing["scenes"]:
|
|
25
|
+
scene_file = ep / "scenes" / f"{s['id']}.html"
|
|
26
|
+
key = sha(scene_file, assets_key, s["duration"], timing["fps"], quality, HF_VERSION)[:16]
|
|
27
|
+
target = cache / f"{s['id']}.{quality}.{key}.mp4"
|
|
28
|
+
outputs.append(target)
|
|
29
|
+
# Cached scenes are reused unless --force names them; missing ones always render.
|
|
30
|
+
if target.exists() and not (force and (not only or s["id"] in only)):
|
|
31
|
+
continue
|
|
32
|
+
for old in cache.glob(f"{s['id']}.{quality}.*.mp4"):
|
|
33
|
+
old.unlink()
|
|
34
|
+
info(f" render {s['id']} ({s['duration']:.1f}s)")
|
|
35
|
+
shell = render_shell(ep, s, timing)
|
|
36
|
+
try:
|
|
37
|
+
run(hf("render", "-c", shell.name, "-f", str(timing["fps"]), "-q", quality,
|
|
38
|
+
"--quiet", "-o", str(target)), cwd=ep)
|
|
39
|
+
finally:
|
|
40
|
+
shell.unlink(missing_ok=True)
|
|
41
|
+
got = ffprobe_duration(target)
|
|
42
|
+
if abs(got - s["duration"]) > 1.5 / timing["fps"]:
|
|
43
|
+
die(f"scene {s['id']} rendered {got:.3f}s, expected {s['duration']:.3f}s")
|
|
44
|
+
return outputs
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def concat(parts: list[Path], dst: Path) -> None:
|
|
48
|
+
lst = dst.with_suffix(".txt")
|
|
49
|
+
lst.write_text("".join(f"file '{p.resolve()}'\n" for p in parts))
|
|
50
|
+
run(["ffmpeg", "-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", str(lst), "-c", "copy", str(dst)])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------- audio
|
|
54
|
+
|
|
55
|
+
def build_vo_track(ep: Path, timing: dict, dst: Path) -> None:
|
|
56
|
+
total = int(round(timing["duration"] * SAMPLE_RATE))
|
|
57
|
+
track = np.zeros(total, dtype=np.float32)
|
|
58
|
+
for s in timing["scenes"]:
|
|
59
|
+
wav = ep / "audio" / "vo" / f"{s['id']}.wav"
|
|
60
|
+
if not wav.exists():
|
|
61
|
+
continue
|
|
62
|
+
data, _ = sf.read(str(wav), dtype="float32")
|
|
63
|
+
at = int(round((s["start"] + s["voStart"]) * SAMPLE_RATE))
|
|
64
|
+
end = min(at + len(data), total)
|
|
65
|
+
track[at:end] += data[: end - at]
|
|
66
|
+
sf.write(str(dst), track, SAMPLE_RATE, subtype="PCM_24")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def mix(ep: Path, script: Script, timing: dict, vo: Path, dst: Path) -> dict:
|
|
70
|
+
"""Narration (+ optional ducked music bed), mastered to the loudness target in two passes."""
|
|
71
|
+
dur = timing["duration"]
|
|
72
|
+
target_i, target_tp = float(script.setting("loudness")), float(script.setting("true_peak"))
|
|
73
|
+
music = next(iter(sorted((ep / "assets").glob("music.*"))), None) if script.setting("music") else None
|
|
74
|
+
|
|
75
|
+
inputs = ["-i", str(vo)]
|
|
76
|
+
if music:
|
|
77
|
+
inputs += ["-stream_loop", "-1", "-i", str(music)]
|
|
78
|
+
gain = float(script.setting("music_db"))
|
|
79
|
+
fade_in = float(script.setting("music_fade_in"))
|
|
80
|
+
fade = f"afade=t=in:d={fade_in}," if fade_in > 0 else ""
|
|
81
|
+
graph = (
|
|
82
|
+
"[0:a]aformat=channel_layouts=mono,asplit=2[v1][vkey];"
|
|
83
|
+
f"[1:a]aformat=channel_layouts=stereo,atrim=0:{dur:.3f},volume={gain}dB,"
|
|
84
|
+
f"{fade}afade=t=out:st={max(dur - 4, 0):.3f}:d=4[m];"
|
|
85
|
+
"[m][vkey]sidechaincompress=threshold=0.03:ratio=6:attack=40:release=600[duck];"
|
|
86
|
+
"[v1]pan=stereo|c0=c0|c1=c0[vs];"
|
|
87
|
+
"[vs][duck]amix=inputs=2:duration=first:normalize=0[mix]"
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
graph = "[0:a]pan=stereo|c0=c0|c1=c0[mix]"
|
|
91
|
+
pre = dst.with_name("premix.wav")
|
|
92
|
+
run(["ffmpeg", "-v", "error", "-y", *inputs, "-filter_complex", graph, "-map", "[mix]",
|
|
93
|
+
"-t", f"{dur:.3f}", "-ar", str(SAMPLE_RATE), "-c:a", "pcm_s24le", str(pre)])
|
|
94
|
+
|
|
95
|
+
ln = f"loudnorm=I={target_i}:TP={target_tp}:LRA=11"
|
|
96
|
+
# Pass 1 measures (loudnorm prints its JSON on stderr); pass 2 applies a linear gain.
|
|
97
|
+
proc = subprocess.run(["ffmpeg", "-hide_banner", "-nostats", "-i", str(pre), "-af",
|
|
98
|
+
f"{ln}:print_format=json", "-f", "null", "-"], capture_output=True, text=True)
|
|
99
|
+
m = json.loads(re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S).group(0))
|
|
100
|
+
second = (f"{ln}:measured_I={m['input_i']}:measured_TP={m['input_tp']}:measured_LRA={m['input_lra']}"
|
|
101
|
+
f":measured_thresh={m['input_thresh']}:offset={m['target_offset']}:linear=true")
|
|
102
|
+
run(["ffmpeg", "-v", "error", "-y", "-i", str(pre), "-af", second, "-ar", str(SAMPLE_RATE),
|
|
103
|
+
"-c:a", "pcm_s24le", str(dst)])
|
|
104
|
+
pre.unlink()
|
|
105
|
+
return m
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------- captions & chapters
|
|
109
|
+
|
|
110
|
+
def _ts(t: float, sep: str = ",") -> str:
|
|
111
|
+
ms = int(round(t * 1000))
|
|
112
|
+
h, ms = divmod(ms, 3_600_000)
|
|
113
|
+
m, ms = divmod(ms, 60_000)
|
|
114
|
+
s, ms = divmod(ms, 1000)
|
|
115
|
+
return f"{h:02d}:{m:02d}:{s:02d}{sep}{ms:03d}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _caption_cues(ep: Path, timing: dict, max_chars: int, max_secs: float, max_words: int = 99,
|
|
119
|
+
min_sentence: int = 12) -> list[list[dict]]:
|
|
120
|
+
"""Group the script's own words (correct spelling) into cues; each word carries absolute times."""
|
|
121
|
+
cues = []
|
|
122
|
+
for s in timing["scenes"]:
|
|
123
|
+
wf = ep / "audio" / "vo" / f"{s['id']}.words.json"
|
|
124
|
+
words = json.loads(wf.read_text())["words"] if wf.exists() else []
|
|
125
|
+
offset = s["start"] + s["voStart"]
|
|
126
|
+
group: list[dict] = []
|
|
127
|
+
|
|
128
|
+
def flush():
|
|
129
|
+
if group:
|
|
130
|
+
cues.append(list(group))
|
|
131
|
+
group.clear()
|
|
132
|
+
|
|
133
|
+
for w in words:
|
|
134
|
+
w = {"text": w["text"], "start": offset + w["start"], "end": offset + w["end"]}
|
|
135
|
+
text_len = len(" ".join(x["text"] for x in group + [w]))
|
|
136
|
+
if group and (text_len > 2 * max_chars or len(group) >= max_words
|
|
137
|
+
or w["end"] - group[0]["start"] > max_secs):
|
|
138
|
+
flush()
|
|
139
|
+
group.append(w)
|
|
140
|
+
sofar = len(" ".join(x["text"] for x in group))
|
|
141
|
+
if re.search(r"[.!?]['\")\]]*$", w["text"]) and sofar > min_sentence:
|
|
142
|
+
flush() # end of sentence
|
|
143
|
+
elif re.search(r"[,;:—]['\")\]]*$", w["text"]) and sofar > max_chars:
|
|
144
|
+
flush() # a clause break once the cue is already a full line
|
|
145
|
+
flush()
|
|
146
|
+
return cues
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def captions(ep: Path, timing: dict, dst: Path, max_chars: int = 42, max_secs: float = 6.0) -> int:
|
|
150
|
+
"""SRT from the script's own words (correct spelling) with aligned timings; 2 lines max."""
|
|
151
|
+
cues = [(g[0]["start"], g[-1]["end"], [w["text"] for w in g]) for g in _caption_cues(ep, timing, max_chars, max_secs)]
|
|
152
|
+
lines = []
|
|
153
|
+
for i, (a, b, words) in enumerate(cues, 1):
|
|
154
|
+
nxt = cues[i][0] if i < len(cues) else b + 1.0
|
|
155
|
+
b = min(max(b + 0.25, a + 1.0), nxt - 0.04) # linger a little, never overlap the next
|
|
156
|
+
text = " ".join(words)
|
|
157
|
+
if len(text) > max_chars: # break into two balanced lines at a word boundary
|
|
158
|
+
mid, best = len(text) / 2, None
|
|
159
|
+
for k in range(1, len(words)):
|
|
160
|
+
cut = len(" ".join(words[:k]))
|
|
161
|
+
if best is None or abs(cut - mid) < abs(best[0] - mid):
|
|
162
|
+
best = (cut, k)
|
|
163
|
+
text = " ".join(words[: best[1]]) + "\n" + " ".join(words[best[1]:])
|
|
164
|
+
lines.append(f"{i}\n{_ts(a)} --> {_ts(b)}\n{text}\n")
|
|
165
|
+
dst.write_text("\n".join(lines))
|
|
166
|
+
return len(cues)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def chapters(timing: dict) -> tuple[list[str], list[str]]:
|
|
170
|
+
"""YouTube chapter lines (consecutive scenes sharing a title merge) and any rule violations."""
|
|
171
|
+
merged: list[list] = []
|
|
172
|
+
for s in timing["scenes"]:
|
|
173
|
+
if merged and merged[-1][1] == s["title"]:
|
|
174
|
+
merged[-1][2] += s["duration"]
|
|
175
|
+
else:
|
|
176
|
+
merged.append([s["start"], s["title"], s["duration"]])
|
|
177
|
+
out, problems = [], []
|
|
178
|
+
for start, title, dur in merged:
|
|
179
|
+
t = int(start)
|
|
180
|
+
stamp = f"{t // 3600}:{t % 3600 // 60:02d}:{t % 60:02d}" if t >= 3600 else f"{t // 60}:{t % 60:02d}"
|
|
181
|
+
out.append(f"{stamp} {title}")
|
|
182
|
+
if dur < 10:
|
|
183
|
+
problems.append(f"chapter '{title}' is {dur:.1f}s (YouTube needs >= 10s)")
|
|
184
|
+
if len(merged) < 3:
|
|
185
|
+
problems.append("YouTube needs at least 3 chapters to show them")
|
|
186
|
+
return out, problems
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _ass_color(hexcolor: str) -> str:
|
|
190
|
+
r, g, b = hexcolor[1:3], hexcolor[3:5], hexcolor[5:7]
|
|
191
|
+
return f"&H00{b}{g}{r}".upper()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def ass_captions(ep: Path, timing: dict, dst: Path) -> int:
|
|
195
|
+
"""Burn-in captions in house style: white Source Sans on a dark tag box, the spoken word in red.
|
|
196
|
+
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 the static brand/fonts/SourceSans3-Bold.ttf."""
|
|
198
|
+
w, h = timing["width"], timing["height"]
|
|
199
|
+
portrait = h > w
|
|
200
|
+
size, y = (72, int(h * 0.69)) if portrait else (52, int(h * 0.86)) # reel caption band / lower third
|
|
201
|
+
ink, red, white = _ass_color("#1b2129"), _ass_color("#e71e25"), _ass_color("#ffffff")
|
|
202
|
+
head = [
|
|
203
|
+
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {w}", f"PlayResY: {h}", "WrapStyle: 2",
|
|
204
|
+
"ScaledBorderAndShadow: yes", "",
|
|
205
|
+
"[V4+ Styles]",
|
|
206
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, "
|
|
207
|
+
"Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, "
|
|
208
|
+
"MarginL, MarginR, MarginV, Encoding",
|
|
209
|
+
f"Style: Cap,Source Sans 3 Bold,{size},{white},{white},{ink},{ink},-1,0,0,0,100,100,0,0,3,14,0,5,0,0,0,1",
|
|
210
|
+
"", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
211
|
+
]
|
|
212
|
+
cues = _caption_cues(ep, timing, max_chars=13 if portrait else 21, max_secs=2.5, max_words=4, min_sentence=0)
|
|
213
|
+
events = []
|
|
214
|
+
for i, cue in enumerate(cues):
|
|
215
|
+
nxt = cues[i + 1][0]["start"] if i + 1 < len(cues) else cue[-1]["end"] + 1.0
|
|
216
|
+
cue_end = min(cue[-1]["end"] + 0.3, nxt)
|
|
217
|
+
for k, word in enumerate(cue):
|
|
218
|
+
a = cue[0]["start"] if k == 0 else word["start"]
|
|
219
|
+
b = cue[k + 1]["start"] if k + 1 < len(cue) else cue_end
|
|
220
|
+
text = " ".join(("{\\c" + red + "&}" + x["text"] + "{\\c" + white + "&}") if j == k else x["text"]
|
|
221
|
+
for j, x in enumerate(cue))
|
|
222
|
+
events.append(f"Dialogue: 0,{_ts(a, '.')[1:-1]},{_ts(b, '.')[1:-1]},Cap,,0,0,0,,{{\\pos({w // 2},{y})}}{text}")
|
|
223
|
+
dst.write_text("\n".join(head + events) + "\n")
|
|
224
|
+
return len(cues)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---------------------------------------------------------------- top level
|
|
228
|
+
|
|
229
|
+
def render_episode(ep: Path, script: Script, timing: dict, draft: bool, only: set[str] | None, force: bool) -> Path:
|
|
230
|
+
quality = "draft" if draft else "high"
|
|
231
|
+
build = ep / "build"
|
|
232
|
+
build.mkdir(exist_ok=True)
|
|
233
|
+
parts = render_scenes(ep, timing, quality, only, force)
|
|
234
|
+
|
|
235
|
+
info(" concatenating scenes")
|
|
236
|
+
video = build / f"video.{quality}.mp4"
|
|
237
|
+
concat(parts, video)
|
|
238
|
+
|
|
239
|
+
info(" mixing and mastering audio")
|
|
240
|
+
vo = build / "vo.wav"
|
|
241
|
+
build_vo_track(ep, timing, vo)
|
|
242
|
+
master = build / "master.wav"
|
|
243
|
+
m = mix(ep, script, timing, vo, master)
|
|
244
|
+
|
|
245
|
+
OUT.mkdir(exist_ok=True)
|
|
246
|
+
name = ep.name + ("-draft" if draft else "")
|
|
247
|
+
final = OUT / f"{name}.mp4"
|
|
248
|
+
vcodec = ["-c:v", "copy"]
|
|
249
|
+
if script.setting("burn_captions"):
|
|
250
|
+
info(" burning in captions")
|
|
251
|
+
ass = build / "captions.ass"
|
|
252
|
+
ass_captions(ep, timing, ass)
|
|
253
|
+
crf = "26" if draft else "16"
|
|
254
|
+
vcodec = ["-vf", f"ass={ass.name}:fontsdir={BRAND / 'fonts'}", "-c:v", "libx264", "-preset", "medium",
|
|
255
|
+
"-crf", crf, "-pix_fmt", "yuv420p"]
|
|
256
|
+
run(["ffmpeg", "-v", "error", "-y", "-i", str(video.resolve()), "-i", str(master.resolve()), "-map", "0:v", "-map", "1:a",
|
|
257
|
+
*vcodec, "-c:a", "aac", "-b:a", "384k", "-ar", str(SAMPLE_RATE),
|
|
258
|
+
"-movflags", "+faststart", str(final.resolve())], cwd=build)
|
|
259
|
+
|
|
260
|
+
n = captions(ep, timing, OUT / f"{ep.name}.srt")
|
|
261
|
+
chap, problems = chapters(timing)
|
|
262
|
+
if timing.get("format") == "reel":
|
|
263
|
+
problems = [] # chapters are a YouTube long-form feature
|
|
264
|
+
(OUT / f"{ep.name}.chapters.txt").write_text("\n".join(chap) + "\n")
|
|
265
|
+
|
|
266
|
+
info(f"\n {final.relative_to(OUT.parent)} {ffprobe_duration(final):.1f}s")
|
|
267
|
+
info(f" loudness in {m['input_i']} LUFS -> {script.setting('loudness')} LUFS target")
|
|
268
|
+
info(f" captions: out/{ep.name}.srt ({n} cues) chapters: out/{ep.name}.chapters.txt")
|
|
269
|
+
for p in problems:
|
|
270
|
+
info(f" note: {p}")
|
|
271
|
+
return final
|