From 6949124fa727dd3feaf7ba659001c050ebdc512a Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 14 Mar 2026 21:29:59 +0100 Subject: [PATCH] Adding fixes to the pipeline --- README.md | 34 +++--- gnommo/cli.py | 108 ++++++++------------ gnommo/preprocessor.py | 227 +++++++++++++++++++++++------------------ gnommo/renderer.py | 20 ++-- gnommo/transformer.py | 4 +- gnommo/validator.py | 44 +++++--- 6 files changed, 236 insertions(+), 201 deletions(-) diff --git a/README.md b/README.md index a769d9c..0513920 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,23 @@ gnommo -p myproject youtubeready gnommo -p myproject archive ``` -## Proxying -Using the --proxy keyword makes everything faster because it creates some smaller files. -``` -gnommo -p myproject pre --proxy -gnommo -p myproject stitch --proxy -gnommo -p myproject render --proxy -``` +## Resolution modes -## Lowres -Renders the final video in a low-res mode, for faster iteration -``` -gnommo -p myproject render --res low +All commands accept `--res` to trade quality for speed during iteration: + +| Flag | Resolution | Use case | +|---|---|---| +| `--res full` | Project resolution (default) | Final output | +| `--res low` | 490×270 | Fast preview render | +| `--res tiny` | 320×180 | Ultrafast iteration (preprocess, stitch, render) | + +`--res tiny` and `--res low` create downscaled copies of source files in subdirectories (`proxy/` and `low/` respectively) and work from those. The originals are never modified. + +```bash +gnommo -p myproject pre --res tiny # fast preprocess +gnommo -p myproject stitch --res tiny # fast stitch +gnommo -p myproject render --res tiny # fast preview render +gnommo -p myproject render --res low # medium preview render ``` ## Project Structure @@ -157,9 +162,10 @@ gnommo -p myproject render **Options:** ```bash -gnommo -p myproject render --dry-run # Show FFmpeg command without running -gnommo -p myproject render --slides S1:S10 # Render only slides S1 through S10 -gnommo -p myproject render --proxy # Fast preview at reduced resolution +gnommo -p myproject render --dry-run # Show FFmpeg command without running +gnommo -p myproject render --slides S1:S10 # Render only slides S1 through S10 +gnommo -p myproject render --res low # Fast preview at 490x270 +gnommo -p myproject render --res tiny # Ultrafast preview at 320x180 ``` --- diff --git a/gnommo/cli.py b/gnommo/cli.py index 8218c72..334067f 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -34,7 +34,7 @@ Examples: gnommo -p video1 validate Validate only gnommo -p video1 import Generate slides.json from images gnommo -p video1 pre Preprocess videos (chroma key, etc.) - gnommo -p video1 stitch --proxy -f Fast stitch with new begin/end values + gnommo -p video1 stitch --res tiny -f Fast stitch with new begin/end values gnommo -p video1 all Full pipeline: transcribe → align → render gnommo -p video1 render --dry-run Show FFmpeg command without running gnommo -p video1 description Generate YouTube description file @@ -113,9 +113,9 @@ Examples: parser.add_argument( "--res", type=str, - choices=["low", "full"], + choices=["full", "low", "tiny"], default="full", - help="Resolution: 'low' (490x270) for fast preview, 'full' for project resolution", + help="Resolution: 'full' (project res), 'low' (490x270), 'tiny' (320x180 ultrafast)", ) parser.add_argument( "-w", @@ -124,11 +124,6 @@ Examples: default=1, help="Number of parallel workers for preprocessing (default: 1)", ) - parser.add_argument( - "--proxy", - action="store_true", - help="Use proxy workflow: downsample to 160x90 for fast iteration", - ) parser.add_argument( "--final", action="store_true", @@ -184,14 +179,14 @@ Examples: args.dry_run, args.force, args.workers, - args.proxy, + args.res, ) elif action in ("stitch"): return cmd_stitch( project_path, args.verbose, args.force, - args.proxy, + args.res, ) elif action == "render": return cmd_render( @@ -201,10 +196,9 @@ Examples: args.slides, args.res, args.force, - args.proxy, ) elif action == "transcribe": - return cmd_transcribe(project_path, args.verbose, args.proxy, args.final) + return cmd_transcribe(project_path, args.verbose, args.res, args.final) elif action == "align": return cmd_align(project_path, args.verbose) elif action == "all": @@ -739,17 +733,18 @@ def cmd_preprocess( dry_run: bool, force: bool = False, workers: int = 1, - proxy: bool = False, + res: str = "full", ) -> int: """Run preprocessing pipeline on narration segments.""" from concurrent.futures import ThreadPoolExecutor, as_completed from .parser import parse_project_config, parse_narration from .preprocessor import ( preprocess_video, - create_proxies_for_videos, + create_downscaled_videos, + RES_CONFIGS, ) - mode_str = " (PROXY MODE)" if proxy else "" + mode_str = f" ({res.upper()})" if res != "full" else "" print(f"Preprocessing narration: {project_path.name}{mode_str}") config = parse_project_config(project_path) @@ -760,12 +755,11 @@ def cmd_preprocess( print(" Run 'gnommo -p import' first to populate narration.json") return 1 - # Proxy mode: create low-res copies first, then work from proxy dir - if proxy: - proxy_dir = create_proxies_for_videos(narration_dir, narration, force, verbose) - # Switch to proxy directory for all subsequent operations - narration_dir = proxy_dir - print(f" Working from proxy dir: {proxy_dir}") + # Downscale source files first if a preview res was requested + if res != "full": + narration_dir = create_downscaled_videos(narration_dir, narration, res, force, verbose) + cfg = RES_CONFIGS[res] + print(f" Working from {res} dir ({cfg[0]}x{cfg[1]}): {narration_dir}") # Resolve intermediate directory gnommo_scratch = None @@ -853,7 +847,7 @@ def cmd_stitch( project_path: Path, verbose: bool, force: bool = False, - proxy: bool = False, + res: str = "full", ) -> int: """ Stitch narration segments from narration.json. @@ -861,15 +855,11 @@ def cmd_stitch( Reads segments from media/narration/narration.json, applies begin/end trimming during concatenation, and writes output to media/videos/narration_combined.mov. Also creates/updates an entry in videos.json with volume property. - - This is useful for quickly iterating on begin/end trim points without - waiting for the full preprocessing pipeline. Works especially well - with --proxy for fast feedback. """ from .parser import parse_project_config, parse_narration, parse_videos - from .preprocessor import stitch_narration_segments, ensure_proxy_files_exist + from .preprocessor import stitch_narration_segments, ensure_downscaled_files_exist, RES_CONFIGS - mode_str = " (PROXY MODE)" if proxy else "" + mode_str = f" ({res.upper()})" if res != "full" else "" print(f"Stitching narration: {project_path.name}{mode_str}") config = parse_project_config(project_path) @@ -887,15 +877,13 @@ def cmd_stitch( else: videos_dir = project_path / "media" / "videos" - # Proxy mode: use proxy directory for both input and output - # Create proxy files on-the-fly if they don't exist - if proxy: - proxy_narration_dir = ensure_proxy_files_exist(narration_dir, force=False, verbose=verbose) - proxy_videos_dir = videos_dir / "proxy" - proxy_videos_dir.mkdir(parents=True, exist_ok=True) - narration_dir = proxy_narration_dir - videos_dir = proxy_videos_dir - print(f" Using proxy dirs: {narration_dir}, {videos_dir}") + # Use downscaled dirs for non-full res + if res != "full": + cfg = RES_CONFIGS[res] + narration_dir = ensure_downscaled_files_exist(narration_dir, res, force=False, verbose=verbose) + videos_dir = videos_dir / cfg[2] + videos_dir.mkdir(parents=True, exist_ok=True) + print(f" Using {res} dirs: {narration_dir}, {videos_dir}") # Get segment IDs in sorted order segment_ids = sorted(narration.keys()) @@ -962,7 +950,7 @@ def cmd_stitch( print("\n" + "=" * 60) print("Auto-running transcribe to sync with new narration...") print("=" * 60 + "\n") - return cmd_transcribe(project_path, verbose, proxy=proxy) + return cmd_transcribe(project_path, verbose, res=res) # ============================================================================= @@ -1106,7 +1094,6 @@ def cmd_render( slides_arg: str = None, res: str = "full", force: bool = False, - proxy: bool = False, ) -> int: """Render final video.""" from .parser import ( @@ -1121,7 +1108,7 @@ def cmd_render( from .validator import validate_project from .transformer import build_render_plan from .renderer import render, generate_ffmpeg_command_string - from .preprocessor import PROXY_WIDTH, PROXY_HEIGHT, ensure_proxy_files_exist + from .preprocessor import RES_CONFIGS, ensure_downscaled_files_exist # Parse slide range if provided slide_range = None @@ -1132,10 +1119,9 @@ def cmd_render( print(f"Rendering: {project_path.name}") # Show resolution mode - if proxy: - print(f" Resolution: PROXY ({PROXY_WIDTH}x{PROXY_HEIGHT}) - fast preview mode") - elif res == "low": - print(" Resolution: LOW (490x270) - fast preview mode") + if res != "full": + cfg = RES_CONFIGS[res] + print(f" Resolution: {res.upper()} ({cfg[0]}x{cfg[1]})") # Show cache status cache_info = get_cache_info() @@ -1152,22 +1138,19 @@ def cmd_render( save_citations(citations, citations_path) config = parse_project_config(project_path) - # Override resolution for proxy or low-res preview mode - if proxy: - config.resolution = (PROXY_WIDTH, PROXY_HEIGHT) - elif res == "low": - config.resolution = (490, 270) + # Override resolution for preview modes + if res != "full": + cfg = RES_CONFIGS[res] + config.resolution = (cfg[0], cfg[1]) slides = parse_slides(project_path, config) videos, videos_dir = parse_videos(project_path, config) - # Proxy mode: use videos from proxy directory - # Create proxy files on-the-fly if they don't exist - if proxy: - proxy_dir = ensure_proxy_files_exist(videos_dir, force=False, verbose=verbose) - videos_dir = proxy_dir + # Non-full res: use downscaled video directory, create on-the-fly if needed + if res != "full": + videos_dir = ensure_downscaled_files_exist(videos_dir, res, force=False, verbose=verbose) if verbose: - print(f" Using proxy dir: {proxy_dir}") + print(f" Using {res} dir: {videos_dir}") audio, audio_dir = parse_audio(project_path, config) # Load whisper transcription JSON @@ -1280,7 +1263,6 @@ def cmd_render( audio, audio_dir, slide_range=slide_range, - proxy=proxy, ) if plan.time_offset > 0: print(f" Time offset: {plan.time_offset:.1f}s (partial render)") @@ -1383,18 +1365,18 @@ def _find_narration_video(config, videos: dict) -> Optional[tuple[str, "VideoSou def cmd_transcribe( - project_path: Path, verbose: bool, proxy: bool = False, final: bool = False + project_path: Path, verbose: bool, res: str = "full", final: bool = False ) -> int: """Transcribe video audio using Whisper.""" from .transcriber import transcribe_video, save_transcript, words_to_srt from .parser import parse_project_config, parse_videos - from .preprocessor import ensure_proxy_files_exist + from .preprocessor import ensure_downscaled_files_exist # Handle --final mode: transcribe the rendered output for YouTube captions if final: return _transcribe_final(project_path, verbose) - mode_str = " (PROXY)" if proxy else "" + mode_str = f" ({res.upper()})" if res != "full" else "" print(f"Transcribing: {project_path.name}{mode_str}") config = parse_project_config(project_path) @@ -1403,11 +1385,9 @@ def cmd_transcribe( print("Error: No videos defined in videos.json", file=sys.stderr) return 1 - # Proxy mode: use videos from proxy directory - # Create proxy files on-the-fly if they don't exist - if proxy: - proxy_dir = ensure_proxy_files_exist(videos_dir, force=False, verbose=verbose) - videos_dir = proxy_dir + # Non-full res: use downscaled video directory + if res != "full": + videos_dir = ensure_downscaled_files_exist(videos_dir, res, force=False, verbose=verbose) # Check for multi-segment narration (concatenated file) if isinstance(config.main_video, list) and len(config.main_video) > 1: diff --git a/gnommo/preprocessor.py b/gnommo/preprocessor.py index 3fa4437..96abd0f 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -24,9 +24,16 @@ DEFAULT_CHUNK_WORKERS = 4 # Chunk duration in seconds for parallel filter processing (avoids huge intermediate files) CHUNK_DURATION = 60 -# Proxy resolution for fast preview workflow -PROXY_WIDTH = 320 -PROXY_HEIGHT = 180 +# Resolution presets for preview/proxy workflow +# Each entry: (width, height, subdir_name) +RES_CONFIGS: dict[str, tuple[int, int, str] | None] = { + "full": None, # no downscale, no subdir + "low": (490, 270, "low"), + "tiny": (320, 180, "proxy"), # "proxy" subdir kept for backward compat +} + +# Keep legacy constants pointing at "tiny" values +PROXY_WIDTH, PROXY_HEIGHT = RES_CONFIGS["tiny"][:2] # type: ignore[index] def get_video_duration(video_path: Path) -> float: @@ -82,88 +89,68 @@ def format_time(seconds: float) -> str: return f"{hours}h {mins}m" -def create_proxy_video( +def create_downscaled_video( source_path: Path, - proxy_dir: Path, + out_dir: Path, + width: int, + height: int, force: bool = False, ) -> Path: - """ - Create a low-resolution proxy of a video for fast preview workflow. + """Downscale a video to the given resolution, preserving audio.""" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / source_path.name - Args: - source_path: Path to the source video file - proxy_dir: Directory to store proxy files - force: Overwrite existing proxy if True + if out_path.exists() and not force: + return out_path - Returns: - Path to the proxy video file - """ - proxy_dir.mkdir(parents=True, exist_ok=True) - proxy_path = proxy_dir / source_path.name - - if proxy_path.exists() and not force: - return proxy_path - - # Downsample to proxy resolution, preserving audio quality cmd = [ - "ffmpeg", - "-y", - "-i", - str(source_path), - "-vf", - f"scale={PROXY_WIDTH}:{PROXY_HEIGHT}", - "-c:v", - "libx264", - "-preset", - "ultrafast", - "-crf", - "28", - "-c:a", - "copy", # Keep original audio for transcription - str(proxy_path), + "ffmpeg", "-y", + "-i", str(source_path), + "-vf", f"scale={width}:{height}", + "-c:v", "libx264", + "-preset", "ultrafast", + "-crf", "28", + "-c:a", "copy", + str(out_path), ] - - duration = get_video_duration(source_path) result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: raise PreprocessError( - f"Failed to create proxy for {source_path.name}", - filter_type="proxy", + f"Failed to downscale {source_path.name} to {width}x{height}", + filter_type="downscale", command=" ".join(cmd), stderr=result.stderr, ) - - return proxy_path + return out_path -def create_proxies_for_videos( +# Keep legacy name as alias +def create_proxy_video(source_path: Path, proxy_dir: Path, force: bool = False) -> Path: + w, h, _ = RES_CONFIGS["tiny"] # type: ignore[misc] + return create_downscaled_video(source_path, proxy_dir, w, h, force) + + +def create_downscaled_videos( videos_dir: Path, videos: dict[str, VideoSource], + res: str, force: bool = False, verbose: bool = False, ) -> Path: """ - Create proxy versions of all source videos. - - Args: - videos_dir: Directory containing source videos - videos: Dict of video ID -> VideoSource - force: Overwrite existing proxies if True - verbose: Print progress - - Returns: - Path to the proxy directory + Create downscaled copies of all source videos for the given res preset. + Returns the path to the output subdirectory. """ - proxy_dir = videos_dir / "proxy" - proxy_dir.mkdir(parents=True, exist_ok=True) + cfg = RES_CONFIGS[res] + if cfg is None: + return videos_dir # full res — no subdir + width, height, subdir = cfg - # Collect unique source files that need proxies - source_files: set[str] = set() - for video_id, video_source in videos.items(): - source_files.add(video_source.source_file) + out_dir = videos_dir / subdir + out_dir.mkdir(parents=True, exist_ok=True) - print(f" Creating proxies ({PROXY_WIDTH}x{PROXY_HEIGHT})...") + source_files: set[str] = set(v.source_file for v in videos.values()) + print(f" Creating {res} copies ({width}x{height})...") for source_file in sorted(source_files): source_path = videos_dir / source_file @@ -171,48 +158,49 @@ def create_proxies_for_videos( if verbose: print(f" Skipping {source_file} (not found)") continue - - proxy_path = proxy_dir / source_file - if proxy_path.exists() and not force: + out_path = out_dir / source_file + if out_path.exists() and not force: if verbose: print(f" {source_file}: exists, skipping") continue - print(f" {source_file}...", end=" ", flush=True) - create_proxy_video(source_path, proxy_dir, force) + create_downscaled_video(source_path, out_dir, width, height, force) print("done") - return proxy_dir + return out_dir -def ensure_proxy_files_exist( +# Keep legacy name as alias +def create_proxies_for_videos( + videos_dir: Path, + videos: dict[str, VideoSource], + force: bool = False, + verbose: bool = False, +) -> Path: + return create_downscaled_videos(videos_dir, videos, "tiny", force, verbose) + + +def ensure_downscaled_files_exist( source_dir: Path, + res: str, force: bool = False, verbose: bool = False, ) -> Path: """ - Ensure proxy files exist for all videos in source_dir, creating them on-the-fly if needed. - - This is used when running commands with --proxy to automatically create - missing proxy files without requiring a separate 'pre --proxy' step. - - Args: - source_dir: Directory containing source videos (e.g., media/videos or media/narration) - force: Overwrite existing proxy files if True - verbose: Print progress - - Returns: - Path to the proxy directory + Ensure downscaled copies exist for all videos in source_dir for the given res preset. + Creates them on-the-fly if missing. Returns the output subdirectory. """ + cfg = RES_CONFIGS[res] + if cfg is None: + return source_dir + width, height, subdir = cfg + video_extensions = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"} + out_dir = source_dir / subdir + out_dir.mkdir(parents=True, exist_ok=True) - proxy_dir = source_dir / "proxy" - proxy_dir.mkdir(parents=True, exist_ok=True) - - # Find all video files in source_dir (exclude subdirectories like proxy/, intermediate/) video_files = [ - f - for f in source_dir.iterdir() + f for f in source_dir.iterdir() if f.is_file() and f.suffix.lower() in video_extensions and "_processed" not in f.stem @@ -222,27 +210,31 @@ def ensure_proxy_files_exist( if not video_files: if verbose: print(f" No video files found in {source_dir}") - return proxy_dir + return out_dir - # Check which proxies need to be created - missing_proxies = [] - for video_file in video_files: - proxy_path = proxy_dir / video_file.name - if not proxy_path.exists() or force: - missing_proxies.append(video_file) + missing = [f for f in video_files if not (out_dir / f.name).exists() or force] - if not missing_proxies: + if not missing: if verbose: - print(f" All proxies exist in {proxy_dir}") - return proxy_dir + print(f" All {res} copies exist in {out_dir}") + return out_dir - print(f" Creating {len(missing_proxies)} proxy file(s) on-the-fly...") - for video_file in missing_proxies: + print(f" Creating {len(missing)} {res} file(s) ({width}x{height})...") + for video_file in missing: print(f" {video_file.name}...", end=" ", flush=True) - create_proxy_video(video_file, proxy_dir, force=True) + create_downscaled_video(video_file, out_dir, width, height, force=True) print("done") - return proxy_dir + return out_dir + + +# Keep legacy name as alias +def ensure_proxy_files_exist( + source_dir: Path, + force: bool = False, + verbose: bool = False, +) -> Path: + return ensure_downscaled_files_exist(source_dir, "tiny", force, verbose) import selectors, time, sys, subprocess @@ -343,6 +335,30 @@ def run_ffmpeg_with_progress(cmd, duration, description="Processing"): ) +def check_audio_channel_silent(input_path: Path, channel: str, threshold_db: float = -60.0) -> tuple[bool, float]: + """ + Quick check whether the specified audio channel is silent. + Uses ffmpeg volumedetect (audio-only pass, much faster than full processing). + + Returns (is_silent, max_volume_db). + """ + pan = "pan=mono|c0=c0" if channel == "left" else "pan=mono|c0=c1" + cmd = [ + "ffmpeg", "-i", str(input_path), + "-af", f"{pan},volumedetect", + "-f", "null", "/dev/null", + ] + result = subprocess.run(cmd, capture_output=True, text=True) + for line in result.stderr.splitlines(): + if "max_volume:" in line: + try: + max_vol = float(line.split("max_volume:")[1].strip().replace(" dB", "")) + return max_vol < threshold_db, max_vol + except ValueError: + pass + return False, 0.0 + + def preprocess_video( videos_dir: Path, video_id: str, @@ -386,6 +402,17 @@ def preprocess_video( filter_type=None, ) + # Quick audio sanity check: warn early if selected channel is silent + channel = video_source.use_audio_channels + if channel in ("left", "right"): + is_silent, max_vol = check_audio_channel_silent(current_input, channel) + if is_silent: + raise PreprocessError( + f"Audio channel '{channel}' is silent (max_volume={max_vol:.1f} dB). " + f"Wrong microphone channel selected?", + filter_type="audio_check", + ) + # Track intermediate files for cleanup intermediate_files: list[Path] = [] diff --git a/gnommo/renderer.py b/gnommo/renderer.py index 18fe05a..31e25b6 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -269,17 +269,23 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]: always_visible_inputs.append(input_idx) input_idx += 1 - # Input: background image/video (if specified) - from .cache import resolve_with_cache - bg_file = plan.config.background or plan.config.background_video - has_background = bool(bg_file) + # Input: background — resolved via handle in shared_assets/videos.json + import json as _json + bg_handle = plan.config.background + has_background = bool(bg_handle) bg_idx = None bg_is_image = False if has_background: - bg_path = project_path / bg_file - bg_path, _ = resolve_with_cache(bg_path, project_path) + shared_assets_dir = project_path.parent / "shared_assets" + videos_json_bg = shared_assets_dir / "videos.json" + if not videos_json_bg.exists(): + raise RenderError(f"shared_assets/videos.json not found (needed for background handle '{bg_handle}')") + bg_videos = _json.loads(videos_json_bg.read_text()) + if bg_handle not in bg_videos: + raise RenderError(f"Background handle '{bg_handle}' not found in shared_assets/videos.json") + bg_path = shared_assets_dir / bg_videos[bg_handle]["source_file"] if not bg_path.exists(): - bg_path = project_path.parent / bg_file + raise RenderError(f"Background file not found: {bg_path} (from handle '{bg_handle}')") image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"} bg_is_image = bg_path.suffix.lower() in image_extensions # Loop background videos infinitely diff --git a/gnommo/transformer.py b/gnommo/transformer.py index a5d1237..6c6f825 100644 --- a/gnommo/transformer.py +++ b/gnommo/transformer.py @@ -442,7 +442,6 @@ def build_render_plan( audio: Optional[dict[str, AudioDefinition]] = None, audio_dir: Optional[Path] = None, slide_range: Optional[tuple[str, Optional[str]]] = None, - proxy: Optional[bool] = False, ) -> tuple[RenderPlan, list[MarkerTiming]]: """ Build a complete render plan from manuscript and transcription. @@ -462,8 +461,7 @@ def build_render_plan( audio_dir = audio_dir or project_path # Find the main narration video first (need skip value for timing adjustment) - narration_video_id = "narration_combined.mov" # Default narration video ID - # Handle legacy list format - use first element + narration_video_id = config.main_video if isinstance(narration_video_id, list): narration_video_id = narration_video_id[0] if narration_video_id else None if not (narration_video_id and narration_video_id in videos): diff --git a/gnommo/validator.py b/gnommo/validator.py index cc35a55..160b3e1 100644 --- a/gnommo/validator.py +++ b/gnommo/validator.py @@ -84,6 +84,10 @@ def validate_project( ) continue + # Segment markers are structural annotations, not slide references + if marker.startswith("segment:"): + continue + if marker not in slides: issues.append( ValidationIssue( @@ -166,23 +170,37 @@ def validate_project( ) ) - # Check background exists (image or video) - # Try 'background' first, fall back to deprecated 'background_video' - bg_file = config.background or config.background_video - if bg_file: - # Check in project folder first, then parent (for shared_assets) - bg_path = project_path / bg_file - bg_path, _ = resolve_with_cache(bg_path, project_path) - if not bg_path.exists(): - # Try parent directory (shared_assets at repo root) - bg_path = project_path.parent / bg_file - bg_path, _ = resolve_with_cache(bg_path, project_path.parent) - if not bg_path.exists(): + # Check background exists — must be a handle in shared_assets/videos.json + bg_handle = config.background + if bg_handle: + shared_assets_dir = project_path.parent / "shared_assets" + videos_json_path_bg = shared_assets_dir / "videos.json" + if not videos_json_path_bg.exists(): issues.append( ValidationIssue( - f"Background not found: {bg_file}", project_path / "project.json" + f"shared_assets/videos.json not found (needed for background handle '{bg_handle}')", + project_path / "project.json", ) ) + else: + import json as _json + bg_videos = _json.loads(videos_json_path_bg.read_text()) + if bg_handle not in bg_videos: + issues.append( + ValidationIssue( + f"Background handle '{bg_handle}' not found in shared_assets/videos.json", + project_path / "project.json", + ) + ) + else: + bg_path = shared_assets_dir / bg_videos[bg_handle]["source_file"] + if not bg_path.exists(): + issues.append( + ValidationIssue( + f"Background file not found: {bg_path} (from handle '{bg_handle}')", + project_path / "project.json", + ) + ) # Check we have at least one video source if not videos: