diff --git a/gnommo/cli.py b/gnommo/cli.py index ec7d379..0940c92 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -1430,7 +1430,10 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No "source_file": f"processed/{video_file.name}", } narration_entry["use_audio_channels"] = "auto" - narration_entry["defer_loudnorm"] = True + # Loudnorm is applied per-segment during preprocess (not deferred to + # stitch), so the processed files are already normalized and ready to be + # concatenated directly at render time. + narration_entry["defer_loudnorm"] = False existing_narration[segment_id] = narration_entry added_count += 1 @@ -1455,7 +1458,10 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No narration_entry["filter"] = "talkinghead" narration_entry["use_audio_channels"] = "auto" - narration_entry["defer_loudnorm"] = True + # Loudnorm is applied per-segment during preprocess (not deferred to + # stitch), so the processed files are already normalized and ready to be + # concatenated directly at render time. + narration_entry["defer_loudnorm"] = False existing_narration[segment_id] = narration_entry added_count += 1 @@ -2567,7 +2573,8 @@ def cmd_preprocess( filter=filter_list, output_file=output_file, use_audio_channels=existing_entry.get("use_audio_channels", "auto"), - defer_loudnorm=existing_entry.get("defer_loudnorm", True), + # Default False: apply loudnorm now (in preprocess), not deferred. + defer_loudnorm=existing_entry.get("defer_loudnorm", False), ) segments_to_process.append((segment_id, video_source)) @@ -2671,7 +2678,7 @@ def cmd_preprocess( # Always record the plain path; stitch shifts the base dir for low/tiny. entry["source_file"] = f"processed/{segment_id}_processed.mov" entry.setdefault("use_audio_channels", "auto") - entry.setdefault("defer_loudnorm", True) + entry.setdefault("defer_loudnorm", False) existing_narration[segment_id] = entry with open(narration_json_path, "w", encoding="utf-8") as f: diff --git a/gnommo/narration.py b/gnommo/narration.py new file mode 100644 index 0000000..77c2718 --- /dev/null +++ b/gnommo/narration.py @@ -0,0 +1,119 @@ +"""Deterministic narration scheduling for render-time segment stitching. + +Instead of pre-stitching segments into narration_combined.mov, the render stage +concatenates the processed segments directly. From narration.json + the cached +per-segment transcripts this module computes two things: + + 1. an ordered segment schedule (processed file, skip, take, and offset in the + combined timeline) — this drives the ffmpeg concat at render time; and + 2. the merged word-level transcript, with every word re-timed into the + combined timeline — this drives slide alignment, exactly what + re-transcribing narration_combined.mov used to produce, but derived + deterministically (no re-transcription, no separate combined file). + +The processed files share framerate and format and are uncompressed, so the +computed offsets match the concatenated audio timeline sample-for-sample. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from .models import VideoSource +from .preprocessor import get_preprocessed_path +from .transcriber import TranscribedWord, load_transcript + + +@dataclass +class NarrationSegment: + """One narration segment placed on the combined render timeline.""" + + seg_id: str + source_path: Path # processed file to concatenate + skip: float # seconds trimmed from the segment's start + take: Optional[float] # seconds kept from `skip` (None → to end) + duration: float # effective seconds contributed to the timeline + offset: float # start time of this segment in the combined timeline + + +def segment_order(narration: dict) -> list[str]: + """Natural sort of segment ids (S2 before S10, s1-15 before s16-end).""" + return sorted( + narration.keys(), + key=lambda s: [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)], + ) + + +def build_narration_schedule( + narration: dict[str, VideoSource], + narration_dir: Path, + get_duration: Callable[[Path], float], + transcripts_dir: Optional[Path] = None, + verbose: bool = False, +) -> tuple[list[NarrationSegment], list[TranscribedWord]]: + """Return (ordered segments with offsets, merged transcript in timeline). + + Args: + narration: seg_id -> VideoSource (from parse_narration). + narration_dir: base dir the processed files resolve against. + 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 + (defaults to narration_dir/transcripts). + """ + if transcripts_dir is None: + transcripts_dir = narration_dir / "transcripts" + + segments: list[NarrationSegment] = [] + merged: list[TranscribedWord] = [] + offset = 0.0 + + for seg_id in segment_order(narration): + vs = narration[seg_id] + source_path = get_preprocessed_path(narration_dir, vs) + + skip = vs.skip or 0.0 + take = vs.take + if take is not None: + eff = max(0.0, take) + else: + full_dur = get_duration(source_path) if source_path.exists() else 0.0 + eff = max(0.0, full_dur - skip) + seg_end = skip + eff # kept window end in the segment's own timeline + + segments.append( + NarrationSegment( + seg_id=seg_id, + source_path=source_path, + skip=skip, + take=take, + duration=eff, + offset=offset, + ) + ) + + # Re-time this segment's transcript into the combined timeline: keep only + # words inside [skip, seg_end], subtract skip, and add the running offset. + tpath = transcripts_dir / f"{seg_id}.json" + if tpath.exists(): + kept = 0 + for w in load_transcript(tpath): + if w.end <= skip or w.start >= seg_end: + continue # entirely outside the kept window + new_start = max(w.start, skip) - skip + offset + new_end = min(w.end, seg_end) - skip + offset + merged.append( + TranscribedWord(word=w.word, start=round(new_start, 3), end=round(new_end, 3)) + ) + kept += 1 + if verbose: + print(f" {seg_id}: +{kept} words (skip={skip:.2f}s take={eff:.2f}s → offset {offset:.2f}s)") + elif verbose: + print(f" ⚠ {seg_id}: no transcript at {tpath} — words missing from merged transcript") + + offset += eff + + return segments, merged diff --git a/gnommo/preprocessor.py b/gnommo/preprocessor.py index 9413e91..afdbba5 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -305,16 +305,51 @@ def ensure_proxy_files_exist( import selectors, time, sys, subprocess -def run_ffmpeg_with_progress(cmd, duration, description="Processing"): +# Module-level FFmpeg verbosity switch. When enabled (e.g. via `gnommo -v`), +# run_ffmpeg_with_progress streams FFmpeg's full output at -loglevel verbose +# instead of the quiet progress bar — useful when debugging filter graphs. +_FFMPEG_VERBOSE = False + + +def set_ffmpeg_verbose(enabled: bool) -> None: + """Enable/disable verbose FFmpeg logging for every ffmpeg run in this process.""" + global _FFMPEG_VERBOSE + _FFMPEG_VERBOSE = bool(enabled) + + +def run_ffmpeg_with_progress(cmd, duration, description="Processing", verbose=None, loglevel=None): cmd = cmd.copy() + if verbose is None: + verbose = _FFMPEG_VERBOSE + level = loglevel or ("verbose" if verbose else "warning") + + # Verbose mode: stream FFmpeg's full output live (no progress bar), still + # capturing it so the error path keeps the full log. + if verbose: + insert_pos = cmd.index("-y") + 1 if "-y" in cmd else 1 + cmd[insert_pos:insert_pos] = ["-loglevel", level, "-stats"] + print(f" {description} — FFmpeg (-loglevel {level}):") + print(f" $ {' '.join(cmd)}") + p = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, bufsize=1, universal_newlines=True, + ) + logs = [] + for line in iter(p.stdout.readline, ""): + logs.append(line) + sys.stdout.write(line) + sys.stdout.flush() + p.wait() + return subprocess.CompletedProcess(cmd, p.returncode, stdout="", stderr="".join(logs)) + insert_pos = cmd.index("-y") + 1 if "-y" in cmd else 1 cmd[insert_pos:insert_pos] = [ "-progress", "pipe:1", "-nostats", "-loglevel", - "warning", + level, ] p = subprocess.Popen(