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
30 changes: 30 additions & 0 deletions backend/app/routers/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
from app.routers.media import MEDIA_DIR
from app.schemas import (
DataResponse, ListResponse, VideoEstimateRead, VideoRenderJobRead, VideoRenderRequest,
VideoSpecRead,
)
from app.video import ffmpeg as F
from app.video import worker
from app.video.importer import import_note
from app.video.options import RenderOptions
from app.video.renderer import estimate

Expand Down Expand Up @@ -211,3 +213,31 @@ def estimate_render(payload: VideoRenderRequest, request: Request, session: Sess
shots=shots, narration_chars=chars,
estimated_seconds=round(seconds, 1), warnings=warnings,
))


@router.post("/spec", response_model=DataResponse[VideoSpecRead])
def video_spec(payload: VideoRenderRequest, request: Request, session: Session = Depends(get_session)):
"""The VideoSpec a render of this note would be built from, as a document.

Takes the same body as /estimate, so a selection and the dialog's options
shape it exactly as they would the render. Nothing is rendered or stored.
"""
user_id = _get_user_id(request)
note = _owned_note(session, payload.note_id, user_id)
options = _parse_options(payload.options)
user = session.get(User, user_id)

imported = import_note(
payload.selected_content or note.content or "[]",
user_id=user_id,
media_dir=MEDIA_DIR,
options=options,
note_title=note.title or "Untitled",
author=(user.username if user else "") or "",
has_audio=F.probe_has_audio,
note_id=note.id,
)
return DataResponse(data=VideoSpecRead(
spec=imported.spec.model_dump(mode="json", by_alias=True, exclude_none=True),
warnings=imported.warnings,
))
7 changes: 7 additions & 0 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,13 @@ class VideoEstimateRead(BaseModel):
warnings: List[str] = []


class VideoSpecRead(BaseModel):
# The VideoSpec as JSON, under its own field names ("from", "in") — see
# app/video/spec.py and docs/gecko-video-creator.md §5.
spec: Dict[str, Any]
warnings: List[str] = []


class ActivityJobRead(BaseModel):
"""One running (or recently finished) background job, whatever kind it is.

Expand Down
2 changes: 1 addition & 1 deletion backend/app/video/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def _segment_shots(segment: Segment, spec: VideoSpec, resolve, heading_ms: int)
label=label,
)
if index == 0 and segment.chapter:
# Set after construction, as segment() does for every section, so
# Set after construction, as the walk always has for a section, so
# the mark is exactly what it was — pause markers included, which
# nothing strips from a section's chapter today.
shot.chapter = segment.chapter
Expand Down
36 changes: 19 additions & 17 deletions backend/app/video/importer.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
"""A note (BlockNote JSON) -> a VideoSpec.

The walk is `segmenter.segment()`'s, step for step, so that compiling what this
produces gives back exactly the shots `segment()` would have made — that
equivalence is what tests/test_video_spec.py holds it to, and what lets
`segment()` later become `compile_spec(import_note(...))`. What it records is
richer: the text under a picture is kept as one beat per block rather than one
joined string, a quote or code block is an overlay on its own beat rather than a
shot of its own, and every segment remembers which blocks it came from.

Until the switch, the two walks must change together. Anything the import
decides — whether headings become chapter screens, whether quotes are drawn —
is settled here, from `RenderOptions`, and the spec only records the outcome.
This is the walk every render starts from: `segmenter.segment()` is
`compile_spec(import_note(...))`. It reads a note the way the segmenter always
has — every image or video a boundary, the text below it that section's
narration — but records more than shots: the text under a picture is kept as one
beat per block rather than one joined string, a quote or code block is an
overlay on its own beat rather than a shot of its own, and every segment and
beat remembers which blocks it came from.

The walk is the original segmenter's, step for step, quirks included, and
tests/test_video_spec.py holds it to a verbatim copy of that original
(tests/segment_reference.py). Anything the import decides — whether headings
become chapter screens, whether quotes are drawn — is settled here, from
`RenderOptions`, and the spec only records the outcome.
"""

import json
Expand All @@ -35,8 +37,7 @@
@dataclass
class ImportResult:
spec: VideoSpec
# Media the note references that could not be used, in the order and
# wording segment() reports them.
# Media the note references that could not be used, in document order.
warnings: List[str] = field(default_factory=list)


Expand All @@ -47,7 +48,7 @@ class _Part:
kind: ShotKind
media: Optional[Tuple[str, str]] = None # (url, "image" | "video")
media_label: Optional[str] = None # a readable name, when the file has one
path: Optional[str] = None # resolved, for segment()'s comparisons
path: Optional[str] = None # resolved, for the cleanup passes
label: str = ""
beats: List[Beat] = field(default_factory=list)
chapter: Optional[str] = None
Expand All @@ -61,7 +62,7 @@ def narration(self, heading_pause_ms: int) -> str:
heading_pause_ms=heading_pause_ms)

def panel(self) -> bool:
"""Whether segment()'s shot would carry quote_text or code_text."""
"""Whether the compiled shot will carry quote_text or code_text."""
if isinstance(self.overlay, CodeOverlay):
return True
if isinstance(self.overlay, QuoteOverlay):
Expand Down Expand Up @@ -120,15 +121,16 @@ def segment_via_spec(
author: str = "",
has_audio: Optional[Callable[[str], Any]] = None,
) -> Segmentation:
"""`segment()` by way of a VideoSpec: same arguments, same shots and warnings."""
"""What `segment()` returns: the note imported as a spec and compiled back to shots,
with the import's warnings ahead of the compile's."""
imported = import_note(content, user_id=user_id, media_dir=media_dir, options=options,
note_title=note_title, author=author, has_audio=has_audio)
compiled = compile_spec(imported.spec, user_id=user_id, media_dir=media_dir)
return Segmentation(shots=compiled.segmentation.shots,
warnings=imported.warnings + compiled.segmentation.warnings)


# ── the walk (segmenter.segment(), recording beats) ──────────────────────────
# ── the walk ──────────────────────────

def _walk(content, *, user_id, media_dir, options, note_title, author, has_audio,
warnings) -> List[_Part]:
Expand Down
Loading
Loading