decktalk 0.2.0__tar.gz → 0.2.1__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.2.1}/PKG-INFO +6 -6
- {decktalk-0.2.0 → decktalk-0.2.1}/README.md +5 -5
- {decktalk-0.2.0 → decktalk-0.2.1}/pyproject.toml +2 -2
- {decktalk-0.2.0 → decktalk-0.2.1}/pyproject.toml.orig +2 -2
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/artifacts.py +135 -76
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/project.py +5 -2
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/assemble.py +43 -12
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/narrate.py +73 -9
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/cues.json +41 -14
- decktalk-0.2.1/src/decktalk/template/deck/edit/after-first.webp +0 -0
- decktalk-0.2.1/src/decktalk/template/deck/edit/after-last-864.webp +0 -0
- decktalk-0.2.1/src/decktalk/template/deck/edit/after-last.webp +0 -0
- decktalk-0.2.1/src/decktalk/template/deck/edit/before-first.webp +0 -0
- decktalk-0.2.1/src/decktalk/template/deck/edit/before-last-864.webp +0 -0
- decktalk-0.2.1/src/decktalk/template/deck/edit/before-last.webp +0 -0
- decktalk-0.2.1/src/decktalk/template/deck/edit/s3-last.webp +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/index.html +349 -183
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/lesson.html +213 -118
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/decktalk.toml +40 -1
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/script.md +34 -12
- {decktalk-0.2.0 → decktalk-0.2.1}/LICENSE +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/NOTICE +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/THIRD_PARTY_NOTICES.md +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/__init__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/__main__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/_env.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/_report.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/cli.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/config.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/errors.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/media/__init__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/media/browser.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/media/ffmpeg.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/__init__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/_http.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/elevenlabs.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/speech.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/py.typed +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/runtime/decktalk-runtime.js +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/scaffold.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/__init__.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/beats.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/build.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/measure.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/record.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/shots.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/soundscape.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/verify.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/status.py +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/Inter-OFL.txt +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/Inter-greek.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/Inter-latin.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/InterTight-OFL.txt +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/InterTight-greek.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/InterTight-latin.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/JetBrainsMono-OFL.txt +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/JetBrainsMono-greek.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/JetBrainsMono-latin.woff2 +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/env.example +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/gitignore +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/media/markers.json +0 -0
- {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/verdicts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: decktalk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
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
165
|
1:1.1bowl 1.25 1.25 1.05 0.00 -10ms -9ms changed
|
|
166
|
-
3:3.4name
|
|
167
|
-
|
|
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
|
|
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
132
|
1:1.1bowl 1.25 1.25 1.05 0.00 -10ms -9ms changed
|
|
133
|
-
3:3.4name
|
|
134
|
-
|
|
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
|
|
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.
|
|
3
|
+
version = "0.2.1"
|
|
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.2.
|
|
3
|
+
version = "0.2.1"
|
|
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]
|
|
@@ -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
|
|
@@ -272,14 +273,15 @@ class Sidecar:
|
|
|
272
273
|
# build/out/<name>.vtt the same cues as WebVTT
|
|
273
274
|
# build/out/<name>.chapters.txt an ffmetadata file with one [CHAPTER] per section
|
|
274
275
|
|
|
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.
|
|
276
|
+
CAPTION_MAX_CHARS = 42 # The longest line a cue may carry, in characters. A cue has at most two lines.
|
|
277
|
+
CAPTION_MIN_SILENCE = 1.0 # A silence at least this long between two words always ends the cue.
|
|
279
278
|
CAPTION_TAIL = 0.2 # Seconds a cue lingers after its last word, unless the next cue begins first.
|
|
279
|
+
CAPTION_MAX_UNITS = 8 # The most sentences one cue is ever considered to hold.
|
|
280
280
|
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
# Short words a line should not end on and a split should not touch, so "billions of billions" stays whole.
|
|
282
|
+
_FUNCTION_WORDS = frozenset("a an the of to in on at for and or but by with as is it its my your".split())
|
|
283
|
+
_SENTENCE_END_RE = re.compile(r"[.?!][\"'”’)]*$")
|
|
284
|
+
_CLAUSE_END_RE = re.compile(r"[,;:—–-]$")
|
|
283
285
|
|
|
284
286
|
|
|
285
287
|
@dataclass(frozen=True)
|
|
@@ -293,42 +295,79 @@ class CaptionCue:
|
|
|
293
295
|
return "\n".join(self.lines)
|
|
294
296
|
|
|
295
297
|
|
|
296
|
-
def
|
|
297
|
-
return
|
|
298
|
+
def _ends_sentence(token: str) -> bool:
|
|
299
|
+
return bool(_SENTENCE_END_RE.search(token))
|
|
298
300
|
|
|
299
301
|
|
|
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
|
-
|
|
302
|
+
def _ends_clause(token: str) -> bool:
|
|
303
|
+
return bool(_CLAUSE_END_RE.search(token))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _is_function_word(token: str) -> bool:
|
|
307
|
+
return re.sub(r"[^\w']", "", token.lower().replace("’", "'")) in _FUNCTION_WORDS
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _join(words: list[Word]) -> str:
|
|
311
|
+
return " ".join(w.word for w in words)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _wrap(words: list[Word], max_chars: int, *, several: bool = False) -> tuple[list[str], bool] | None:
|
|
315
|
+
"""One line if the words fit, else the best break into two lines, or None when no break fits.
|
|
316
|
+
|
|
317
|
+
Returns the lines and whether the break falls after a sentence end. The break goes after a
|
|
318
|
+
sentence end, else after a clause mark, while the shorter line is at least a third of the
|
|
319
|
+
longer. A cue of `several` sentences takes a sentence-end break even when lopsided. Otherwise
|
|
320
|
+
the lines are balanced, and a line that ends on a function word costs the most. No line is a
|
|
321
|
+
single word unless that word is a whole sentence.
|
|
322
|
+
"""
|
|
323
|
+
whole = _join(words)
|
|
324
|
+
if len(whole) <= max_chars:
|
|
325
|
+
return [whole], True
|
|
326
|
+
best: tuple[tuple[int, int], list[str], bool] | None = None
|
|
327
|
+
for i in range(1, len(words)):
|
|
328
|
+
first, second = words[:i], words[i:]
|
|
329
|
+
la, lb = len(_join(first)), len(_join(second))
|
|
330
|
+
if la > max_chars or lb > max_chars:
|
|
331
|
+
continue
|
|
332
|
+
if any(len(part) == 1 and not _ends_sentence(part[0].word) for part in (first, second)):
|
|
333
|
+
continue
|
|
334
|
+
last, nxt = first[-1].word, second[0].word
|
|
335
|
+
even = min(la, lb) >= max(la, lb) / 3
|
|
336
|
+
if _ends_sentence(last) and (even or several):
|
|
337
|
+
rank = 0
|
|
338
|
+
elif _ends_clause(last) and even:
|
|
339
|
+
rank = 1
|
|
340
|
+
else:
|
|
341
|
+
rank = 2
|
|
342
|
+
score = abs(la - lb) + 16 * _is_function_word(last) + 4 * _is_function_word(nxt)
|
|
343
|
+
if best is None or (rank, score) < best[0]:
|
|
344
|
+
best = ((rank, score), [_join(first), _join(second)], _ends_sentence(last))
|
|
345
|
+
return (best[1], best[2]) if best else None
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _split_sentence(words: list[Word], max_chars: int) -> list[list[Word]]:
|
|
349
|
+
"""Split a sentence too long for two lines into parts that each fit in two lines.
|
|
350
|
+
|
|
351
|
+
The split goes at the clause mark nearest the middle when both parts keep a third of the
|
|
352
|
+
characters, else at the word boundary nearest the middle with no function word on either
|
|
353
|
+
side. Each part keeps at least three words. A sentence with no such boundary splits at the
|
|
354
|
+
boundary nearest the middle, so no line runs past max_chars.
|
|
355
|
+
"""
|
|
356
|
+
if len(words) < 2 or _wrap(words, max_chars) is not None:
|
|
357
|
+
return [words]
|
|
358
|
+
total = len(_join(words))
|
|
359
|
+
ranked: list[tuple[int, float, int]] = []
|
|
360
|
+
for i in range(3, len(words) - 2):
|
|
361
|
+
cut = len(_join(words[:i]))
|
|
362
|
+
token, nxt = words[i - 1].word, words[i].word
|
|
363
|
+
if _ends_clause(token) and min(cut, total - cut) >= total / 3:
|
|
364
|
+
ranked.append((0, abs(cut - total / 2), i))
|
|
365
|
+
elif not _ends_clause(token) and not _is_function_word(token) and not _is_function_word(nxt):
|
|
366
|
+
ranked.append((1, abs(cut - total / 2), i))
|
|
367
|
+
if not ranked: # no boundary follows the rules, so take the middle rather than overflow a line
|
|
368
|
+
ranked = [(2, abs(len(_join(words[:i])) - total / 2), i) for i in range(1, len(words))]
|
|
369
|
+
i = min(ranked)[2]
|
|
370
|
+
return _split_sentence(words[:i], max_chars) + _split_sentence(words[i:], max_chars)
|
|
332
371
|
|
|
333
372
|
|
|
334
373
|
def display_words(words: list[Word], text: str) -> list[Word]:
|
|
@@ -363,54 +402,74 @@ def caption_cues(
|
|
|
363
402
|
words: list[Word],
|
|
364
403
|
*,
|
|
365
404
|
max_chars: int = CAPTION_MAX_CHARS,
|
|
366
|
-
|
|
367
|
-
max_gap: float = CAPTION_MAX_GAP,
|
|
368
|
-
max_seconds: float = CAPTION_MAX_SECONDS,
|
|
405
|
+
min_silence: float = CAPTION_MIN_SILENCE,
|
|
369
406
|
) -> list[CaptionCue]:
|
|
370
407
|
"""Cues for one section's words, so a cue never spans a section boundary.
|
|
371
408
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
a
|
|
409
|
+
Every word keeps its start time. A cue ends before any silence of min_silence or more.
|
|
410
|
+
Between those breaks, whole sentences are grouped into cues at the lowest cost: each cue
|
|
411
|
+
costs 1000, a cue of several sentences whose line break cannot fall at a sentence end
|
|
412
|
+
costs 1200 more, a two-word cue 400 more, and a one-word cue 5000 more. A sentence too long
|
|
413
|
+
for two lines is split first (see `_split_sentence`), and each part is a cue of its own.
|
|
414
|
+
Each cue has one or two lines of at most max_chars (see `_wrap`).
|
|
375
415
|
"""
|
|
376
416
|
if not words:
|
|
377
417
|
return []
|
|
378
418
|
runs: list[list[Word]] = [[]]
|
|
379
419
|
for w in words:
|
|
380
|
-
if runs[-1] and w.start - runs[-1][-1].end
|
|
420
|
+
if runs[-1] and w.start - runs[-1][-1].end >= min_silence:
|
|
381
421
|
runs.append([])
|
|
382
422
|
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
423
|
|
|
424
|
+
groups: list[list[Word]] = []
|
|
425
|
+
for run in runs:
|
|
426
|
+
sentences: list[list[Word]] = [[]]
|
|
427
|
+
for w in run:
|
|
428
|
+
sentences[-1].append(w)
|
|
429
|
+
if _ends_sentence(w.word):
|
|
430
|
+
sentences.append([])
|
|
431
|
+
units = [part for s in sentences if s for part in _split_sentence(s, max_chars)]
|
|
432
|
+
groups += _group_units(units, max_chars)
|
|
407
433
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
434
|
+
cues: list[CaptionCue] = []
|
|
435
|
+
for i, group in enumerate(groups):
|
|
436
|
+
wrapped = _wrap(group, max_chars, several=True)
|
|
437
|
+
lines = wrapped[0] if wrapped else [_join(group)]
|
|
438
|
+
end = group[-1].end + CAPTION_TAIL
|
|
439
|
+
if i + 1 < len(groups): # a cue lingers briefly after its last word, but never into the next cue
|
|
440
|
+
end = min(end, groups[i + 1][0].start)
|
|
441
|
+
cues.append(CaptionCue(round(group[0].start, 3), round(max(end, group[-1].end), 3), tuple(lines)))
|
|
442
|
+
return cues
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _group_units(units: list[list[Word]], max_chars: int) -> list[list[Word]]:
|
|
446
|
+
"""Group consecutive units (whole sentences, or parts of a split one) into cues at the lowest cost."""
|
|
447
|
+
|
|
448
|
+
def cost(chunk: list[list[Word]]) -> int | None:
|
|
449
|
+
several = len(chunk) > 1
|
|
450
|
+
if several and not all(_ends_sentence(u[-1].word) for u in chunk):
|
|
451
|
+
return None # only whole sentences share a cue
|
|
452
|
+
flat = [w for u in chunk for w in u]
|
|
453
|
+
wrapped = _wrap(flat, max_chars, several=several)
|
|
454
|
+
if wrapped is None:
|
|
455
|
+
return None if several else 6000 # a unit that cannot wrap still gets a cue of its own
|
|
456
|
+
c = 1000 + (1200 if several and not wrapped[1] else 0)
|
|
457
|
+
return c + (5000 if len(flat) == 1 else 400 if len(flat) == 2 else 0)
|
|
458
|
+
|
|
459
|
+
n = len(units)
|
|
460
|
+
best = [0.0] + [math.inf] * n
|
|
461
|
+
back = [0] * (n + 1)
|
|
462
|
+
for j in range(1, n + 1):
|
|
463
|
+
for i in range(max(0, j - CAPTION_MAX_UNITS), j):
|
|
464
|
+
c = cost(units[i:j])
|
|
465
|
+
if c is not None and best[i] + c < best[j]:
|
|
466
|
+
best[j], back[j] = best[i] + c, i
|
|
467
|
+
groups: list[list[Word]] = []
|
|
468
|
+
j = n
|
|
469
|
+
while j > 0:
|
|
470
|
+
groups.append([w for u in units[back[j] : j] for w in u])
|
|
471
|
+
j = back[j]
|
|
472
|
+
return groups[::-1]
|
|
414
473
|
|
|
415
474
|
|
|
416
475
|
def _stamp(seconds: float, sep: str) -> str:
|
|
@@ -53,7 +53,8 @@ class ClipSection:
|
|
|
53
53
|
"""A section that is your own video clip, with its own audio.
|
|
54
54
|
|
|
55
55
|
A missing clip plays a titled slate for `slate_seconds`. With `strict` that is an error,
|
|
56
|
-
unless the section is `optional`, as the scaffold's B-roll slot is.
|
|
56
|
+
unless the section is `optional`, as the scaffold's B-roll slot is. `words` names a words
|
|
57
|
+
file of the speech inside the clip, in seconds after the clip starts, which the captions add.
|
|
57
58
|
"""
|
|
58
59
|
|
|
59
60
|
number: int
|
|
@@ -61,6 +62,7 @@ class ClipSection:
|
|
|
61
62
|
title: str = ""
|
|
62
63
|
slate_seconds: float = 5.0
|
|
63
64
|
optional: bool = False
|
|
65
|
+
words: str | None = None
|
|
64
66
|
|
|
65
67
|
@property
|
|
66
68
|
def key(self) -> str:
|
|
@@ -241,7 +243,7 @@ class _Table:
|
|
|
241
243
|
|
|
242
244
|
|
|
243
245
|
VOICE_KEYS = frozenset({"provider", "model", "stability", "similarity_boost", "style", "speaker_boost", "speed"})
|
|
244
|
-
CLIP_KEYS = frozenset({"number", "title", "clip", "slate_seconds", "optional"})
|
|
246
|
+
CLIP_KEYS = frozenset({"number", "title", "clip", "slate_seconds", "optional", "words"})
|
|
245
247
|
PAGE_KEYS = frozenset({"number", "title", "page", "scene", "extra_seconds", "hold_seconds", "ambience", "params"})
|
|
246
248
|
SOUND_KEYS = frozenset({"text", "out", "duration_seconds", "prompt_influence", "model_id"})
|
|
247
249
|
|
|
@@ -272,6 +274,7 @@ def _parse_section(raw: dict[str, Any], index: int) -> Section:
|
|
|
272
274
|
title=title,
|
|
273
275
|
slate_seconds=t.get_num("slate_seconds", 5.0),
|
|
274
276
|
optional=t.get_bool("optional"),
|
|
277
|
+
words=t.get_str("words"),
|
|
275
278
|
)
|
|
276
279
|
if "page" not in raw:
|
|
277
280
|
raise ConfigError(f"{where}: needs 'page' (an HTML file) or 'clip' (a video file)")
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
4. EBU R128 loudness: pass one measures integrated loudness and true peak, pass two
|
|
18
18
|
applies the gain that reaches the target and a true-peak limiter at the ceiling,
|
|
19
19
|
oversampled at 192 kHz. The result is measured again and reported.
|
|
20
|
-
5. Captions (srt and vtt) come from the word timestamps,
|
|
21
|
-
section titles are muxed into the
|
|
20
|
+
5. Captions (srt and vtt) come from the word timestamps, plus the words file of any clip
|
|
21
|
+
section that names one, and chapter markers from the section titles are muxed into the
|
|
22
|
+
mp4. Consecutive sections with the same title share one chapter.
|
|
22
23
|
6. Atomic publish: work file, then one rename to build/out/<name>.mp4, plus a
|
|
23
24
|
timestamped copy.
|
|
24
25
|
"""
|
|
@@ -688,6 +689,32 @@ def build_captions(
|
|
|
688
689
|
return cues
|
|
689
690
|
|
|
690
691
|
|
|
692
|
+
def clip_captions(project: Project, rows: list[RenderedSection]) -> list[CaptionCue]:
|
|
693
|
+
"""Cues for the speech inside each clip section that names a words file.
|
|
694
|
+
|
|
695
|
+
The words count from the clip's start and carry their own punctuation. A word that starts
|
|
696
|
+
after the clip's picture ends is dropped. A slate, or a clip with no audio, gets no captions.
|
|
697
|
+
"""
|
|
698
|
+
starts = section_starts(rows)
|
|
699
|
+
cues: list[CaptionCue] = []
|
|
700
|
+
for r in rows:
|
|
701
|
+
sec = r.section
|
|
702
|
+
if not isinstance(sec, ClipSection) or not sec.words or r.audio is None:
|
|
703
|
+
continue
|
|
704
|
+
path = project.path(sec.words)
|
|
705
|
+
if not path.exists():
|
|
706
|
+
log.warning("section %s: words file missing: %s; the clip plays without captions", sec.key, sec.words)
|
|
707
|
+
continue
|
|
708
|
+
shift = starts[sec.key]
|
|
709
|
+
words = [
|
|
710
|
+
Word(w.word, round(shift + w.start, 3), round(shift + min(w.end, r.duration), 3))
|
|
711
|
+
for w in read_words(path)
|
|
712
|
+
if w.start < r.duration
|
|
713
|
+
]
|
|
714
|
+
cues += caption_cues(words)
|
|
715
|
+
return cues
|
|
716
|
+
|
|
717
|
+
|
|
691
718
|
def caption_texts(project: Project, timeline: Timeline) -> dict[str, str]:
|
|
692
719
|
"""The spoken text per section key, which lends the captions their punctuation and case.
|
|
693
720
|
|
|
@@ -706,15 +733,17 @@ def caption_texts(project: Project, timeline: Timeline) -> dict[str, str]:
|
|
|
706
733
|
|
|
707
734
|
|
|
708
735
|
def build_chapters(rows: list[RenderedSection]) -> list[Chapter]:
|
|
736
|
+
"""One chapter per section, where consecutive sections with the same title share one chapter."""
|
|
709
737
|
starts = section_starts(rows)
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
738
|
+
chapters: list[Chapter] = []
|
|
739
|
+
for r in rows:
|
|
740
|
+
start = starts[r.section.key]
|
|
741
|
+
title = r.section.title or f"Section {r.section.number}"
|
|
742
|
+
if chapters and chapters[-1].title == title:
|
|
743
|
+
chapters[-1] = Chapter(start=chapters[-1].start, end=start + r.duration, title=title)
|
|
744
|
+
else:
|
|
745
|
+
chapters.append(Chapter(start=start, end=start + r.duration, title=title))
|
|
746
|
+
return chapters
|
|
718
747
|
|
|
719
748
|
|
|
720
749
|
def mux_chapters(src: Path, chapters: Path, dst: Path) -> None:
|
|
@@ -801,14 +830,16 @@ def assemble(project: Project, *, nomix: bool = False, loudnorm: bool = True, st
|
|
|
801
830
|
|
|
802
831
|
starts = section_starts(rows)
|
|
803
832
|
cues = build_captions(timeline, narration_offsets(rows, timeline, starts), caption_texts(project, timeline))
|
|
833
|
+
cues = sorted(cues + clip_captions(project, rows), key=lambda c: c.start)
|
|
834
|
+
chapters = build_chapters(rows)
|
|
804
835
|
write_srt(paths["srt"], cues)
|
|
805
836
|
write_vtt(paths["vtt"], cues)
|
|
806
|
-
write_chapters(paths["chapters"],
|
|
837
|
+
write_chapters(paths["chapters"], chapters)
|
|
807
838
|
log.info("[caps] %d cue(s) -> %s, %s", len(cues), paths["srt"].name, paths["vtt"].name)
|
|
808
839
|
chaptered = out_dir / f".{project.name}.chapters.mp4"
|
|
809
840
|
mux_chapters(work, paths["chapters"], chaptered)
|
|
810
841
|
chaptered.replace(work)
|
|
811
|
-
log.info("[chap] %d chapter(s) -> %s", len(
|
|
842
|
+
log.info("[chap] %d chapter(s) -> %s", len(chapters), paths["chapters"].name)
|
|
812
843
|
|
|
813
844
|
if not work.exists() or work.stat().st_size == 0:
|
|
814
845
|
raise ToolError("render produced no output")
|
|
@@ -9,6 +9,8 @@ project maps to a clip are skipped.
|
|
|
9
9
|
Each section is synthesized with word timestamps, cached by a hash of model, voice,
|
|
10
10
|
settings and text, padded so speech ends at least min_tail_seconds before the file
|
|
11
11
|
ends, then every section is concatenated with no gaps into build/audio/narration.mp3.
|
|
12
|
+
A renumbered section keeps its take: when its key misses, an entry of the previous
|
|
13
|
+
manifest with the same hash lends its files, copied to the new name.
|
|
12
14
|
build/audio/timeline.json records each section's absolute start and end and every
|
|
13
15
|
word at absolute time; the recorder and the assembler cut the visuals to it.
|
|
14
16
|
|
|
@@ -23,7 +25,8 @@ import hashlib
|
|
|
23
25
|
import json
|
|
24
26
|
import logging
|
|
25
27
|
import re
|
|
26
|
-
|
|
28
|
+
import shutil
|
|
29
|
+
from dataclasses import dataclass, replace
|
|
27
30
|
from pathlib import Path
|
|
28
31
|
from typing import Any
|
|
29
32
|
|
|
@@ -251,6 +254,54 @@ def text_hash(segment: Segment, cfg: NarrationConfig, provider_key: str, setting
|
|
|
251
254
|
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
|
252
255
|
|
|
253
256
|
|
|
257
|
+
def is_cached(entry: ManifestSegment | None, seg: Segment, digest: str, audio_dir: Path) -> bool:
|
|
258
|
+
"""True when the entry holds this section's take under its current file names."""
|
|
259
|
+
return (
|
|
260
|
+
entry is not None
|
|
261
|
+
and entry.hash == digest
|
|
262
|
+
and entry.file == seg.filename
|
|
263
|
+
and (audio_dir / seg.filename).exists()
|
|
264
|
+
and (audio_dir / seg.words_filename).exists()
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def reusable_entry(
|
|
269
|
+
previous: Manifest | None, seg: Segment, digest: str, audio_dir: Path
|
|
270
|
+
) -> tuple[str, ManifestSegment] | None:
|
|
271
|
+
"""(key, entry) of a previous take of the same text under another section number, or None.
|
|
272
|
+
|
|
273
|
+
A section that was renumbered keeps its hash, because the hash has no section number in
|
|
274
|
+
it. Only the first spoken section carries the lead-in silence, so a take never moves into
|
|
275
|
+
or out of that place.
|
|
276
|
+
"""
|
|
277
|
+
if previous is None or seg.lead_break or not previous.segments:
|
|
278
|
+
return None
|
|
279
|
+
first = min(previous.segments)
|
|
280
|
+
for key, entry in previous.segments.items():
|
|
281
|
+
if (
|
|
282
|
+
key != first
|
|
283
|
+
and key != seg.key
|
|
284
|
+
and entry.hash == digest
|
|
285
|
+
and (audio_dir / entry.file).exists()
|
|
286
|
+
and (audio_dir / entry.words_file).exists()
|
|
287
|
+
):
|
|
288
|
+
return key, entry
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def reuse_takes(moves: list[tuple[ManifestSegment, Segment]], audio_dir: Path) -> None:
|
|
293
|
+
"""Copy each take to its new file names. Every source is copied aside first, so a move never
|
|
294
|
+
overwrites a file that another move still reads."""
|
|
295
|
+
staged: list[tuple[Path, Path]] = []
|
|
296
|
+
for i, (entry, seg) in enumerate(moves):
|
|
297
|
+
for j, (src, dst) in enumerate(((entry.file, seg.filename), (entry.words_file, seg.words_filename))):
|
|
298
|
+
tmp = audio_dir / f".reuse-{i}-{j}.tmp"
|
|
299
|
+
shutil.copyfile(audio_dir / src, tmp)
|
|
300
|
+
staged.append((tmp, audio_dir / dst))
|
|
301
|
+
for tmp, dst in staged:
|
|
302
|
+
tmp.replace(dst)
|
|
303
|
+
|
|
304
|
+
|
|
254
305
|
def build_timeline(project: Project, manifest: Manifest, order: list[Segment]) -> Timeline:
|
|
255
306
|
cfg = project.settings.narration
|
|
256
307
|
keys = [s.key for s in order if s.key in manifest.segments]
|
|
@@ -332,6 +383,26 @@ def narrate(
|
|
|
332
383
|
if unfilled and not allow_placeholders:
|
|
333
384
|
raise ConfigError(f"unfilled placeholders {unfilled} in the script; fill them or pass allow_placeholders")
|
|
334
385
|
provider = get_provider(project)
|
|
386
|
+
# A renumbered section keeps its take. Its files are copied to the new names before any
|
|
387
|
+
# section is voiced, so a new take never replaces a file that a move still needs.
|
|
388
|
+
moves: list[tuple[ManifestSegment, Segment]] = []
|
|
389
|
+
for seg in targets:
|
|
390
|
+
request = SpeechRequest(
|
|
391
|
+
text=seg.tts_text(cfg), model=model, voice_settings=voice_settings, output_format=cfg.output_format
|
|
392
|
+
)
|
|
393
|
+
digest = text_hash(seg, cfg, provider.cache_key(request), voice_settings)
|
|
394
|
+
if force or is_cached(manifest.segments.get(seg.key), seg, digest, project.audio_dir):
|
|
395
|
+
continue
|
|
396
|
+
found = reusable_entry(previous, seg, digest, project.audio_dir)
|
|
397
|
+
if found is not None:
|
|
398
|
+
key, entry = found
|
|
399
|
+
moves.append((entry, seg))
|
|
400
|
+
log.info("[move] %s from %s, the same text under section %s", seg.filename, entry.file, int(key))
|
|
401
|
+
reuse_takes(moves, project.audio_dir)
|
|
402
|
+
for entry, seg in moves:
|
|
403
|
+
manifest.segments[seg.key] = replace(
|
|
404
|
+
entry, index=seg.index, title=seg.title, file=seg.filename, words_file=seg.words_filename
|
|
405
|
+
)
|
|
335
406
|
|
|
336
407
|
by_index = {s.index: s for s in all_segments}
|
|
337
408
|
order = [s.index for s in all_segments]
|
|
@@ -377,14 +448,7 @@ def narrate(
|
|
|
377
448
|
)
|
|
378
449
|
digest = text_hash(seg, cfg, provider.cache_key(request), voice_settings)
|
|
379
450
|
entry = manifest.segments.get(seg.key)
|
|
380
|
-
if (
|
|
381
|
-
not force
|
|
382
|
-
and entry is not None
|
|
383
|
-
and entry.hash == digest
|
|
384
|
-
and entry.file == seg.filename
|
|
385
|
-
and out_path.exists()
|
|
386
|
-
and words_path.exists()
|
|
387
|
-
):
|
|
451
|
+
if not force and entry is not None and is_cached(entry, seg, digest, project.audio_dir):
|
|
388
452
|
# min_tail_seconds is not part of the hash, so a cached take made under a shorter
|
|
389
453
|
# tail is padded here. ensure_tail measures the silence first, so a take that
|
|
390
454
|
# already has enough is left untouched.
|