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.
- decktalk/__init__.py +59 -0
- decktalk/__main__.py +5 -0
- decktalk/_env.py +70 -0
- decktalk/_report.py +120 -0
- decktalk/artifacts.py +232 -0
- decktalk/cli.py +375 -0
- decktalk/config.py +164 -0
- decktalk/errors.py +23 -0
- decktalk/media/__init__.py +1 -0
- decktalk/media/browser.py +161 -0
- decktalk/media/ffmpeg.py +273 -0
- decktalk/project.py +568 -0
- decktalk/providers/__init__.py +1 -0
- decktalk/providers/_http.py +50 -0
- decktalk/providers/elevenlabs.py +136 -0
- decktalk/providers/speech.py +60 -0
- decktalk/py.typed +0 -0
- decktalk/runtime/decktalk-runtime.js +381 -0
- decktalk/scaffold.py +107 -0
- decktalk/stages/__init__.py +34 -0
- decktalk/stages/assemble.py +538 -0
- decktalk/stages/beats.py +196 -0
- decktalk/stages/build.py +85 -0
- decktalk/stages/measure.py +135 -0
- decktalk/stages/narrate.py +410 -0
- decktalk/stages/record.py +93 -0
- decktalk/stages/shots.py +94 -0
- decktalk/stages/soundscape.py +183 -0
- decktalk/stages/verify.py +135 -0
- decktalk/template/cues.json +33 -0
- decktalk/template/deck/index.html +79 -0
- decktalk/template/decktalk.toml +92 -0
- decktalk/template/env.example +4 -0
- decktalk/template/gitignore +8 -0
- decktalk/template/media/markers.json +8 -0
- decktalk/template/script.md +38 -0
- decktalk-0.1.0.dist-info/METADATA +137 -0
- decktalk-0.1.0.dist-info/RECORD +43 -0
- decktalk-0.1.0.dist-info/WHEEL +4 -0
- decktalk-0.1.0.dist-info/entry_points.txt +3 -0
- decktalk-0.1.0.dist-info/licenses/LICENSE +202 -0
- decktalk-0.1.0.dist-info/licenses/NOTICE +4 -0
- 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
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))
|