From ab88e4b881df874c2602972c0cb131811e173243 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 01:38:31 +0000 Subject: [PATCH] Make the video dialog and render honour a text selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting part of a note before opening the video export was meant to render only that part, but two things kept it from working: - The dialog's estimate request never sent the selection, so the footer ("N segments · N characters to narrate · about Xm") always described the whole note. - The editor sent the selection as Markdown, but the segmenter parses BlockNote JSON and silently treats anything else as an empty note. A selected render therefore had nothing in it beyond the title card and bumpers, or failed with "no text or media". The editor now sends the selected blocks as BlockNote JSON (the same form as the stored note), the estimate passes them through, and the footer says "Selection only" when one is in effect. Diagram rasterising walks the selected blocks directly. The API refuses a selection that is not a JSON block array rather than rendering it as empty, and an empty one means the whole note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiJqUAvLhqbSCdkPRDbh7J --- backend/app/schemas.py | 16 +++++ backend/tests/test_video_selection.py | 85 +++++++++++++++++++++++ frontend/src/api/videoGen.ts | 7 +- frontend/src/components/VideoGenModal.tsx | 5 +- frontend/src/views/EditorView.tsx | 17 ++--- 5 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 backend/tests/test_video_selection.py diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 51990e2..9df37bf 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -714,6 +714,22 @@ class VideoRenderRequest(BaseModel): quality: str = "full" # "preview" renders small and fast, reusing cached narration selected_content: Optional[str] = None # If provided, render only this content instead of full note + @field_validator("selected_content") + @classmethod + def _validate_selected_content(cls, v: Optional[str]) -> Optional[str]: + # The segmenter reads BlockNote JSON and quietly treats anything else as an + # empty note, so Markdown here once produced a video with nothing in it. + # Refuse it up front instead. An empty selection means the whole note. + if v is None or not v.strip(): + return None + try: + blocks = json.loads(v) + except ValueError: + raise ValueError("selected_content must be a JSON array of note blocks") + if not isinstance(blocks, list): + raise ValueError("selected_content must be a JSON array of note blocks") + return v if blocks else None + class VideoEstimateRead(BaseModel): shots: int diff --git a/backend/tests/test_video_selection.py b/backend/tests/test_video_selection.py new file mode 100644 index 0000000..0daca00 --- /dev/null +++ b/backend/tests/test_video_selection.py @@ -0,0 +1,85 @@ +"""Rendering a selection of a note instead of the whole of it. + +The editor sends the selected blocks as BlockNote JSON. Both halves have to see +the same thing: the estimate the dialog shows and the render it starts. An +earlier version sent Markdown, which the segmenter can't read — the estimate +quietly described the whole note and the render came out empty. +""" + +import json +from datetime import datetime +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError +from sqlmodel import Session, SQLModel, create_engine + +from app.models import Note +from app.routers import video +from app.schemas import VideoRenderRequest + +USER = "user-1" + + +def _block(block_id, kind, text, **props): + return { + "id": block_id, "type": kind, "props": props, + "content": [{"type": "text", "text": text, "styles": {}}], "children": [], + } + + +# Three sections; the selection is the middle one. +NOTE_BLOCKS = [ + block + for n in range(3) + for block in ( + _block(f"h{n}", "heading", f"Section {n}", level=2), + _block(f"p{n}a", "paragraph", f"The first paragraph of section {n}."), + _block(f"p{n}b", "paragraph", f"The second paragraph of section {n}."), + ) +] +SELECTION = NOTE_BLOCKS[3:6] # the middle section + + +@pytest.fixture +def session(): + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}) + SQLModel.metadata.create_all(engine) + with Session(engine) as s: + now = datetime.utcnow() + for note_id, blocks in (("whole", NOTE_BLOCKS), ("only-selection", SELECTION)): + s.add(Note( + id=note_id, title="A note", content=json.dumps(blocks), + category_id="cat-1", user_id=USER, created_at=now, modified_at=now, + )) + s.commit() + yield s + + +def _estimate(session, note_id="whole", **fields): + payload = VideoRenderRequest(note_id=note_id, options={"title_card": False}, **fields) + request = SimpleNamespace(state=SimpleNamespace(user_id=USER)) + return video.estimate_render(payload, request, session).data + + +def test_a_selection_estimates_as_if_the_note_held_only_those_blocks(session): + selected = _estimate(session, selected_content=json.dumps(SELECTION)) + assert selected == _estimate(session, note_id="only-selection") + + +def test_a_selection_is_smaller_than_the_whole_note(session): + whole = _estimate(session) + selected = _estimate(session, selected_content=json.dumps(SELECTION)) + assert 0 < selected.narration_chars < whole.narration_chars + assert selected.estimated_seconds < whole.estimated_seconds + + +@pytest.mark.parametrize("value", [None, "", " ", "[]"]) +def test_an_empty_selection_means_the_whole_note(value): + assert VideoRenderRequest(note_id="n", selected_content=value).selected_content is None + + +@pytest.mark.parametrize("value", ["# A heading\n\nSome **markdown**", '{"type": "paragraph"}']) +def test_a_selection_that_is_not_a_block_list_is_refused(value): + with pytest.raises(ValidationError): + VideoRenderRequest(note_id="n", selected_content=value) diff --git a/frontend/src/api/videoGen.ts b/frontend/src/api/videoGen.ts index d7d1a5e..7095494 100644 --- a/frontend/src/api/videoGen.ts +++ b/frontend/src/api/videoGen.ts @@ -221,8 +221,9 @@ export const videoGenApi = { // this module is just no longer the client for that half. /** Segment the note without rendering, so the dialog can show how long the - * video will be and how much narration it needs before anything is paid for. */ - estimate(noteId: string, options: RenderOptions): Promise<{ data: VideoEstimate }> { - return client.post('/video/estimate', { note_id: noteId, options }).then((r) => r.data) + * video will be and how much narration it needs before anything is paid for. + * Pass the same `selectedContent` as `createJob` so the numbers describe the selection. */ + estimate(noteId: string, options: RenderOptions, selectedContent?: string): Promise<{ data: VideoEstimate }> { + return client.post('/video/estimate', { note_id: noteId, options, selected_content: selectedContent }).then((r) => r.data) }, } diff --git a/frontend/src/components/VideoGenModal.tsx b/frontend/src/components/VideoGenModal.tsx index 2435603..2a36608 100644 --- a/frontend/src/components/VideoGenModal.tsx +++ b/frontend/src/components/VideoGenModal.tsx @@ -252,13 +252,13 @@ export default function VideoGenModal({ noteId, noteTitle, diagramImages, select useEffect(() => { let cancelled = false const timer = setTimeout(() => { - videoGenApi.estimate(noteId, payload) + videoGenApi.estimate(noteId, payload, selectedContent) .then((r) => { if (!cancelled) setEstimate(r.data) }) .catch(() => { if (!cancelled) setEstimate(null) }) }, 250) return () => { cancelled = true; clearTimeout(timer) } }, [ - noteId, payload.speed, payload.title_card, payload.chapter_screens, + noteId, selectedContent, payload.speed, payload.title_card, payload.chapter_screens, payload.read_chapters, payload.narrate_code, payload.min_shot_seconds, payload.card_seconds, payload.quotes.enabled, @@ -996,6 +996,7 @@ export default function VideoGenModal({ noteId, noteTitle, diagramImages, select
{estimate ? ( <> + {selectedContent && 'Selection only · '} {estimate.shots} segment{estimate.shots === 1 ? '' : 's'} ·{' '} {estimate.narration_chars.toLocaleString()} characters to narrate · about{' '} {formatDuration(estimate.estimated_seconds)} diff --git a/frontend/src/views/EditorView.tsx b/frontend/src/views/EditorView.tsx index 47d6a93..7ecf02c 100644 --- a/frontend/src/views/EditorView.tsx +++ b/frontend/src/views/EditorView.tsx @@ -52,7 +52,7 @@ import { annotationsApi, type Annotation } from '@/api/annotations' import { useDictation } from '@/hooks/useDictation' import { insertDictationAtSelection, normalizeDictatedText } from '@/utils/dictationInsert' import { useTextToSpeech } from '@/hooks/useTextToSpeech' -import { extractPlainText, getEditorSelectionMarkdown } from '@/utils/blocks' +import { extractPlainText } from '@/utils/blocks' import { noteToMarkdownBody, svgToPngData } from '@/utils/export' import { ARCHIVE_SYSTEM_KEY } from '@/utils/folderTree' @@ -958,18 +958,15 @@ export default function EditorView() { if (!id) return if (hasPendingChanges.current) await doSave(true) - // Detect selected content and get selected block IDs - const selected = getEditorSelectionMarkdown(editor) - const selectedBlockIds = new Set(editor?.getSelection?.()?.blocks?.map((b) => b.id) ?? []) - setSelectedContent(selected || undefined) + // A selection renders only the blocks it spans. They go to the server as + // BlockNote JSON, the same form as the stored note, because that is what the + // segmenter parses — Markdown here segmented to nothing. + const selectedBlocks = editor?.getSelection?.()?.blocks ?? [] + setSelectedContent(selectedBlocks.length ? JSON.stringify(selectedBlocks) : undefined) - // Rasterize only diagrams within selected blocks (if selection exists) or all diagrams (if no selection) const rasterised: Record = {} - const blocks = (editor?.document ?? []) as { id?: string; type?: string; props?: { source?: string } }[] + const blocks = (selectedBlocks.length ? selectedBlocks : editor?.document ?? []) as { id?: string; type?: string; props?: { source?: string } }[] for (const block of blocks) { - // Skip if selection exists and this block is not in the selection - if (selected && !selectedBlockIds.has(block.id ?? '')) continue - const source = block.type === 'diagram' ? block.props?.source : undefined if (!block.id || !source) continue try {