Adding more verbose logging to ffmpg

This commit is contained in:
2026-07-15 16:20:50 +02:00
parent e8fc8411ea
commit 456070903c
3 changed files with 167 additions and 6 deletions
+11 -4
View File
@@ -1430,7 +1430,10 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
"source_file": f"processed/{video_file.name}", "source_file": f"processed/{video_file.name}",
} }
narration_entry["use_audio_channels"] = "auto" 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 existing_narration[segment_id] = narration_entry
added_count += 1 added_count += 1
@@ -1455,7 +1458,10 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
narration_entry["filter"] = "talkinghead" narration_entry["filter"] = "talkinghead"
narration_entry["use_audio_channels"] = "auto" 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 existing_narration[segment_id] = narration_entry
added_count += 1 added_count += 1
@@ -2567,7 +2573,8 @@ def cmd_preprocess(
filter=filter_list, filter=filter_list,
output_file=output_file, output_file=output_file,
use_audio_channels=existing_entry.get("use_audio_channels", "auto"), 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)) 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. # Always record the plain path; stitch shifts the base dir for low/tiny.
entry["source_file"] = f"processed/{segment_id}_processed.mov" entry["source_file"] = f"processed/{segment_id}_processed.mov"
entry.setdefault("use_audio_channels", "auto") entry.setdefault("use_audio_channels", "auto")
entry.setdefault("defer_loudnorm", True) entry.setdefault("defer_loudnorm", False)
existing_narration[segment_id] = entry existing_narration[segment_id] = entry
with open(narration_json_path, "w", encoding="utf-8") as f: with open(narration_json_path, "w", encoding="utf-8") as f:
+119
View File
@@ -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
+37 -2
View File
@@ -305,16 +305,51 @@ def ensure_proxy_files_exist(
import selectors, time, sys, subprocess 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() 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 insert_pos = cmd.index("-y") + 1 if "-y" in cmd else 1
cmd[insert_pos:insert_pos] = [ cmd[insert_pos:insert_pos] = [
"-progress", "-progress",
"pipe:1", "pipe:1",
"-nostats", "-nostats",
"-loglevel", "-loglevel",
"warning", level,
] ]
p = subprocess.Popen( p = subprocess.Popen(