diff --git a/backend/app/video/options.py b/backend/app/video/options.py
index 9a0877b..4e25228 100644
--- a/backend/app/video/options.py
+++ b/backend/app/video/options.py
@@ -397,6 +397,9 @@ class RenderOptions(BaseModel):
title_card: bool = True
chapter_screens: bool = False
+ # Speak each heading aloud. Off, headings are still shown (chapter screens,
+ # the on-video chapter name) and marked, just not narrated.
+ read_chapters: bool = True
embed_chapters: bool = True
thumbnail: bool = True
subtitles: SubtitleMode = "sidecar"
diff --git a/backend/app/video/segmenter.py b/backend/app/video/segmenter.py
index 09ec4f0..de8a715 100644
--- a/backend/app/video/segmenter.py
+++ b/backend/app/video/segmenter.py
@@ -368,9 +368,17 @@ def flush(next_shot: Optional[Shot]) -> None:
result.shots.append(pending_card)
pending_card = None
+ # A heading with nothing spoken under it (read_chapters off) has no shot
+ # to hang its mark on; hand it to the shot that starts here instead.
+ carried_chapter = None
+ if (pending_chapter and open_shot is None
+ and next_shot is not None and next_shot.kind != "card"):
+ carried_chapter = pending_chapter
pending_text = []
pending_chapter = None
open_shot = next_shot
+ if carried_chapter and open_shot is not None and not open_shot.chapter:
+ open_shot.chapter = carried_chapter
for block in _flatten(blocks):
kind = _classify(block, options)
@@ -408,7 +416,8 @@ def flush(next_shot: Optional[Shot]) -> None:
# the words were no longer on screen.
pending_card = Shot(
kind="card", card_title=heading, chapter=heading,
- card_kind="chapter", narration=_as_sentence(heading),
+ card_kind="chapter",
+ narration=_as_sentence(heading) if options.read_chapters else "",
label="chapter card",
)
# The card carries the chapter mark, so the section after
@@ -484,6 +493,10 @@ def flush(next_shot: Optional[Shot]) -> None:
label="after code"))
continue
+ if btype == "heading" and not options.read_chapters:
+ # The chapter mark was taken above; the words are just not read.
+ continue
+
text = _block_narration(block, options)
if text.strip():
spoken = _as_sentence(text)
diff --git a/backend/tests/test_video_segmenter.py b/backend/tests/test_video_segmenter.py
index 167dcb7..7ef071b 100644
--- a/backend/tests/test_video_segmenter.py
+++ b/backend/tests/test_video_segmenter.py
@@ -140,6 +140,34 @@ def test_headings_become_chapter_marks_and_are_still_narrated():
assert plan.shots[0].narration == "Chapter One."
+def test_read_chapters_off_keeps_the_mark_but_drops_the_narration():
+ root = _media("a.png")
+ plan = _run([
+ {"id": "1", "type": "heading", "props": {"level": 1}, "content": _text("Chapter One")},
+ {"id": "2", "type": "image", "props": {"url": "/media/u1/a.png"}},
+ {"id": "3", "type": "paragraph", "content": _text("Body.")},
+ ], media_root=root, options=RenderOptions(title_card=False, read_chapters=False))
+
+ assert plan.shots[0].chapter == "Chapter One"
+ assert "Chapter One" not in plan.shots[0].narration
+
+
+def test_read_chapters_off_leaves_chapter_screens_silent_but_present():
+ root = _media("a.png")
+ plan = _run([
+ {"id": "1", "type": "image", "props": {"url": "/media/u1/a.png"}},
+ {"id": "2", "type": "paragraph", "content": _text("First.")},
+ {"id": "3", "type": "heading", "props": {"level": 2}, "content": _text("Next Up")},
+ {"id": "4", "type": "paragraph", "content": _text("Second.")},
+ ], media_root=root,
+ options=RenderOptions(title_card=False, chapter_screens=True, read_chapters=False))
+
+ cards = [s for s in plan.shots if s.kind == "card"]
+ assert [c.card_title for c in cards] == ["Next Up"]
+ assert cards[0].narration == ""
+ assert cards[0].chapter == "Next Up"
+
+
def _with_chapter_screens():
return RenderOptions(title_card=False, chapter_screens=True)
diff --git a/frontend/src/api/videoGen.ts b/frontend/src/api/videoGen.ts
index 4eaf8fb..1c145d5 100644
--- a/frontend/src/api/videoGen.ts
+++ b/frontend/src/api/videoGen.ts
@@ -137,6 +137,7 @@ export interface RenderOptions {
insert_into_note: boolean
title_card: boolean
chapter_screens: boolean
+ read_chapters: boolean
embed_chapters: boolean
thumbnail: boolean
subtitles: SubtitleMode
@@ -178,6 +179,7 @@ export const DEFAULT_RENDER_OPTIONS: RenderOptions = {
insert_into_note: true,
title_card: true,
chapter_screens: false,
+ read_chapters: true,
embed_chapters: true,
thumbnail: true,
subtitles: 'sidecar',
diff --git a/frontend/src/components/VideoGenModal.tsx b/frontend/src/components/VideoGenModal.tsx
index c9c280a..07f8c19 100644
--- a/frontend/src/components/VideoGenModal.tsx
+++ b/frontend/src/components/VideoGenModal.tsx
@@ -147,6 +147,7 @@ export default function VideoGenModal({ noteId, noteTitle, diagramImages, onGene
return () => { cancelled = true; clearTimeout(timer) }
}, [
noteId, payload.speed, payload.title_card, payload.chapter_screens,
+ payload.read_chapters,
payload.narrate_code, payload.min_shot_seconds, payload.card_seconds,
payload.quotes.enabled,
payload.shot_end_pause_ms,
@@ -677,6 +678,11 @@ export default function VideoGenModal({ noteId, noteTitle, diagramImages, onGene
onChange={(e) => patch({ chapter_screens: e.target.checked })} />
Chapter screens
+