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 {