diff --git a/backend/app/video/compiler.py b/backend/app/video/compiler.py index c725d2e..7ccd187 100644 --- a/backend/app/video/compiler.py +++ b/backend/app/video/compiler.py @@ -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) @@ -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 @@ -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") diff --git a/backend/app/video/ffmpeg.py b/backend/app/video/ffmpeg.py index e38507e..5f15079 100644 --- a/backend/app/video/ffmpeg.py +++ b/backend/app/video/ffmpeg.py @@ -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"] @@ -557,12 +561,14 @@ 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]") @@ -570,7 +576,7 @@ def build_shot_command( # 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 @@ -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. @@ -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: @@ -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}:" @@ -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( diff --git a/backend/app/video/importer.py b/backend/app/video/importer.py index 7966f9f..ac0a95a 100644 --- a/backend/app/video/importer.py +++ b/backend/app/video/importer.py @@ -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 @@ -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", @@ -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.""" @@ -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"))) @@ -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) @@ -292,9 +335,11 @@ 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: @@ -302,9 +347,21 @@ def carry(label: str, **extra: Any) -> _Part: 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: @@ -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]: @@ -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), diff --git a/backend/app/video/renderer.py b/backend/app/video/renderer.py index 592f2ae..dac77f1 100644 --- a/backend/app/video/renderer.py +++ b/backend/app/video/renderer.py @@ -80,28 +80,28 @@ def build_timeline( """Lay the rendered shots out on the finished video's timeline. Returns the chapter marks, every subtitle cue shifted onto that timeline, - and the total length. `overlap` is how much a crossfade eats at each join; - at zero this is the plain-concat timeline, which is why both stitch paths - can share one piece of arithmetic instead of drifting apart. + and the total length. `overlap` is how much a crossfade eats at each join + but a straight cut (`Shot.cut_in`); at zero this is the plain-concat + timeline, which is why both stitch paths can share one piece of arithmetic + instead of drifting apart. """ chapters: List[Tuple[float, float, str]] = [] cues: List[Cue] = [] timeline = 0.0 - for shot, duration, shot_cue in zip(shots, durations, shot_cues): + count = min(len(shots), len(durations)) + for index, (shot, duration, shot_cue) in enumerate(zip(shots, durations, shot_cues)): start = timeline cues.extend(shift_cues(shot_cue, start)) - # A shot starts `overlap` seconds before its predecessor ends, and a + # The next shot starts `overlap` seconds before this one ends, and a # chapter ends where the next one begins — so the marks stay contiguous # rather than overlapping each other by the length of the transition. - timeline += duration - overlap + # Nothing overlaps the last shot, or a shot the next one cuts into. + following = shots[index + 1] if index + 1 < count else None + timeline += duration - (overlap if following is not None and not following.cut_in else 0.0) if shot.chapter: chapters.append((start, timeline, shot.chapter)) elif chapters: chapters[-1] = (chapters[-1][0], timeline, chapters[-1][2]) - # Nothing overlaps the last shot, so it plays to its full length. - timeline += overlap - if chapters: - chapters[-1] = (chapters[-1][0], timeline, chapters[-1][2]) return chapters, cues, timeline @@ -349,6 +349,9 @@ def _argv(with_options: RenderOptions) -> List[str]: duration=duration, output=output, options=with_options, preview=preview, overlay_png=shot_overlay, subtitle_file=shot_srt, background_has_audio=has_audio, index=index, + # No dip where this shot and a neighbour join by a straight cut. + dip_in=not shot.cut_in, + dip_out=not (index + 1 < len(shots) and shots[index + 1].cut_in), ) def _encode(with_options: RenderOptions) -> None: @@ -426,6 +429,7 @@ def _encode(with_options: RenderOptions) -> None: F.build_xfade_command( shot_files, durations, "stitched.mp4", options=options, preview=preview, style=options.transition.style, overlap=overlap, + cuts=[shot.cut_in for shot in shots], ), cwd=work_dir, timeout=F.shot_timeout(sum(durations)), ) @@ -586,4 +590,5 @@ def estimate( options.transition.style, durations, options.transition.duration, filters=frozenset({"xfade", "concat"}), ) or 0.0 - return len(plan.shots), plan.narration_chars, F.crossfade_total(durations, overlap), plan.warnings + cuts = [shot.cut_in for shot in plan.shots] + return len(plan.shots), plan.narration_chars, F.crossfade_total(durations, overlap, cuts), plan.warnings diff --git a/backend/app/video/segmenter.py b/backend/app/video/segmenter.py index 48be7c6..91713e8 100644 --- a/backend/app/video/segmenter.py +++ b/backend/app/video/segmenter.py @@ -65,6 +65,10 @@ class Shot: bumper: Optional[str] = None # Set on the second half of a sounded-clip pair, purely for readable logs. label: str = "" + # Joined to the shot before by a straight cut, whatever the video's + # transition: it carries on over the same picture (a heading partway + # through a section), so a dip or blend would show nothing changing. + cut_in: bool = False def __post_init__(self) -> None: # A pause marker is an instruction to the voice, not something to look diff --git a/backend/tests/segment_reference.py b/backend/tests/segment_reference.py index c591afc..9de08b8 100644 --- a/backend/tests/segment_reference.py +++ b/backend/tests/segment_reference.py @@ -2,17 +2,19 @@ the oracle the spec path is held to. `segment()` now imports a note as a VideoSpec and compiles it back to shots -(app/video/importer.py, app/video/compiler.py). This is the original, verbatim, -so test_video_spec.py can keep proving that changed nothing: every segmenter -case, a generated corpus and real renders must come out identical. When a -deliberate behaviour change lands (the chapter-mark fixes in -docs/gecko-video-creator.md §3.2), it changes here too or this is retired. +(app/video/importer.py, app/video/compiler.py). This is the original, verbatim +but for the chapter-mark fixes (docs/gecko-video-creator.md §3.2 and open +decision 15, each marked "Fixed:" below), so test_video_spec.py can keep proving that the spec path +changes nothing else: every segmenter case, a generated corpus and real +renders must come out identical. A deliberate behaviour change lands here too, +written independently of the importer, or this is retired. """ import json from typing import Any, List, Optional from app.video.options import RenderOptions +from app.video.pause_markup import strip_pause_markup from app.video.segmenter import ( Segmentation, Shot, ShotKind, _as_sentence, _block_narration, _classify, _flatten, _inline_text, _media_kind, _media_url, _set_apart, _split_attribution, @@ -61,39 +63,69 @@ def segment( open_shot: Optional[Shot] = None # A heading seen since the last boundary, to hang a chapter mark on. pending_chapter: Optional[str] = None + # Fixed: a heading after something was said splits the section there, so + # its mark is where it stands: (lines before it, its mark) for each. + splits: List[tuple] = [] + # Whether anything but a heading was said since the section began or the + # last split — if not, a heading now is the same moment as the last one. + said_plain = False pending_card: Optional[Shot] = None - def flush(next_shot: Optional[Shot]) -> None: - """Close the open shot with whatever narration has accumulated.""" - nonlocal pending_text, open_shot, pending_chapter, pending_card + def flush(next_shot: Optional[Shot]) -> Optional[str]: + """Close the open shot with whatever narration has accumulated. + + Fixed: returns the chapter mark it would have handed to `next_shot` + when there is none, rather than dropping it.""" + nonlocal pending_text, open_shot, pending_chapter, pending_card, splits, said_plain text = "\n".join(t for t in pending_text if t).strip() if open_shot is None and text: # Text before the first media block: give it the fallback background. open_shot = Shot(kind="still", background=None, label="opening") + # Fixed: a last split with only headings after it has nothing to show + # under its mark; that heading stays here and marks the next shot. + late_mark = splits.pop()[1] if splits and not said_plain else None if open_shot is not None: - open_shot.narration = text + ends = [at for at, _ in splits] + [len(pending_text)] + open_shot.narration = "\n".join(t for t in pending_text[:ends[0]] if t).strip() if pending_chapter and not open_shot.chapter: open_shot.chapter = pending_chapter + pending_chapter = None + elif pending_chapter and not pending_text: + # Fixed: the shot already has its mark, so this one used to be + # dropped. Unread, with nothing said under it, the heading sits + # where the next shot starts: that shot gets it, below. + pass + else: + pending_chapter = None if pending_card is not None: result.shots.append(pending_card) pending_card = None result.shots.append(open_shot) + carry_kind: ShotKind = "video_muted" if open_shot.kind.startswith("video") else "still" + for (at, mark), end in zip(splits, ends[1:]): + result.shots.append(Shot( + kind=carry_kind, background=open_shot.background, + narration="\n".join(t for t in pending_text[at:end] if t).strip(), + chapter=mark, label="after heading", cut_in=True, + )) elif pending_card is not None: result.shots.append(pending_card) pending_card = None # A heading with nothing spoken under it (read_chapters off) has no shot # to hang its mark on; hand it to the shot that starts here instead. - carried_chapter = None - if (pending_chapter and open_shot is None - and next_shot is not None and next_shot.kind != "card"): - carried_chapter = pending_chapter + carried_chapter = pending_chapter or late_mark pending_text = [] pending_chapter = None + splits = [] + said_plain = False open_shot = next_shot - if carried_chapter and open_shot is not None and not open_shot.chapter: + if carried_chapter and open_shot is None: + return carried_chapter + if carried_chapter and not open_shot.chapter: open_shot.chapter = carried_chapter + return None for block in _flatten(blocks): kind = _classify(block, options) @@ -101,8 +133,11 @@ def flush(next_shot: Optional[Shot]) -> None: if kind is None: btype = block.get("type") if btype == "heading": + # Fixed: the chapter list and the card show the heading without + # its [pause:…] markers, and one that is only markers is no chapter. heading = _inline_text(block.get("content")).strip() - if heading: + mark = strip_pause_markup(heading) + if mark: if options.chapter_screens: # A chapter screen interrupts: close the current shot so # the card lands between sections rather than mid-thought. @@ -130,7 +165,7 @@ def flush(next_shot: Optional[Shot]) -> None: # silence and then speak it over the following shot, once # the words were no longer on screen. pending_card = Shot( - kind="card", card_title=heading, chapter=heading, + kind="card", card_title=mark, chapter=mark, card_kind="chapter", narration=_as_sentence(heading) if options.read_chapters else "", label="chapter card", @@ -141,8 +176,13 @@ def flush(next_shot: Optional[Shot]) -> None: continue # Without a chapter screen the heading is read inside the # section it introduces, so that shot carries the mark. - if pending_chapter is None: - pending_chapter = heading + if said_plain: + # Fixed: partway through the section, where this used + # to mark its start or nothing at all. + splits.append((len(pending_text), mark)) + said_plain = False + elif pending_chapter is None and not splits: + pending_chapter = mark if btype == "quote" and options.quotes.enabled: quoted = _split_attribution(_inline_text(block.get("content"))) @@ -170,6 +210,9 @@ def flush(next_shot: Optional[Shot]) -> None: # The open shot has nothing of its own to say, so the # quote takes it over rather than being preceded by a # blank copy of itself held for min_shot_seconds. + # Fixed: and the chapter it was to start, too. + if open_shot is not None and not quote_shot.chapter: + quote_shot.chapter = open_shot.chapter open_shot = quote_shot # Closing the quote's own shot immediately is what keeps the # quotation as its narration rather than whatever follows it. @@ -201,6 +244,8 @@ def flush(next_shot: Optional[Shot]) -> None: or pending_chapter is not None or pending_card is not None): flush(code_shot) else: + if open_shot is not None and not code_shot.chapter: + code_shot.chapter = open_shot.chapter # Fixed, as for a quote. open_shot = code_shot if options.narrate_code: pending_text.append(_as_sentence(code)) @@ -221,6 +266,7 @@ def flush(next_shot: Optional[Shot]) -> None: if btype == "heading" and options.heading_pause_ms > 0: spoken = _set_apart(spoken) pending_text.append(spoken) + said_plain = said_plain or btype != "heading" continue url = _media_url(block, options) @@ -244,8 +290,10 @@ def flush(next_shot: Optional[Shot]) -> None: # looped silently, so the section stays visually continuous. sounded = bool(has_audio(path)) if callable(has_audio) else False if sounded: - flush(None) - result.shots.append(Shot(kind="video_sound", background=path, label="clip with audio")) + # Fixed: a chapter mark with no shot before the clip starts the clip. + unplaced = flush(None) + result.shots.append(Shot(kind="video_sound", background=path, chapter=unplaced, + label="clip with audio")) open_shot = Shot(kind="video_muted", background=path, label="clip continued, muted") else: flush(Shot(kind="video_muted", background=path)) @@ -256,10 +304,20 @@ def flush(next_shot: Optional[Shot]) -> None: # shot with no narration is kept — it still displays for min_shot_seconds. # So is a silent code block (narrate_code off, nothing narrated yet) — it # still has its panel to show, even with no background of its own. - result.shots = [ - s for s in result.shots - if s.kind != "still" or s.background is not None or s.narration.strip() or s.code_text - ] + # Fixed: a chapter mark on a dropped shot starts the next one kept instead. + # Its heading came first, so it wins over a mark that shot has of its own, + # as the first of two headings in a row does. + kept: List[Shot] = [] + unplaced: Optional[str] = None + for s in result.shots: + if s.kind != "still" or s.background is not None or s.narration.strip() or s.code_text: + if unplaced: + s.chapter = unplaced + unplaced = None + kept.append(s) + elif unplaced is None: + unplaced = s.chapter + result.shots = kept # The section reopened after a quote or a code block is a continuation, # not a shot of its own: when nothing followed it, it would replay the diff --git a/backend/tests/test_video_ffmpeg.py b/backend/tests/test_video_ffmpeg.py index 693a178..40df32b 100644 --- a/backend/tests/test_video_ffmpeg.py +++ b/backend/tests/test_video_ffmpeg.py @@ -452,6 +452,24 @@ def test_no_transition_leaves_the_graph_as_it_was(): assert graph.endswith("aresample=48000,aformat=channel_layouts=stereo,apad[a]") +def test_a_dip_is_left_off_the_end_that_joins_by_a_straight_cut(): + """Where a heading carries a section on over the same picture, dipping to + black and back would show nothing changing.""" + options = RenderOptions(transition={"style": "fade", "duration": 0.6}) + into = _graph(F.build_shot_command( + kind="still", background="bg.png", audio="n.wav", duration=6.0, output="s.mp4", + options=options, preview=False, dip_in=False)) + assert "fade=t=in" not in into + assert "fade=t=out:st=5.400:d=0.600:color=black" in into + assert "afade=t=out:st=5.400:d=0.600" in into + + out_of = _graph(F.build_shot_command( + kind="still", background="bg.png", audio="n.wav", duration=6.0, output="s.mp4", + options=options, preview=False, dip_out=False)) + assert "fade=t=in:st=0:d=0.600:color=black[fd]" in out_of + assert "fade=t=out" not in out_of and "afade" not in out_of + + # ── the crossfade stitch ───────────────────────────────────────────────────── ALL = frozenset({"xfade", "concat"}) @@ -494,6 +512,21 @@ def test_a_crossfade_shortens_the_video_by_one_overlap_per_join(): assert F.crossfade_total([], 0.6) == 0.0 +def test_a_shot_that_cuts_in_is_appended_whole_with_no_blend(): + argv = F.build_xfade_command( + ["a.mp4", "b.mp4", "c.mp4"], [5.0, 4.0, 6.0], "out.mp4", + options=RenderOptions(), preview=False, style="dissolve", overlap=0.6, + cuts=[False, True, False], + ) + graph = _graph(argv) + assert "[0:v][1:v]concat=n=2:v=1:a=0[vx1]" in graph + assert "[0:a][1:a]concat=n=2:v=0:a=1[ax1]" in graph + # Nothing was eaten at the cut, so the blend into c starts at 9 - 0.6. + assert "[vx1][2:v]xfade=transition=dissolve:duration=0.600:offset=8.400[vx2]" in graph + assert "[ax1]atrim=end=8.400,asetpts=PTS-STARTPTS[at2]" in graph + assert F.crossfade_total([5.0, 4.0, 6.0], 0.6, [False, True, False]) == pytest.approx(14.4) + + def test_an_overlap_can_never_exceed_half_the_shortest_shot(): """Overlapping more than a short shot's own length would blend it away.""" assert F.crossfade_overlap("dissolve", [10.0, 1.0, 8.0], 0.6, filters=ALL) == pytest.approx(0.5) @@ -934,6 +967,18 @@ def test_subtitle_cues_are_shifted_onto_the_overlapped_timeline(): assert cues[1].start == pytest.approx(4.4) # the second shot starts early +def test_a_shot_that_cuts_in_starts_where_the_one_before_ends(): + shots = [Shot(kind="still", chapter="One"), Shot(kind="still", chapter="Two", cut_in=True), + Shot(kind="still", chapter="Three")] + marks, _cues, total = build_timeline(shots, [5.0, 4.0, 6.0], [[], [], []], 0.6) + assert total == pytest.approx(14.4) # one blend, one straight cut + assert [m[:2] for m in marks] == [ + (pytest.approx(0.0), pytest.approx(5.0)), + (pytest.approx(5.0), pytest.approx(8.4)), + (pytest.approx(8.4), pytest.approx(14.4)), + ] + + def test_a_video_with_no_chapters_still_reports_its_length(): marks, cues, total = _timeline([3.0]) assert marks == [] and cues == [] and total == pytest.approx(3.0) diff --git a/backend/tests/test_video_segmenter.py b/backend/tests/test_video_segmenter.py index e600681..8ce5e04 100644 --- a/backend/tests/test_video_segmenter.py +++ b/backend/tests/test_video_segmenter.py @@ -620,16 +620,19 @@ def _heading(value): def test_a_heading_read_in_its_section_is_set_apart_by_blank_lines(): """The blank line is what chunk_narration turns into an actual gap; without - it the voice has only a full stop between the paragraph and the heading.""" + it the voice has only a full stop between the heading and what follows. + Partway through a section the heading opens a shot of its own, whose end + holds the gap before it.""" root = _media() shots = _run([ _para("The first section ends here"), _heading("A New Chapter"), _para("And the next section begins"), ], media_root=root).shots - assert shots[0].narration == ( - "The first section ends here.\n\nA New Chapter.\n\nAnd the next section begins." - ) + assert [s.narration for s in shots] == [ + "The first section ends here.", + "A New Chapter.\n\nAnd the next section begins.", + ] def test_ordinary_paragraphs_are_not_set_apart_from_each_other(): @@ -643,7 +646,7 @@ def test_turning_the_heading_pause_off_leaves_the_narration_as_it_was(): options = RenderOptions(title_card=False, heading_pause_ms=0) shots = _run([_para("Before"), _heading("A Heading"), _para("After")], media_root=root, options=options).shots - assert shots[0].narration == "Before.\nA Heading.\nAfter." + assert [s.narration for s in shots] == ["Before.", "A Heading.\nAfter."] def test_a_leading_or_trailing_break_is_trimmed_off_the_shot(): @@ -791,6 +794,223 @@ def test_markers_are_not_counted_as_narration_characters(): assert marked.narration_chars == plain.narration_chars +def test_a_marker_in_a_section_heading_is_not_in_its_chapter_mark(): + """A section's mark used to be set after its shot was built, so it kept the + markers the card's mark lost — and the chapter list showed them.""" + root = _media("a.png") + for read in (True, False): + plan = _run([ + {"type": "heading", "content": _text("Chapter One [pause:2s]")}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("Words."), + ], media_root=root, options=RenderOptions(title_card=False, read_chapters=read)) + assert _marks(plan) == ["Chapter One"] + assert "[pause:2s]" in _run([ + {"type": "heading", "content": _text("Chapter One [pause:2s]")}, + ], media_root=root).shots[0].narration + + +def test_a_heading_of_nothing_but_markers_marks_no_chapter(): + """It is a pause, not a chapter: no blank card, no blank entry in the list.""" + root = _media("a.png") + for screens in (False, True): + plan = _run([ + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("Before."), + {"type": "heading", "content": _text("[pause:2s]")}, + _para("After."), + ], media_root=root, options=RenderOptions(title_card=False, chapter_screens=screens)) + assert [s.kind for s in plan.shots] == ["still"] + assert _marks(plan) == [] + assert "[pause:2s]" in plan.shots[0].narration + + +# ── chapter marks reach the chapter list ───────────────────────────────────── +# With read_chapters off a heading takes no time of its own, so its mark has to +# start whatever comes where it stands. These are the places it used to be +# dropped instead (docs/gecko-video-creator.md §3.2). + +def _unread(): + return RenderOptions(title_card=False, read_chapters=False, quotes={"enabled": True}) + + +def _marks(plan): + return [s.chapter for s in plan.shots if s.chapter] + + +def test_a_quote_taking_over_a_section_keeps_its_chapter_mark(): + root = _media("a.png") + plan = _run([ + {"type": "heading", "content": _text("Opening")}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _quote("A quotation."), + ], media_root=root, options=_unread()) + assert plan.shots[0].quote_text == "A quotation." + assert _marks(plan) == ["Opening"] + + +def test_a_code_block_taking_over_a_section_keeps_its_chapter_mark(): + root = _media("a.png") + plan = _run([ + {"type": "heading", "content": _text("Opening")}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + {"type": "codeBlock", "content": _text("print(1)")}, + ], media_root=root, options=_unread()) + assert plan.shots[0].code_text == "print(1)" + assert _marks(plan) == ["Opening"] + + +def test_an_unread_heading_at_the_end_of_a_section_marks_the_next_one(): + """The commonest shape of all: a heading above each picture. The section + above already has its mark, and this heading took no time in it.""" + root = _media("a.png", "b.png") + plan = _run([ + {"type": "heading", "content": _text("One")}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("First."), + {"type": "heading", "content": _text("Two")}, + {"type": "image", "props": {"url": "/media/u1/b.png"}}, + _para("Second."), + ], media_root=root, options=_unread()) + assert [(os.path.basename(s.background), s.chapter) for s in plan.shots] == [ + ("a.png", "One"), ("b.png", "Two")] + + +def test_an_unread_heading_above_a_sounded_clip_marks_the_clip(): + root = _media("a.png", "loud.mp4") + clip = {"type": "videoFile", "props": {"url": "/media/u1/loud.mp4"}} + alone = _run([{"type": "heading", "content": _text("One")}, clip, _para("Over it.")], + media_root=root, options=_unread(), loud=("loud.mp4",)) + assert alone.shots[0].kind == "video_sound" + assert _marks(alone) == ["One"] + + after = _run([ + {"type": "heading", "content": _text("One")}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("First."), + {"type": "heading", "content": _text("Two")}, + clip, + ], media_root=root, options=_unread(), loud=("loud.mp4",)) + assert [(s.kind, s.chapter) for s in after.shots[:2]] == [("still", "One"), ("video_sound", "Two")] + assert _marks(after) == ["One", "Two"] + + +def test_a_chapter_mark_on_missing_media_starts_what_comes_next(): + root = _media("a.png") + plan = _run([ + {"type": "heading", "content": _text("One")}, + {"type": "image", "props": {"url": "/media/u1/gone.png"}}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("Words."), + ], media_root=root, options=_unread()) + assert [(os.path.basename(s.background), s.chapter) for s in plan.shots] == [("a.png", "One")] + assert any("missing" in w for w in plan.warnings) + + +def test_a_chapter_mark_on_an_empty_continuation_starts_what_comes_next(): + """After a quote with no picture behind it, the section it reopens shows + nothing and is dropped — but the heading in it still starts a chapter.""" + root = _media("a.png") + plan = _run([ + _quote("A quotation."), + {"type": "heading", "content": _text("Next")}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("Words."), + ], media_root=root, options=_unread()) + assert [s.chapter for s in plan.shots] == [None, "Next"] + + +# ── a heading partway through a section ───────────────────────────────────── +# A section is one shot, which can hold one chapter mark at its start, so a +# heading after something has been said there opens a shot of its own over the +# same picture, joined to the one before by a straight cut. + +def test_a_heading_partway_through_a_section_starts_a_shot_over_the_same_picture(): + root = _media("a.png") + blocks = [ + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("First."), + {"type": "heading", "content": _text("Two")}, + _para("Second."), + ] + a = os.path.join(root, "u1", "a.png") + for read, said in ((True, "Two.\n\nSecond."), (False, "Second.")): + plan = _run(blocks, media_root=root, + options=RenderOptions(title_card=False, read_chapters=read)) + assert [(s.kind, s.background, s.chapter, s.narration, s.cut_in) for s in plan.shots] == [ + ("still", a, None, "First.", False), + ("still", a, "Two", said, True), + ] + + +def test_a_note_of_text_alone_has_a_chapter_for_every_heading(): + root = _media() + plan = _run([ + {"type": "heading", "content": _text("One")}, _para("Words."), + {"type": "heading", "content": _text("Two")}, _para("More words."), + {"type": "heading", "content": _text("Three")}, _para("The last words."), + ], media_root=root) + assert _marks(plan) == ["One", "Two", "Three"] + assert [s.background for s in plan.shots] == [None, None, None] + assert [s.cut_in for s in plan.shots] == [False, True, True] + + +def test_a_heading_over_a_silent_clip_carries_on_over_the_clip(): + root = _media("quiet.mp4") + plan = _run([ + {"type": "videoFile", "props": {"url": "/media/u1/quiet.mp4"}}, + _para("First."), + {"type": "heading", "content": _text("Two")}, + _para("Second."), + ], media_root=root) + assert [(s.kind, os.path.basename(s.background), s.chapter) for s in plan.shots] == [ + ("video_muted", "quiet.mp4", None), ("video_muted", "quiet.mp4", "Two")] + + +def test_a_heading_right_above_a_picture_marks_the_picture(): + """Nothing is said under it before the picture changes, so it opens no shot + of its own: read aloud it still ends the section above, and its mark starts + the picture below.""" + root = _media("a.png", "b.png") + plan = _run([ + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("First."), + {"type": "heading", "content": _text("Two")}, + {"type": "image", "props": {"url": "/media/u1/b.png"}}, + _para("Second."), + ], media_root=root) + assert [(os.path.basename(s.background), s.chapter, s.narration) for s in plan.shots] == [ + ("a.png", None, "First.\n\nTwo."), ("b.png", "Two", "Second.")] + assert not any(s.cut_in for s in plan.shots) + + +def test_back_to_back_headings_partway_through_a_section_keep_the_first(): + root = _media("a.png") + plan = _run([ + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("First."), + {"type": "heading", "content": _text("Two")}, + {"type": "heading", "content": _text("Two and a half")}, + _para("Second."), + ], media_root=root) + assert _marks(plan) == ["Two"] + assert plan.shots[1].narration.startswith("Two.") + + +def test_two_marks_at_the_same_moment_keep_the_first(): + """Both headings come before anything is shown or said, so only one + entry can start there; the first, as with two headings in a row.""" + root = _media("a.png") + plan = _run([ + {"type": "heading", "content": _text("One")}, + {"type": "image", "props": {"url": "/media/u1/gone.png"}}, + {"type": "heading", "content": _text("Two")}, + {"type": "image", "props": {"url": "/media/u1/a.png"}}, + _para("Words."), + ], media_root=root, options=_unread()) + assert _marks(plan) == ["One"] + + # ── intro and outro ────────────────────────────────────────────────────────── def _bracketed(**overrides): diff --git a/backend/tests/test_video_spec.py b/backend/tests/test_video_spec.py index 9074029..0efcddd 100644 --- a/backend/tests/test_video_spec.py +++ b/backend/tests/test_video_spec.py @@ -305,6 +305,8 @@ def _render_commands(monkeypatch, media, content, options, *, via_spec): {"id": "2", "type": "heading", "content": [{"type": "text", "text": "The road [pause]"}]}, {"id": "3", "type": "image", "props": {"url": "/media/u1/a.png"}}, {"id": "4", "type": "paragraph", "content": [{"type": "text", "text": "Under the map."}]}, + {"id": "4a", "type": "heading", "content": [{"type": "text", "text": "The market"}]}, + {"id": "4b", "type": "paragraph", "content": [{"type": "text", "text": "Stalls of salt."}]}, {"id": "5", "type": "quote", "content": [{"type": "text", "text": "Salt was gold — A Trader"}]}, {"id": "6", "type": "paragraph", "content": [{"type": "text", "text": "After the quote."}]}, {"id": "7", "type": "videoFile", "props": {"url": "/media/u1/loud.mp4"}}, @@ -341,6 +343,18 @@ def test_the_renderer_issues_the_same_ffmpeg_commands_either_way(tmp_path, monke assert len(want[0]) > 5 +def test_a_heading_partway_through_a_section_is_joined_by_a_straight_cut(tmp_path, monkeypatch): + """Over the same picture a dip to black and back would show nothing changing.""" + media = _media_root(tmp_path) + options = RenderOptions(quotes={"enabled": True}, transition={"style": "fade"}) + calls, _warnings = _render_commands(monkeypatch, media, _RENDER_NOTE, options, via_spec=True) + graphs = [call[call.index("-filter_complex") + 1] for call in calls + if call[-1].startswith("shot_")] + no_in = [i for i, graph in enumerate(graphs) if "fade=t=in" not in graph] + no_out = [i for i, graph in enumerate(graphs) if "fade=t=out" not in graph] + assert len(no_in) == 1 and no_out == [no_in[0] - 1] + + def test_music_switched_on_with_no_track_is_not_part_of_the_video(tmp_path, monkeypatch): # The one thing the trip deliberately drops. A spec's music is a track, and # a track names its file, so "music on, nothing chosen" has no spec form: @@ -529,6 +543,35 @@ def test_a_clip_whose_file_has_gone_is_skipped_not_left_without_a_picture(tmp_pa assert plan.warnings == ["Skipped missing media: /media/u1/loud.mp4"] +def test_a_heading_partway_through_a_section_is_a_segment_that_cuts_in(tmp_path): + root = _media_root(tmp_path) + note = json.dumps([ + {"id": "img", "type": "image", "props": {"url": "/media/u1/a.png"}}, + {"id": "p1", "type": "paragraph", "content": [{"type": "text", "text": "Under the map."}]}, + {"id": "h", "type": "heading", "content": [{"type": "text", "text": "The market"}]}, + {"id": "p2", "type": "paragraph", "content": [{"type": "text", "text": "Stalls of salt."}]}, + ]) + spec = import_note(note, user_id="u1", media_dir=root, + options=RenderOptions(title_card=False, read_chapters=False)).spec + first, second = spec.segments + assert (second.kind, second.chapter, second.visual.asset) == ("scene", "The market", first.visual.asset) + assert first.transition_in is None and second.transition_in.style == "none" + # The heading isn't read, but it is where the second segment came from. + assert first.source.block_ids == ["img", "p1"] + assert second.source.block_ids == ["h", "p2"] + shots = compile_spec(spec, user_id="u1", media_dir=root).segmentation.shots + assert [s.cut_in for s in shots] == [False, True] + + +def test_a_chapter_written_into_a_spec_is_drawn_without_its_markers(tmp_path): + """The importer strips them; a spec written by hand, or by the AI, may not.""" + spec = _spec(segments=[{"id": "s1", "kind": "scene", "chapter": "Salt [pause:1s]", + "visual": {"asset": "a1"}, + "narration": {"beats": [{"id": "b1", "text": "One."}]}}]) + plan = compile_spec(spec, user_id="u1", media_dir=_media_root(tmp_path)).segmentation + assert [s.chapter for s in plan.shots] == ["Salt"] + + # ── POST /api/video/spec ───────────────────────────────────────────────────── _SPEC_NOTE = [ diff --git a/docs/gecko-video-creator.md b/docs/gecko-video-creator.md index 2b25b21..039b25b 100644 --- a/docs/gecko-video-creator.md +++ b/docs/gecko-video-creator.md @@ -167,31 +167,43 @@ Seven existing defects should be fixed on the way in rather than carried over: dialog lists the voices of the engine that will read the video, the worker passes the choice on (`video/worker.py:_render_voice`), and the speech core honours a Flux voice id for Deepgram. A choice saved under the other engine falls back to the account's voice. -- **A heading placed above its image** attaches to the *previous* shot, not the image - (pinned by `backend/tests/test_video_segmenter.py`). That's correct for the - "text below the picture" model, but surprising. +- **A heading placed above its image** is read at the end of the *previous* section, + not over the image (pinned by `backend/tests/test_video_segmenter.py`). That's correct + for the "text below the picture" model, but surprising. Its chapter mark now starts the + image's segment (open decision 15), so only where it is *read* is left to + `heading_attaches` (§7, open decision 5). - **The frontend and backend clamp differently.** Speed is 0.75–1.5 in the UI and 0.5–2.0 on the server. The longest transition is 2 s versus 3 s. `min_shot_seconds` maxes out at 10 versus 30, and there are a few more. - **Some settings have no UI at all:** `fps`, `fallback.angle`, waveform opacity and scrim, watermark opacity and margin, overlay margin and shadow. -- **A chapter mark can vanish.** With headings not read aloud, a heading followed by a - picture and then straight away a quote loses its chapter: the mark is carried onto the - picture's shot, and the quote then takes that shot over (the takeover in `importer._walk`, as in the - original walk kept in `tests/segment_reference.py`). -- **Pause markers leak into chapter marks.** `Shot.__post_init__` strips `[pause:…]` - from drawn fields set when a shot is built, but a section's chapter is set afterwards - (`compiler._segment_shots`, as in the original walk), so `The Road [pause:2s]` lands in the MP4's chapter list - and the "title + chapter" overlay as typed. - +- ~~**A chapter mark can vanish.**~~ **Fixed.** With headings not read aloud, a heading + takes no time of its own, and its mark was dropped wherever it had no shot to start: + when a quote or code block took over the picture's shot, before a clip with sound, on a + missing picture or an empty continuation that was then left out, and at the end of a + section that already had a mark. That last one is the commonest note shape there is, a + heading above each picture. Each now starts whatever comes where the heading stands + (`importer._walk`). A scan of every note up to five blocks long, under each chapter + setting, found 7,203 marks dropped this way. A heading partway through a section now + starts a segment of its own (open decision 15). The only marks still dropped are a + heading with nothing after it, and the second of two marks that fall on the same + moment: the first is kept, as with two headings in a row. +- ~~**Pause markers leak into chapter marks.**~~ **Fixed.** A section's chapter was set + after its shot was built, so `Shot.__post_init__` never stripped it, and + `The Road [pause:2s]` reached the MP4's chapter list and the "title + chapter" overlay + as typed. Now the importer takes the mark without its markers, and the compiler builds + it into the shot, so markers in a hand-written spec are stripped too. A heading that is + nothing but markers is a pause, not a chapter, and no longer makes a blank card or a + blank entry. - **A sounded clip leaves an empty, muted copy of itself behind** when nothing is said under it (it ends the note, or another picture follows): a `scene` with no beats over the same clip, held for `min_segment_seconds`. -Phase 0 reproduces all of these exactly, so its parity tests can hold the spec path to -the original output. The spec path is now the only one, so each is a small follow-up fix, -made in the importer and in `tests/segment_reference.py` together (or that oracle is -retired). +Phase 0 reproduced all of these exactly, so its parity tests could hold the spec path to +the original output. The spec path is now the only one, so each is a small follow-up fix. +Each is made in the importer and again, written independently, in +`tests/segment_reference.py`, so the parity tests still compare two implementations (or +that oracle is retired). The voice and chapter fixes were made that way. --- @@ -798,6 +810,7 @@ spec for any note that renders exactly as Notes renders it now. | Video block with audio (`video_sound` + `video_muted` pair) | a `clip` segment, then a `scene` reusing the same asset muted and looped (even with nothing said under it, a quirk kept for parity, §3.2) | | Missing or remote media | `scene` with no visual, plus an import warning (same as today's warning) | | Heading, `chapter_screens` off | beat `kind: "heading"`; `chapter` set on the segment that carries the mark | +| Heading partway through a section, `chapter_screens` off | a new `scene` over the same `visual`, with that `chapter`, the beats from the heading on, and `transition_in: { style: "none" }` (a straight cut, the one per-segment transition Phase 0 renders). With only headings after it before the picture changes, it opens nothing and marks the next segment instead | | Heading, `chapter_screens` on | `card` segment (`variant: "chapter"`), then a continuation `scene` | | `read_chapters` off | heading beat with `speak: false`; card with no narration | | `title_card` | `card` segment, `role: "title"`, subtitle = author | @@ -828,7 +841,8 @@ continuing shot, with its own `fade`, as a separate choice. **Importer options** fix the surprises noted in §3.2 without breaking parity: - `heading_attaches: "previous" | "next_media"` (**planned**, not in Phase 0, which - always behaves as `"previous"`, today's behaviour). The recommended default in GVC is + always reads such a heading as `"previous"` does, today's behaviour, though its + chapter mark already starts the image's segment). The recommended default in GVC is `"next_media"`, where a heading above an image opens that image's segment. - `chapter_screens`, `read_chapters`, `quotes`, `narrate_code` stay as **import-time** choices, because they decide the *shape* of the spec. Once imported, the spec just @@ -1015,7 +1029,7 @@ bug fixes only, each logged so it can be ported. | Phase | What | Done when | |---|---|---| -| **0 — Spec inside Notes** | **In progress.** Done: the VideoSpec in Pydantic ([`spec.py`](../backend/app/video/spec.py)); an importer that walks a note as the segmenter always has ([`importer.py`](../backend/app/video/importer.py)); a compiler back to the `Shot`s and `RenderOptions` the renderer uses ([`compiler.py`](../backend/app/video/compiler.py)); and `segment()` now going through them, so every render is built from a spec. `POST /api/video/spec` returns that spec for a note (same body as `/estimate`). The dialog's Voice choice now reaches the render. Still to do: the chapter-mark quirks (§3.2). | [`tests/test_video_spec.py`](../backend/tests/test_video_spec.py): every segmenter test case, a few thousand generated notes and the renderer's ffmpeg commands come out identical to the original walk, kept verbatim as an oracle ([`segment_reference.py`](../backend/tests/segment_reference.py)), with no real encodes | +| **0 — Spec inside Notes** | **Done.** The VideoSpec in Pydantic ([`spec.py`](../backend/app/video/spec.py)); an importer that walks a note as the segmenter always has ([`importer.py`](../backend/app/video/importer.py)); a compiler back to the `Shot`s and `RenderOptions` the renderer uses ([`compiler.py`](../backend/app/video/compiler.py)); and `segment()` now going through them, so every render is built from a spec. `POST /api/video/spec` returns that spec for a note (same body as `/estimate`). The dialog's Voice choice now reaches the render, and chapter marks are no longer dropped or drawn with their pause markers (§3.2). | [`tests/test_video_spec.py`](../backend/tests/test_video_spec.py): every segmenter test case, a few thousand generated notes and the renderer's ffmpeg commands come out identical to the original walk, kept as an oracle ([`segment_reference.py`](../backend/tests/segment_reference.py), verbatim but for the §3.2 fixes), with no real encodes | | **1 — GVC renders what Notes renders** | New repo `davior/gvc` from GAM's skeleton. The engine copied from the Phase 0 merge, SSO, project storage and history, import from Notes over HTTP, speech through Notes, preview and full render, download. Output to GAM and back into the note follow once those are designed. | Any note renders in GVC the same as in Notes | | **2 — The new capabilities** | Timed overlays, multiple audio tracks, trims, per-segment voice/motion/transition, GAM search and picker, `generate` / `search` placeholders, credits | The §6 example renders | | **3 — AI editing** | The operations of §8.2, the suggestions tray, chat, staged review | "Make a video from this note" produces a reviewed, rendered video | @@ -1096,6 +1110,19 @@ already renders, it's wrong, and finding that out costs a branch, not a new app. `audio_tracks` by list index, so a staged suggestion or a selective undo can hit the wrong item after a reorder. The §8.2 operations address by id; store those, or patches resolved by id, rather than raw index paths. +15. ~~**A chapter mark inside a section.**~~ **Decided: cut a segment at each heading.** + A mark starts a segment, and a section was one segment, so it held one mark. With + `chapter_screens` off, a second heading inside a section (`## A`, text, `## B`, more + text, all over one picture) got no entry, whether or not it was read, and a note of + text alone with three headings had one chapter. Now a heading after something has + been said in its section starts a new `scene` there, over the same picture, joined to + the one before by a straight cut: `transition_in: { style: "none" }`. The renderer + honours that on both the dip and the blend paths. The costs: + - each new segment is held for at least `min_shot_seconds`; + - a looping clip, or a Ken Burns drift, starts again at the cut. + + The alternative, a mark on the beat where the heading is read, would have changed + nothing on screen. ---