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.
Files changed (62) hide show
  1. {decktalk-0.2.0 → decktalk-0.2.1}/PKG-INFO +6 -6
  2. {decktalk-0.2.0 → decktalk-0.2.1}/README.md +5 -5
  3. {decktalk-0.2.0 → decktalk-0.2.1}/pyproject.toml +2 -2
  4. {decktalk-0.2.0 → decktalk-0.2.1}/pyproject.toml.orig +2 -2
  5. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/artifacts.py +135 -76
  6. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/project.py +5 -2
  7. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/assemble.py +43 -12
  8. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/narrate.py +73 -9
  9. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/cues.json +41 -14
  10. decktalk-0.2.1/src/decktalk/template/deck/edit/after-first.webp +0 -0
  11. decktalk-0.2.1/src/decktalk/template/deck/edit/after-last-864.webp +0 -0
  12. decktalk-0.2.1/src/decktalk/template/deck/edit/after-last.webp +0 -0
  13. decktalk-0.2.1/src/decktalk/template/deck/edit/before-first.webp +0 -0
  14. decktalk-0.2.1/src/decktalk/template/deck/edit/before-last-864.webp +0 -0
  15. decktalk-0.2.1/src/decktalk/template/deck/edit/before-last.webp +0 -0
  16. decktalk-0.2.1/src/decktalk/template/deck/edit/s3-last.webp +0 -0
  17. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/index.html +349 -183
  18. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/lesson.html +213 -118
  19. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/decktalk.toml +40 -1
  20. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/script.md +34 -12
  21. {decktalk-0.2.0 → decktalk-0.2.1}/LICENSE +0 -0
  22. {decktalk-0.2.0 → decktalk-0.2.1}/NOTICE +0 -0
  23. {decktalk-0.2.0 → decktalk-0.2.1}/THIRD_PARTY_NOTICES.md +0 -0
  24. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/__init__.py +0 -0
  25. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/__main__.py +0 -0
  26. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/_env.py +0 -0
  27. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/_report.py +0 -0
  28. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/cli.py +0 -0
  29. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/config.py +0 -0
  30. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/errors.py +0 -0
  31. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/media/__init__.py +0 -0
  32. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/media/browser.py +0 -0
  33. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/media/ffmpeg.py +0 -0
  34. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/__init__.py +0 -0
  35. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/_http.py +0 -0
  36. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/elevenlabs.py +0 -0
  37. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/providers/speech.py +0 -0
  38. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/py.typed +0 -0
  39. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/runtime/decktalk-runtime.js +0 -0
  40. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/scaffold.py +0 -0
  41. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/__init__.py +0 -0
  42. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/beats.py +0 -0
  43. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/build.py +0 -0
  44. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/measure.py +0 -0
  45. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/record.py +0 -0
  46. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/shots.py +0 -0
  47. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/soundscape.py +0 -0
  48. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/stages/verify.py +0 -0
  49. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/status.py +0 -0
  50. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/Inter-OFL.txt +0 -0
  51. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/Inter-greek.woff2 +0 -0
  52. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/Inter-latin.woff2 +0 -0
  53. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/InterTight-OFL.txt +0 -0
  54. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/InterTight-greek.woff2 +0 -0
  55. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/InterTight-latin.woff2 +0 -0
  56. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/JetBrainsMono-OFL.txt +0 -0
  57. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/JetBrainsMono-greek.woff2 +0 -0
  58. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/deck/fonts/JetBrainsMono-latin.woff2 +0 -0
  59. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/env.example +0 -0
  60. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/gitignore +0 -0
  61. {decktalk-0.2.0 → decktalk-0.2.1}/src/decktalk/template/media/markers.json +0 -0
  62. {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.0
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 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
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
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 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
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
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.0"
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.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.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.12.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
- 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.
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
- _SENTENCE_END = (".", "?", "!")
282
- _CLAUSE_END = (",", ";", ":", "—", "-")
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 _chars(words: list[Word]) -> int:
297
- return sum(len(w.word) for w in words) + max(len(words) - 1, 0)
298
+ def _ends_sentence(token: str) -> bool:
299
+ return bool(_SENTENCE_END_RE.search(token))
298
300
 
299
301
 
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
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
- max_lines: int = CAPTION_MAX_LINES,
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
- 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.
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 > max_gap:
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
- 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
- )
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, and chapter markers from the
21
- section titles are muxed into the mp4.
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
- return [
711
- Chapter(
712
- start=starts[r.section.key],
713
- end=starts[r.section.key] + r.duration,
714
- title=r.section.title or f"Section {r.section.number}",
715
- )
716
- for r in rows
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"], build_chapters(rows))
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(rows), paths["chapters"].name)
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
- from dataclasses import dataclass
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.