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.
- {decktalk-0.2.0 → decktalk-0.3.0}/PKG-INFO +7 -7
- {decktalk-0.2.0 → decktalk-0.3.0}/README.md +6 -6
- {decktalk-0.2.0 → decktalk-0.3.0}/pyproject.toml +2 -2
- {decktalk-0.2.0 → decktalk-0.3.0}/pyproject.toml.orig +2 -2
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/__init__.py +16 -4
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/_report.py +85 -2
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/artifacts.py +138 -76
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/cli.py +118 -8
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/config.py +4 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/media/ffmpeg.py +38 -5
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/project.py +77 -16
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/runtime/decktalk-runtime.js +298 -90
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/__init__.py +8 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/assemble.py +110 -21
- {decktalk-0.2.0 → 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.0 → decktalk-0.3.0}/src/decktalk/stages/measure.py +36 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/narrate.py +240 -25
- decktalk-0.3.0/src/decktalk/stages/preflight.py +538 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/verify.py +210 -43
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/cues.json +41 -14
- decktalk-0.3.0/src/decktalk/template/deck/edit/after-first.webp +0 -0
- decktalk-0.3.0/src/decktalk/template/deck/edit/after-last-864.webp +0 -0
- decktalk-0.3.0/src/decktalk/template/deck/edit/after-last.webp +0 -0
- decktalk-0.3.0/src/decktalk/template/deck/edit/before-first.webp +0 -0
- decktalk-0.3.0/src/decktalk/template/deck/edit/before-last-864.webp +0 -0
- decktalk-0.3.0/src/decktalk/template/deck/edit/before-last.webp +0 -0
- decktalk-0.3.0/src/decktalk/template/deck/edit/s3-last.webp +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/index.html +349 -183
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/lesson.html +213 -118
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/decktalk.toml +42 -2
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/script.md +34 -12
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/verdicts.py +17 -2
- {decktalk-0.2.0 → decktalk-0.3.0}/LICENSE +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/NOTICE +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/THIRD_PARTY_NOTICES.md +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/__main__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/_env.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/errors.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/media/__init__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/media/browser.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/__init__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/_http.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/elevenlabs.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/providers/speech.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/py.typed +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/scaffold.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/build.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/record.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/shots.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/stages/soundscape.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/status.py +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-OFL.txt +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-greek.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/Inter-latin.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-OFL.txt +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-greek.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/InterTight-latin.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-OFL.txt +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-greek.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/deck/fonts/JetBrainsMono-latin.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/env.example +0 -0
- {decktalk-0.2.0 → decktalk-0.3.0}/src/decktalk/template/gitignore +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
167
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
134
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
|
297
|
-
return
|
|
301
|
+
def _ends_sentence(token: str) -> bool:
|
|
302
|
+
return bool(_SENTENCE_END_RE.search(token))
|
|
298
303
|
|
|
299
304
|
|
|
300
|
-
def
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
a
|
|
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
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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:
|