decktalk 0.1.0__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 (43) hide show
  1. decktalk/__init__.py +59 -0
  2. decktalk/__main__.py +5 -0
  3. decktalk/_env.py +70 -0
  4. decktalk/_report.py +120 -0
  5. decktalk/artifacts.py +232 -0
  6. decktalk/cli.py +375 -0
  7. decktalk/config.py +164 -0
  8. decktalk/errors.py +23 -0
  9. decktalk/media/__init__.py +1 -0
  10. decktalk/media/browser.py +161 -0
  11. decktalk/media/ffmpeg.py +273 -0
  12. decktalk/project.py +568 -0
  13. decktalk/providers/__init__.py +1 -0
  14. decktalk/providers/_http.py +50 -0
  15. decktalk/providers/elevenlabs.py +136 -0
  16. decktalk/providers/speech.py +60 -0
  17. decktalk/py.typed +0 -0
  18. decktalk/runtime/decktalk-runtime.js +381 -0
  19. decktalk/scaffold.py +107 -0
  20. decktalk/stages/__init__.py +34 -0
  21. decktalk/stages/assemble.py +538 -0
  22. decktalk/stages/beats.py +196 -0
  23. decktalk/stages/build.py +85 -0
  24. decktalk/stages/measure.py +135 -0
  25. decktalk/stages/narrate.py +410 -0
  26. decktalk/stages/record.py +93 -0
  27. decktalk/stages/shots.py +94 -0
  28. decktalk/stages/soundscape.py +183 -0
  29. decktalk/stages/verify.py +135 -0
  30. decktalk/template/cues.json +33 -0
  31. decktalk/template/deck/index.html +79 -0
  32. decktalk/template/decktalk.toml +92 -0
  33. decktalk/template/env.example +4 -0
  34. decktalk/template/gitignore +8 -0
  35. decktalk/template/media/markers.json +8 -0
  36. decktalk/template/script.md +38 -0
  37. decktalk-0.1.0.dist-info/METADATA +137 -0
  38. decktalk-0.1.0.dist-info/RECORD +43 -0
  39. decktalk-0.1.0.dist-info/WHEEL +4 -0
  40. decktalk-0.1.0.dist-info/entry_points.txt +3 -0
  41. decktalk-0.1.0.dist-info/licenses/LICENSE +202 -0
  42. decktalk-0.1.0.dist-info/licenses/NOTICE +4 -0
  43. decktalk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +14 -0
decktalk/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """DeckTalk: narrated presentation videos, cut to the word.
2
+
3
+ Public API (stable within a minor version once 1.0 is reached; before that, the file
4
+ formats and the page contract are stable and the Python names below may still move):
5
+
6
+ Project, Section types, Settings, load_settings
7
+ errors: DeckTalkError, ConfigError, MissingInputError, ProviderError, ToolError
8
+ artifacts: Manifest, Timeline, Beats, Word, Sidecar
9
+ stages: narrate, resolve_beats, record, measure, check, assemble, verify, shoot,
10
+ soundscape, build
11
+
12
+ Everything under decktalk.media, decktalk.providers and names starting with an
13
+ underscore is internal.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from importlib.metadata import PackageNotFoundError, version
19
+
20
+ from .artifacts import Beats, Manifest, Sidecar, Timeline, Word
21
+ from .config import Settings, load_settings
22
+ from .errors import ConfigError, DeckTalkError, MissingInputError, ProviderError, ToolError
23
+ from .project import ClipSection, PageSection, Project, Section
24
+ from .stages import assemble, build, check, measure, narrate, record, resolve_beats, shoot, soundscape, verify
25
+
26
+ try:
27
+ __version__ = version("decktalk")
28
+ except PackageNotFoundError: # running from a checkout without an install
29
+ __version__ = "0+unknown"
30
+
31
+ __all__ = [
32
+ "Beats",
33
+ "ClipSection",
34
+ "ConfigError",
35
+ "DeckTalkError",
36
+ "Manifest",
37
+ "MissingInputError",
38
+ "PageSection",
39
+ "Project",
40
+ "ProviderError",
41
+ "Section",
42
+ "Settings",
43
+ "Sidecar",
44
+ "Timeline",
45
+ "ToolError",
46
+ "Word",
47
+ "__version__",
48
+ "assemble",
49
+ "build",
50
+ "check",
51
+ "load_settings",
52
+ "measure",
53
+ "narrate",
54
+ "record",
55
+ "resolve_beats",
56
+ "shoot",
57
+ "soundscape",
58
+ "verify",
59
+ ]
decktalk/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """`python -m decktalk` runs the CLI."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
decktalk/_env.py ADDED
@@ -0,0 +1,70 @@
1
+ """Build a dataclass tree from defaults, a nested mapping (parsed TOML), and environment variables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import fields, is_dataclass
7
+ from types import UnionType
8
+ from typing import Any, Union, cast, get_args, get_origin, get_type_hints
9
+
10
+
11
+ def from_env[T](
12
+ cls: type[T],
13
+ overrides: dict[str, str] | None = None,
14
+ prefixes: list[str] | None = None,
15
+ base: dict[str, Any] | None = None,
16
+ environ: dict[str, str] | None = None,
17
+ ) -> T:
18
+ """Instantiate a dataclass recursively.
19
+
20
+ Precedence, lowest to highest: the dataclass default, `base` (a nested mapping keyed
21
+ by field name, for example a parsed TOML file with one table per nested dataclass),
22
+ then the environment variable named PREFIX_FIELD (upper case, nested names joined
23
+ with underscores). `overrides` maps a computed env name to an alias. `environ`
24
+ replaces os.environ, for tests.
25
+ """
26
+ overrides = overrides or {}
27
+ prefixes = prefixes or []
28
+ base = base or {}
29
+ env = os.environ if environ is None else environ
30
+
31
+ def is_optional(t: Any) -> bool:
32
+ origin = get_origin(t)
33
+ args = get_args(t)
34
+ return origin in (Union, UnionType) and len(args) == 2 and args[1] is type(None)
35
+
36
+ hints = get_type_hints(cls)
37
+ args: dict[str, Any] = {}
38
+ for f in fields(cast(Any, cls)):
39
+ ftype = hints.get(f.name, f.type)
40
+ if is_dataclass(ftype):
41
+ sub = base.get(f.name)
42
+ args[f.name] = from_env(
43
+ cast(type[Any], ftype),
44
+ overrides,
45
+ [*prefixes, f.name],
46
+ sub if isinstance(sub, dict) else None,
47
+ environ,
48
+ )
49
+ continue
50
+ env_name = "_".join([*prefixes, f.name]).upper()
51
+ key = overrides.get(env_name, env_name)
52
+ raw: Any = env.get(key)
53
+ if raw is None and f.name in base:
54
+ raw = base[f.name]
55
+ if raw is None:
56
+ continue
57
+ if ftype is bool:
58
+ args[f.name] = raw if isinstance(raw, bool) else str(raw).lower() not in ("false", "0", "no", "")
59
+ elif is_optional(ftype):
60
+ inner = get_args(ftype)[0]
61
+ args[f.name] = raw if isinstance(raw, inner) else inner(raw)
62
+ elif get_origin(ftype) is tuple:
63
+ items = raw.split(",") if isinstance(raw, str) else list(raw)
64
+ inner = get_args(ftype)[0] if get_args(ftype) else str
65
+ args[f.name] = tuple(inner(x) for x in items)
66
+ elif isinstance(ftype, type):
67
+ args[f.name] = raw if isinstance(raw, ftype) else ftype(raw)
68
+ else:
69
+ args[f.name] = raw
70
+ return cls(**args)
decktalk/_report.py ADDED
@@ -0,0 +1,120 @@
1
+ """Tables the CLI prints from stage results. Internal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .artifacts import Timeline
6
+ from .stages.beats import BeatsResult
7
+ from .stages.measure import LeadMeasurement, RecordingCheck
8
+ from .stages.narrate import NarrateResult, Segment
9
+ from .stages.soundscape import SoundscapeItem
10
+ from .stages.verify import VerifyResult
11
+
12
+
13
+ def mmss(seconds: float | None) -> str:
14
+ if seconds is None:
15
+ return " -- "
16
+ return f"{int(seconds // 60)}:{int(round(seconds % 60)):02d}"
17
+
18
+
19
+ def segments_table(segments: list[Segment], wpm: int, result: NarrateResult | None = None) -> str:
20
+ lines = [f"{'#':>2} {'section':<22} {'words':>5} {'est':>5} {'target':>6} {'actual':>6} placeholders"]
21
+ lines.append("-" * len(lines[0]))
22
+ total_words = total_est = total_actual = 0.0
23
+ for seg in segments:
24
+ est = seg.word_count / wpm * 60
25
+ total_words += seg.word_count
26
+ total_est += est
27
+ actual = None
28
+ if result is not None:
29
+ entry = result.manifest.segments.get(seg.key)
30
+ if entry:
31
+ actual = entry.duration_seconds
32
+ total_actual += actual
33
+ ph = ",".join(seg.placeholders) if seg.placeholders else "-"
34
+ lines.append(
35
+ f"{seg.index:>2} {seg.slug[:22]:<22} {seg.word_count:>5} {mmss(est):>5} "
36
+ f"{mmss(seg.target_seconds):>6} {mmss(actual):>6} {ph}"
37
+ )
38
+ lines.append("-" * len(lines[0]))
39
+ actual_total = mmss(total_actual) if total_actual else " -- "
40
+ lines.append(f"{'':>2} {'total':<22} {int(total_words):>5} {mmss(total_est):>5} {'':>6} {actual_total:>6}")
41
+ return "\n".join(lines)
42
+
43
+
44
+ def timeline_table(timeline: Timeline) -> str:
45
+ lines = [f"{'#':>3} {'section':<22} {'start':>7} {'end':>7} {'length':>7}"]
46
+ for key, sec in timeline.sections.items():
47
+ lines.append(
48
+ f"{int(key):>3} {sec.title[:22]:<22} {mmss(sec.start):>7} {mmss(sec.end):>7} {sec.duration:>7.1f}"
49
+ )
50
+ est = " (estimated: silent placeholders)" if timeline.estimated else ""
51
+ lines.append(f" narration total {mmss(timeline.total_seconds)}{est}")
52
+ return "\n".join(lines)
53
+
54
+
55
+ def beats_table(result: BeatsResult) -> str:
56
+ lines = [f"{'sec':>3} {'speech':>6} {'need':>5} cues"]
57
+ for s in result.sections:
58
+ if s.skipped:
59
+ lines.append(f"{s.key:>3} {'--':>6} {s.min_seconds or '-':>5} ({s.skipped})")
60
+ continue
61
+ cues = ",".join(f"{k}@{v}" for k, v in s.resolved.items()) or "-"
62
+ lines.append(f"{s.key:>3} {s.speech_end:>6.1f} {str(s.min_seconds or '-'):>5} {cues}")
63
+ for note in s.notes:
64
+ lines.append(f"{'':>3} {'':>6} {'':>5} ! {note}")
65
+ tail = f"{len(result.beats.sections)} sections with cues; {result.unresolved} unresolved"
66
+ if result.estimated:
67
+ tail += " (estimated words: times are placeholders)"
68
+ lines.append(tail)
69
+ return "\n".join(lines)
70
+
71
+
72
+ def leads_table(rows: list[LeadMeasurement]) -> str:
73
+ lines = [f"{'sec':>3} {'trim':>7} {'flash':>6} {'wall':>7} method"]
74
+ lines += [
75
+ f"{r.key:>3} {r.lead_in_seconds:>7.3f} {r.flash_seconds:>6.3f} {r.wallclock_seconds:>7.3f} {r.method}"
76
+ for r in rows
77
+ ]
78
+ return "\n".join(lines)
79
+
80
+
81
+ def checks_table(rows: list[RecordingCheck]) -> str:
82
+ lines = [f"{'sec':<4} {'webm_s':<8} {'want_s':<8} {'Y10':<6} {'Y50':<6} {'Y90':<6} {'MAX50':<6} verdict"]
83
+ for r in rows:
84
+ lines.append(
85
+ f"{r.key:<4} {r.duration:<8.1f} {r.wanted:<8.1f} {r.y10:<6.0f} {r.y50:<6.0f} {r.y90:<6.0f} "
86
+ f"{r.max50:<6.0f} {r.verdict}"
87
+ )
88
+ return "\n".join(lines)
89
+
90
+
91
+ def verify_table(result: VerifyResult) -> str:
92
+ lines = [f"{'sec':>3} {'start':>8} {'probe':>8} {'YAVG':>6} {'YMAX':>6} result"]
93
+ for s in result.starts:
94
+ lines.append(
95
+ f"{s.key:>3} {s.start:>8.2f} {s.probe_at:>8.2f} {s.yavg:>6.0f} {s.ymax:>6.0f} {'ok' if s.ok else 'BLACK'}"
96
+ )
97
+ lines.append(f"total {result.total_seconds:.2f}s; {result.black_starts} black section start(s)")
98
+ if result.cues:
99
+ lines.append("")
100
+ lines.append(f"{'check':<18} {'cue':>6} {'at':>8} {'chg %':>7} {'ctl %':>7} result")
101
+ for c in result.cues:
102
+ if c.cue_seconds is None:
103
+ lines.append(f"{c.check:<18} {'-':>6} {'-':>8} {'-':>7} {'-':>7} {c.note or 'MISSING'}")
104
+ else:
105
+ lines.append(
106
+ f"{c.check:<18} {c.cue_seconds:>6.2f} {c.final_seconds or 0:>8.2f} {c.changed_percent or 0:>7.2f} "
107
+ f"{c.control_percent or 0:>7.2f} {'changed' if c.ok else 'NO CHANGE'}"
108
+ )
109
+ return "\n".join(lines)
110
+
111
+
112
+ def soundscape_table(items: list[SoundscapeItem]) -> str:
113
+ lines = []
114
+ for it in items:
115
+ dur = f" ({it.duration_seconds}s)" if it.duration_seconds else ""
116
+ lines.append(f"== {it.name} -> {it.out} [{it.status}{dur}]")
117
+ lines.append(f" POST {it.endpoint}")
118
+ for r in it.requests:
119
+ lines.append(f" {r}")
120
+ return "\n".join(lines) or "nothing to generate"
decktalk/artifacts.py ADDED
@@ -0,0 +1,232 @@
1
+ """Typed build artifacts and their JSON files under build/.
2
+
3
+ These file shapes are part of the public contract: the page runtime and users' own
4
+ scripts read them. Field names match the JSON keys.
5
+
6
+ build/audio/manifest.json Manifest: one entry per narrated section
7
+ build/audio/NN-slug.words.json list[Word]
8
+ build/audio/timeline.json Timeline: absolute section and word times in narration.mp3
9
+ build/audio/beats.json Beats: {"NN": "cue@seconds,..."} relative to the section start
10
+ build/rec/NN-scene.json Sidecar: what the recorder did and where t=0 landed
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from dataclasses import asdict, dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any, Self
19
+
20
+
21
+ def _read_json(path: Path) -> Any:
22
+ return json.loads(path.read_text())
23
+
24
+
25
+ def _write_json(path: Path, data: Any, indent: int = 2) -> None:
26
+ """Write atomically: a reader never sees a half-written file."""
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ tmp = path.with_name(f".{path.name}.tmp")
29
+ tmp.write_text(json.dumps(data, indent=indent) + "\n")
30
+ tmp.replace(path)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Word:
35
+ word: str
36
+ start: float
37
+ end: float
38
+
39
+ @classmethod
40
+ def from_dict(cls, d: dict[str, Any]) -> Self:
41
+ return cls(word=str(d["word"]), start=float(d["start"]), end=float(d["end"]))
42
+
43
+
44
+ def read_words(path: Path) -> list[Word]:
45
+ if not path.exists():
46
+ return []
47
+ return [Word.from_dict(w) for w in _read_json(path)]
48
+
49
+
50
+ def write_words(path: Path, words: list[Word]) -> None:
51
+ _write_json(path, [asdict(w) for w in words], indent=1)
52
+
53
+
54
+ @dataclass
55
+ class ManifestSegment:
56
+ index: int
57
+ title: str
58
+ file: str
59
+ words_file: str
60
+ hash: str
61
+ words: int
62
+ est_seconds: float
63
+ duration_seconds: float
64
+ target_seconds: float | None = None
65
+ speech_end_seconds: float | None = None
66
+ tail_padded_seconds: float = 0.0
67
+
68
+ @classmethod
69
+ def from_dict(cls, d: dict[str, Any]) -> Self:
70
+ return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
71
+
72
+
73
+ @dataclass
74
+ class Manifest:
75
+ script: str
76
+ model: str
77
+ output_format: str
78
+ estimated: bool = False
79
+ estimate_basis: str = ""
80
+ segments: dict[str, ManifestSegment] = field(default_factory=dict)
81
+ total_seconds: float = 0.0
82
+
83
+ @classmethod
84
+ def load(cls, path: Path) -> Self | None:
85
+ if not path.exists():
86
+ return None
87
+ d = _read_json(path)
88
+ segs = {k: ManifestSegment.from_dict(v) for k, v in d.get("segments", {}).items()}
89
+ return cls(
90
+ script=str(d.get("script", "")),
91
+ model=str(d.get("model", "")),
92
+ output_format=str(d.get("output_format", "")),
93
+ estimated=bool(d.get("estimated", False)),
94
+ estimate_basis=str(d.get("estimate_basis", "")),
95
+ segments=dict(sorted(segs.items())),
96
+ total_seconds=float(d.get("total_seconds", 0.0)),
97
+ )
98
+
99
+ def save(self, path: Path) -> None:
100
+ self.segments = dict(sorted(self.segments.items()))
101
+ self.total_seconds = round(sum(s.duration_seconds for s in self.segments.values()), 3)
102
+ _write_json(path, asdict(self))
103
+
104
+
105
+ @dataclass
106
+ class TimelineSection:
107
+ title: str
108
+ start: float
109
+ end: float
110
+ duration: float
111
+ speech_end: float | None
112
+ words: list[Word] = field(default_factory=list)
113
+
114
+ @classmethod
115
+ def from_dict(cls, d: dict[str, Any]) -> Self:
116
+ return cls(
117
+ title=str(d["title"]),
118
+ start=float(d["start"]),
119
+ end=float(d["end"]),
120
+ duration=float(d["duration"]),
121
+ speech_end=None if d.get("speech_end") is None else float(d["speech_end"]),
122
+ words=[Word.from_dict(w) for w in d.get("words", [])],
123
+ )
124
+
125
+
126
+ @dataclass
127
+ class Timeline:
128
+ """Section and word times, absolute in narration.mp3."""
129
+
130
+ narration: str
131
+ total_seconds: float
132
+ sections: dict[str, TimelineSection]
133
+ estimated: bool = False
134
+
135
+ @classmethod
136
+ def load(cls, path: Path) -> Self | None:
137
+ if not path.exists():
138
+ return None
139
+ d = _read_json(path)
140
+ return cls(
141
+ narration=str(d.get("narration", "narration.mp3")),
142
+ total_seconds=float(d.get("total_seconds", 0.0)),
143
+ estimated=bool(d.get("estimated", False)),
144
+ sections={k: TimelineSection.from_dict(v) for k, v in d.get("sections", {}).items()},
145
+ )
146
+
147
+ def save(self, path: Path) -> None:
148
+ _write_json(path, asdict(self), indent=1)
149
+
150
+ def span(self, key: str) -> float | None:
151
+ sec = self.sections.get(key)
152
+ return None if sec is None else sec.end - sec.start
153
+
154
+ @property
155
+ def keys(self) -> list[str]:
156
+ return sorted(self.sections)
157
+
158
+
159
+ @dataclass
160
+ class Beats:
161
+ """Resolved cue times per section, seconds relative to the section's start."""
162
+
163
+ sections: dict[str, dict[str, float]] = field(default_factory=dict)
164
+
165
+ @classmethod
166
+ def load(cls, path: Path) -> Self:
167
+ if not path.exists():
168
+ return cls()
169
+ return cls({k: parse_beats_string(v) for k, v in _read_json(path).items()})
170
+
171
+ def save(self, path: Path) -> None:
172
+ _write_json(path, {k: self.query(k) for k in sorted(self.sections) if self.sections[k]})
173
+
174
+ def query(self, key: str) -> str | None:
175
+ """The ?beats= value for a section, or None when it has no resolved cues."""
176
+ cues = self.sections.get(key)
177
+ if not cues:
178
+ return None
179
+ return ",".join(f"{cue}@{t}" for cue, t in cues.items())
180
+
181
+ def get(self, key: str, cue: str) -> float | None:
182
+ return self.sections.get(key, {}).get(cue)
183
+
184
+
185
+ def parse_beats_string(value: str) -> dict[str, float]:
186
+ """'a@1.5,b@2' -> {'a': 1.5, 'b': 2.0}; malformed items are skipped."""
187
+ out: dict[str, float] = {}
188
+ for item in value.split(","):
189
+ item = item.strip()
190
+ if not item or "@" not in item:
191
+ continue
192
+ cue, _, t = item.rpartition("@")
193
+ try:
194
+ out[cue] = float(t)
195
+ except ValueError:
196
+ continue
197
+ return out
198
+
199
+
200
+ @dataclass
201
+ class Sidecar:
202
+ """What the recorder did for one section, and where narration t=0 sits in the webm."""
203
+
204
+ url: str
205
+ requested_seconds: float
206
+ settle_seconds: float
207
+ load_seconds: float
208
+ lead_seconds: float # wall-clock estimate from the recorder
209
+ lead_in_seconds: float | None = None # end of the magenta marker: trim the webm here
210
+ marker_start_seconds: float | None = None # start of the marker: narration t=0 in the webm
211
+ lead_method: str | None = None
212
+
213
+ @classmethod
214
+ def load(cls, path: Path) -> Self | None:
215
+ if not path.exists():
216
+ return None
217
+ d = _read_json(path)
218
+ return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
219
+
220
+ def save(self, path: Path) -> None:
221
+ _write_json(path, asdict(self))
222
+
223
+ @property
224
+ def trim_seconds(self) -> float:
225
+ return self.lead_in_seconds if self.lead_in_seconds is not None else self.lead_seconds
226
+
227
+ @property
228
+ def flash_seconds(self) -> float:
229
+ """How long the marker covered after t=0; the assembler holds the first clean frame this long."""
230
+ if self.lead_in_seconds is None or self.marker_start_seconds is None:
231
+ return 0.0
232
+ return max(0.0, round(self.lead_in_seconds - self.marker_start_seconds, 3))