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
studio/script.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Parse an episode's script.md into scenes, narration chunks and cue markers.
|
|
2
|
+
|
|
3
|
+
Format (see templates/script.md for a worked example):
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
title: The Paris Commune
|
|
7
|
+
voice: bm_george
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## hook: Seventy-two days
|
|
11
|
+
> template: title
|
|
12
|
+
> Any other quoted line is a note for the visuals and is never spoken.
|
|
13
|
+
|
|
14
|
+
Narration paragraph. A {cue} marker names the moment the next word is spoken.
|
|
15
|
+
|
|
16
|
+
Another paragraph. {pause 1.2} An explicit pause, in seconds.
|
|
17
|
+
|
|
18
|
+
A scene's heading is `## <id>: <chapter title>`. The id must start with a letter and use
|
|
19
|
+
only a-z, 0-9 and hyphens. Consecutive scenes with the same title become one YouTube chapter.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
|
|
30
|
+
from .common import DEFAULTS, die
|
|
31
|
+
|
|
32
|
+
SCENE_RE = re.compile(r"^##\s+([A-Za-z0-9][A-Za-z0-9-]*)\s*:\s*(.+?)\s*$")
|
|
33
|
+
MARKER_RE = re.compile(r"\{\s*([^{}]+?)\s*\}")
|
|
34
|
+
ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Chunk:
|
|
39
|
+
"""A run of narration synthesised in one TTS call."""
|
|
40
|
+
words: list[str] # display words, punctuation attached, as written
|
|
41
|
+
gap_after: float # silence after this chunk, seconds
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Scene:
|
|
46
|
+
id: str
|
|
47
|
+
title: str
|
|
48
|
+
template: str = "blank"
|
|
49
|
+
min_duration: float = 0.0
|
|
50
|
+
notes: list[str] = field(default_factory=list)
|
|
51
|
+
chunks: list[Chunk] = field(default_factory=list)
|
|
52
|
+
cues: dict[str, int] = field(default_factory=dict) # cue name -> index into words
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def words(self) -> list[str]:
|
|
56
|
+
return [w for c in self.chunks for w in c.words]
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def text(self) -> str:
|
|
60
|
+
return " ".join(self.words)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Script:
|
|
65
|
+
meta: dict
|
|
66
|
+
scenes: list[Scene]
|
|
67
|
+
|
|
68
|
+
def setting(self, key: str):
|
|
69
|
+
return self.meta.get(key, DEFAULTS.get(key))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_paragraph(text: str, scene: Scene, gap: float) -> None:
|
|
73
|
+
"""Split one paragraph into chunks at {pause n} markers, recording {cue} positions."""
|
|
74
|
+
words: list[str] = []
|
|
75
|
+
pos = 0
|
|
76
|
+
for m in MARKER_RE.finditer(text):
|
|
77
|
+
words += text[pos:m.start()].split()
|
|
78
|
+
pos = m.end()
|
|
79
|
+
token = m.group(1)
|
|
80
|
+
if token.startswith("pause"):
|
|
81
|
+
try:
|
|
82
|
+
seconds = float(token.split()[1]) if len(token.split()) > 1 else 1.0
|
|
83
|
+
except ValueError:
|
|
84
|
+
die(f"scene '{scene.id}': bad pause marker {{{token}}}")
|
|
85
|
+
scene.chunks.append(Chunk(words, seconds))
|
|
86
|
+
words = []
|
|
87
|
+
else:
|
|
88
|
+
name = token.replace(" ", "-")
|
|
89
|
+
if name in scene.cues:
|
|
90
|
+
die(f"scene '{scene.id}': cue {{{name}}} used twice")
|
|
91
|
+
scene.cues[name] = len(scene.words) + len(words)
|
|
92
|
+
words += text[pos:].split()
|
|
93
|
+
scene.chunks.append(Chunk(words, gap))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse(path: Path) -> Script:
|
|
97
|
+
raw = path.read_text()
|
|
98
|
+
raw = re.sub(r"<!--.*?-->", "", raw, flags=re.S)
|
|
99
|
+
meta: dict = {}
|
|
100
|
+
if raw.startswith("---"):
|
|
101
|
+
_, front, raw = raw.split("---", 2)
|
|
102
|
+
meta = yaml.safe_load(front) or {}
|
|
103
|
+
gap = float(meta.get("paragraph_gap", DEFAULTS["paragraph_gap"]))
|
|
104
|
+
|
|
105
|
+
scenes: list[Scene] = []
|
|
106
|
+
para: list[str] = []
|
|
107
|
+
|
|
108
|
+
def flush():
|
|
109
|
+
if para and scenes:
|
|
110
|
+
_parse_paragraph(" ".join(para), scenes[-1], gap)
|
|
111
|
+
para.clear()
|
|
112
|
+
|
|
113
|
+
for line in raw.splitlines():
|
|
114
|
+
s = line.strip()
|
|
115
|
+
m = SCENE_RE.match(s)
|
|
116
|
+
if m:
|
|
117
|
+
flush()
|
|
118
|
+
sid = m.group(1)
|
|
119
|
+
if not ID_RE.match(sid):
|
|
120
|
+
die(f"scene id '{sid}' must start with a lowercase letter and use only a-z, 0-9, '-'")
|
|
121
|
+
if any(x.id == sid for x in scenes):
|
|
122
|
+
die(f"duplicate scene id '{sid}'")
|
|
123
|
+
scenes.append(Scene(sid, m.group(2)))
|
|
124
|
+
elif s.startswith("#"):
|
|
125
|
+
flush() # top-level title or other headings: not narration
|
|
126
|
+
elif s.startswith(">"):
|
|
127
|
+
flush()
|
|
128
|
+
if not scenes:
|
|
129
|
+
continue
|
|
130
|
+
note = s.lstrip(">").strip()
|
|
131
|
+
key, _, val = note.partition(":")
|
|
132
|
+
if key.strip().lower() == "template" and val.strip():
|
|
133
|
+
scenes[-1].template = val.strip()
|
|
134
|
+
elif key.strip().lower() == "duration" and val.strip():
|
|
135
|
+
scenes[-1].min_duration = float(val.strip().rstrip("s"))
|
|
136
|
+
elif note:
|
|
137
|
+
scenes[-1].notes.append(note)
|
|
138
|
+
elif not s:
|
|
139
|
+
flush()
|
|
140
|
+
else:
|
|
141
|
+
para.append(s)
|
|
142
|
+
flush()
|
|
143
|
+
|
|
144
|
+
for sc in scenes:
|
|
145
|
+
merged: list[Chunk] = []
|
|
146
|
+
for c in sc.chunks:
|
|
147
|
+
if c.words:
|
|
148
|
+
merged.append(c)
|
|
149
|
+
elif merged: # a pause with no words before it extends the previous gap
|
|
150
|
+
merged[-1].gap_after += c.gap_after
|
|
151
|
+
sc.chunks = merged
|
|
152
|
+
if not sc.chunks and not sc.min_duration:
|
|
153
|
+
die(f"scene '{sc.id}' has no narration; add '> duration: <seconds>' for a silent scene")
|
|
154
|
+
if sc.chunks:
|
|
155
|
+
sc.chunks[-1].gap_after = 0.0
|
|
156
|
+
if not scenes:
|
|
157
|
+
die(f"{path}: no scenes found (headings must look like '## id: Chapter title')")
|
|
158
|
+
return Script(meta, scenes)
|