Tweaks ton esure that

This commit is contained in:
2026-05-09 12:38:05 +02:00
parent d722272edc
commit e6a6968109
5 changed files with 173 additions and 64 deletions
+117 -41
View File
@@ -418,7 +418,9 @@ def _probe_audio_durations(
if verbose: if verbose:
print(f" Audio '{audio_id}': file not found, skipping") print(f" Audio '{audio_id}': file not found, skipping")
continue continue
print(f" Probing audio '{audio_id}' ({audio_path.name})...", end=" ", flush=True) print(
f" Probing audio '{audio_id}' ({audio_path.name})...", end=" ", flush=True
)
try: try:
duration = _get_audio_duration(audio_path) duration = _get_audio_duration(audio_path)
data[audio_id]["duration"] = round(duration, 3) data[audio_id]["duration"] = round(duration, 3)
@@ -434,7 +436,11 @@ def _probe_audio_durations(
def _probe_video_metadata( def _probe_video_metadata(
project_path: Path, config, shared_assets_dir: Optional[Path], force: bool, verbose: bool project_path: Path,
config,
shared_assets_dir: Optional[Path],
force: bool,
verbose: bool,
) -> None: ) -> None:
"""Probe and cache video file duration and audio presence into videos.json. """Probe and cache video file duration and audio presence into videos.json.
@@ -459,7 +465,11 @@ def _probe_video_metadata(
# Load shared_assets/videos.json separately — shared probes write there # Load shared_assets/videos.json separately — shared probes write there
shared_json_path = shared_assets_dir / "videos.json" if shared_assets_dir else None shared_json_path = shared_assets_dir / "videos.json" if shared_assets_dir else None
shared_data = _read_json(shared_json_path) if shared_json_path and shared_json_path.exists() else {} shared_data = (
_read_json(shared_json_path)
if shared_json_path and shared_json_path.exists()
else {}
)
local_updated = False local_updated = False
shared_updated = False shared_updated = False
@@ -478,10 +488,14 @@ def _probe_video_metadata(
if not force and "duration" in canonical and "has_audio" in canonical: if not force and "duration" in canonical and "has_audio" in canonical:
if verbose: if verbose:
print(f" Video '{video_id}': cached ({canonical['duration']:.1f}s, audio={canonical['has_audio']})") print(
f" Video '{video_id}': cached ({canonical['duration']:.1f}s, audio={canonical['has_audio']})"
)
continue continue
base_dir = shared_assets_dir if (is_shared and shared_assets_dir) else videos_dir base_dir = (
shared_assets_dir if (is_shared and shared_assets_dir) else videos_dir
)
# Mirror renderer._resolve_video_path: try output_file first, then source_file # Mirror renderer._resolve_video_path: try output_file first, then source_file
video_path = None video_path = None
@@ -507,7 +521,9 @@ def _probe_video_metadata(
print(f" Video '{video_id}': file not found, skipping") print(f" Video '{video_id}': file not found, skipping")
continue continue
print(f" Probing video '{video_id}' ({video_path.name})...", end=" ", flush=True) print(
f" Probing video '{video_id}' ({video_path.name})...", end=" ", flush=True
)
try: try:
duration = get_video_duration(video_path) duration = get_video_duration(video_path)
has_audio = _has_audio_stream(video_path) has_audio = _has_audio_stream(video_path)
@@ -569,7 +585,10 @@ def _sync_shared_videos_to_local(
# Propagate any metadata fields that were probed into shared_assets/videos.json # Propagate any metadata fields that were probed into shared_assets/videos.json
changed = False changed = False
for field in _METADATA_FIELDS: for field in _METADATA_FIELDS:
if field in shared_entry and local_videos[video_id].get(field) != shared_entry[field]: if (
field in shared_entry
and local_videos[video_id].get(field) != shared_entry[field]
):
local_videos[video_id][field] = shared_entry[field] local_videos[video_id][field] = shared_entry[field]
changed = True changed = True
if changed: if changed:
@@ -588,9 +607,13 @@ def _sync_shared_videos_to_local(
with open(local_json_path, "w", encoding="utf-8") as f: with open(local_json_path, "w", encoding="utf-8") as f:
json.dump(local_videos, f, indent=4) json.dump(local_videos, f, indent=4)
if added: if added:
print(f" Synced {len(added)} shared asset(s) to local videos.json: {', '.join(added)}") print(
f" Synced {len(added)} shared asset(s) to local videos.json: {', '.join(added)}"
)
if metadata_updated: if metadata_updated:
print(f" Updated metadata for {len(metadata_updated)} shared asset(s): {', '.join(metadata_updated)}") print(
f" Updated metadata for {len(metadata_updated)} shared asset(s): {', '.join(metadata_updated)}"
)
elif verbose: elif verbose:
print(" No new shared assets to sync to local videos.json") print(" No new shared assets to sync to local videos.json")
@@ -887,8 +910,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
# If a raw_mov equivalent exists, skip — step 2 will handle it # If a raw_mov equivalent exists, skip — step 2 will handle it
raw_mov_has_file = raw_dir.exists() and any( raw_mov_has_file = raw_dir.exists() and any(
(raw_dir / f"{segment_id}{ext}").exists() (raw_dir / f"{segment_id}{ext}").exists() for ext in _raw_video_exts
for ext in _raw_video_exts
) )
if raw_mov_has_file: if raw_mov_has_file:
continue continue
@@ -1200,7 +1222,9 @@ def cmd_preprocess(
# --- Filter pipeline --- # --- Filter pipeline ---
talkinghead_filter = (config.default_filters or {}).get("talkinghead", []) talkinghead_filter = (config.default_filters or {}).get("talkinghead", [])
if not talkinghead_filter: if not talkinghead_filter:
print(" ERROR: No 'talkinghead' filter defined in project.json default_filters.") print(
" ERROR: No 'talkinghead' filter defined in project.json default_filters."
)
print(" Add a 'talkinghead' entry under 'default_filters' in project.json.") print(" Add a 'talkinghead' entry under 'default_filters' in project.json.")
return 1 return 1
@@ -1211,8 +1235,11 @@ def cmd_preprocess(
if not d.exists(): if not d.exists():
return [] return []
return sorted( return sorted(
f for f in d.iterdir() f
if f.is_file() and f.suffix.lower() in _video_exts and not f.name.startswith(".") for f in d.iterdir()
if f.is_file()
and f.suffix.lower() in _video_exts
and not f.name.startswith(".")
) )
raw_mov_files = _scan_dir(raw_dir) raw_mov_files = _scan_dir(raw_dir)
@@ -1224,7 +1251,9 @@ def cmd_preprocess(
elif raw_mp4_files: elif raw_mp4_files:
source_files = raw_mp4_files source_files = raw_mp4_files
using_compressed = True using_compressed = True
print(" WARNING: raw_mov/ is empty — using compressed files from raw_mp4/ instead. Quality may be reduced.") print(
" WARNING: raw_mov/ is empty — using compressed files from raw_mp4/ instead. Quality may be reduced."
)
else: else:
print(f" No source files found in raw_mov/ or raw_mp4/.") print(f" No source files found in raw_mov/ or raw_mp4/.")
print(f" Place .mov recordings in {raw_dir}") print(f" Place .mov recordings in {raw_dir}")
@@ -1259,7 +1288,9 @@ def cmd_preprocess(
raw_filter = existing_entry.get("filter") raw_filter = existing_entry.get("filter")
if raw_filter: if raw_filter:
if isinstance(raw_filter, str): if isinstance(raw_filter, str):
filter_list = (config.default_filters or {}).get(raw_filter, talkinghead_filter) filter_list = (config.default_filters or {}).get(
raw_filter, talkinghead_filter
)
else: else:
filter_list = raw_filter filter_list = raw_filter
else: else:
@@ -1276,7 +1307,9 @@ def cmd_preprocess(
if not segments_to_process: if not segments_to_process:
if skipped_count: if skipped_count:
print(f"\n All {skipped_count} segment(s) already preprocessed. Use --force to reprocess.") print(
f"\n All {skipped_count} segment(s) already preprocessed. Use --force to reprocess."
)
else: else:
print("\n No segments to preprocess.") print("\n No segments to preprocess.")
return 0 return 0
@@ -1294,19 +1327,27 @@ def cmd_preprocess(
if workers > 1 and len(segments_to_process) > 1: if workers > 1 and len(segments_to_process) > 1:
num_workers = min(workers, len(segments_to_process)) num_workers = min(workers, len(segments_to_process))
print(f"\n Processing {len(segments_to_process)} segments in parallel ({num_workers} workers)") print(
f"\n Processing {len(segments_to_process)} segments in parallel ({num_workers} workers)"
)
def process_segment_task(task): def process_segment_task(task):
seg_id, seg_source = task seg_id, seg_source = task
preprocess_video( preprocess_video(
narration_dir, seg_id, seg_source, narration_dir,
verbose=False, force=force, custom_gnommo_scratch=gnommo_scratch, seg_id,
seg_source,
verbose=False,
force=force,
custom_gnommo_scratch=gnommo_scratch,
) )
return task return task
completed = 0 completed = 0
with ThreadPoolExecutor(max_workers=num_workers) as executor: with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = {executor.submit(process_segment_task, t): t for t in segments_to_process} futures = {
executor.submit(process_segment_task, t): t for t in segments_to_process
}
for future in as_completed(futures): for future in as_completed(futures):
seg_id, seg_source = future.result() seg_id, seg_source = future.result()
completed += 1 completed += 1
@@ -1321,8 +1362,12 @@ def cmd_preprocess(
print(f" Output: {segment_source.output_file}") print(f" Output: {segment_source.output_file}")
print(f" Filters: {len(segment_source.filter)} step(s)") print(f" Filters: {len(segment_source.filter)} step(s)")
preprocess_video( preprocess_video(
narration_dir, segment_id, segment_source, narration_dir,
verbose, force, gnommo_scratch, segment_id,
segment_source,
verbose,
force,
gnommo_scratch,
) )
output_path = narration_dir / segment_source.output_file output_path = narration_dir / segment_source.output_file
if output_path.exists(): if output_path.exists():
@@ -1330,8 +1375,17 @@ def cmd_preprocess(
# --- Update narration.json --- # --- Update narration.json ---
# Write processed segments; preserve any existing per-segment settings (skip/take/etc.) # Write processed segments; preserve any existing per-segment settings (skip/take/etc.)
_PRESERVE_KEYS = ("skip", "take", "begin", "end", "cutout", "use_audio_channels", _PRESERVE_KEYS = (
"defer_loudnorm", "volume", "zoom") "skip",
"take",
"begin",
"end",
"cutout",
"use_audio_channels",
"defer_loudnorm",
"volume",
"zoom",
)
for segment_id, segment_source in successfully_processed: for segment_id, segment_source in successfully_processed:
existing_entry = existing_narration.get(segment_id, {}) existing_entry = existing_narration.get(segment_id, {})
entry: dict = {} entry: dict = {}
@@ -1930,9 +1984,7 @@ def cmd_stitch(
# Get cutout from first narration segment # Get cutout from first narration segment
first_seg = narration[segment_ids[0]] first_seg = narration[segment_ids[0]]
cutout = ( cutout = first_seg.cutout or "talkinghead"
first_seg.cutout or "talkinghead"
)
# Create/update narration_combined entry # Create/update narration_combined entry
existing_videos["narration_combined"] = { existing_videos["narration_combined"] = {
@@ -2043,12 +2095,32 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None:
print(f' {marker_id:6} {time_str}{conf_str} "{context}"') print(f' {marker_id:6} {time_str}{conf_str} "{context}"')
elif any( elif any(
marker_id.startswith(p) marker_id.startswith(p)
for p in ("video:", "vft:", "vfb:", "vst:", "vsb:", "vft:", "vfbp:", "vstp:", "vsbp:") for p in (
"video:",
"vft:",
"vfb:",
"vst:",
"vsb:",
"vft:",
"vfbp:",
"vstp:",
"vsbp:",
)
): ):
aligned_count += 1 aligned_count += 1
pfx_len = next( pfx_len = next(
len(p) len(p)
for p in ("video:", "vft:", "vfb:", "vst:", "vsb:", "vft:", "vfbp:", "vstp:", "vsbp:") for p in (
"video:",
"vft:",
"vfb:",
"vst:",
"vsb:",
"vft:",
"vfbp:",
"vstp:",
"vsbp:",
)
if marker_id.startswith(p) if marker_id.startswith(p)
) )
video_id = marker_id[pfx_len:] video_id = marker_id[pfx_len:]
@@ -2062,8 +2134,7 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None:
cutout_name = "?" cutout_name = "?"
end_on = "next_slide" end_on = "next_slide"
layer_tag = "" layer_tag = ""
cache_ind = " 📁" if video_id in plan.cached_files else "" cache_ind = " 📁" if video_id in plan.cached_files else ""
print( print(
f" {marker_id:20} {time_str} in '{cutout_name}' [{end_on}]{layer_tag}{cache_ind}" f" {marker_id:20} {time_str} in '{cutout_name}' [{end_on}]{layer_tag}{cache_ind}"
@@ -2790,13 +2861,14 @@ def cmd_all(
print("\n>>> Step 2/6: Preprocess\n") print("\n>>> Step 2/6: Preprocess\n")
t0 = time.time() t0 = time.time()
result = cmd_preprocess(project_path, verbose, dry_run, cascade_force, workers=1, res=res) result = cmd_preprocess(
project_path, verbose, dry_run, cascade_force, workers=1, res=res
)
if result != 0: if result != 0:
return result return result
if ( if _files_modified_since(
_files_modified_since(project_path, t0, "*_processed.mov") project_path, t0, "*_processed.mov"
or _files_modified_since(project_path, t0, "*_processed.webm") ) or _files_modified_since(project_path, t0, "*_processed.webm"):
):
cascade_force = True cascade_force = True
print("\n>>> Step 3/6: Trim\n") print("\n>>> Step 3/6: Trim\n")
@@ -2938,14 +3010,15 @@ def cmd_description(project_path: Path, verbose: bool) -> int:
# Files and directories excluded from all sync/archive/load operations. # Files and directories excluded from all sync/archive/load operations.
# Covers intermediate processing artifacts, chunk scratch dirs, venv, and # Covers intermediate processing artifacts, chunk scratch dirs, venv, and
# common OS/editor noise. # common OS/editor noise.
_RSYNC_EXCLUDES = [ _RSYNC_EXCLUDES = [
# Intermediate processing files # Intermediate processing files
"media/narration/intermediate/", "media/narration/intermediate/",
"media/narration/intermediate/**", "media/narration/intermediate/**",
"media/videos/intermediate/", "media/videos/intermediate/",
"media/videos/intermediate/**", "media/videos/intermediate/**",
"media/videos/processed/", "media/narration/processed/",
"media/videos/processed/**", "media/narration/processed/**",
# Chunk scratch directories # Chunk scratch directories
"**/chunks/", "**/chunks/",
"**/chunks/**", "**/chunks/**",
@@ -3145,7 +3218,9 @@ def cmd_sync(project_path: Path, verbose: bool, dry_run: bool, download: bool) -
else: else:
remote_dir = f"{server['path']}/{project_path.name}" remote_dir = f"{server['path']}/{project_path.name}"
ssh_cmd = [ ssh_cmd = [
"ssh", "-p", server["port"], "ssh",
"-p",
server["port"],
f"{server['user']}@{server['host']}", f"{server['user']}@{server['host']}",
f"mkdir -p {remote_dir}", f"mkdir -p {remote_dir}",
] ]
@@ -3160,7 +3235,8 @@ def cmd_sync(project_path: Path, verbose: bool, dry_run: bool, download: bool) -
"rsync", "rsync",
"-av", "-av",
"--progress", "--progress",
"-e", f"ssh -p {server['port']}", "-e",
f"ssh -p {server['port']}",
*[f"--exclude={p}" for p in _RSYNC_EXCLUDES], *[f"--exclude={p}" for p in _RSYNC_EXCLUDES],
src, src,
dest, dest,
+6 -2
View File
@@ -298,9 +298,13 @@ class VideoSource:
) )
volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce) volume: float = 1.0 # Volume multiplier (1.0=full, >1.0=boost, <1.0=reduce)
layer: str = "above" # "above" = renders on top of slides; "below" = behind slides layer: str = "above" # "above" = renders on top of slides; "below" = behind slides
duration: Optional[float] = None # Pre-probed file duration in seconds (set by import) duration: Optional[
float
] = None # Pre-probed file duration in seconds (set by import)
has_audio: Optional[bool] = None # Pre-detected audio presence (set by import) has_audio: Optional[bool] = None # Pre-detected audio presence (set by import)
end_on: Optional[str] = None # When video event ends: "next_slide" | "end" | "take" (None = marker-type default) end_on: Optional[
str
] = None # When video event ends: "next_slide" | "end" | "take" (None = marker-type default)
@dataclass @dataclass
+28 -11
View File
@@ -302,7 +302,6 @@ def run_ffmpeg_with_progress(cmd, duration, description="Processing"):
while True: while True:
# If process ended and no more output, break # If process ended and no more output, break
if p.poll() is not None: if p.poll() is not None:
# drain any remaining output quickly # drain any remaining output quickly
while True: while True:
line = p.stdout.readline() line = p.stdout.readline()
@@ -358,7 +357,9 @@ def run_ffmpeg_with_progress(cmd, duration, description="Processing"):
else: else:
code = p.returncode code = p.returncode
# On macOS/Linux, -9 means SIGKILL (OOM kill by OS), -6 = SIGABRT # On macOS/Linux, -9 means SIGKILL (OOM kill by OS), -6 = SIGABRT
signal_hint = " (OOM kill)" if code == -9 else (" (abort)" if code == -6 else "") signal_hint = (
" (OOM kill)" if code == -9 else (" (abort)" if code == -6 else "")
)
sys.stdout.write(f"\n FFmpeg exited with code {code}{signal_hint}\n") sys.stdout.write(f"\n FFmpeg exited with code {code}{signal_hint}\n")
sys.stdout.flush() sys.stdout.flush()
@@ -371,12 +372,19 @@ def _has_audio_stream(video_path: Path) -> bool:
"""Return True if the file has a real (non-ghost) audio stream.""" """Return True if the file has a real (non-ghost) audio stream."""
result = subprocess.run( result = subprocess.run(
[ [
"ffprobe", "-v", "error", "ffprobe",
"-analyzeduration", "0", "-v",
"-probesize", "1000000", "error",
"-select_streams", "a:0", "-analyzeduration",
"-show_entries", "stream=index,nb_frames", "0",
"-of", "csv=p=0", "-probesize",
"1000000",
"-select_streams",
"a:0",
"-show_entries",
"stream=index,nb_frames",
"-of",
"csv=p=0",
str(video_path), str(video_path),
], ],
capture_output=True, capture_output=True,
@@ -1380,9 +1388,18 @@ def _process_chunk_to_prores4444(
# FFmpeg can return 0 but write a corrupt/incomplete file (e.g. moov atom # FFmpeg can return 0 but write a corrupt/incomplete file (e.g. moov atom
# missing) when faststart rewrite fails or disk is under pressure. # missing) when faststart rewrite fails or disk is under pressure.
probe = subprocess.run( probe = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=duration", [
"-of", "csv=p=0", str(output_path)], "ffprobe",
capture_output=True, text=True, "-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
str(output_path),
],
capture_output=True,
text=True,
) )
if probe.returncode != 0 or not probe.stdout.strip(): if probe.returncode != 0 or not probe.stdout.strip():
raise PreprocessError( raise PreprocessError(
+8 -3
View File
@@ -410,7 +410,9 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
input_idx += 1 input_idx += 1
has_audio = event.video_source.has_audio has_audio = event.video_source.has_audio
if has_audio is None: if has_audio is None:
print(f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing") print(
f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing"
)
has_audio = _has_audio_stream(video_path) has_audio = _has_audio_stream(video_path)
if has_audio: if has_audio:
video_events_with_audio.add(i) video_events_with_audio.add(i)
@@ -436,7 +438,9 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
input_idx += 1 input_idx += 1
has_audio = event.video_source.has_audio has_audio = event.video_source.has_audio
if has_audio is None: if has_audio is None:
print(f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing") print(
f" Warning: no cached metadata for '{event.video_source.source_file}' — run 'gnommo import' to avoid slow probing"
)
has_audio = _has_audio_stream(video_path) has_audio = _has_audio_stream(video_path)
if has_audio: if has_audio:
outro_events_with_audio.add(i) outro_events_with_audio.add(i)
@@ -468,7 +472,8 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
# Cache duration for crossfade loop filter # Cache duration for crossfade loop filter
if event.audio_def.loop and event.audio_def.overlap: if event.audio_def.loop and event.audio_def.overlap:
audio_durations[event.audio_id] = ( audio_durations[event.audio_id] = (
file_duration if file_duration is not None file_duration
if file_duration is not None
else _get_audio_duration(audio_path) else _get_audio_duration(audio_path)
) )
+14 -7
View File
@@ -134,7 +134,18 @@ def _is_known_marker(
return True return True
# Video/narration triggers (all supported prefixes) # Video/narration triggers (all supported prefixes)
_VIDEO_PREFIXES = ("video:", "narration:", "vft:", "vfb:", "vst:", "vsb:", "vftp:", "vfbp:", "vstp:", "vsbp:") _VIDEO_PREFIXES = (
"video:",
"narration:",
"vft:",
"vfb:",
"vst:",
"vsb:",
"vftp:",
"vfbp:",
"vstp:",
"vsbp:",
)
if any(marker_id.startswith(p) for p in _VIDEO_PREFIXES): if any(marker_id.startswith(p) for p in _VIDEO_PREFIXES):
return True return True
@@ -923,9 +934,7 @@ def _extract_video_events(
f"Marker [video:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. " f"Marker [video:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
f"Available: {list(cutouts.keys())}" f"Available: {list(cutouts.keys())}"
) )
video_markers.append( video_markers.append((timing.timestamp, video_id, "video", None, None))
(timing.timestamp, video_id, "video", None, None)
)
continue continue
# --- [narration:xxx] --- # --- [narration:xxx] ---
@@ -946,9 +955,7 @@ def _extract_video_events(
f"Marker [narration:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. " f"Marker [narration:{video_id}] — cutout '{video_source.cutout}' is not defined in project config. "
f"Available: {list(cutouts.keys())}" f"Available: {list(cutouts.keys())}"
) )
video_markers.append( video_markers.append((timing.timestamp, video_id, "narration", None, None))
(timing.timestamp, video_id, "narration", None, None)
)
events: list[VideoEvent] = [] events: list[VideoEvent] = []
for ( for (