From 9fe38b0c3fc33b3076df6b38546a2d7ab6da9eb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 23:38:29 +0000 Subject: [PATCH] Add 'Read chapters aloud' option to video export When off, headings are not narrated. Chapter screens, chapter names on the video and chapter markers are unaffected; a chapter screen becomes a silent card held for card_seconds. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UxooCFzFEvCi6Qo2Ee9H8J --- backend/app/video/options.py | 3 +++ backend/app/video/segmenter.py | 15 +++++++++++- backend/tests/test_video_segmenter.py | 28 +++++++++++++++++++++++ frontend/src/api/videoGen.ts | 2 ++ frontend/src/components/VideoGenModal.tsx | 6 +++++ 5 files changed, 53 insertions(+), 1 deletion(-) 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 +