diff --git a/gnommo/cli.py b/gnommo/cli.py index 5edb6b6..35eb2cb 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -3649,14 +3649,18 @@ def _format_time(seconds: float) -> str: 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 + events_by_id = {e["id"]: e for e in (events or [])} + print("\n RENDER PLAN:") print(" " + "-" * 76) @@ -3765,7 +3769,10 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None: ) 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 event = video_events_by_id.get(video_id) if event: @@ -3773,9 +3780,20 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None: end_on = event.video_source.end_on or "next_slide" layer_tag = f" [{event.layer}]" else: - cutout_name = "?" + # 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: + cutout_name = "?" + layer_tag = "" end_on = "next_slide" - layer_tag = "" cache_ind = " 📁" if video_id in plan.cached_files else "" print( @@ -3798,8 +3816,14 @@ def _print_render_plan_details(plan, marker_timings, slides: dict) -> None: print(f' {marker_id:6} {_st} "{context}"') else: unaligned_count += 1 - # Check if this is a slide that was interpolated into the plan - if marker_id in slides: + # The aligner couldn't place this marker, but events.json interpolates + # 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( (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) 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. 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}" ) # 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 _summ["interpolated"]: print( diff --git a/gnommo/renderer.py b/gnommo/renderer.py index c105bea..b43bbfc 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -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. _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: _audio_probe_cache[path] = _has_audio_stream(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 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 - # files moved from local to external disk). Results are cached per path so each - # unique file is only probed once per render call. - has_audio = _probe_has_audio(video_path) + # Audio presence from stored metadata (no ffprobe unless it's missing). + has_audio = _has_audio(event.video_source, video_path) if has_audio: 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 ) 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: outro_events_with_audio.add(i) diff --git a/gnommo/transformer.py b/gnommo/transformer.py index e2788a5..15550bd 100644 --- a/gnommo/transformer.py +++ b/gnommo/transformer.py @@ -1489,7 +1489,10 @@ def _extract_outro_events( ) if is_cached and cached_files is not None: 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) else: full_duration = 10.0 # Fallback