diff --git a/example/media/narration/narration.json b/example/media/narration/narration.json index 8a095f2..6ff2094 100644 --- a/example/media/narration/narration.json +++ b/example/media/narration/narration.json @@ -1,14 +1,14 @@ { "talking_head_S1": { "source_file": "talking_head_S1.mov", - "output_file": "talking_head_S1_processed.mov", + "output_file": "processed/talking_head_S1_processed.mov", "cutout": "talkinghead", "always_visible": true, "filter": "talkinghead" }, "talking_head_S3": { "source_file": "talking_head_S3.mov", - "output_file": "talking_head_S3_processed.mov", + "output_file": "processed/talking_head_S3_processed.mov", "cutout": "talkinghead", "always_visible": true, "filter": "talkinghead" diff --git a/example/project.json b/example/project.json index c73bee0..927b185 100644 --- a/example/project.json +++ b/example/project.json @@ -9,7 +9,7 @@ "keynote_file": "media/example.key", "transcript": "media/videos/talking_head.transcript.json", "narration": "media/narration/narration.json", - "background": "shared_assets/solarpunk.png", + "background": "blackbackground", "videos": "media/videos/videos.json", "slides": "media/slides/Example/slides.json", "audio": "media/audio/audio.json", diff --git a/example/tasks.md b/example/tasks.md new file mode 100644 index 0000000..b335843 --- /dev/null +++ b/example/tasks.md @@ -0,0 +1,13 @@ +# Tasks: example +_Generated: 2026-07-15_ + +## Slide Alignment Issues (7) +Slide markers that could not be matched to the spoken narration (likely adlibbed). + +- [ ] `S6` — _"(repaired: voice continues after the video finished)"_ +- [ ] `S7` — _"This is the first slide. It appears immediately."_ +- [ ] `S8` — _"However, this is the second slide. It should appea"_ +- [ ] `S9` — _"This is me talking alongside a video. The video is"_ +- [ ] `S10` — _"I will continue to talk without pause, but in the"_ +- [ ] `S11` — _"Notice how my voice continues after the video fini"_ +- [ ] `S12` — _"(repaired: voice continues after the video finished)"_ diff --git a/gnommo/cli.py b/gnommo/cli.py index e848d97..967edf1 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -4221,6 +4221,42 @@ def _chunked_render( return 0 +def _build_merged_transcription(project_path: Path, config, verbose: bool = False): + """Deterministic merged transcript for slide alignment. + + Builds a single word-level transcript from the per-segment transcripts + + the current narration.json skip/take, re-timed into the combined timeline + (see narration.build_narration_schedule). This keeps alignment in sync with + narration.json and avoids re-transcribing the combined file. + + Returns None (caller falls back to the on-disk transcript) when there are no + narration segments or any segment is missing its per-segment transcript. + """ + from .parser import parse_narration, get_video_duration + from .narration import build_narration_schedule + + try: + narration, narration_dir = parse_narration(project_path, config) + except GnommoError: + return None + if not narration: + return None + + transcripts_dir = narration_dir / "transcripts" + missing = [sid for sid in narration if not (transcripts_dir / f"{sid}.json").exists()] + if missing: + if verbose: + print(f" Merged transcript unavailable (no per-segment transcript for: " + f"{', '.join(missing)}) — using on-disk transcript.") + return None + + _segments, merged = build_narration_schedule( + narration, narration_dir, get_video_duration, + transcripts_dir=transcripts_dir, verbose=verbose, + ) + return merged or None + + def cmd_render( project_path: Path, verbose: bool, @@ -4303,129 +4339,59 @@ def cmd_render( print(f" Using {res} dir: {videos_dir}") audio, audio_dir = parse_audio(project_path, config) - # Load whisper transcription JSON - # Resolve the combined narration skeleton. The .mov file — not the videos.json - # entry — is the source of truth: stitch run without the external drive leaves - # the file in media/videos/ even when the videos.json entry is absent (e.g. a - # metadata pull overwrote it). Legacy multi-segment projects are handled below. - combined_path = videos_dir / "narration_combined.mov" - resolved_combined = _resolve_narration_combined(project_path, videos_dir, config) - narration_json = project_path / "media" / "narration" / "narration.json" - _narr_segments = _read_json(narration_json) if narration_json.exists() else {} - if resolved_combined and resolved_combined.exists(): - # File is available (locally or via process cache). Ensure a videos.json - # entry exists — synthesizing one when stitch's entry was lost — then use it. - if "narration_combined" not in videos: - from .models import VideoSource + # --- Narration: render-time concat of the processed segments --- + # narration.json is the single source of truth. The processed segments are + # concatenated directly in the render graph — there is no narration_combined + # file anymore. + from .narration import build_narration_schedule + from .parser import parse_narration as _parse_narr, get_video_duration - _first_seg = next(iter(_narr_segments.values()), {}) - _seg_cutout = ( - _first_seg.get("cutout") if isinstance(_first_seg, dict) else None - ) - videos["narration_combined"] = VideoSource( - source_file="narration_combined.mov", - cutout=_seg_cutout or "talkinghead", - always_visible=True, - volume=1.0, - ) - if resolved_combined != combined_path: - # File lives on external disk — point the VideoSource at the absolute - # path so the renderer doesn't re-resolve it via the local videos_dir. - videos["narration_combined"].source_file = str(resolved_combined) - transcript_path = resolved_combined.with_suffix(".transcript.json") - config.main_video = "narration_combined" - if verbose: + narration_map, narration_seg_dir = _parse_narr(project_path, config) + narration_schedule: list = [] + narration_source = None + transcript_path = None + if narration_map: + narration_schedule, _ = build_narration_schedule( + narration_map, narration_seg_dir, get_video_duration + ) + missing = [s.seg_id for s in narration_schedule if not s.source_path.exists()] + if missing: print( - f" Using combined narration: {resolved_combined.name} (volume={videos['narration_combined'].volume})" - ) - elif isinstance(config.main_video, list) and len(config.main_video) > 1: - # Legacy: Multi-segment narration with main_video array in project.json - resolved_combined, _ = resolve_with_cache(combined_path, project_path) - transcript_path = resolved_combined.with_suffix(".transcript.json") - - if not resolved_combined.exists(): - print( - f"Error: Combined narration not found: {combined_path}", file=sys.stderr - ) - print( - "Run 'gnommo -p concat' first to concatenate segments.", + f"Error: processed narration segment(s) not found: {', '.join(missing)}", file=sys.stderr, ) + print(f"Run 'gnommo -p {project_path.name} preprocess' first.", file=sys.stderr) return 1 + # Talking-head cutout/zoom/audio settings come from the first segment. + narration_source = narration_map[narration_schedule[0].seg_id] - # Create a synthetic video entry for the combined narration - # Inherit settings from the first segment - first_segment_id = config.main_video[0] - if first_segment_id in videos: - first_segment = videos[first_segment_id] - from .models import VideoSource - - combined_video = VideoSource( - source_file="narration_combined.mov", - filter=first_segment.filter, - output_file=None, # Already processed - cutout=first_segment.cutout, - always_visible=True, - skip=0.0, # Already trimmed during concatenation - take=None, - ) - videos["_narration_combined"] = combined_video - config.main_video = "_narration_combined" - + # --- Transcript for slide alignment --- + # Prefer the deterministic merged transcript (per-segment transcripts re-timed + # into the concatenated timeline). Otherwise fall back to an on-disk transcript. + transcription = _build_merged_transcription(project_path, config, verbose) + if transcription is not None: if verbose: - print(f" Using combined narration: {combined_path.name}") - elif _narr_segments: - # narration.json has segments, but the combined .mov could not be found. - # Distinguish "stitched, file unreachable" from "never stitched" so the - # hint is actionable instead of always blaming videos.json. - if "narration_combined" in videos: - print( - f"Error: narration_combined.mov could not be found.", file=sys.stderr - ) - print( - f"videos.json references narration_combined, but the file is not on disk " - f"(checked local media/videos and the process cache).", - file=sys.stderr, - ) - print(_narration_combined_hint(project_path, config), file=sys.stderr) - else: - print( - f"Error: narration_combined not found in videos.json", file=sys.stderr - ) - print( - f"You have narration segments in narration.json but haven't stitched them.", - file=sys.stderr, - ) - print( - f"Run 'gnommo -p {project_path.name} stitch' first.", - file=sys.stderr, - ) - return 1 + print(f" Using merged per-segment transcript ({len(transcription)} words)") else: - # Single video - look for .transcript.json next to the narration video - result = _find_narration_video(config, videos) - if result: - video_id, narration_source = result - config.main_video = video_id # Ensure main_video is set to the found video - video_path = videos_dir / narration_source.source_file - transcript_path = video_path.with_suffix(".transcript.json") + if config.transcript_path and (project_path / config.transcript_path).exists(): + transcript_path = project_path / config.transcript_path + elif narration_map: + # Legacy on-disk transcript that used to accompany narration_combined. + transcript_path = videos_dir / "narration_combined.transcript.json" else: - transcript_path = project_path / "transcript.json" - - # If project.json specifies a transcript path, prefer it (always local) - if config.transcript_path: - local_transcript = project_path / config.transcript_path - if local_transcript.exists(): - transcript_path = local_transcript - - # Try cache fallback for transcript - transcript_path, _ = resolve_with_cache(transcript_path, project_path) - if not transcript_path.exists(): - print(f"Error: Transcription not found: {transcript_path}", file=sys.stderr) - print(f"Run 'gnommo -p {project_path.name} transcribe' first.", file=sys.stderr) - return 1 - - transcription = load_transcript(transcript_path, project_path) + result = _find_narration_video(config, videos) + if result: + _vid, _src = result + config.main_video = _vid + transcript_path = (videos_dir / _src.source_file).with_suffix(".transcript.json") + else: + transcript_path = project_path / "transcript.json" + transcript_path, _ = resolve_with_cache(transcript_path, project_path) + if not transcript_path.exists(): + print(f"Error: Transcription not found: {transcript_path}", file=sys.stderr) + print(f"Run 'gnommo -p {project_path.name} transcribe' first.", file=sys.stderr) + return 1 + transcription = load_transcript(transcript_path, project_path) if verbose: print(f" - Markers in manuscript: {len(markers)}") @@ -4455,9 +4421,13 @@ def cmd_render( audio, audio_dir, slide_range=slide_range, + narration_schedule=narration_schedule, + narration_source=narration_source, ) if plan.time_offset > 0: print(f" Time offset: {plan.time_offset:.1f}s (partial render)") + if plan.narration_segments: + print(f" Narration concat: {len(plan.narration_segments)} segment(s) at render time") # Print detailed render plan with alignment info _print_render_plan_details(plan, marker_timings, slides) @@ -4584,10 +4554,19 @@ def cmd_render( ("manuscript.txt", project_path / "manuscript.txt", _state.HASH), ("project.json", project_path / "project.json", _state.HASH), ("slides.json", _slides_json, _state.HASH), - ("transcript", transcript_path, _state.HASH), + # narration.json (skip/take) drives the concat timeline + alignment. + ("narration.json", project_path / "media" / "narration" / "narration.json", _state.HASH), ] - if resolved_combined: - specs.append(("narration_combined", resolved_combined, _state.META)) + if transcript_path: + specs.append(("transcript", transcript_path, _state.HASH)) + # The concatenated narration segments (the render's main video input). + for _seg in plan.narration_segments: + specs.append((f"narr:{_seg.seg_id}", _seg.source_path, _state.META)) + # Per-segment transcripts feed the merged transcript for alignment. + _tdir = project_path / "media" / "narration" / "transcripts" + if _tdir.is_dir(): + for _tj in sorted(_tdir.glob("*.json")): + specs.append((f"transcript:{_tj.stem}", _tj, _state.HASH)) for _sid, _sdef in slides.items(): specs.append((f"slide:{_sid}", _slides_dir / _sdef.image, _state.META)) return specs @@ -4606,7 +4585,10 @@ def cmd_render( if _render_current and output_path.exists(): try: _out_mtime = output_path.stat().st_mtime - for _dep in (resolved_combined, transcript_path): + _deps = [s.source_path for s in plan.narration_segments] + if transcript_path: + _deps.append(transcript_path) + for _dep in _deps: if _dep and Path(_dep).exists() and Path(_dep).stat().st_mtime > _out_mtime: print(f" {Path(_dep).name} is newer than the render — regenerating.") _render_current = False diff --git a/gnommo/models.py b/gnommo/models.py index a7c3495..34f39ed 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -526,6 +526,11 @@ class RenderPlan: narration_pauses: list[NarrationPause] = field( default_factory=list ) # Gaps in narration for interstitial videos + # Render-time narration concat: ordered segments (skip/take + offset) to + # concatenate directly at render time instead of using a single pre-stitched + # narration_combined input. Typed loosely (list of narration.NarrationSegment) + # to avoid a circular import between models and narration. + narration_segments: list = field(default_factory=list) # Outro sequence (plays after narration ends) outro_events: list["OutroEvent"] = field( default_factory=list diff --git a/gnommo/narration.py b/gnommo/narration.py index 77c2718..6f44fb4 100644 --- a/gnommo/narration.py +++ b/gnommo/narration.py @@ -58,7 +58,8 @@ def build_narration_schedule( Args: narration: seg_id -> VideoSource (from parse_narration). - narration_dir: base dir the processed files resolve against. + narration_dir: base dir the processed files resolve against + (media/narration; output_file is like processed/…mov). get_duration: callable(Path) -> float (ffprobe duration), used only when a segment has no explicit take. transcripts_dir: where per-segment {seg_id}.json transcripts live diff --git a/gnommo/renderer.py b/gnommo/renderer.py index 4ebd6d6..fb3d089 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -328,6 +328,36 @@ def _build_audio_channel_filter(use_audio_channels: str) -> str: return "" # "both" - no filter needed +def _build_narration_concat_prefilter(seg_input_idxs: list[int], config) -> tuple: + """Normalize + concatenate narration segment inputs into one narration stream. + + Each segment is scaled to the project resolution with alpha-safe transparent + padding and setsar=1 (and its audio resampled to a canonical format) so that + mismatched source formats can't crash the concat. Returns + (video_label, audio_label, filter_lines). + """ + width, height = config.resolution + fps = config.fps + lines: list[str] = [] + pairs: list[tuple[str, str]] = [] + for j, idx in enumerate(seg_input_idxs): + v, a = f"ncv{j}", f"nca{j}" + lines.append( + f"[{idx}:v]fps={fps},setpts=PTS-STARTPTS,format=yuva444p10le," + f"scale={width}:{height}:force_original_aspect_ratio=decrease," + f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,setsar=1[{v}]" + ) + lines.append( + f"[{idx}:a]aresample=async=1," + f"aformat=sample_rates=48000:channel_layouts=stereo," + f"asetpts=PTS-STARTPTS[{a}]" + ) + pairs.append((v, a)) + joins = "".join(f"[{v}][{a}]" for v, a in pairs) + lines.append(f"{joins}concat=n={len(pairs)}:v=1:a=1[narrsrc_v][narrsrc_a]") + return "narrsrc_v", "narrsrc_a", lines + + def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]: """Build the complete FFmpeg command as a list of arguments.""" cmd = ["ffmpeg", "-y"] # -y to overwrite output @@ -351,25 +381,47 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]: # Track input indices input_idx = 0 - # Input: always_visible videos (like talking head) - # Add -ss seek BEFORE -i for skip parameter and/or partial rendering + # Input: narration (talking head). + # Concat mode: when plan.narration_segments is set, add each processed segment + # as its own input (trimmed by skip/take) and concatenate them in-graph into a + # single normalized narration stream — replacing the pre-stitched + # narration_combined input. Otherwise, the single always-visible input path. + # Add -ss seek BEFORE -i for skip parameter and/or partial rendering. always_visible_inputs: list[int] = [] - for video_id, video_source, cutout in plan.narration_videos: - video_path = _resolve_video_path( - videos_dir, video_source, shared_assets_dir, project_path + narration_concat = None # (video_label, audio_label) when concat mode is active + if plan.narration_segments: + seg_input_idxs: list[int] = [] + for seg in plan.narration_segments: + total_seek = (seg.skip or 0.0) + plan.input_seek_time + if total_seek > 0: + cmd.extend(["-ss", f"{total_seek:.3f}"]) + if seg.take is not None: + cmd.extend(["-t", f"{seg.take:.3f}"]) + cmd.extend(["-analyzeduration", "0", "-probesize", "1000"]) + cmd.extend(["-i", str(seg.source_path)]) + seg_input_idxs.append(input_idx) + input_idx += 1 + _nv_label, _na_label, _narr_concat_lines = _build_narration_concat_prefilter( + seg_input_idxs, plan.config ) - # Combine video skip setting with partial render offset - total_seek = video_source.skip + plan.input_seek_time - if total_seek > 0: - cmd.extend(["-ss", f"{total_seek:.3f}"]) - # Skip stream analysis — codec params are in the container header, and - # duration is already known by gnommo via ffprobe (plan.total_duration). - # Without this, FFmpeg reads 100MB+ of compressed data per input at 4K - # bitrates before encoding starts ("Estimating duration from bitrate"). - cmd.extend(["-analyzeduration", "0", "-probesize", "1000"]) - cmd.extend(["-i", str(video_path)]) - always_visible_inputs.append(input_idx) - input_idx += 1 + narration_concat = (_nv_label, _na_label, _narr_concat_lines) + else: + for video_id, video_source, cutout in plan.narration_videos: + video_path = _resolve_video_path( + videos_dir, video_source, shared_assets_dir, project_path + ) + # Combine video skip setting with partial render offset + total_seek = video_source.skip + plan.input_seek_time + if total_seek > 0: + cmd.extend(["-ss", f"{total_seek:.3f}"]) + # Skip stream analysis — codec params are in the container header, and + # duration is already known by gnommo via ffprobe (plan.total_duration). + # Without this, FFmpeg reads 100MB+ of compressed data per input at 4K + # bitrates before encoding starts ("Estimating duration from bitrate"). + cmd.extend(["-analyzeduration", "0", "-probesize", "1000"]) + cmd.extend(["-i", str(video_path)]) + always_visible_inputs.append(input_idx) + input_idx += 1 from .cache import resolve_with_cache @@ -563,6 +615,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]: video_events_with_audio, outro_inputs, outro_events_with_audio, + narration_concat, ) cmd.extend(["-filter_complex", filter_complex]) @@ -571,8 +624,9 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]: # Determine audio source # Priority: [aout] from filter > triggered video > no audio - # Note: we always create [aout] when always_visible_inputs exists - if always_visible_inputs: + # Note: [aout] is created whenever there's narration audio — either a single + # always-visible input or the in-graph concatenated narration [narrsrc_a]. + if always_visible_inputs or narration_concat: cmd.extend( ["-map", "[aout]"] ) # Audio from filter (may be segmented or simple copy) @@ -869,6 +923,7 @@ def build_filter_complex( video_events_with_audio: set[int] = None, outro_inputs: dict[int, int] = None, # outro event_index -> input_idx outro_events_with_audio: set[int] = None, + narration_concat: tuple = None, # (video_label, audio_label, filter_lines) or None ) -> str: """ Build the filter_complex string for FFmpeg. @@ -888,6 +943,14 @@ def build_filter_complex( width, height = plan.config.resolution filters: list[str] = [] + # Concat mode: emit the narration segment normalize+concat prefilter up front. + # It produces [narrsrc_v]/[narrsrc_a], which the talking-head node and the + # main-audio path consume in place of a single narration input. + narr_v_label = narr_a_label = None + if narration_concat: + narr_v_label, narr_a_label, _concat_lines = narration_concat + filters.extend(_concat_lines) + # Create base layer (background) if has_background: if bg_is_image: @@ -946,7 +1009,9 @@ def build_filter_complex( # Layer 3: Talking head — above below-videos, but under slides so fullscreen slides cover it for i, (video_id, video_source, cutout) in enumerate(plan.narration_videos): - input_idx = always_visible_inputs[i] + # Concat mode feeds the talking head from the in-graph concatenated + # narration [narrsrc_v]; otherwise from the single narration input pad. + narr_src = f"[{narr_v_label}]" if narr_v_label else f"[{always_visible_inputs[i]}:v]" cut_x, cut_y, cut_width, cut_height = _calculate_cutout_position( cutout, width, height ) @@ -955,10 +1020,10 @@ def build_filter_complex( zoomed_width = int(cut_width * zoom) zoomed_height = int(cut_height * zoom) - if not plan.narration_pauses: + if not plan.narration_pauses or narration_concat: video_label = f"av{i}" filters.append( - f"[{input_idx}:v]fps={plan.config.fps},setpts=PTS-STARTPTS," + f"{narr_src}fps={plan.config.fps},setpts=PTS-STARTPTS," f"format=yuva444p10le," f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," f"crop={cut_width}:{cut_height}:(iw-{cut_width})/2:(ih-{cut_height})/2," @@ -981,7 +1046,7 @@ def build_filter_complex( seg_label = f"av{i}_seg{seg_idx}" pts_offset = out_start filters.append( - f"[{input_idx}:v]trim={src_start:.3f}:{src_end:.3f}," + f"{narr_src}trim={src_start:.3f}:{src_end:.3f}," f"setpts=PTS-STARTPTS+{pts_offset:.3f}/TB," f"format=yuva444p10le," f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," @@ -1174,8 +1239,10 @@ def build_filter_complex( filters.append(f"[{current_label}]copy[vout]") # Audio mixing: combine main audio with sound effects - if always_visible_inputs: - main_audio_idx = always_visible_inputs[0] + if always_visible_inputs or narration_concat: + # In concat mode the main narration audio is the in-graph [narrsrc_a]; + # otherwise it's the first always-visible input's audio pad. + _main_aud = f"[{narr_a_label}]" if narr_a_label else f"[{always_visible_inputs[0]}:a]" audio_labels_to_mix = [] # Get audio channel setting and volume from first narration video @@ -1202,7 +1269,7 @@ def build_filter_complex( plan.narration_end_time if plan.outro_events else plan.total_duration ) - if not plan.narration_pauses: + if not plan.narration_pauses or narration_concat: # Simple case: trim main audio to end before outro (with optional channel and volume filters) filter_parts = [] if channel_filter: @@ -1215,16 +1282,16 @@ def build_filter_complex( filter_parts.append(f"atrim=0:{audio_end_time:.3f}") filter_parts.append("asetpts=PTS-STARTPTS") filters.append( - f"[{main_audio_idx}:a]{','.join(filter_parts)}[main_aud]" + f"{_main_aud}{','.join(filter_parts)}[main_aud]" ) audio_labels_to_mix.append("[main_aud]") elif filter_parts: filters.append( - f"[{main_audio_idx}:a]{','.join(filter_parts)}[main_aud]" + f"{_main_aud}{','.join(filter_parts)}[main_aud]" ) audio_labels_to_mix.append("[main_aud]") else: - audio_labels_to_mix.append(f"[{main_audio_idx}:a]") + audio_labels_to_mix.append(f"{_main_aud}") else: # Complex case: segment the narration audio for pauses segments = _build_narration_segments(plan.narration_pauses, audio_end_time) @@ -1244,7 +1311,7 @@ def build_filter_complex( if volume_filter: filter_parts.append(volume_filter) filters.append( - f"[{main_audio_idx}:a]{','.join(filter_parts)}[{seg_label}]" + f"{_main_aud}{','.join(filter_parts)}[{seg_label}]" ) audio_labels_to_mix.append(f"[{seg_label}]") diff --git a/gnommo/transformer.py b/gnommo/transformer.py index d026d77..e714fb5 100644 --- a/gnommo/transformer.py +++ b/gnommo/transformer.py @@ -637,6 +637,8 @@ def build_render_plan( audio: Optional[dict[str, AudioDefinition]] = None, audio_dir: Optional[Path] = None, slide_range: Optional[tuple[str, Optional[str]]] = None, + narration_schedule: Optional[list] = None, + narration_source: Optional[VideoSource] = None, ) -> tuple[RenderPlan, list[MarkerTiming]]: """ Build a complete render plan from manuscript and transcription. @@ -655,25 +657,52 @@ def build_render_plan( audio = audio or {} audio_dir = audio_dir or project_path - # Find the main narration video first (need skip value for timing adjustment) - narration_video_id = config.main_video - if isinstance(narration_video_id, list): - narration_video_id = narration_video_id[0] if narration_video_id else None - if not (narration_video_id and narration_video_id in videos): - raise ValueError( - f"Main video '{narration_video_id}' not specified or not found in videos. " - f"Available: {list(videos.keys())}" - ) - narration_video = videos[narration_video_id] - # Align markers to transcription timestamps marker_timings = align_markers_to_transcription( manuscript_text, transcription, slides=slides, videos=videos, audio=audio ) - # Apply skip offset: if narration video has skip, subtract it from all timestamps - # This accounts for the fact that the video will start at skip seconds, not 0 - narration_skip = narration_video.skip + # Find shared_assets directory + shared_assets_dir = None + if (project_path / "shared_assets").exists(): + shared_assets_dir = project_path / "shared_assets" + elif (project_path.parent / "shared_assets").exists(): + shared_assets_dir = project_path.parent / "shared_assets" + + # Track which files are loaded from external cache + cached_files: set[str] = set() + + # --- Narration source --- + # Render-time concat: narration is the concatenation of the scheduled + # segments, so there is no single file to probe — the total duration is the + # sum of the segment durations and skip is already baked into each segment. + if narration_schedule: + narration_video_id = "narration" + narration_video = narration_source or VideoSource( + source_file="", cutout=config.default_slide_type, always_visible=True + ) + narration_skip = 0.0 + full_duration = sum(seg.duration for seg in narration_schedule) + else: + narration_video_id = config.main_video + if isinstance(narration_video_id, list): + narration_video_id = narration_video_id[0] if narration_video_id else None + if not (narration_video_id and narration_video_id in videos): + raise ValueError( + f"Main video '{narration_video_id}' not specified or not found in videos. " + f"Available: {list(videos.keys())}" + ) + narration_video = videos[narration_video_id] + narration_skip = narration_video.skip + video_path, is_cached = _resolve_video_path( + videos_dir, narration_video, shared_assets_dir, project_path + ) + if is_cached: + cached_files.add(narration_video_id) + full_duration = get_video_duration(video_path) + + # Apply skip offset: if narration starts at `skip` seconds, subtract it from + # all marker timestamps so they line up with the trimmed timeline. if narration_skip > 0: for timing in marker_timings: if timing.timestamp >= 0: @@ -685,30 +714,12 @@ def build_render_plan( if timing.timestamp >= 0: marker_times[timing.marker_id] = timing.timestamp - # Find shared_assets directory - shared_assets_dir = None - if (project_path / "shared_assets").exists(): - shared_assets_dir = project_path / "shared_assets" - elif (project_path.parent / "shared_assets").exists(): - shared_assets_dir = project_path.parent / "shared_assets" - - narration_video = videos[narration_video_id] cutout = config.cutouts[narration_video.cutout] - - # Track which files are loaded from external cache - cached_files: set[str] = set() - - narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = [] - video_path, is_cached = _resolve_video_path( - videos_dir, narration_video, shared_assets_dir, project_path - ) - if is_cached: - cached_files.add(narration_video_id) - full_duration = get_video_duration(video_path) # Adjust duration for skip (content starts at skip, so effective duration is less) effective_duration = full_duration - narration_skip - # Get total duration from first always_visible video - narration_videos.append((narration_video_id, narration_video, cutout)) + narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = [ + (narration_video_id, narration_video, cutout) + ] # Resolve slide range to time range time_offset = 0.0 render_end_time = effective_duration @@ -925,6 +936,7 @@ def build_render_plan( input_seek_time=time_offset, shared_assets_dir=shared_assets_dir, narration_pauses=narration_pauses, + narration_segments=narration_schedule or [], outro_events=outro_events, narration_end_time=narration_end_time, cached_files=cached_files,