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
22 changes: 12 additions & 10 deletions backend/app/video/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ def resolve(key: str) -> Optional[str]:

heading_ms = spec.voice.pauses.heading_ms
for segment in spec.segments:
for shot in _segment_shots(segment, spec, resolve, heading_ms):
made = _segment_shots(segment, spec, resolve, heading_ms)
if made and segment.transition_in is not None:
# Validated above to be "none": no transition into this segment.
made[0].cut_in = True
for shot in made:
shots.append(shot)
owners.append(segment.id)

Expand Down Expand Up @@ -145,20 +149,17 @@ def _segment_shots(segment: Segment, spec: VideoSpec, resolve, heading_ms: int)
label = overlay.type
else:
label = f"after {runs[index - 1][0].type}"
shot = Shot(
shots.append(Shot(
kind=kind, background=path,
narration=narration_text(beats, card=False, heading_pause_ms=heading_ms),
quote_text=overlay.text if isinstance(overlay, QuoteOverlay) else None,
quote_attribution=overlay.attribution if isinstance(overlay, QuoteOverlay) else None,
code_text=overlay.text if isinstance(overlay, CodeOverlay) else None,
# Built in, like every other drawn field, so pause markers in a
# hand-written chapter are stripped too.
chapter=segment.chapter if index == 0 else None,
label=label,
)
if index == 0 and segment.chapter:
# 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
shots.append(shot)
))
return shots


Expand Down Expand Up @@ -268,7 +269,8 @@ def _refuse_unsupported(spec: VideoSpec) -> None:
for segment in spec.segments:
where = f"segment {segment.id!r}"
bumper = segment.role in ("intro", "outro")
if segment.transition_in is not None:
if segment.transition_in is not None and segment.transition_in.style != "none":
# A straight cut into one segment is all the renderer does per join.
problems.append(f"{where}: per-segment transition_in")
if segment.duration is not None:
problems.append(f"{where}: duration overrides")
Expand Down
41 changes: 31 additions & 10 deletions backend/app/video/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,11 +473,15 @@ def build_shot_command(
subtitle_file: Optional[str] = None,
background_has_audio: bool = False,
index: int = 0,
dip_in: bool = True,
dip_out: bool = True,
) -> List[str]:
"""Full argv for rendering one shot to its own MP4.

Paths are expected to be relative to the working directory the caller runs
this in, which keeps the `subtitles=` filter free of any escaping.
`dip_in`/`dip_out` False leave a dip transition off that end of the shot,
where it joins a neighbour by a straight cut (see `Shot.cut_in`).
"""
width, height = frame_size(options.aspect, options.resolution, preview)
argv: List[str] = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
Expand Down Expand Up @@ -557,20 +561,22 @@ def build_shot_command(
# unchanged and the stitch stays a `concat -c copy` remux. A blending
# transition cannot be expressed here at all — see build_xfade_command.
dip = dip_seconds(options.transition.style, options.transition.duration, duration)
if dip > 0:
colour = transition_colour(options.transition.style)
chains.append(
f"[{stage}]fade=t=in:st=0:d={dip:.3f}:color={colour},"
f"fade=t=out:st={duration - dip:.3f}:d={dip:.3f}:color={colour}[fd]"
)
colour = transition_colour(options.transition.style)
fades = []
if dip > 0 and dip_in:
fades.append(f"fade=t=in:st=0:d={dip:.3f}:color={colour}")
if dip > 0 and dip_out:
fades.append(f"fade=t=out:st={duration - dip:.3f}:d={dip:.3f}:color={colour}")
if fades:
chains.append(f"[{stage}]{','.join(fades)}[fd]")
stage = "fd"

chains.append(f"[{stage}]setsar=1,format=yuv420p[v]")

# Pad the audio so a shot always reaches its target length even when the
# narration is shorter (a media block with little text under it).
audio_chain = f"[{audio_main}]aresample=48000,aformat=channel_layouts=stereo,apad"
if dip > 0:
if dip > 0 and dip_out:
# Only the tail is faded, to match the picture dipping to colour there —
# and it lands in the trailing hold `shot_end_pause_ms` leaves after the
# last word, not on speech itself. The start has no equivalent lead-in
Expand Down Expand Up @@ -600,6 +606,7 @@ def build_concat_command(list_file: str, output: str) -> List[str]:
def build_xfade_command(
shot_files: Sequence[str], durations: Sequence[float], output: str,
*, options: RenderOptions, preview: bool, style: str, overlap: float,
cuts: Sequence[bool] = (),
) -> List[str]:
"""Stitch the shots with a blending transition instead of a plain concat.

Expand All @@ -622,6 +629,10 @@ def build_xfade_command(
shot's track is appended untouched: a clean cut under a blended
picture, eating the same `overlap` seconds the video xfade does so the
two streams stay in sync without the words ever overlapping.

A shot whose `cuts` entry is True joins by a straight cut instead: it
carries on over the same picture, so it is appended whole, picture and
sound, with nothing eaten.
"""
argv = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
for name in shot_files:
Expand All @@ -632,6 +643,12 @@ def build_xfade_command(
video, audio = "0:v", "0:a"
total = durations[0]
for index in range(1, len(shot_files)):
if index < len(cuts) and cuts[index]:
chains.append(f"[{video}][{index}:v]concat=n=2:v=1:a=0[vx{index}]")
chains.append(f"[{audio}][{index}:a]concat=n=2:v=0:a=1[ax{index}]")
video, audio = f"vx{index}", f"ax{index}"
total += durations[index]
continue
offset = max(0.0, total - overlap)
chains.append(
f"[{video}][{index}:v]xfade=transition={transition}:"
Expand All @@ -649,11 +666,15 @@ def build_xfade_command(
return argv


def crossfade_total(durations: Sequence[float], overlap: float) -> float:
"""Length of an xfade stitch: every join eats `overlap` seconds."""
def crossfade_total(durations: Sequence[float], overlap: float,
cuts: Sequence[bool] = ()) -> float:
"""Length of an xfade stitch: every join eats `overlap` seconds, except
where a shot cuts straight in (see `build_xfade_command`)."""
if not durations:
return 0.0
return max(0.0, sum(durations) - overlap * (len(durations) - 1))
blends = sum(1 for index in range(1, len(durations))
if not (index < len(cuts) and cuts[index]))
return max(0.0, sum(durations) - overlap * blends)


def build_music_command(
Expand Down
117 changes: 92 additions & 25 deletions backend/app/video/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
`RenderOptions`, and the spec only records the outcome.
"""

import bisect
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Tuple
Expand Down Expand Up @@ -56,6 +57,9 @@ class _Part:
role: Optional[str] = None
overlay: Optional[Any] = None # QuoteOverlay | CodeOverlay, not yet anchored
block_ids: List[str] = field(default_factory=list)
# Joined to the part before by a straight cut: it carries on over the
# same picture, so a dip or blend there would show nothing changing.
cut_in: bool = False

def narration(self, heading_pause_ms: int) -> str:
return narration_text(self.beats, card=self.kind == "card",
Expand Down Expand Up @@ -158,46 +162,76 @@ 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] = []
# aloud), with how many beats came before each, so each lands on the part
# of the section it stood in.
pending_blocks: List[Tuple[int, str]] = []
open_part: Optional[_Part] = None
# The mark of a heading before anything was said in the section.
pending_chapter: Optional[str] = None
# Headings after something was said: (beats before it, its mark). Each
# starts a segment of its own there, over the same picture, so its mark
# lands where it stands.
cuts: List[Tuple[int, str]] = []
pending_card: Optional[_Part] = None

def spoken() -> str:
return narration_text(pending, card=False, heading_pause_ms=heading_ms)

def flush(next_part: Optional[_Part]) -> None:
nonlocal pending, pending_blocks, open_part, pending_chapter, pending_card
def flush(next_part: Optional[_Part]) -> Optional[str]:
"""Close the open section, and open `next_part`. Returns a chapter mark
that was to start the next part when there is none to start: its
heading had nothing said under it where it stood."""
nonlocal pending, pending_blocks, open_part, pending_chapter, pending_card, cuts
text = spoken()
carried_chapter: Optional[str] = None

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)
if cuts and not _said_after(pending, cuts[-1][0]):
# Nothing but headings after the last one, so it would start a
# segment with nothing under it: it stays in this one (read at its
# end, if read at all) and its mark starts the next part.
carried_chapter = cuts.pop()[1]
sections: List[_Part] = []
if open_part is not None:
sections = [open_part] + [carry("after heading", chapter=mark, cut_in=True)
for _at, mark in cuts]
starts = [0] + [at for at, _mark in cuts]
for index, section in enumerate(sections):
end = starts[index + 1] if index + 1 < len(starts) else len(pending)
section.beats = pending[starts[index]:end]
for at, block in pending_blocks:
sections[bisect.bisect_right(starts, at) - 1].block_ids.append(block)
elif next_part is not None:
next_part.block_ids.extend(block for _at, block in pending_blocks)
pending_blocks = []
cuts = []
if open_part is not None:
open_part.beats = pending
if pending_chapter and not open_part.chapter:
open_part.chapter = pending_chapter
elif pending_chapter and not pending:
# The section already has its mark, and this heading — not read,
# with nothing said under it — takes no time here: its moment
# is where the next part starts, so it marks that instead.
carried_chapter = pending_chapter
if pending_card is not None:
parts.append(pending_card)
pending_card = None
parts.append(open_part)
parts.extend(sections)
elif pending_card is not None:
parts.append(pending_card)
pending_card = None

carried_chapter = None
if (pending_chapter and open_part is None
and next_part is not None and next_part.kind != "card"):
if pending_chapter and open_part is None:
carried_chapter = pending_chapter
pending = []
pending_chapter = None
open_part = next_part
if carried_chapter and open_part is not None and not open_part.chapter:
if carried_chapter and open_part is None:
return carried_chapter
if carried_chapter and not open_part.chapter:
open_part.chapter = carried_chapter
return None

def carry(label: str, **extra: Any) -> _Part:
"""A part over whatever the interrupted section was showing."""
Expand All @@ -217,20 +251,29 @@ def carry(label: str, **extra: Any) -> _Part:
btype = block.get("type")
if btype == "heading":
heading = _inline_text(block.get("content")).strip()
if heading:
# The mark is what is shown, in the chapter list and on screen,
# so it is the heading without its [pause:…] markers — and a
# heading that is nothing but markers marks no chapter at all.
mark = strip_pause_markup(heading)
if mark:
if options.chapter_screens:
flush(carry("after chapter card"))
beats = ([Beat(id="", kind="heading", text=_as_sentence(heading), block_id=block_id)]
if options.read_chapters else [])
pending_card = _Part(
kind="card", card=Card(variant="chapter", title=heading),
chapter=heading, beats=beats, label="chapter card",
kind="card", card=Card(variant="chapter", title=mark),
chapter=mark, beats=beats, label="chapter card",
block_ids=[block_id] if block_id else [],
)
pending_chapter = None
continue
if pending_chapter is None:
pending_chapter = heading
if _said_after(pending, cuts[-1][0] if cuts else 0):
# Partway through the section: it starts a segment here.
cuts.append((len(pending), mark))
elif pending_chapter is None and not cuts:
pending_chapter = mark
# Otherwise only headings came since the last mark, so this
# is the same moment, and the first heading keeps it.

if btype == "quote" and options.quotes.enabled:
quoted = _split_attribution(_inline_text(block.get("content")))
Expand Down Expand Up @@ -266,7 +309,7 @@ def carry(label: str, **extra: Any) -> _Part:

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

text = _block_narration(block, options)
Expand All @@ -292,19 +335,33 @@ def carry(label: str, **extra: Any) -> _Part:

sounded = bool(has_audio(path)) if callable(has_audio) else False
if sounded:
flush(None)
# A chapter whose heading wasn't read has no section of its own
# before the clip, so the clip is where it starts.
unplaced = flush(None)
parts.append(_Part(kind="video_sound", media=(url, "video"), path=path,
label="clip with audio", block_ids=ids))
chapter=unplaced, label="clip with audio", block_ids=ids))
open_part = _Part(kind="video_muted", media=(url, "video"), path=path,
label="clip continued, muted")
else:
flush(_Part(kind="video_muted", media=(url, "video"), path=path, block_ids=ids))

flush(None)

parts = [p for p in parts
if p.kind != "still" or p.path is not None or p.narration(heading_ms)
or isinstance(p.overlay, CodeOverlay)]
kept: List[_Part] = []
unplaced: Optional[str] = None
for part in parts:
if (part.kind == "still" and part.path is None and not part.narration(heading_ms)
and not isinstance(part.overlay, CodeOverlay)):
# Nothing to show or say, so no screen time — and a chapter mark it
# carried starts whatever comes next instead. Marks that land on the
# same moment keep the first, as two headings in a row do.
unplaced = unplaced or part.chapter
continue
if unplaced:
part.chapter = unplaced
unplaced = None
kept.append(part)
parts = kept

trimmed: List[_Part] = []
for part in parts:
Expand All @@ -330,11 +387,18 @@ def carry(label: str, **extra: Any) -> _Part:
return parts, order


def _said_after(beats: List[Beat], at: int) -> bool:
"""Whether anything but headings is said from `at` on."""
return any(beat.kind != "heading" for beat in beats[at:])


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."""
that section's picture, so it keeps the blocks the picture came from too,
and the chapter mark the section was to start."""
if section is not None:
part.block_ids = section.block_ids + part.block_ids
part.chapter = part.chapter or section.chapter


def _bumper(which, options, user_id, media_dir, warnings) -> Optional[_Part]:
Expand Down Expand Up @@ -438,6 +502,9 @@ def _group(parts: List[_Part], assets: _Assets, order: Dict[str, int]) -> List[S
card=first.card,
narration=Narration(beats=beats) if beats else None,
overlays=overlays,
# Carrying on over the same picture: a straight cut, so nothing
# marks the join but the new chapter.
transition_in=Transition(style="none") if first.cut_in else None,
chrome=False if first.role in ("intro", "outro") else None,
source=(SegmentSource(block_ids=sorted(_dedupe(block_ids), key=order.__getitem__))
if block_ids else None),
Expand Down
Loading
Loading