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: 5 additions & 1 deletion backend/app/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2129,7 +2129,11 @@ async def synthesize_tts_bytes(

# ── Deepgram (Flux) path ──────────────────────────────────────────────────
if use_deepgram:
dg_voice = speech_cfg["deepgram_tts_model"] or DEFAULT_DEEPGRAM_TTS_MODEL
# A caller may name a Flux voice (the video dialog's voice choice does);
# anything else — read-aloud passes a fal.ai voice name — means the
# account's Deepgram voice.
dg_voice = (voice if voice in _DEEPGRAM_TTS_VOICE_IDS
else speech_cfg["deepgram_tts_model"] or DEFAULT_DEEPGRAM_TTS_MODEL)
dg_expressivity = speech_cfg["deepgram_tts_expressivity"]
dg_speed = speech_cfg["deepgram_tts_speed"]
# Expressivity and speed both change the audio itself, so both are part
Expand Down
4 changes: 3 additions & 1 deletion backend/app/routers/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import json
import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional

from fastapi import APIRouter, Depends, HTTPException, Request
Expand Down Expand Up @@ -236,6 +236,8 @@ def video_spec(payload: VideoRenderRequest, request: Request, session: Session =
author=(user.username if user else "") or "",
has_audio=F.probe_has_audio,
note_id=note.id,
imported_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
selection=bool(payload.selected_content),
)
return DataResponse(data=VideoSpecRead(
spec=_utf8_safe(imported.spec.model_dump(mode="json", by_alias=True, exclude_none=True)),
Expand Down
5 changes: 4 additions & 1 deletion backend/app/video/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@

Module layout:
options.py RenderOptions + the aspect/resolution/quality tables
segmenter.py BlockNote JSON -> [Shot] (pure)
spec.py the VideoSpec: a video described as data (pure)
importer.py BlockNote JSON -> VideoSpec (pure)
compiler.py VideoSpec -> [Shot] + RenderOptions (pure)
segmenter.py the Shot model; segment() = compile(import()) (pure)
compose.py Pillow: title cards, overlay PNGs, backgrounds
narration.py chunking, TTS, silence padding, durations, SRT
ffmpeg.py argv + filtergraph builders, run/probe helpers (pure builders)
Expand Down
40 changes: 34 additions & 6 deletions backend/app/video/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
from typing import Dict, List, Optional, Tuple

from app.video.options import (
BackgroundSpec, BumperSpec, KenBurnsSpec, MusicSpec, OverlayTextSpec, QuoteSpec,
RenderOptions, WatermarkSpec, WaveformSpec,
BackgroundSpec, BumperSpec, CardTextSpec, CodeSpec, KenBurnsSpec, MusicSpec,
OverlayTextSpec, QuoteSpec, RenderOptions, TransitionSpec, WatermarkSpec, WaveformSpec,
)
from app.video.segmenter import (
Segmentation, Shot, ShotKind, _set_apart, resolve_media_path,
Expand Down Expand Up @@ -110,6 +110,10 @@ def _segment_shots(segment: Segment, spec: VideoSpec, resolve, heading_ms: int)
)]

if segment.kind == "clip":
if path is None:
# Its file has gone since the import: skipped, as the warning says,
# since a sounded clip is nothing without its own picture and sound.
return []
bumper = segment.role if segment.role in ("intro", "outro") else None
return [Shot(kind="video_sound", background=path, chapter=segment.chapter,
bumper=bumper, label=segment.label)]
Expand Down Expand Up @@ -163,6 +167,11 @@ def _overlay_beat(segment: Segment, overlay) -> str:
show = overlay.show
start = show.from_ if show is not None else None
end = show.to if show is not None else None
for anchor in (start, end):
if (isinstance(anchor, AnchorRef) and anchor.beat is not None
and not any(b.id == anchor.beat for b in segment.beats)):
raise UnsupportedSpec(f"overlay {overlay.id!r} in segment {segment.id!r} is anchored "
f"to beat {anchor.beat!r}, which is no longer there")
if (isinstance(start, AnchorRef) and isinstance(end, AnchorRef)
and start.beat is not None and start.beat == end.beat
and start.edge == "start" and end.edge == "end"
Expand Down Expand Up @@ -194,16 +203,16 @@ def url(key: Optional[str]) -> Optional[str]:
type=style.background.type, colors=list(style.background.colors),
angle=style.background.angle, url=url(style.background.asset),
),
title_card_text=style.cards.title.model_copy(),
chapter_card_text=style.cards.chapter.model_copy(),
transition=style.transition.model_copy(),
title_card_text=CardTextSpec(**style.cards.title.model_dump()),
chapter_card_text=CardTextSpec(**style.cards.chapter.model_dump()),
transition=TransitionSpec(**style.transition.model_dump()),
ken_burns=KenBurnsSpec(effect=style.motion.effect, amount=style.motion.amount,
include_cards=style.cards.motion),
quotes=QuoteSpec(
enabled=any(isinstance(o, QuoteOverlay) for s in spec.segments for o in s.overlays),
**style.quote.model_dump(),
),
code=style.code.model_copy(),
code=CodeSpec(**style.code.model_dump()),
min_shot_seconds=style.min_segment_seconds,
card_seconds=style.cards.seconds,
voice=voice.voice,
Expand Down Expand Up @@ -247,6 +256,10 @@ def _refuse_unsupported(spec: VideoSpec) -> None:
if not isinstance(spec.assets[key], NotesAsset):
problems.append(f"asset {key!r} is not a file in the note's media "
f"({spec.assets[key].from_} assets need Gecko Video Creator)")
for where, key in (("style.background", spec.style.background.asset),
("chrome.watermark", spec.chrome.watermark.asset if spec.chrome.watermark else None)):
if key is not None and spec.assets[key].type != "image":
problems.append(f"{where}: must be an image")
if spec.voice.provider is not None or spec.voice.expressivity is not None:
problems.append("voice.provider and voice.expressivity come from the account here")
if spec.credits is not None:
Expand Down Expand Up @@ -286,6 +299,8 @@ def _refuse_unsupported(spec: VideoSpec) -> None:
problems.append(f"{where}: a clip carries no narration or overlays")
if segment.kind == "clip" and visual is not None and spec.assets[visual.asset].type != "video":
problems.append(f"{where}: a clip must be a video")
if segment.kind == "scene" and visual is not None and spec.assets[visual.asset].type == "audio":
problems.append(f"{where}: a scene's picture can't be an audio file")
if segment.kind == "card" and segment.overlays:
problems.append(f"{where}: overlays on a card")
anchored = []
Expand All @@ -302,6 +317,15 @@ def _refuse_unsupported(spec: VideoSpec) -> None:
if len(anchored) != len(set(anchored)):
problems.append(f"{where}: two overlays on one beat")

# The renderer finds the intro and outro by position, to keep the music and
# its drawing off them, so that is the only place either can be.
roles = [s.role for s in spec.segments]
if "intro" in roles[1:] or roles.count("intro") > 1:
problems.append("an intro can only be the first segment")
if "outro" in roles[:-1] or roles.count("outro") > 1:
problems.append("an outro can only be the last segment")

ids = {s.id for s in spec.segments}
content = [s.id for s in spec.segments if s.role not in ("intro", "outro")]
if len(spec.audio_tracks) > 1:
problems.append("more than one audio track")
Expand All @@ -313,6 +337,10 @@ def _refuse_unsupported(spec: VideoSpec) -> None:
problems.append(f"{where}: trim / loop off")
if spec.assets[track.asset].type != "audio":
problems.append(f"{where}: must be an audio file")
for anchor in (track.start, track.end):
if isinstance(anchor, AnchorRef) and anchor.segment not in ids:
problems.append(f"{where}: anchored to segment {anchor.segment!r}, "
f"which is no longer there")
if not content or not (_at(track.start, spec, content[0], "start")
and track.end is not None
and _at(track.end, spec, content[-1], "end")):
Expand Down
76 changes: 52 additions & 24 deletions backend/app/video/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@
_media_kind, _media_url, _split_attribution, resolve_media_path,
)
from app.video.spec import (
AnchorRef, AudioTrack, Background, Beat, Card, Cards, Chrome, CodeOverlay, Motion,
Narration, NotesAsset, Output, OverlayText, Pauses, QuoteOverlay, QuoteStyle,
Segment, SegmentSource, Span, SpecSource, Style, VideoSpec, Visual, Voice,
Waveform, Watermark,
AnchorRef, AudioTrack, Background, Beat, Card, CardText, Cards, Chrome, CodeOverlay,
CodeStyle, Motion, Narration, NotesAsset, Output, OverlayText, Pauses, QuoteOverlay,
QuoteStyle, Segment, SegmentSource, Span, SpecSource, Style, Transition, VideoSpec,
Visual, Voice, Waveform, Watermark,
)


Expand Down Expand Up @@ -80,17 +80,21 @@ def import_note(
author: str = "",
has_audio: Optional[Callable[[str], Any]] = None,
note_id: Optional[str] = None,
imported_at: Optional[str] = None,
selection: bool = False,
) -> ImportResult:
"""Same arguments as `segment()`, plus the note's id for provenance."""
"""Same arguments as `segment()`, plus provenance: the note's id, when it was
read, and whether `content` is only a selection of it."""
warnings: List[str] = []
parts = _walk(content, user_id=user_id, media_dir=media_dir, options=options,
note_title=note_title, author=author, has_audio=has_audio,
warnings=warnings)
parts, order = _walk(content, user_id=user_id, media_dir=media_dir, options=options,
note_title=note_title, author=author, has_audio=has_audio,
warnings=warnings)
assets = _Assets()
segments = _group(parts, assets, options.heading_pause_ms)
segments = _group(parts, assets, order)
spec = VideoSpec(
title=note_title,
source=SpecSource(note_id=note_id) if note_id else None,
source=(SpecSource(note_id=note_id, imported_at=imported_at, selection=selection)
if note_id or imported_at else None),
output=Output(
aspect=options.aspect, resolution=options.resolution, quality=options.quality,
fps=options.fps, subtitles=options.subtitles, chapters=options.embed_chapters,
Expand Down Expand Up @@ -133,7 +137,7 @@ def segment_via_spec(
# ── the walk ──────────────────────────

def _walk(content, *, user_id, media_dir, options, note_title, author, has_audio,
warnings) -> List[_Part]:
warnings) -> Tuple[List[_Part], Dict[str, int]]:
parts: List[_Part] = []
heading_ms = options.heading_pause_ms

Expand All @@ -153,6 +157,9 @@ def _walk(content, *, user_id, media_dir, options, note_title, author, has_audio
))

pending: List[Beat] = []
# Blocks read in this section that leave no beat (a heading not read
# aloud), recorded on whichever part the section's beats would go to.
pending_blocks: List[str] = []
open_part: Optional[_Part] = None
pending_chapter: Optional[str] = None
pending_card: Optional[_Part] = None
Expand All @@ -161,11 +168,15 @@ def spoken() -> str:
return narration_text(pending, card=False, heading_pause_ms=heading_ms)

def flush(next_part: Optional[_Part]) -> None:
nonlocal pending, open_part, pending_chapter, pending_card
nonlocal pending, pending_blocks, open_part, pending_chapter, pending_card
text = spoken()

if open_part is None and text:
open_part = _Part(kind="still", label="opening")
holder = open_part if open_part is not None else next_part
if holder is not None:
holder.block_ids.extend(pending_blocks)
pending_blocks = []
if open_part is not None:
open_part.beats = pending
if pending_chapter and not open_part.chapter:
Expand Down Expand Up @@ -196,7 +207,9 @@ def carry(label: str, **extra: Any) -> _Part:
return _Part(kind=kind, label=label, **extra)
return _Part(kind=kind, media=open_part.media, path=open_part.path, label=label, **extra)

for block in _flatten(blocks):
flat = _flatten(blocks)
order = {b["id"]: i for i, b in enumerate(flat) if isinstance(b.get("id"), str)}
for block in flat:
kind = _classify(block, options)
block_id = block.get("id") if isinstance(block.get("id"), str) else None

Expand All @@ -223,11 +236,12 @@ def carry(label: str, **extra: Any) -> _Part:
quoted = _split_attribution(_inline_text(block.get("content")))
if quoted[0]:
quote_part = carry("quote", overlay=QuoteOverlay(
id="", text=quoted[0], attribution=quoted[1] or None))
id="", type="quote", text=quoted[0], attribution=quoted[1] or None))
after = carry("after quote")
if spoken() or pending_chapter is not None or pending_card is not None:
flush(quote_part)
else:
_take_over(open_part, quote_part)
open_part = quote_part
pending.append(Beat(id="", kind="quote", text=_as_sentence(quoted[0]), block_id=block_id))
flush(after)
Expand All @@ -236,11 +250,12 @@ def carry(label: str, **extra: Any) -> _Part:
if btype == "codeBlock":
code = _inline_text(block.get("content"))
if code.strip():
code_part = carry("code", overlay=CodeOverlay(id="", text=code))
code_part = carry("code", overlay=CodeOverlay(id="", type="code", text=code))
after = carry("after code")
if spoken() or pending_chapter is not None or pending_card is not None:
flush(code_part)
else:
_take_over(open_part, code_part)
open_part = code_part
# Kept even when it isn't read, so the panel has a beat to
# hang on; `speak` decides whether it is heard.
Expand All @@ -250,6 +265,8 @@ def carry(label: str, **extra: Any) -> _Part:
continue

if btype == "heading" and not options.read_chapters:
if block_id:
pending_blocks.append(block_id)
continue

text = _block_narration(block, options)
Expand Down Expand Up @@ -297,6 +314,8 @@ def carry(label: str, **extra: Any) -> _Part:
and part.chapter is None
and part.kind == previous.kind
and part.path == previous.path):
# Its blocks are still on screen, in the part it continued.
previous.block_ids.extend(part.block_ids)
continue
trimmed.append(part)
parts = trimmed
Expand All @@ -308,7 +327,14 @@ def carry(label: str, **extra: Any) -> _Part:
parts.insert(0, intro)
if outro is not None:
parts.append(outro)
return parts
return parts, order


def _take_over(section: Optional[_Part], part: _Part) -> None:
"""A quote or code block replacing a section that had nothing to say keeps
that section's picture, so it keeps the blocks the picture came from too."""
if section is not None:
part.block_ids = section.block_ids + part.block_ids


def _bumper(which, options, user_id, media_dir, warnings) -> Optional[_Part]:
Expand Down Expand Up @@ -340,7 +366,7 @@ def key(self, url: Optional[str], media_type: str, label: Optional[str] = None)
if found is None:
found = f"a{len(self._keys) + 1}"
self._keys[(url, media_type)] = found
self.registry[found] = NotesAsset(url=url, type=media_type, label=label or None)
self.registry[found] = NotesAsset(from_="notes", url=url, type=media_type, label=label or None)
return found


Expand All @@ -362,7 +388,7 @@ def _joins(segment_parts: List[_Part], part: _Part) -> bool:
return last.overlay is not None and part.label == f"after {last.overlay.type}"


def _group(parts: List[_Part], assets: _Assets, heading_ms: int) -> List[Segment]:
def _group(parts: List[_Part], assets: _Assets, order: Dict[str, int]) -> List[Segment]:
groups: List[List[_Part]] = []
for part in parts:
if groups and _joins(groups[-1], part):
Expand Down Expand Up @@ -390,7 +416,8 @@ def _group(parts: List[_Part], assets: _Assets, heading_ms: int) -> List[Segment
if not part_beats and part.overlay is None and len(group) > 1:
# A section with nothing to say, interrupted at once by a quote
# or code block, is still its own moment on screen.
part_beats = [Beat(id="", text="", speak=False)]
part_beats = [Beat(id="", text="", speak=False,
block_id=part.block_ids[0] if part.block_ids else None)]
for beat in part_beats:
beats.append(beat.model_copy(update={"id": f"b{len(beats) + 1}"}))
if part.overlay is not None:
Expand All @@ -412,7 +439,8 @@ def _group(parts: List[_Part], assets: _Assets, heading_ms: int) -> List[Segment
narration=Narration(beats=beats) if beats else None,
overlays=overlays,
chrome=False if first.role in ("intro", "outro") else None,
source=SegmentSource(block_ids=_dedupe(block_ids)) if block_ids else None,
source=(SegmentSource(block_ids=sorted(_dedupe(block_ids), key=order.__getitem__))
if block_ids else None),
))
return segments

Expand All @@ -437,15 +465,15 @@ def _style(options: RenderOptions, assets: _Assets) -> Style:
),
fit=options.fit,
motion=Motion(effect=options.ken_burns.effect, amount=options.ken_burns.amount),
transition=options.transition.model_copy(),
transition=Transition(**options.transition.model_dump()),
min_segment_seconds=options.min_shot_seconds,
cards=Cards(
seconds=options.card_seconds, motion=options.ken_burns.include_cards,
title=options.title_card_text.model_copy(),
chapter=options.chapter_card_text.model_copy(),
title=CardText(**options.title_card_text.model_dump()),
chapter=CardText(**options.chapter_card_text.model_dump()),
),
quote=QuoteStyle(**options.quotes.model_dump(exclude={"enabled"})),
code=options.code.model_copy(),
code=CodeStyle(**options.code.model_dump()),
)


Expand Down
Loading
Loading