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.
Files changed (64) hide show
  1. {decktalk-0.2.1 → decktalk-0.3.0}/PKG-INFO +4 -4
  2. {decktalk-0.2.1 → decktalk-0.3.0}/README.md +3 -3
  3. {decktalk-0.2.1 → decktalk-0.3.0}/pyproject.toml +1 -1
  4. {decktalk-0.2.1 → decktalk-0.3.0}/pyproject.toml.orig +1 -1
  5. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/__init__.py +16 -4
  6. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/_report.py +85 -2
  7. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/artifacts.py +3 -0
  8. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/cli.py +118 -8
  9. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/config.py +4 -0
  10. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/media/ffmpeg.py +38 -5
  11. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/project.py +73 -15
  12. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/runtime/decktalk-runtime.js +298 -90
  13. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/__init__.py +8 -0
  14. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/assemble.py +67 -9
  15. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/beats.py +92 -40
  16. decktalk-0.3.0/src/decktalk/stages/clip.py +245 -0
  17. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/measure.py +36 -0
  18. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/narrate.py +179 -28
  19. decktalk-0.3.0/src/decktalk/stages/preflight.py +538 -0
  20. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/verify.py +210 -43
  21. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/decktalk.toml +2 -1
  22. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/verdicts.py +17 -2
  23. {decktalk-0.2.1 → decktalk-0.3.0}/LICENSE +0 -0
  24. {decktalk-0.2.1 → decktalk-0.3.0}/NOTICE +0 -0
  25. {decktalk-0.2.1 → decktalk-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
  26. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/__main__.py +0 -0
  27. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/_env.py +0 -0
  28. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/errors.py +0 -0
  29. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/media/__init__.py +0 -0
  30. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/media/browser.py +0 -0
  31. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/__init__.py +0 -0
  32. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/_http.py +0 -0
  33. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/elevenlabs.py +0 -0
  34. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/providers/speech.py +0 -0
  35. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/py.typed +0 -0
  36. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/scaffold.py +0 -0
  37. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/build.py +0 -0
  38. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/record.py +0 -0
  39. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/shots.py +0 -0
  40. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/stages/soundscape.py +0 -0
  41. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/status.py +0 -0
  42. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/cues.json +0 -0
  43. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/after-first.webp +0 -0
  44. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/after-last-864.webp +0 -0
  45. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/after-last.webp +0 -0
  46. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/before-first.webp +0 -0
  47. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/before-last-864.webp +0 -0
  48. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/before-last.webp +0 -0
  49. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/edit/s3-last.webp +0 -0
  50. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-OFL.txt +0 -0
  51. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-greek.woff2 +0 -0
  52. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-latin.woff2 +0 -0
  53. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-OFL.txt +0 -0
  54. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-greek.woff2 +0 -0
  55. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-latin.woff2 +0 -0
  56. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-OFL.txt +0 -0
  57. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-greek.woff2 +0 -0
  58. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-latin.woff2 +0 -0
  59. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/index.html +0 -0
  60. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/deck/lesson.html +0 -0
  61. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/env.example +0 -0
  62. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/gitignore +0 -0
  63. {decktalk-0.2.1 → decktalk-0.3.0}/src/decktalk/template/media/markers.json +0 -0
  64. {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.2.1
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 1.05 0.00 -10ms -9ms changed
166
- 3:3.4name 84.72 123.32 2.74 0.00 +0ms -4ms changed
167
- 8:7.1checked 10.03 238.31 6.56 0.00 +10ms +3ms changed
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 1.05 0.00 -10ms -9ms changed
133
- 3:3.4name 84.72 123.32 2.74 0.00 +0ms -4ms changed
134
- 8:7.1checked 10.03 238.31 6.56 0.00 +10ms +3ms changed
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "decktalk"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Narrated presentations, cut to the word. A markdown script, an ElevenLabs voice, and HTML slides go in, and one mp4 comes out."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "decktalk"
3
- version = "0.2.1"
3
+ version = "0.3.0"
4
4
  description = "Narrated presentations, cut to the word. A markdown script, an ElevenLabs voice, and HTML slides go in, and one mp4 comes out."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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={args.model or project.voice.model or cfg.model} {project.voice.api_settings()}")
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("--force", action="store_true", help="ignore the text-hash cache")
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("--dry-run", action="store_true", help="parse and print without any API call")
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("--force", action="store_true", help="re-synthesize every section")
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
- if len(ends) < len(starts) or float(ends[-1]) >= duration - 0.05:
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(files: list[Path], out: Path, *, bitrate: str, sample_rate: int) -> None:
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
- labels = "".join(f"[{i}:a]" for i in range(len(files)))
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]]: