decktalk 0.2.1__tar.gz → 0.3.0__tar.gz
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-0.2.1 → decktalk-0.3.0}/PKG-INFO +4 -4
- {decktalk-0.2.1 → decktalk-0.3.0}/README.md +3 -3
- {decktalk-0.2.1 → decktalk-0.3.0}/pyproject.toml +1 -1
- {decktalk-0.2.1 → decktalk-0.3.0}/pyproject.toml.orig +1 -1
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/__init__.py +16 -4
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/_report.py +85 -2
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/artifacts.py +3 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/cli.py +118 -8
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/config.py +4 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/media/ffmpeg.py +38 -5
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/project.py +73 -15
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/runtime/decktalk-runtime.js +298 -90
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/__init__.py +8 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/assemble.py +67 -9
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/beats.py +92 -40
- decktalk-0.3.0/src/decktalk/stages/clip.py +245 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/measure.py +36 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/narrate.py +179 -28
- decktalk-0.3.0/src/decktalk/stages/preflight.py +538 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/verify.py +210 -43
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/decktalk.toml +2 -1
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/verdicts.py +17 -2
- {decktalk-0.2.1 → decktalk-0.3.0}/LICENSE +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/NOTICE +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/__main__.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/_env.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/errors.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/media/__init__.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/media/browser.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/__init__.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/_http.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/elevenlabs.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/speech.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/py.typed +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/scaffold.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/build.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/record.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/shots.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/soundscape.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/status.py +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/cues.json +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/after-first.webp +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/after-last-864.webp +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/after-last.webp +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/before-first.webp +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/before-last-864.webp +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/before-last.webp +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/s3-last.webp +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-OFL.txt +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-greek.woff2 +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-latin.woff2 +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-OFL.txt +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-greek.woff2 +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-latin.woff2 +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-OFL.txt +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-greek.woff2 +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-latin.woff2 +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/index.html +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/lesson.html +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/env.example +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/gitignore +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/media/markers.json +0 -0
- {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/script.md +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: decktalk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Narrated presentations, cut to the word. A markdown script, an ElevenLabs voice, and HTML slides go in, and one mp4 comes out.
|
|
5
5
|
Keywords: presentation,narration,elevenlabs,video,slides,text-to-speech,markdown,video-generation,explainer-video,lesson,screencast,word-timestamps,playwright,ffmpeg
|
|
6
6
|
Author: Jacob Beaudin
|
|
@@ -162,9 +162,9 @@ A browser does not start recording at a known time, so DeckTalk does not use a t
|
|
|
162
162
|
<!-- sample: decktalk verify 1:1.1bowl 3:3.4name 8:7.1checked, silent build of the scaffold (cue table only) -->
|
|
163
163
|
```text
|
|
164
164
|
check cue at chg % ctl % offset a/v result
|
|
165
|
-
1:1.1bowl 1.25 1.25
|
|
166
|
-
3:3.4name 84.72 123.32 2.
|
|
167
|
-
8:7.1checked 10.03 238.31
|
|
165
|
+
1:1.1bowl 1.25 1.25 0.93 0.00 +30ms +31ms changed
|
|
166
|
+
3:3.4name 84.72 123.32 2.76 0.00 +0ms -4ms changed
|
|
167
|
+
8:7.1checked 10.03 238.31 0.83 0.00 +10ms +3ms changed
|
|
168
168
|
```
|
|
169
169
|
|
|
170
170
|
The offset column is the time from the cue time to the onset of the reveal, in milliseconds. [Verify](https://docs.decktalk.app/reference/verify) defines every column and limit.
|
|
@@ -129,9 +129,9 @@ A browser does not start recording at a known time, so DeckTalk does not use a t
|
|
|
129
129
|
<!-- sample: decktalk verify 1:1.1bowl 3:3.4name 8:7.1checked, silent build of the scaffold (cue table only) -->
|
|
130
130
|
```text
|
|
131
131
|
check cue at chg % ctl % offset a/v result
|
|
132
|
-
1:1.1bowl 1.25 1.25
|
|
133
|
-
3:3.4name 84.72 123.32 2.
|
|
134
|
-
8:7.1checked 10.03 238.31
|
|
132
|
+
1:1.1bowl 1.25 1.25 0.93 0.00 +30ms +31ms changed
|
|
133
|
+
3:3.4name 84.72 123.32 2.76 0.00 +0ms -4ms changed
|
|
134
|
+
8:7.1checked 10.03 238.31 0.83 0.00 +10ms +3ms changed
|
|
135
135
|
```
|
|
136
136
|
|
|
137
137
|
The offset column is the time from the cue time to the onset of the reveal, in milliseconds. [Verify](https://docs.decktalk.app/reference/verify) defines every column and limit.
|
|
@@ -8,10 +8,10 @@ still move, and a rename is a breaking change once the project leaves the 0.x se
|
|
|
8
8
|
project tables: Voice, Mix, Soundscape, Transition
|
|
9
9
|
errors: DeckTalkError, ConfigError, MissingInputError, ProviderError, ToolError
|
|
10
10
|
artifacts: Manifest, Timeline, Beats, Word, Sidecar
|
|
11
|
-
stages: narrate, resolve_beats, record, measure, check, assemble, verify, shoot,
|
|
12
|
-
soundscape, build, status
|
|
13
|
-
results: NarrateResult, BeatsResult, Recording, LeadMeasurement, RecordingCheck,
|
|
14
|
-
AssembleResult, VerifyResult, SoundscapeItem, BuildResult, StatusReport
|
|
11
|
+
stages: narrate, resolve_beats, preflight, record, measure, check, assemble, verify, shoot,
|
|
12
|
+
soundscape, build, status, cut_clip, spoken_words
|
|
13
|
+
results: NarrateResult, BeatsResult, PreflightResult, Recording, LeadMeasurement, RecordingCheck,
|
|
14
|
+
AssembleResult, VerifyResult, SoundscapeItem, BuildResult, StatusReport, ClipResult, SectionWords
|
|
15
15
|
speech: SpeechProvider, SpeechRequest, register
|
|
16
16
|
|
|
17
17
|
Everything under decktalk.media, everything under decktalk.providers other than the
|
|
@@ -31,21 +31,27 @@ from .stages import (
|
|
|
31
31
|
AssembleResult,
|
|
32
32
|
BeatsResult,
|
|
33
33
|
BuildResult,
|
|
34
|
+
ClipResult,
|
|
34
35
|
LeadMeasurement,
|
|
35
36
|
NarrateResult,
|
|
37
|
+
PreflightResult,
|
|
36
38
|
Recording,
|
|
37
39
|
RecordingCheck,
|
|
40
|
+
SectionWords,
|
|
38
41
|
SoundscapeItem,
|
|
39
42
|
VerifyResult,
|
|
40
43
|
assemble,
|
|
41
44
|
build,
|
|
42
45
|
check,
|
|
46
|
+
cut_clip,
|
|
43
47
|
measure,
|
|
44
48
|
narrate,
|
|
49
|
+
preflight,
|
|
45
50
|
record,
|
|
46
51
|
resolve_beats,
|
|
47
52
|
shoot,
|
|
48
53
|
soundscape,
|
|
54
|
+
spoken_words,
|
|
49
55
|
verify,
|
|
50
56
|
)
|
|
51
57
|
from .status import StatusReport, status
|
|
@@ -60,6 +66,7 @@ __all__ = [
|
|
|
60
66
|
"Beats",
|
|
61
67
|
"BeatsResult",
|
|
62
68
|
"BuildResult",
|
|
69
|
+
"ClipResult",
|
|
63
70
|
"ClipSection",
|
|
64
71
|
"ConfigError",
|
|
65
72
|
"DeckTalkError",
|
|
@@ -69,11 +76,13 @@ __all__ = [
|
|
|
69
76
|
"Mix",
|
|
70
77
|
"NarrateResult",
|
|
71
78
|
"PageSection",
|
|
79
|
+
"PreflightResult",
|
|
72
80
|
"Project",
|
|
73
81
|
"ProviderError",
|
|
74
82
|
"Recording",
|
|
75
83
|
"RecordingCheck",
|
|
76
84
|
"Section",
|
|
85
|
+
"SectionWords",
|
|
77
86
|
"Settings",
|
|
78
87
|
"Sidecar",
|
|
79
88
|
"Soundscape",
|
|
@@ -91,14 +100,17 @@ __all__ = [
|
|
|
91
100
|
"assemble",
|
|
92
101
|
"build",
|
|
93
102
|
"check",
|
|
103
|
+
"cut_clip",
|
|
94
104
|
"load_settings",
|
|
95
105
|
"measure",
|
|
96
106
|
"narrate",
|
|
107
|
+
"preflight",
|
|
97
108
|
"record",
|
|
98
109
|
"register",
|
|
99
110
|
"resolve_beats",
|
|
100
111
|
"shoot",
|
|
101
112
|
"soundscape",
|
|
113
|
+
"spoken_words",
|
|
102
114
|
"status",
|
|
103
115
|
"verify",
|
|
104
116
|
]
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from .artifacts import Timeline
|
|
6
|
+
from .config import NarrationConfig
|
|
6
7
|
from .stages.beats import BeatsResult
|
|
8
|
+
from .stages.clip import SectionWords
|
|
7
9
|
from .stages.measure import LeadMeasurement, RecordingCheck
|
|
8
|
-
from .stages.narrate import NarrateResult, Segment
|
|
10
|
+
from .stages.narrate import NarrateResult, Segment, TakePlan, plan_totals
|
|
11
|
+
from .stages.preflight import PreflightResult
|
|
9
12
|
from .stages.soundscape import SoundscapeItem
|
|
10
13
|
from .stages.verify import VerifyResult
|
|
11
14
|
from .status import StatusReport, relpath
|
|
12
|
-
from .verdicts import BLACK, OK, QUIET, SKIPPED, SPEECH_AT_CUT
|
|
15
|
+
from .verdicts import BLACK, CHANGED, NO_CHANGE, OK, QUIET, SKIPPED, SPEECH_AT_CUT, THIN_CHANGE
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
def mmss(seconds: float | None) -> str:
|
|
@@ -44,6 +47,28 @@ def segments_table(segments: list[Segment], wpm: int, result: NarrateResult | No
|
|
|
44
47
|
return "\n".join(lines)
|
|
45
48
|
|
|
46
49
|
|
|
50
|
+
def plan_table(plans: list[TakePlan], cfg: NarrationConfig, note: str | None = None) -> str:
|
|
51
|
+
"""What a voiced narrate would do with each section: voice it, use its cached take, or move a take."""
|
|
52
|
+
lines = [f"{'#':>2} {'section':<22} {'take':<10} {'sent':>6} {'spoken':>6} reason"]
|
|
53
|
+
lines.append("-" * len(lines[0]))
|
|
54
|
+
for p in plans:
|
|
55
|
+
seg = p.segment
|
|
56
|
+
lines.append(
|
|
57
|
+
f"{seg.index:>2} {seg.slug[:22]:<22} {p.status:<10} {len(seg.tts_text(cfg)):>6} "
|
|
58
|
+
f"{len(seg.spoken):>6} {p.reason or '-'}"
|
|
59
|
+
)
|
|
60
|
+
t = plan_totals(plans, cfg)
|
|
61
|
+
lines.append("-" * len(lines[0]))
|
|
62
|
+
unknown = f", {t['unknown']} unknown" if t["unknown"] else ""
|
|
63
|
+
lines.append(
|
|
64
|
+
f"voice {t['synthesize']} section(s): {t['characters_sent']} characters sent, "
|
|
65
|
+
f"{t['characters_spoken']} spoken. {t['cached']} cached, {t['moved']} moved{unknown}."
|
|
66
|
+
)
|
|
67
|
+
if note:
|
|
68
|
+
lines.append(f"note: {note}")
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
|
|
47
72
|
def timeline_table(timeline: Timeline) -> str:
|
|
48
73
|
lines = [f"{'#':>3} {'section':<22} {'start':>7} {'end':>7} {'length':>7}"]
|
|
49
74
|
for key, sec in timeline.sections.items():
|
|
@@ -72,6 +97,42 @@ def beats_table(result: BeatsResult) -> str:
|
|
|
72
97
|
return "\n".join(lines)
|
|
73
98
|
|
|
74
99
|
|
|
100
|
+
def preflight_table(result: PreflightResult) -> str:
|
|
101
|
+
"""The take plan, the cues resolved on the words each section will have, and the frozen-frame estimates."""
|
|
102
|
+
root = result.root
|
|
103
|
+
voice = result.voice
|
|
104
|
+
lines = [f"voice: provider={voice['provider']} model={voice['model']}"]
|
|
105
|
+
lines.append(plan_table(result.takes, result.narration, result.note))
|
|
106
|
+
if result.placeholders:
|
|
107
|
+
lines.append(f"a voiced run refuses the unfilled placeholders {result.placeholders}")
|
|
108
|
+
lines += ["", beats_table(result.beats)]
|
|
109
|
+
if result.estimated:
|
|
110
|
+
lines.append(f"estimated words in sections {', '.join(result.estimated)}, which a voiced run will voice")
|
|
111
|
+
if result.frames is None:
|
|
112
|
+
lines += ["", "frames skipped (--no-frames)"]
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
lines.append("")
|
|
115
|
+
lines.append(f"{'check':<18} {'step':<8} {'cue':>6} {'chg %':>7} result")
|
|
116
|
+
for c in result.cues:
|
|
117
|
+
chg = f"{c.changed_percent:>7.2f}" if c.changed_percent is not None else f"{'-':>7}"
|
|
118
|
+
label = " ".join(part for part in (c.verdict, c.reason) if part)
|
|
119
|
+
label += f": {c.detail}" if c.detail else ""
|
|
120
|
+
label += f" ({c.note})" if c.note else ""
|
|
121
|
+
lines.append(f"{c.check:<18} {c.step or '-':<8} {c.cue_seconds:>6.2f} {chg} {label}")
|
|
122
|
+
tally = {v: sum(c.verdict == v for c in result.cues) for v in (CHANGED, THIN_CHANGE, NO_CHANGE, SKIPPED)}
|
|
123
|
+
counts = ", ".join(f"{n} {v}" for v, n in tally.items() if n) or "none"
|
|
124
|
+
where = relpath(result.frames, root) if root is not None else result.frames
|
|
125
|
+
lines.append(f"{len(result.cues)} cue(s): {counts}. Frozen frames in {where}")
|
|
126
|
+
if result.carries:
|
|
127
|
+
lines.append("")
|
|
128
|
+
lines.append(f"{'sec':>3} {'chg %':>7} result")
|
|
129
|
+
for k in result.carries:
|
|
130
|
+
chg = f"{k.changed_percent:>7.2f}" if k.changed_percent is not None else f"{'-':>7}"
|
|
131
|
+
label = " ".join(part for part in (k.verdict, k.reason) if part) + (f": {k.detail}" if k.detail else "")
|
|
132
|
+
lines.append(f"{k.key:>3} {chg} {label}")
|
|
133
|
+
return "\n".join(lines)
|
|
134
|
+
|
|
135
|
+
|
|
75
136
|
def leads_table(rows: list[LeadMeasurement]) -> str:
|
|
76
137
|
lines = [f"{'sec':>3} {'trim':>7} {'wall':>7} method"]
|
|
77
138
|
lines += [f"{r.key:>3} {r.lead_in_seconds:>7.3f} {r.wallclock_seconds:>7.3f} {r.method}" for r in rows]
|
|
@@ -100,6 +161,11 @@ def verify_table(result: VerifyResult) -> str:
|
|
|
100
161
|
lines.append(f"{'sec':>3} {'cut at':>8} {'before cut':>11} result")
|
|
101
162
|
for c in result.cuts:
|
|
102
163
|
lines.append(f"{c.key:>3} {c.cut_at:>8.2f} {c.rms_db:>8.1f} dB {QUIET if c.ok else SPEECH_AT_CUT}")
|
|
164
|
+
if result.carries:
|
|
165
|
+
lines.append("")
|
|
166
|
+
lines.append(f"{'sec':>3} {'cut at':>8} {'chg %':>7} result")
|
|
167
|
+
for k in result.carries:
|
|
168
|
+
lines.append(f"{k.key:>3} {k.cut_at:>8.2f} {k.changed_percent:>7.2f} {k.verdict}")
|
|
103
169
|
if result.cues:
|
|
104
170
|
lines.append("")
|
|
105
171
|
av = any(c.av_ms is not None for c in result.cues)
|
|
@@ -156,6 +222,23 @@ def status_table(report: StatusReport) -> str:
|
|
|
156
222
|
return "\n".join(lines)
|
|
157
223
|
|
|
158
224
|
|
|
225
|
+
def words_table(sections: list[SectionWords]) -> str:
|
|
226
|
+
"""The text of `decktalk words`: each section's words, in seconds after the section starts."""
|
|
227
|
+
lines: list[str] = []
|
|
228
|
+
for sec in sections:
|
|
229
|
+
notes = [f"{sec.duration:.2f}s"]
|
|
230
|
+
if sec.lead_seconds:
|
|
231
|
+
notes.append(f"lead {sec.lead_seconds:g}s")
|
|
232
|
+
if sec.estimated:
|
|
233
|
+
notes.append("estimated")
|
|
234
|
+
if lines:
|
|
235
|
+
lines.append("")
|
|
236
|
+
lines.append(f"== {sec.key} {sec.title} ({', '.join(notes)})")
|
|
237
|
+
lines.append(" start end word")
|
|
238
|
+
lines += [f"{w.start:7.3f} {w.end:7.3f} {text}" for w, text in zip(sec.words, sec.texts, strict=True)]
|
|
239
|
+
return "\n".join(lines) or "no spoken sections"
|
|
240
|
+
|
|
241
|
+
|
|
159
242
|
def soundscape_table(items: list[SoundscapeItem]) -> str:
|
|
160
243
|
lines = []
|
|
161
244
|
for it in items:
|
|
@@ -113,6 +113,7 @@ class TimelineSection:
|
|
|
113
113
|
duration: float
|
|
114
114
|
speech_end: float | None
|
|
115
115
|
words: list[Word] = field(default_factory=list)
|
|
116
|
+
lead_seconds: float = 0.0 # Silence joined in before the take, part of `duration`. The words already include it.
|
|
116
117
|
|
|
117
118
|
@classmethod
|
|
118
119
|
def from_dict(cls, d: dict[str, Any]) -> Self:
|
|
@@ -123,6 +124,7 @@ class TimelineSection:
|
|
|
123
124
|
duration=float(d["duration"]),
|
|
124
125
|
speech_end=None if d.get("speech_end") is None else float(d["speech_end"]),
|
|
125
126
|
words=[Word.from_dict(w) for w in d.get("words", [])],
|
|
127
|
+
lead_seconds=float(d.get("lead_seconds", 0.0)),
|
|
126
128
|
)
|
|
127
129
|
|
|
128
130
|
|
|
@@ -223,6 +225,7 @@ class Sidecar:
|
|
|
223
225
|
lead_seconds: float # wall-clock estimate from the recorder
|
|
224
226
|
lead_in_seconds: float | None = None # first clean frame after the magenta cover: narration t=0
|
|
225
227
|
lead_method: str | None = None
|
|
228
|
+
lead_in_hash: str | None = None # the sha256 prefix of the webm that measure read, so a stale measure shows
|
|
226
229
|
warnings: list[str] = field(default_factory=list)
|
|
227
230
|
page_errors: list[str] = field(default_factory=list) # uncaught exceptions, or no runtime catalog at all
|
|
228
231
|
# (seconds, ms) where the page stalled. The time is None for a gap that ended before narration t=0.
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
decktalk setup fetch headless Chromium, ffmpeg, and KaTeX (once per machine)
|
|
5
5
|
decktalk doctor report what is installed
|
|
6
6
|
decktalk narrate [--silent] script.md -> build/audio (ElevenLabs, word timestamps, timeline)
|
|
7
|
+
decktalk narrate --dry-run what a voiced run would send, cache, or move (--json)
|
|
7
8
|
decktalk beats cues.json -> build/audio/beats.json
|
|
9
|
+
decktalk preflight takes, cues and frozen reveals before a voiced build: no credits, no recording
|
|
8
10
|
decktalk soundscape ambience, sfx, underscore (ElevenLabs)
|
|
9
11
|
decktalk record pages -> build/rec/NN-scene.webm (Chromium)
|
|
10
12
|
decktalk measure find narration t=0 in each recording
|
|
@@ -12,6 +14,9 @@
|
|
|
12
14
|
decktalk assemble ffmpeg -> build/out/<name>.mp4
|
|
13
15
|
decktalk verify [SEC:CUE ...] section starts, cuts, and every cue landing on the final mp4
|
|
14
16
|
decktalk shots per-step screenshots, or frames from a playing section
|
|
17
|
+
decktalk words [--json] each spoken section's words, in seconds after the section starts
|
|
18
|
+
decktalk clip N --from S --to E --out FILE
|
|
19
|
+
a span of a built section and its narration -> a clip and its words file
|
|
15
20
|
decktalk build [--silent] narrate -> beats -> record -> measure -> check -> assemble -> verify
|
|
16
21
|
decktalk status timeline and what is built
|
|
17
22
|
decktalk runtime copy the packaged decktalk-runtime.js into the project
|
|
@@ -19,7 +24,7 @@
|
|
|
19
24
|
Every project command takes --project/-p DIR (default: DECKTALK_PROJECT, else the current
|
|
20
25
|
directory). The -v and -q flags go before or after the command name.
|
|
21
26
|
Tuning flags such as --preset override decktalk.toml and DECKTALK_* env for one run.
|
|
22
|
-
status, beats, check, verify and doctor take --json, --strict and --no-fail. They exit 1
|
|
27
|
+
status, beats, preflight, check, verify and doctor take --json, --strict and --no-fail. They exit 1
|
|
23
28
|
on a certain finding, and on an uncertain one (a verdict ending in ?) only with --strict.
|
|
24
29
|
"""
|
|
25
30
|
|
|
@@ -46,7 +51,8 @@ JSON_HELP = "Print the result as one JSON object on stdout instead of the tables
|
|
|
46
51
|
ALLOW_UNKNOWN_HELP = "Continue when a cue id in cues.json appears nowhere in the page that plays it."
|
|
47
52
|
BUILD_STRICT_HELP = (
|
|
48
53
|
"fail on a missing clip or recording instead of substituting a slate "
|
|
49
|
-
"(a clip section with optional = true still plays its slate)"
|
|
54
|
+
"(a clip section with optional = true still plays its slate), "
|
|
55
|
+
"and on a recording that measure has not read since it was recorded"
|
|
50
56
|
)
|
|
51
57
|
|
|
52
58
|
|
|
@@ -104,6 +110,7 @@ def _verify_findings(result: Any) -> Findings:
|
|
|
104
110
|
verdicts: Iterable[str] = [
|
|
105
111
|
*(OK if s.ok else BLACK for s in result.starts),
|
|
106
112
|
*(QUIET if c.ok else SPEECH_AT_CUT for c in result.cuts),
|
|
113
|
+
*(c.verdict for c in result.carries),
|
|
107
114
|
*(c.verdict for c in result.cues),
|
|
108
115
|
]
|
|
109
116
|
return count(verdicts)
|
|
@@ -163,19 +170,35 @@ def cmd_runtime(args: argparse.Namespace) -> int:
|
|
|
163
170
|
|
|
164
171
|
|
|
165
172
|
def cmd_narrate(args: argparse.Namespace) -> int:
|
|
166
|
-
from .stages.narrate import narrate, script_segments
|
|
173
|
+
from .stages.narrate import narrate, narration_plan, plan_totals, script_segments
|
|
167
174
|
|
|
175
|
+
if args.json and not args.dry_run:
|
|
176
|
+
args.parser.error("--json needs --dry-run")
|
|
168
177
|
project = _project(args)
|
|
169
178
|
cfg = project.settings.narration
|
|
170
179
|
if args.dry_run:
|
|
171
180
|
_all, spoken = script_segments(project)
|
|
172
181
|
targets = [s for s in spoken if not args.only or s.index in set(args.only)]
|
|
182
|
+
model = args.model or project.voice.model or cfg.model
|
|
183
|
+
plans, note = narration_plan(project, targets, model=model, force=args.force)
|
|
184
|
+
if args.json:
|
|
185
|
+
payload = {
|
|
186
|
+
"voice": {"provider": project.voice.provider, "model": model, "settings": project.voice.api_settings()},
|
|
187
|
+
"note": note,
|
|
188
|
+
"sections": [{**p.to_dict(cfg), "text": p.segment.tts_text(cfg)} for p in plans],
|
|
189
|
+
"totals": plan_totals(plans, cfg),
|
|
190
|
+
}
|
|
191
|
+
doc = {"command": args.cmd, "version": __version__, "ok": True, "findings": Findings().to_dict()}
|
|
192
|
+
print(json.dumps({**doc, args.cmd: payload}, indent=2))
|
|
193
|
+
return 0
|
|
173
194
|
for seg in targets:
|
|
174
195
|
print(f"=== {seg.key} {seg.title} -> {seg.filename}")
|
|
175
196
|
print(seg.tts_text(cfg))
|
|
176
197
|
print()
|
|
177
|
-
print(f"voice: model={
|
|
198
|
+
print(f"voice: model={model} {project.voice.api_settings()}")
|
|
178
199
|
print(_report.segments_table(targets, cfg.words_per_minute))
|
|
200
|
+
print()
|
|
201
|
+
print(_report.plan_table(plans, cfg, note))
|
|
179
202
|
unfilled = sorted({p for s in targets for p in s.placeholders})
|
|
180
203
|
if unfilled:
|
|
181
204
|
print(f"\nnote: unfilled placeholders {unfilled}; fill them before the real run.")
|
|
@@ -214,6 +237,15 @@ def cmd_beats(args: argparse.Namespace) -> int:
|
|
|
214
237
|
return _finish(args, findings, result.to_dict(project.root), lambda: _report.beats_table(result))
|
|
215
238
|
|
|
216
239
|
|
|
240
|
+
def cmd_preflight(args: argparse.Namespace) -> int:
|
|
241
|
+
from .stages.preflight import preflight
|
|
242
|
+
|
|
243
|
+
project = _project(args)
|
|
244
|
+
result = preflight(project, only=_only(args.only), frames=not args.no_frames, model=args.model)
|
|
245
|
+
findings = result.findings(allow_unknown=args.allow_unknown)
|
|
246
|
+
return _finish(args, findings, result.to_dict(project.root), lambda: _report.preflight_table(result))
|
|
247
|
+
|
|
248
|
+
|
|
217
249
|
def cmd_soundscape(args: argparse.Namespace) -> int:
|
|
218
250
|
from .stages.soundscape import soundscape
|
|
219
251
|
|
|
@@ -277,6 +309,46 @@ def cmd_shots(args: argparse.Namespace) -> int:
|
|
|
277
309
|
return 0
|
|
278
310
|
|
|
279
311
|
|
|
312
|
+
def cmd_words(args: argparse.Namespace) -> int:
|
|
313
|
+
from .stages.clip import spoken_words
|
|
314
|
+
|
|
315
|
+
project = _project(args)
|
|
316
|
+
sections = spoken_words(project, only=_only(args.only))
|
|
317
|
+
if args.json:
|
|
318
|
+
payload = {"sections": [s.to_dict() for s in sections]}
|
|
319
|
+
doc = {"command": args.cmd, "version": __version__, "ok": True, "findings": Findings().to_dict()}
|
|
320
|
+
print(json.dumps({**doc, args.cmd: payload}, indent=2))
|
|
321
|
+
return 0
|
|
322
|
+
print(_report.words_table(sections))
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def cmd_clip(args: argparse.Namespace) -> int:
|
|
327
|
+
from .stages.clip import cut_clip
|
|
328
|
+
from .status import relpath
|
|
329
|
+
|
|
330
|
+
project = _project(args)
|
|
331
|
+
result = cut_clip(
|
|
332
|
+
project,
|
|
333
|
+
args.section,
|
|
334
|
+
start=args.start,
|
|
335
|
+
end=args.end,
|
|
336
|
+
out=args.out,
|
|
337
|
+
words_out=args.words,
|
|
338
|
+
gain_db=args.gain,
|
|
339
|
+
hold_seconds=args.hold,
|
|
340
|
+
)
|
|
341
|
+
video, words = (relpath(p, project.root) for p in (result.video, result.words_file))
|
|
342
|
+
source = f"{args.section:02d}-section.mp4"
|
|
343
|
+
print(
|
|
344
|
+
f"wrote {video} ({result.duration:.2f}s: frames {result.first_frame} to {result.last_frame} of {source}, "
|
|
345
|
+
f"{result.start:.2f} to {result.end:.2f}s, hold {result.hold_seconds:g}s, gain {result.gain_db:+g} dB)"
|
|
346
|
+
)
|
|
347
|
+
print(f"wrote {words} ({len(result.words)} words)")
|
|
348
|
+
print(f'use it in a clip section: clip = "{video}" and words = "{words}"')
|
|
349
|
+
return 0
|
|
350
|
+
|
|
351
|
+
|
|
280
352
|
def cmd_status(args: argparse.Namespace) -> int:
|
|
281
353
|
from .status import status
|
|
282
354
|
|
|
@@ -372,19 +444,35 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
372
444
|
|
|
373
445
|
s = proj(sub.add_parser("narrate", help="synthesize narration with word timestamps"))
|
|
374
446
|
s.add_argument("--only", type=int, action="append", help=only_help)
|
|
375
|
-
s.add_argument(
|
|
447
|
+
s.add_argument(
|
|
448
|
+
"--force", action="store_true", help="ignore the text-hash cache, and let --silent replace voiced takes"
|
|
449
|
+
)
|
|
376
450
|
s.add_argument(
|
|
377
451
|
"--allow-placeholders", action="store_true", help="synthesize a section that still has a [CAPITAL] placeholder"
|
|
378
452
|
)
|
|
379
|
-
s.add_argument(
|
|
453
|
+
s.add_argument(
|
|
454
|
+
"--dry-run",
|
|
455
|
+
action="store_true",
|
|
456
|
+
help="print each section's text and what a voiced run would voice, cache, or move, without any API call",
|
|
457
|
+
)
|
|
458
|
+
s.add_argument("--json", action="store_true", help="with --dry-run, print the plan as one JSON object on stdout")
|
|
380
459
|
s.add_argument("--silent", action="store_true", help="silent placeholders, no API key")
|
|
381
460
|
s.add_argument("--model", help="ElevenLabs model for this run")
|
|
382
|
-
s.set_defaults(fn=cmd_narrate)
|
|
461
|
+
s.set_defaults(fn=cmd_narrate, parser=s)
|
|
383
462
|
|
|
384
463
|
s = policy(proj(sub.add_parser("beats", help="resolve cue phrases to timestamps")))
|
|
385
464
|
s.add_argument("--allow-unknown", action="store_true", help=ALLOW_UNKNOWN_HELP)
|
|
386
465
|
s.set_defaults(fn=cmd_beats)
|
|
387
466
|
|
|
467
|
+
s = policy(proj(sub.add_parser("preflight", help="plan the takes, resolve the cues, and estimate every reveal")))
|
|
468
|
+
s.add_argument("--only", type=int, action="append", help=only_help)
|
|
469
|
+
s.add_argument(
|
|
470
|
+
"--no-frames", action="store_true", help="skip the frozen frames, so no browser starts and nothing is written"
|
|
471
|
+
)
|
|
472
|
+
s.add_argument("--model", help="speech model to check the narration cache for")
|
|
473
|
+
s.add_argument("--allow-unknown", action="store_true", help=ALLOW_UNKNOWN_HELP)
|
|
474
|
+
s.set_defaults(fn=cmd_preflight)
|
|
475
|
+
|
|
388
476
|
s = proj(sub.add_parser("soundscape", help="generate ambience, sfx and underscore"))
|
|
389
477
|
s.add_argument("--only", action="append", help="one item: ambience, music, or an effect name (repeat for several)")
|
|
390
478
|
s.add_argument("--force", action="store_true", help="regenerate even if the file exists")
|
|
@@ -429,11 +517,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
429
517
|
s.add_argument("--at", type=float, action="append", help="seconds after narration t=0 (with --section)")
|
|
430
518
|
s.set_defaults(fn=cmd_shots, parser=s)
|
|
431
519
|
|
|
520
|
+
s = proj(sub.add_parser("words", help="each spoken section's words, in seconds after the section starts"))
|
|
521
|
+
s.add_argument("--only", type=int, action="append", help=only_help)
|
|
522
|
+
s.add_argument("--json", action="store_true", help=JSON_HELP)
|
|
523
|
+
s.set_defaults(fn=cmd_words)
|
|
524
|
+
|
|
525
|
+
s = proj(sub.add_parser("clip", help="cut a span of a built section and its narration into a clip"))
|
|
526
|
+
s.add_argument("section", type=int, help="the page section to cut from")
|
|
527
|
+
s.add_argument(
|
|
528
|
+
"--from", dest="start", type=float, required=True, metavar="SECONDS", help="start, after the section starts"
|
|
529
|
+
)
|
|
530
|
+
s.add_argument(
|
|
531
|
+
"--to", dest="end", type=float, required=True, metavar="SECONDS", help="end, after the section starts"
|
|
532
|
+
)
|
|
533
|
+
s.add_argument("--out", required=True, help="the clip file, relative to the project, such as media/open.mp4")
|
|
534
|
+
s.add_argument("--words", help="the words file (default: the clip's path with .words.json)")
|
|
535
|
+
s.add_argument("--gain", type=float, default=0.0, metavar="DB", help="gain on the clip's sound, in dB")
|
|
536
|
+
s.add_argument("--hold", type=float, default=0.0, metavar="SECONDS", help="hold the last frame this long, silent")
|
|
537
|
+
encoding(s)
|
|
538
|
+
s.set_defaults(fn=cmd_clip)
|
|
539
|
+
|
|
432
540
|
policy(proj(sub.add_parser("status", help="what is built"))).set_defaults(fn=cmd_status)
|
|
433
541
|
|
|
434
542
|
s = proj(sub.add_parser("build", help="run the whole pipeline"))
|
|
435
543
|
s.add_argument("--silent", action="store_true", help="placeholder narration, no API key")
|
|
436
|
-
s.add_argument(
|
|
544
|
+
s.add_argument(
|
|
545
|
+
"--force", action="store_true", help="re-synthesize every section, and let --silent replace voiced takes"
|
|
546
|
+
)
|
|
437
547
|
s.add_argument(
|
|
438
548
|
"--only", type=int, action="append", help="re-record only these sections (repeat the flag for several)"
|
|
439
549
|
)
|
|
@@ -121,6 +121,8 @@ class VerifyConfig:
|
|
|
121
121
|
diff_level: int = 40 # Luma difference that a pixel must exceed to count as changed, for probes and control shares.
|
|
122
122
|
min_changed_percent: float = 0.1 # Smallest changed share, in percent, that the reported probe needs.
|
|
123
123
|
min_margin_percent: float = 0.1 # Smallest margin, in percentage points, that the reported probe needs.
|
|
124
|
+
thin_change_factor: float = 3.0 # A passing cue whose changed share or margin is below this many times its floor
|
|
125
|
+
# reads `THIN CHANGE?`, an uncertain finding. 1 turns the warning off.
|
|
124
126
|
onset_percent: float = 0.01 # Rise in changed share from one frame to the next that marks the onset.
|
|
125
127
|
# It is in percentage points. 0.01 is about 13 pixels.
|
|
126
128
|
onset_diff_level: int = 12 # Luma difference that a pixel must exceed to count as changed, for the onset scan only.
|
|
@@ -131,6 +133,8 @@ class VerifyConfig:
|
|
|
131
133
|
visible_ymax: float = 60 # The start check reports `BLACK` when the brightest luma of its frame is at most this.
|
|
132
134
|
cut_window_seconds: float = 0.15 # Seconds of narration before each cut that the cut check measures.
|
|
133
135
|
cut_max_db: float = -40.0 # Loudest RMS level of the cut window, in dBFS, that passes the cut check.
|
|
136
|
+
max_pop_percent: float = 0.1 # Largest changed share, in percent, across the cut into a section that sets
|
|
137
|
+
# `carries_previous`. A larger share reads `POP AT CUT`.
|
|
134
138
|
probe_width: int = 480 # Width in pixels that frames are scaled to before a comparison.
|
|
135
139
|
probe_height: int = 270 # Height in pixels that frames are scaled to before a comparison.
|
|
136
140
|
block_width: int = 240 # Width in pixels of the block-averaged copy of each frame that confirms an onset.
|
|
@@ -177,15 +177,28 @@ def rms_db(path: Path, start: float, seconds: float) -> float:
|
|
|
177
177
|
return -120.0 if m[-1] == "-inf" else float(m[-1])
|
|
178
178
|
|
|
179
179
|
|
|
180
|
+
SILENCE_END_TOLERANCE_SECONDS = 0.06 # A silence that ends this close to the end of the file runs to the end.
|
|
181
|
+
MP3_FRAME_SAMPLES = 1152 # Samples in one MPEG-1 Layer III frame.
|
|
182
|
+
|
|
183
|
+
|
|
180
184
|
def trailing_silence(path: Path, *, noise_db: int = -35, min_run: float = 0.05) -> float:
|
|
181
|
-
"""Seconds of silence at the end of an audio file.
|
|
185
|
+
"""Seconds of silence at the end of an audio file.
|
|
186
|
+
|
|
187
|
+
The container length includes the encoder padding, which decodes to nothing. For an mp3 that
|
|
188
|
+
padding is up to about 50 ms, so silencedetect reports the last silence ending that far before
|
|
189
|
+
the container end. A silence that ends within one audio frame or SILENCE_END_TOLERANCE_SECONDS
|
|
190
|
+
of the end, whichever is longer, counts as running to the end.
|
|
191
|
+
"""
|
|
182
192
|
duration = probe_duration(path)
|
|
183
193
|
err = stderr("-i", str(path), "-af", f"silencedetect=noise={noise_db}dB:d={min_run}", "-f", "null", "-")
|
|
184
194
|
starts = re.findall(r"silence_start: ([0-9.]+)", err)
|
|
185
195
|
ends = re.findall(r"silence_end: ([0-9.]+)", err)
|
|
186
196
|
if not starts:
|
|
187
197
|
return 0.0
|
|
188
|
-
|
|
198
|
+
rate = re.search(r"Audio: [^\n]*?(\d+) Hz", err)
|
|
199
|
+
frame = MP3_FRAME_SAMPLES / int(rate.group(1)) if rate and int(rate.group(1)) > 0 else 0.0
|
|
200
|
+
tolerance = max(SILENCE_END_TOLERANCE_SECONDS, frame)
|
|
201
|
+
if len(ends) < len(starts) or float(ends[-1]) >= duration - tolerance:
|
|
189
202
|
return round(duration - float(starts[-1]), 3)
|
|
190
203
|
return 0.0
|
|
191
204
|
|
|
@@ -250,15 +263,20 @@ def pad_tail(path: Path, seconds: float, *, bitrate: str) -> None:
|
|
|
250
263
|
tmp.replace(path)
|
|
251
264
|
|
|
252
265
|
|
|
253
|
-
def concat_audio(
|
|
266
|
+
def concat_audio(
|
|
267
|
+
files: list[Path], out: Path, *, bitrate: str, sample_rate: int, leads: list[float] | None = None
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Join audio files back to back. `leads` gives each file seconds of silence before it, in whole milliseconds."""
|
|
254
270
|
inputs: list[str] = []
|
|
255
271
|
for f in files:
|
|
256
272
|
inputs += ["-i", str(f)]
|
|
257
|
-
|
|
273
|
+
delays = [int(round(x * 1000)) for x in (leads or [])] + [0] * len(files)
|
|
274
|
+
pads = "".join(f"[{i}:a]adelay=delays={delays[i]}:all=1[l{i}];" for i in range(len(files)) if delays[i] > 0)
|
|
275
|
+
labels = "".join(f"[l{i}]" if delays[i] > 0 else f"[{i}:a]" for i in range(len(files)))
|
|
258
276
|
run(
|
|
259
277
|
*inputs,
|
|
260
278
|
"-filter_complex",
|
|
261
|
-
f"{labels}concat=n={len(files)}:v=0:a=1[a]",
|
|
279
|
+
f"{pads}{labels}concat=n={len(files)}:v=0:a=1[a]",
|
|
262
280
|
"-map",
|
|
263
281
|
"[a]",
|
|
264
282
|
"-c:a",
|
|
@@ -386,6 +404,21 @@ def changed_pixels_percent(path: Path, t1: float, t2: float, *, level: int, widt
|
|
|
386
404
|
return (float(m.group(1)) / 255 * 100) if m else 0.0
|
|
387
405
|
|
|
388
406
|
|
|
407
|
+
def changed_images_percent(a: Path, b: Path, *, level: int, width: int, height: int) -> float:
|
|
408
|
+
"""Share (0-100) of pixels whose luma differs by more than `level` between two still images.
|
|
409
|
+
|
|
410
|
+
Both images are scaled to width x height and read as luma, as the frames of changed_pixels_percent are.
|
|
411
|
+
"""
|
|
412
|
+
err = stderr(
|
|
413
|
+
"-i", str(a), "-i", str(b), "-filter_complex",
|
|
414
|
+
f"[0:v]scale={width}:{height},format=gray[a];[1:v]scale={width}:{height},format=gray[b];"
|
|
415
|
+
f"[a][b]blend=all_mode=difference,{_changed_mask(level)}",
|
|
416
|
+
"-frames:v", "1", "-f", "null", "-",
|
|
417
|
+
) # fmt: skip
|
|
418
|
+
m = re.search(r"YAVG=([0-9.]+)", err)
|
|
419
|
+
return (float(m.group(1)) / 255 * 100) if m else 0.0
|
|
420
|
+
|
|
421
|
+
|
|
389
422
|
def changed_series(
|
|
390
423
|
path: Path, ref_t: float, start: float, end: float, *, fps: int, level: int, width: int, height: int
|
|
391
424
|
) -> list[tuple[float, float]]:
|