Removing ffprobe thingy that crashes the renderer

This commit is contained in:
2026-07-25 13:52:22 +02:00
parent a91acca695
commit 9f30698801
3 changed files with 81 additions and 16 deletions
+64 -8
View File
@@ -3649,14 +3649,18 @@ def _format_time(seconds: float) -> str:
return f"{mins:02d}:{secs:05.2f}" return f"{mins:02d}:{secs:05.2f}"
def _print_render_plan_details(plan, marker_timings, slides: dict) -> None: def _print_render_plan_details(plan, marker_timings, slides: dict, events=None) -> None:
""" """
Print a detailed render plan showing each marker with its aligned time. Print a detailed render plan showing each marker with its (final) time.
Uses marker_timings from the transformer which contains alignment info. `events` is the resolved events.json list — the source of truth. Every marker
has an interpolated final_time there even when the aligner couldn't place it,
so markers the raw alignment marks unaligned still show their real position.
""" """
from .models import CAMERA_PRESETS from .models import CAMERA_PRESETS
events_by_id = {e["id"]: e for e in (events or [])}
print("\n RENDER PLAN:") print("\n RENDER PLAN:")
print(" " + "-" * 76) print(" " + "-" * 76)
@@ -3765,17 +3769,31 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None:
) )
if marker_id.startswith(p) if marker_id.startswith(p)
) )
video_id = marker_id[pfx_len:] # Handles are stored lowercased in videos.json (and the plan's video
# events), so lowercase before the lookup — otherwise a camel-cased
# marker like vst:KnightRotating misses and shows '?'.
video_id = marker_id[pfx_len:].lower()
# Find corresponding event by video_id # Find corresponding event by video_id
event = video_events_by_id.get(video_id) event = video_events_by_id.get(video_id)
if event: if event:
cutout_name = event.cutout_name cutout_name = event.cutout_name
end_on = event.video_source.end_on or "next_slide" end_on = event.video_source.end_on or "next_slide"
layer_tag = f" [{event.layer}]" layer_tag = f" [{event.layer}]"
else:
# No resolved event — but the shorthand prefix itself fixes the
# cutout and layer (vst: = square/above), so never show '?'.
from .transformer import _SHORTHAND_PREFIXES
_pfx = next(
(p for p in _SHORTHAND_PREFIXES if marker_id.startswith(p)), None
)
if _pfx:
cutout_name, _layer = _SHORTHAND_PREFIXES[_pfx]
layer_tag = f" [{_layer}]"
else: else:
cutout_name = "?" cutout_name = "?"
end_on = "next_slide"
layer_tag = "" layer_tag = ""
end_on = "next_slide"
cache_ind = " 📁" if video_id in plan.cached_files else "" cache_ind = " 📁" if video_id in plan.cached_files else ""
print( print(
@@ -3798,8 +3816,14 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None:
print(f' {marker_id:6} {_st} "{context}"') print(f' {marker_id:6} {_st} "{context}"')
else: else:
unaligned_count += 1 unaligned_count += 1
# Check if this is a slide that was interpolated into the plan # The aligner couldn't place this marker, but events.json interpolates
if marker_id in slides: # EVERY marker (slide/video/audio/camera), so show that final time — it's
# where the render actually puts it. Only truly-missing markers read '??'.
_ev = events_by_id.get(marker_id)
if _ev and _ev.get("final_time") is not None:
interp_str = _format_time(_ev["final_time"])
print(f' {marker_id:20} ~{interp_str} INTERPOLATED - "{context}"')
elif marker_id in slides:
interp_event = next( interp_event = next(
(e for e in plan.slide_events if e.slide_id == marker_id), None (e for e in plan.slide_events if e.slide_id == marker_id), None
) )
@@ -4704,6 +4728,38 @@ def _cmd_render_impl(
) )
print(f"Run 'gnommo -p {project_path.name} preprocess' first.", file=sys.stderr) print(f"Run 'gnommo -p {project_path.name} preprocess' first.", file=sys.stderr)
return 1 return 1
# Detect segments rendering from RAW because the processed file is missing.
# get_preprocessed_path silently falls back to raw_mov (the render-before-
# preprocess preview path), but for a real render that means un-keyed/un-graded
# footage — and large raw camera files with -probesize 1000 + -ss seeks are
# exactly what chokes ffmpeg on the render rig. Refuse rather than crash/ship it.
_raw_fallback = [
s.seg_id for s in narration_schedule if "_processed" not in s.source_path.name
]
if _raw_fallback and not force:
print(
f"\nError: narration would render from RAW footage — the processed files "
f"are missing for: {', '.join(_raw_fallback)}",
file=sys.stderr,
)
print(
" That produces un-keyed (green screen), un-graded output, and reading the "
"large raw camera files can crash ffmpeg on the render rig.",
file=sys.stderr,
)
print(
f" Run 'gnommo -p {project_path.name} preprocess' first, or pass -f/--force to "
"render from raw anyway (quick preview only).",
file=sys.stderr,
)
return 1
elif _raw_fallback:
print(
f" ⚠ WARNING: rendering narration from RAW (processed missing): "
f"{', '.join(_raw_fallback)} — un-keyed/un-graded preview."
)
# Talking-head cutout/zoom/audio settings come from the first segment. # Talking-head cutout/zoom/audio settings come from the first segment.
narration_source = narration_map[narration_schedule[0].seg_id] narration_source = narration_map[narration_schedule[0].seg_id]
@@ -4871,7 +4927,7 @@ def _cmd_render_impl(
f"{_scaffold.EVENTS_FILE} + {_scaffold.SCAFFOLD_FILE} + {_scaffold.TRANSCRIBED_FILE}" f"{_scaffold.EVENTS_FILE} + {_scaffold.SCAFFOLD_FILE} + {_scaffold.TRANSCRIBED_FILE}"
) )
# The full render plan is printed here at build time — render just executes it. # The full render plan is printed here at build time — render just executes it.
_print_render_plan_details(plan, marker_timings, slides) _print_render_plan_details(plan, marker_timings, slides, events=_events)
if plan_only: if plan_only:
if _summ["interpolated"]: if _summ["interpolated"]:
print( print(
+12 -6
View File
@@ -495,7 +495,15 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
# "[N:a] matches no streams" errors that crash the render. # "[N:a] matches no streams" errors that crash the render.
_audio_probe_cache: dict[Path, bool] = {} _audio_probe_cache: dict[Path, bool] = {}
def _probe_has_audio(path: Path) -> bool: def _has_audio(video_source, path: Path) -> bool:
"""Trust the import-time `has_audio` in videos.json so we don't spawn an
ffprobe per video at render time — that per-file probing is what can hang or
crash the render rig on large/remote inputs. Only live-probe when the stored
value is absent (unimported entry). Keep videos.json fresh by re-running
import after swapping a file, so a stale value can't cause '[N:a] matches no
streams'."""
if video_source.has_audio is not None:
return bool(video_source.has_audio)
if path not in _audio_probe_cache: if path not in _audio_probe_cache:
_audio_probe_cache[path] = _has_audio_stream(path) _audio_probe_cache[path] = _has_audio_stream(path)
return _audio_probe_cache[path] return _audio_probe_cache[path]
@@ -518,10 +526,8 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
remaining = event.video_source.duration - skip remaining = event.video_source.duration - skip
needs_loop = remaining < clip_duration - 0.1 # 0.1 s tolerance needs_loop = remaining < clip_duration - 0.1 # 0.1 s tolerance
# Always live-probe audio presence — cached has_audio can be stale (e.g. when # Audio presence from stored metadata (no ffprobe unless it's missing).
# files moved from local to external disk). Results are cached per path so each has_audio = _has_audio(event.video_source, video_path)
# unique file is only probed once per render call.
has_audio = _probe_has_audio(video_path)
if has_audio: if has_audio:
video_events_with_audio.add(i) video_events_with_audio.add(i)
@@ -555,7 +561,7 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
videos_dir, event.video_source, shared_assets_dir, project_path videos_dir, event.video_source, shared_assets_dir, project_path
) )
skip = event.video_source.skip or 0.0 skip = event.video_source.skip or 0.0
has_audio = _probe_has_audio(video_path) has_audio = _has_audio(event.video_source, video_path)
if has_audio: if has_audio:
outro_events_with_audio.add(i) outro_events_with_audio.add(i)
+4 -1
View File
@@ -1489,7 +1489,10 @@ def _extract_outro_events(
) )
if is_cached and cached_files is not None: if is_cached and cached_files is not None:
cached_files.add(video_id) cached_files.add(video_id)
if video_path.exists(): # Prefer the import-time duration from videos.json; only probe when absent.
if video_source.duration is not None:
full_duration = video_source.duration
elif video_path.exists():
full_duration = get_video_duration(video_path) full_duration = get_video_duration(video_path)
else: else:
full_duration = 10.0 # Fallback full_duration = 10.0 # Fallback