decktalk 0.2.0__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.0 → decktalk-0.3.0}/PKG-INFO +7 -7
  2. {decktalk-0.2.0 → decktalk-0.3.0}/README.md +6 -6
  3. {decktalk-0.2.0 → decktalk-0.3.0}/pyproject.toml +2 -2
  4. {decktalk-0.2.0 → decktalk-0.3.0}/pyproject.toml.orig +2 -2
  5. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/__init__.py +16 -4
  6. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/_report.py +85 -2
  7. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/artifacts.py +138 -76
  8. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/cli.py +118 -8
  9. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/config.py +4 -0
  10. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/media/ffmpeg.py +38 -5
  11. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/project.py +77 -16
  12. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/runtime/decktalk-runtime.js +298 -90
  13. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/__init__.py +8 -0
  14. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/assemble.py +110 -21
  15. {decktalk-0.2.0 → 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.0 → decktalk-0.3.0}/src/decktalk/stages/measure.py +36 -0
  18. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/narrate.py +240 -25
  19. decktalk-0.3.0/src/decktalk/stages/preflight.py +538 -0
  20. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/verify.py +210 -43
  21. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/cues.json +41 -14
  22. decktalk-0.3.0/src/decktalk/template/deck/edit/after-first.webp +0 -0
  23. decktalk-0.3.0/src/decktalk/template/deck/edit/after-last-864.webp +0 -0
  24. decktalk-0.3.0/src/decktalk/template/deck/edit/after-last.webp +0 -0
  25. decktalk-0.3.0/src/decktalk/template/deck/edit/before-first.webp +0 -0
  26. decktalk-0.3.0/src/decktalk/template/deck/edit/before-last-864.webp +0 -0
  27. decktalk-0.3.0/src/decktalk/template/deck/edit/before-last.webp +0 -0
  28. decktalk-0.3.0/src/decktalk/template/deck/edit/s3-last.webp +0 -0
  29. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/index.html +349 -183
  30. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/lesson.html +213 -118
  31. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/decktalk.toml +42 -2
  32. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/script.md +34 -12
  33. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/verdicts.py +17 -2
  34. {decktalk-0.2.0 → decktalk-0.3.0}/LICENSE +0 -0
  35. {decktalk-0.2.0 → decktalk-0.3.0}/NOTICE +0 -0
  36. {decktalk-0.2.0 → decktalk-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
  37. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/__main__.py +0 -0
  38. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/_env.py +0 -0
  39. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/errors.py +0 -0
  40. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/media/__init__.py +0 -0
  41. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/media/browser.py +0 -0
  42. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/__init__.py +0 -0
  43. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/_http.py +0 -0
  44. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/elevenlabs.py +0 -0
  45. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/speech.py +0 -0
  46. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/py.typed +0 -0
  47. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/scaffold.py +0 -0
  48. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/build.py +0 -0
  49. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/record.py +0 -0
  50. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/shots.py +0 -0
  51. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/soundscape.py +0 -0
  52. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/status.py +0 -0
  53. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-OFL.txt +0 -0
  54. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-greek.woff2 +0 -0
  55. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-latin.woff2 +0 -0
  56. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-OFL.txt +0 -0
  57. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-greek.woff2 +0 -0
  58. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-latin.woff2 +0 -0
  59. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-OFL.txt +0 -0
  60. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-greek.woff2 +0 -0
  61. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-latin.woff2 +0 -0
  62. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/env.example +0 -0
  63. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/gitignore +0 -0
  64. {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/media/markers.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: decktalk
3
- Version: 0.2.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
@@ -50,7 +50,7 @@ If you change a sentence, DeckTalk voices only that section again.</p>
50
50
  1. Upload the mp4 through GitHub's editor.
51
51
  2. Put the user-attachments URL on its own line in place of the hero picture at the top.
52
52
  3. Move the caption below under that line, and uncomment it.
53
- 4. Under "Make your first video", change "The scaffold is a five-section lesson" to
53
+ 4. Under "Make your first video", change "The scaffold is a lesson in nine sections, two of them optional clips" to
54
54
  "The scaffold is the project that made the demo video above".
55
55
  <p align="center">DeckTalk built this video from the project that <code>decktalk init</code> writes, in a cloned voice. Turn the sound on, and each reveal starts on its word.</p>
56
56
  -->
@@ -126,7 +126,7 @@ You need Python 3.12 or later. These steps use `uv`, a Python package manager. I
126
126
 
127
127
  This build spends ElevenLabs credits for every section. It writes the video, SRT and VTT captions, and one chapter per section. [What spends credits](https://docs.decktalk.app/requirements#what-spends-credits) lists the cost.
128
128
 
129
- The scaffold is a five-section lesson. The [quickstart](https://docs.decktalk.app/quickstart) shows the output of each step and [what the example video shows](https://docs.decktalk.app/quickstart#what-the-example-video-shows).
129
+ The scaffold is a lesson in nine sections, two of them optional clips. The [quickstart](https://docs.decktalk.app/quickstart) shows the output of each step and [what the example video shows](https://docs.decktalk.app/quickstart#what-the-example-video-shows).
130
130
 
131
131
  ## What you write
132
132
 
@@ -159,12 +159,12 @@ A browser does not start recording at a known time, so DeckTalk does not use a t
159
159
 
160
160
  `decktalk verify` then measures every reveal in the finished video. This sample comes from a silent build of the scaffold.
161
161
 
162
- <!-- sample: decktalk verify 1:1.1bowl 3:3.4name 4:4.1words, silent build of the scaffold (cue table only) -->
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 71.98 110.58 2.77 0.00 +20ms +11ms changed
167
- 4:4.1words 14.31 172.99 1.47 0.00 +10ms -11ms 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.
@@ -17,7 +17,7 @@ If you change a sentence, DeckTalk voices only that section again.</p>
17
17
  1. Upload the mp4 through GitHub's editor.
18
18
  2. Put the user-attachments URL on its own line in place of the hero picture at the top.
19
19
  3. Move the caption below under that line, and uncomment it.
20
- 4. Under "Make your first video", change "The scaffold is a five-section lesson" to
20
+ 4. Under "Make your first video", change "The scaffold is a lesson in nine sections, two of them optional clips" to
21
21
  "The scaffold is the project that made the demo video above".
22
22
  <p align="center">DeckTalk built this video from the project that <code>decktalk init</code> writes, in a cloned voice. Turn the sound on, and each reveal starts on its word.</p>
23
23
  -->
@@ -93,7 +93,7 @@ You need Python 3.12 or later. These steps use `uv`, a Python package manager. I
93
93
 
94
94
  This build spends ElevenLabs credits for every section. It writes the video, SRT and VTT captions, and one chapter per section. [What spends credits](https://docs.decktalk.app/requirements#what-spends-credits) lists the cost.
95
95
 
96
- The scaffold is a five-section lesson. The [quickstart](https://docs.decktalk.app/quickstart) shows the output of each step and [what the example video shows](https://docs.decktalk.app/quickstart#what-the-example-video-shows).
96
+ The scaffold is a lesson in nine sections, two of them optional clips. The [quickstart](https://docs.decktalk.app/quickstart) shows the output of each step and [what the example video shows](https://docs.decktalk.app/quickstart#what-the-example-video-shows).
97
97
 
98
98
  ## What you write
99
99
 
@@ -126,12 +126,12 @@ A browser does not start recording at a known time, so DeckTalk does not use a t
126
126
 
127
127
  `decktalk verify` then measures every reveal in the finished video. This sample comes from a silent build of the scaffold.
128
128
 
129
- <!-- sample: decktalk verify 1:1.1bowl 3:3.4name 4:4.1words, silent build of the scaffold (cue table only) -->
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 71.98 110.58 2.77 0.00 +20ms +11ms changed
134
- 4:4.1words 14.31 172.99 1.47 0.00 +10ms -11ms 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.0"
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"
@@ -60,7 +60,7 @@ Changelog = "https://github.com/jacobcbeaudin/decktalk/blob/main/CHANGELOG.md"
60
60
  decktalk = "decktalk.cli:main"
61
61
 
62
62
  [build-system]
63
- requires = ["uv_build>=0.8.0,<0.12.0"]
63
+ requires = ["uv_build>=0.8.0,<0.13.0"]
64
64
  build-backend = "uv_build"
65
65
 
66
66
  [dependency-groups]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "decktalk"
3
- version = "0.2.0"
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"
@@ -51,7 +51,7 @@ Changelog = "https://github.com/jacobcbeaudin/decktalk/blob/main/CHANGELOG.md"
51
51
  decktalk = "decktalk.cli:main"
52
52
 
53
53
  [build-system]
54
- requires = ["uv_build>=0.8.0,<0.12.0"]
54
+ requires = ["uv_build>=0.8.0,<0.13.0"]
55
55
  build-backend = "uv_build"
56
56
 
57
57
  [dependency-groups]
@@ -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:
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
 
15
15
  import json
16
16
  import math
17
+ import re
17
18
  from dataclasses import asdict, dataclass, field
18
19
  from pathlib import Path
19
20
  from typing import Any, Self
@@ -112,6 +113,7 @@ class TimelineSection:
112
113
  duration: float
113
114
  speech_end: float | None
114
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.
115
117
 
116
118
  @classmethod
117
119
  def from_dict(cls, d: dict[str, Any]) -> Self:
@@ -122,6 +124,7 @@ class TimelineSection:
122
124
  duration=float(d["duration"]),
123
125
  speech_end=None if d.get("speech_end") is None else float(d["speech_end"]),
124
126
  words=[Word.from_dict(w) for w in d.get("words", [])],
127
+ lead_seconds=float(d.get("lead_seconds", 0.0)),
125
128
  )
126
129
 
127
130
 
@@ -222,6 +225,7 @@ class Sidecar:
222
225
  lead_seconds: float # wall-clock estimate from the recorder
223
226
  lead_in_seconds: float | None = None # first clean frame after the magenta cover: narration t=0
224
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
225
229
  warnings: list[str] = field(default_factory=list)
226
230
  page_errors: list[str] = field(default_factory=list) # uncaught exceptions, or no runtime catalog at all
227
231
  # (seconds, ms) where the page stalled. The time is None for a gap that ended before narration t=0.
@@ -272,14 +276,15 @@ class Sidecar:
272
276
  # build/out/<name>.vtt the same cues as WebVTT
273
277
  # build/out/<name>.chapters.txt an ffmetadata file with one [CHAPTER] per section
274
278
 
275
- CAPTION_MAX_CHARS = 42 # The longest line a cue may carry, in characters.
276
- CAPTION_MAX_LINES = 2
277
- CAPTION_MAX_GAP = 1.5 # A pause longer than this between two words ends the cue.
278
- CAPTION_MAX_SECONDS = 7.0 # No cue stays on screen longer than this.
279
+ CAPTION_MAX_CHARS = 42 # The longest line a cue may carry, in characters. A cue has at most two lines.
280
+ CAPTION_MIN_SILENCE = 1.0 # A silence at least this long between two words always ends the cue.
279
281
  CAPTION_TAIL = 0.2 # Seconds a cue lingers after its last word, unless the next cue begins first.
282
+ CAPTION_MAX_UNITS = 8 # The most sentences one cue is ever considered to hold.
280
283
 
281
- _SENTENCE_END = (".", "?", "!")
282
- _CLAUSE_END = (",", ";", ":", "—", "-")
284
+ # Short words a line should not end on and a split should not touch, so "billions of billions" stays whole.
285
+ _FUNCTION_WORDS = frozenset("a an the of to in on at for and or but by with as is it its my your".split())
286
+ _SENTENCE_END_RE = re.compile(r"[.?!][\"'”’)]*$")
287
+ _CLAUSE_END_RE = re.compile(r"[,;:—–-]$")
283
288
 
284
289
 
285
290
  @dataclass(frozen=True)
@@ -293,42 +298,79 @@ class CaptionCue:
293
298
  return "\n".join(self.lines)
294
299
 
295
300
 
296
- def _chars(words: list[Word]) -> int:
297
- return sum(len(w.word) for w in words) + max(len(words) - 1, 0)
301
+ def _ends_sentence(token: str) -> bool:
302
+ return bool(_SENTENCE_END_RE.search(token))
298
303
 
299
304
 
300
- def _caption_lines(words: list[Word], max_chars: int) -> list[list[Word]]:
301
- """Split one run of words into lines of at most max_chars, preferring punctuation breaks."""
302
- lines: list[list[Word]] = []
303
- cur: list[Word] = []
304
- for w in words:
305
- if cur and _chars([*cur, w]) > max_chars:
306
- # Back up to the last clause break inside the line when it leaves a reasonable line behind.
307
- cut = next(
308
- (
309
- i + 1
310
- for i in range(len(cur) - 2, -1, -1)
311
- if cur[i].word.endswith(_CLAUSE_END) and _chars(cur[: i + 1]) >= max_chars // 2
312
- ),
313
- None,
314
- )
315
- if cut is None:
316
- lines.append(cur)
317
- cur = []
318
- else:
319
- lines.append(cur[:cut])
320
- cur = cur[cut:]
321
- # The carried tail plus the new word can still overflow, so flush it as its own line.
322
- if _chars([*cur, w]) > max_chars:
323
- lines.append(cur)
324
- cur = []
325
- cur.append(w)
326
- if w.word.endswith(_SENTENCE_END) and _chars(cur) >= max_chars // 2:
327
- lines.append(cur)
328
- cur = []
329
- if cur:
330
- lines.append(cur)
331
- return lines
305
+ def _ends_clause(token: str) -> bool:
306
+ return bool(_CLAUSE_END_RE.search(token))
307
+
308
+
309
+ def _is_function_word(token: str) -> bool:
310
+ return re.sub(r"[^\w']", "", token.lower().replace("’", "'")) in _FUNCTION_WORDS
311
+
312
+
313
+ def _join(words: list[Word]) -> str:
314
+ return " ".join(w.word for w in words)
315
+
316
+
317
+ def _wrap(words: list[Word], max_chars: int, *, several: bool = False) -> tuple[list[str], bool] | None:
318
+ """One line if the words fit, else the best break into two lines, or None when no break fits.
319
+
320
+ Returns the lines and whether the break falls after a sentence end. The break goes after a
321
+ sentence end, else after a clause mark, while the shorter line is at least a third of the
322
+ longer. A cue of `several` sentences takes a sentence-end break even when lopsided. Otherwise
323
+ the lines are balanced, and a line that ends on a function word costs the most. No line is a
324
+ single word unless that word is a whole sentence.
325
+ """
326
+ whole = _join(words)
327
+ if len(whole) <= max_chars:
328
+ return [whole], True
329
+ best: tuple[tuple[int, int], list[str], bool] | None = None
330
+ for i in range(1, len(words)):
331
+ first, second = words[:i], words[i:]
332
+ la, lb = len(_join(first)), len(_join(second))
333
+ if la > max_chars or lb > max_chars:
334
+ continue
335
+ if any(len(part) == 1 and not _ends_sentence(part[0].word) for part in (first, second)):
336
+ continue
337
+ last, nxt = first[-1].word, second[0].word
338
+ even = min(la, lb) >= max(la, lb) / 3
339
+ if _ends_sentence(last) and (even or several):
340
+ rank = 0
341
+ elif _ends_clause(last) and even:
342
+ rank = 1
343
+ else:
344
+ rank = 2
345
+ score = abs(la - lb) + 16 * _is_function_word(last) + 4 * _is_function_word(nxt)
346
+ if best is None or (rank, score) < best[0]:
347
+ best = ((rank, score), [_join(first), _join(second)], _ends_sentence(last))
348
+ return (best[1], best[2]) if best else None
349
+
350
+
351
+ def _split_sentence(words: list[Word], max_chars: int) -> list[list[Word]]:
352
+ """Split a sentence too long for two lines into parts that each fit in two lines.
353
+
354
+ The split goes at the clause mark nearest the middle when both parts keep a third of the
355
+ characters, else at the word boundary nearest the middle with no function word on either
356
+ side. Each part keeps at least three words. A sentence with no such boundary splits at the
357
+ boundary nearest the middle, so no line runs past max_chars.
358
+ """
359
+ if len(words) < 2 or _wrap(words, max_chars) is not None:
360
+ return [words]
361
+ total = len(_join(words))
362
+ ranked: list[tuple[int, float, int]] = []
363
+ for i in range(3, len(words) - 2):
364
+ cut = len(_join(words[:i]))
365
+ token, nxt = words[i - 1].word, words[i].word
366
+ if _ends_clause(token) and min(cut, total - cut) >= total / 3:
367
+ ranked.append((0, abs(cut - total / 2), i))
368
+ elif not _ends_clause(token) and not _is_function_word(token) and not _is_function_word(nxt):
369
+ ranked.append((1, abs(cut - total / 2), i))
370
+ if not ranked: # no boundary follows the rules, so take the middle rather than overflow a line
371
+ ranked = [(2, abs(len(_join(words[:i])) - total / 2), i) for i in range(1, len(words))]
372
+ i = min(ranked)[2]
373
+ return _split_sentence(words[:i], max_chars) + _split_sentence(words[i:], max_chars)
332
374
 
333
375
 
334
376
  def display_words(words: list[Word], text: str) -> list[Word]:
@@ -363,54 +405,74 @@ def caption_cues(
363
405
  words: list[Word],
364
406
  *,
365
407
  max_chars: int = CAPTION_MAX_CHARS,
366
- max_lines: int = CAPTION_MAX_LINES,
367
- max_gap: float = CAPTION_MAX_GAP,
368
- max_seconds: float = CAPTION_MAX_SECONDS,
408
+ min_silence: float = CAPTION_MIN_SILENCE,
369
409
  ) -> list[CaptionCue]:
370
410
  """Cues for one section's words, so a cue never spans a section boundary.
371
411
 
372
- Words are grouped into lines of at most max_chars, broken at punctuation where the line
373
- would overflow, and lines are paired into cues of at most max_lines. A sentence end,
374
- a pause longer than max_gap, or a cue running past max_seconds also closes the cue.
412
+ Every word keeps its start time. A cue ends before any silence of min_silence or more.
413
+ Between those breaks, whole sentences are grouped into cues at the lowest cost: each cue
414
+ costs 1000, a cue of several sentences whose line break cannot fall at a sentence end
415
+ costs 1200 more, a two-word cue 400 more, and a one-word cue 5000 more. A sentence too long
416
+ for two lines is split first (see `_split_sentence`), and each part is a cue of its own.
417
+ Each cue has one or two lines of at most max_chars (see `_wrap`).
375
418
  """
376
419
  if not words:
377
420
  return []
378
421
  runs: list[list[Word]] = [[]]
379
422
  for w in words:
380
- if runs[-1] and w.start - runs[-1][-1].end > max_gap:
423
+ if runs[-1] and w.start - runs[-1][-1].end >= min_silence:
381
424
  runs.append([])
382
425
  runs[-1].append(w)
383
- cues: list[CaptionCue] = []
384
- for run in runs:
385
- pending: list[list[Word]] = []
386
- for line in _caption_lines(run, max_chars):
387
- pending.append(line)
388
- closes = (
389
- len(pending) >= max_lines
390
- or line[-1].word.endswith(_SENTENCE_END)
391
- or line[-1].end - pending[0][0].start >= max_seconds
392
- )
393
- if closes:
394
- cues.append(_cue(pending))
395
- pending = []
396
- if pending:
397
- cues.append(_cue(pending))
398
- # A cue lingers briefly after its last word, but never into the next cue.
399
- out: list[CaptionCue] = []
400
- for i, cue in enumerate(cues):
401
- end = cue.end + CAPTION_TAIL
402
- if i + 1 < len(cues):
403
- end = min(end, cues[i + 1].start)
404
- out.append(CaptionCue(cue.start, round(max(end, cue.end), 3), cue.lines))
405
- return out
406
426
 
427
+ groups: list[list[Word]] = []
428
+ for run in runs:
429
+ sentences: list[list[Word]] = [[]]
430
+ for w in run:
431
+ sentences[-1].append(w)
432
+ if _ends_sentence(w.word):
433
+ sentences.append([])
434
+ units = [part for s in sentences if s for part in _split_sentence(s, max_chars)]
435
+ groups += _group_units(units, max_chars)
407
436
 
408
- def _cue(lines: list[list[Word]]) -> CaptionCue:
409
- return CaptionCue(
410
- start=round(lines[0][0].start, 3),
411
- end=round(lines[-1][-1].end, 3),
412
- lines=tuple(" ".join(w.word for w in line) for line in lines),
413
- )
437
+ cues: list[CaptionCue] = []
438
+ for i, group in enumerate(groups):
439
+ wrapped = _wrap(group, max_chars, several=True)
440
+ lines = wrapped[0] if wrapped else [_join(group)]
441
+ end = group[-1].end + CAPTION_TAIL
442
+ if i + 1 < len(groups): # a cue lingers briefly after its last word, but never into the next cue
443
+ end = min(end, groups[i + 1][0].start)
444
+ cues.append(CaptionCue(round(group[0].start, 3), round(max(end, group[-1].end), 3), tuple(lines)))
445
+ return cues
446
+
447
+
448
+ def _group_units(units: list[list[Word]], max_chars: int) -> list[list[Word]]:
449
+ """Group consecutive units (whole sentences, or parts of a split one) into cues at the lowest cost."""
450
+
451
+ def cost(chunk: list[list[Word]]) -> int | None:
452
+ several = len(chunk) > 1
453
+ if several and not all(_ends_sentence(u[-1].word) for u in chunk):
454
+ return None # only whole sentences share a cue
455
+ flat = [w for u in chunk for w in u]
456
+ wrapped = _wrap(flat, max_chars, several=several)
457
+ if wrapped is None:
458
+ return None if several else 6000 # a unit that cannot wrap still gets a cue of its own
459
+ c = 1000 + (1200 if several and not wrapped[1] else 0)
460
+ return c + (5000 if len(flat) == 1 else 400 if len(flat) == 2 else 0)
461
+
462
+ n = len(units)
463
+ best = [0.0] + [math.inf] * n
464
+ back = [0] * (n + 1)
465
+ for j in range(1, n + 1):
466
+ for i in range(max(0, j - CAPTION_MAX_UNITS), j):
467
+ c = cost(units[i:j])
468
+ if c is not None and best[i] + c < best[j]:
469
+ best[j], back[j] = best[i] + c, i
470
+ groups: list[list[Word]] = []
471
+ j = n
472
+ while j > 0:
473
+ groups.append([w for u in units[back[j] : j] for w in u])
474
+ j = back[j]
475
+ return groups[::-1]
414
476
 
415
477
 
416
478
  def _stamp(seconds: float, sep: str) -> str: