Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions backend/tests/test_video_selection.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 4 additions & 3 deletions frontend/src/api/videoGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}
5 changes: 3 additions & 2 deletions frontend/src/components/VideoGenModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -996,6 +996,7 @@ export default function VideoGenModal({ noteId, noteTitle, diagramImages, select
<div className="flex-1 text-xs text-gray-500 dark:text-gray-400">
{estimate ? (
<>
{selectedContent && 'Selection only · '}
{estimate.shots} segment{estimate.shots === 1 ? '' : 's'} ·{' '}
{estimate.narration_chars.toLocaleString()} characters to narrate · about{' '}
{formatDuration(estimate.estimated_seconds)}
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/views/EditorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string, string> = {}
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 {
Expand Down
Loading