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