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
6 changes: 6 additions & 0 deletions backend/app/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,12 @@ def _run_migrations():
conn.commit()
except Exception:
pass
# Video render: selected content (render only selection instead of full note)
try:
conn.execute(text("ALTER TABLE video_render_job ADD COLUMN selected_content TEXT"))
conn.commit()
except Exception:
pass


def _seed_after_migrations():
Expand Down
1 change: 1 addition & 0 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ class VideoRenderJob(SQLModel, table=True):
detail: str = Field(default="") # e.g. "shot 7 of 19"
options: str = Field(default="{}") # RenderOptions, JSON as text
quality: str = Field(default="full") # "preview" | "full"
selected_content: Optional[str] = None # If set, render only this BlockNote content; otherwise render full note
note_title: str = Field(default="") # snapshot, for download filenames
result_filename: Optional[str] = None # rendered .mp4
subtitle_filename: Optional[str] = None # .srt sidecar
Expand Down
3 changes: 2 additions & 1 deletion backend/app/routers/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def create_job(payload: VideoRenderRequest, request: Request, session: Session =
stage="Queued",
options=json.dumps(options.model_dump()),
quality="preview" if payload.quality == "preview" else "full",
selected_content=payload.selected_content,
created_at=now,
updated_at=now,
)
Expand Down Expand Up @@ -201,7 +202,7 @@ def estimate_render(payload: VideoRenderRequest, request: Request, session: Sess
shots, chars, seconds, warnings = estimate(
user_id=user_id,
media_dir=MEDIA_DIR,
note_content=note.content or "[]",
note_content=payload.selected_content or note.content or "[]",
note_title=note.title or "Untitled",
author=(user.username if user else "") or "",
options=options,
Expand Down
1 change: 1 addition & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ class VideoRenderRequest(BaseModel):
# option schema lives in one place next to the renderer that consumes it.
options: Dict[str, Any] = {}
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


class VideoEstimateRead(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion backend/app/video/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ def raise_cancelled() -> None:
job_id=job_id,
user_id=job.user_id,
media_dir=_media_dir(),
note_content=note.content or "[]",
note_content=job.selected_content or note.content or "[]",
note_title=note.title or "Untitled",
author=author,
options=options,
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/api/videoGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,10 @@ export const DEFAULT_RENDER_OPTIONS: RenderOptions = {

export const videoGenApi = {
/** Queue a render. `preview` renders small and fast; because narration is
* cached by voice+text, a full render afterwards reuses it at no extra cost. */
createJob(noteId: string, options: RenderOptions, quality: 'preview' | 'full' = 'full'): Promise<{ data: VideoRenderJob }> {
return client.post('/video/jobs', { note_id: noteId, options, quality }).then((r) => r.data)
* cached by voice+text, a full render afterwards reuses it at no extra cost.
* `selectedContent` (if provided) renders only that portion instead of the full note. */
createJob(noteId: string, options: RenderOptions, quality: 'preview' | 'full' = 'full', selectedContent?: string): Promise<{ data: VideoRenderJob }> {
return client.post('/video/jobs', { note_id: noteId, options, quality, selected_content: selectedContent }).then((r) => r.data)
},

// Reading and cancelling a render go through `api/activity` now, which covers
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/components/VideoGenModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ interface Props {
noteTitle: string
/** blockId -> /media URL for rasterised diagram blocks, gathered by the caller. */
diagramImages: Record<string, string>
onGenerate: (options: RenderOptions, quality: 'preview' | 'full') => Promise<void>
/** Selected text to render (if any); if undefined, full note is rendered. */
selectedContent?: string
onGenerate: (options: RenderOptions, quality: 'preview' | 'full', selected?: string) => Promise<void>
onClose: () => void
}

Expand Down Expand Up @@ -186,7 +188,7 @@ function formatDuration(seconds: number): string {
return m > 0 ? `${m}m ${s}s` : `${s}s`
}

export default function VideoGenModal({ noteId, noteTitle, diagramImages, onGenerate, onClose }: Props) {
export default function VideoGenModal({ noteId, noteTitle, diagramImages, selectedContent, onGenerate, onClose }: Props) {
const [options, setOptions] = useState<RenderOptions>(loadStoredOptions)
const [tab, setTab] = useState<TabId>('format')
const [estimate, setEstimate] = useState<VideoEstimate | null>(null)
Expand Down Expand Up @@ -302,7 +304,7 @@ export default function VideoGenModal({ noteId, noteTitle, diagramImages, onGene
setBusy(quality)
setError(null)
try {
await onGenerate(payload, quality)
await onGenerate(payload, quality, selectedContent)
onClose()
} catch (e) {
setError(apiErrorMessage(e, 'Could not start the render'))
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/stores/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface ActivityState {
noteId: string,
options: RenderOptions,
quality?: 'preview' | 'full',
selectedContent?: string,
) => Promise<void>
reset: () => void
}
Expand Down Expand Up @@ -154,8 +155,8 @@ export const useActivityStore = create<ActivityState>((set, get) => {
}
},

async startVideo(noteId, options, quality = 'full') {
await videoGenApi.createJob(noteId, options, quality)
async startVideo(noteId, options, quality = 'full', selectedContent?: string) {
await videoGenApi.createJob(noteId, options, quality, selectedContent)
// Read it straight back through the activity view rather than mapping the
// richer render shape by hand, so there is one place that knows the wire form.
await get().resume()
Expand Down
17 changes: 14 additions & 3 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 } from '@/utils/blocks'
import { extractPlainText, getEditorSelectionMarkdown } from '@/utils/blocks'
import { noteToMarkdownBody, svgToPngData } from '@/utils/export'
import { ARCHIVE_SYSTEM_KEY } from '@/utils/folderTree'

Expand Down Expand Up @@ -166,6 +166,7 @@ export default function EditorView() {
const [showImageGen, setShowImageGen] = useState(false)
const [showVideoGen, setShowVideoGen] = useState(false)
const [diagramImages, setDiagramImages] = useState<Record<string, string>>({})
const [selectedContent, setSelectedContent] = useState<string | undefined>(undefined)
const [showHistory, setShowHistory] = useState(false)
const [showStats, setShowStats] = useState(false)
const [findOpen, setFindOpen] = useState(false)
Expand Down Expand Up @@ -957,9 +958,18 @@ 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)

// 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 } }[]
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 All @@ -977,10 +987,10 @@ export default function EditorView() {
setShowVideoGen(true)
}

async function runVideoGen(options: RenderOptions, quality: 'preview' | 'full') {
async function runVideoGen(options: RenderOptions, quality: 'preview' | 'full', selected?: string) {
const id = createdNoteId.current || latestNoteId.current
if (!id) throw new Error('Save the note first')
await startVideoJob(id, options, quality)
await startVideoJob(id, options, quality, selected)
showToast(quality === 'preview' ? 'Rendering a preview…' : 'Rendering your video…')
}

Expand Down Expand Up @@ -2086,6 +2096,7 @@ export default function EditorView() {
noteId={createdNoteId.current || latestNoteId.current || ''}
noteTitle={title || 'Untitled'}
diagramImages={diagramImages}
selectedContent={selectedContent}
onGenerate={runVideoGen}
onClose={() => setShowVideoGen(false)}
/>
Expand Down
Loading