explainer-studio 0.1.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. explainer_studio-0.1.5.dist-info/METADATA +149 -0
  2. explainer_studio-0.1.5.dist-info/RECORD +37 -0
  3. explainer_studio-0.1.5.dist-info/WHEEL +4 -0
  4. explainer_studio-0.1.5.dist-info/entry_points.txt +2 -0
  5. explainer_studio-0.1.5.dist-info/licenses/LICENSE +41 -0
  6. studio/__init__.py +0 -0
  7. studio/build.py +196 -0
  8. studio/chatterbox_worker.py +96 -0
  9. studio/cli.py +265 -0
  10. studio/common.py +160 -0
  11. studio/data/brand/brand.css +145 -0
  12. studio/data/brand/fonts/JetBrainsMono.ttf +0 -0
  13. studio/data/brand/fonts/OFL-JetBrainsMono.txt +93 -0
  14. studio/data/brand/fonts/OFL-PTSerif.txt +93 -0
  15. studio/data/brand/fonts/OFL-SourceSans3.txt +93 -0
  16. studio/data/brand/fonts/PTSerif-Bold.ttf +0 -0
  17. studio/data/brand/fonts/PTSerif-BoldItalic.ttf +0 -0
  18. studio/data/brand/fonts/PTSerif-Italic.ttf +0 -0
  19. studio/data/brand/fonts/PTSerif-Regular.ttf +0 -0
  20. studio/data/brand/fonts/SourceSans3-Bold.ttf +0 -0
  21. studio/data/brand/fonts/SourceSans3-Italic.ttf +0 -0
  22. studio/data/brand/fonts/SourceSans3.ttf +0 -0
  23. studio/data/brand/frame.md +108 -0
  24. studio/data/brand/pronunciations.yaml +8 -0
  25. studio/data/brand/vendor/gsap.min.js +11 -0
  26. studio/data/templates/scenes/blank.html +46 -0
  27. studio/data/templates/scenes/outro.html +50 -0
  28. studio/data/templates/scenes/points.html +55 -0
  29. studio/data/templates/scenes/quote.html +41 -0
  30. studio/data/templates/scenes/stat.html +57 -0
  31. studio/data/templates/scenes/title.html +46 -0
  32. studio/data/templates/script.md +51 -0
  33. studio/data/templates/thumbnail.html +28 -0
  34. studio/music.py +66 -0
  35. studio/render.py +271 -0
  36. studio/script.py +158 -0
  37. studio/voice.py +633 -0
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)