"""Validation stage: fail-fast checks on parsed data.""" from pathlib import Path from .cache import resolve_with_cache from .errors import ValidationError, ValidationIssue from .parser import _read_json, resolve_missing_videos from .models import ( ProjectConfig, SlideDefinition, VideoSource, SLIDE_LAYOUTS, CAMERA_PRESETS, ) def validate_project( project_path: Path, manuscript_markers: list[str], config: ProjectConfig, slides: dict[str, SlideDefinition], videos: dict[str, VideoSource], videos_dir: Path, malformed_markers: list[tuple[int, str]] = None, audio: dict = None, ) -> list[ValidationIssue]: """ Validate all parsed project data. Raises ValidationError if any issues found. Returns a list of warnings (non-fatal issues). Checks: - All slide markers in manuscript exist in slides.json - All slide images exist on disk - All video files exist on disk - Background video exists (if specified) - Slide types are valid - No malformed markers in manuscript - All audio: markers in manuscript exist in audio.json """ issues: list[ValidationIssue] = [] warnings: list[ValidationIssue] = [] # Collect video IDs actually referenced in the manuscript (for file-existence checks) _VIDEO_PREFIXES = { "video:": 6, "vft:": 4, "vfb:": 4, "vfm:": 4, "vf2t:": 5, "vf2b:": 5, "vf2m:": 5, "vst:": 4, "vsb:": 4, "vsm:": 4, "vftp:": 5, "vfbp:": 5, "vfmp:": 5, "vf2tp:": 6, "vf2bp:": 6, "vf2mp:": 6, "vstp:": 5, "vsbp:": 5, "vsmp:": 5, } referenced_video_ids: set[str] = set() for marker in manuscript_markers: prefix = next((p for p in _VIDEO_PREFIXES if marker.startswith(p)), None) if prefix is not None: referenced_video_ids.add(marker[_VIDEO_PREFIXES[prefix]:].lower()) elif marker.startswith("narration:"): referenced_video_ids.add(marker[10:].lower()) # Check for malformed markers first (these are likely typos) if malformed_markers: for line_num, marker_text in malformed_markers: issues.append( ValidationIssue( f"Malformed marker: {marker_text}", project_path / "manuscript.txt", line_num, ) ) # Check all manuscript markers have corresponding slides or videos for marker in manuscript_markers: # Skip camera effect markers (Zoom0, TiltLeft, Reset, etc.) if marker in CAMERA_PRESETS: continue # Skip audio markers (start with 'A' followed by audio id, e.g., Awoosh) if marker.startswith("A") and len(marker) > 1 and marker[1:].isalnum(): continue # Skip audio: prefix markers (e.g., audio:woosh) if marker.startswith("audio:"): continue # Validate video trigger markers — both legacy [video:xxx] and # shorthand [vft:xxx] / [vfb:xxx] / [vst:xxx] / [vsb:xxx]. matched_prefix = next( (p for p in _VIDEO_PREFIXES if marker.startswith(p)), None ) if matched_prefix is not None: video_id = marker[_VIDEO_PREFIXES[matched_prefix] :].lower() if video_id not in videos: hint = "" if "." in video_id: base_name = video_id.rsplit(".", 1)[0] if base_name in videos: hint = f" (Did you mean [{matched_prefix}{base_name}]? Don't include file extensions in markers)" warnings.append( ValidationIssue( f"Video marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json{hint} — using PlaceholderVideo instead", project_path / "manuscript.txt", ) ) else: vs = videos[video_id] if not vs.cutout or vs.cutout not in config.cutouts: warnings.append( ValidationIssue( f"[{marker}] video '{video_id}' has no valid cutout in videos.json — " f"run 'gnommo import' to project values, or set cutout manually.", project_path / "manuscript.txt", ) ) continue # Validate narration trigger markers (narration:xxx) - continuous videos if marker.startswith("narration:"): video_id = marker[10:].lower() # Remove 'narration:' prefix if video_id not in videos: warnings.append( ValidationIssue( f"Narration marker [{marker}] referenced in manuscript but '{video_id}' not defined in videos.json — using PlaceholderVideo instead", project_path / "manuscript.txt", ) ) else: vs = videos[video_id] if not vs.cutout or vs.cutout not in config.cutouts: warnings.append( ValidationIssue( f"[{marker}] video '{video_id}' has no valid cutout in videos.json — " f"run 'gnommo import' to project values, or set cutout manually.", project_path / "manuscript.txt", ) ) continue # Segment markers are structural annotations, not slide references if marker.startswith("segment:"): continue # Bare narrator cues (teleprompter hints, not pipeline markers) if marker in ("pause", "stop"): continue # Unknown namespaced markers (e.g. [background:xxx]) — not supported, ignore with warning if ":" in marker: warnings.append( ValidationIssue( f"Unknown marker type [{marker}] — ignoring (no support for '{marker.split(':', 1)[0]}:' markers)", project_path / "manuscript.txt", ) ) continue if marker not in slides: issues.append( ValidationIssue( f"Slide marker [{marker}] referenced in manuscript but not defined in slides.json", project_path / "manuscript.txt", ) ) # Check all slide images exist # Slides are in the same directory as the slides.json file. # Lowercase the configured path so capital-cased project names (e.g. # "media/slides/Video2/slides.json") resolve on case-sensitive filesystems. slides_json_path = project_path / config.slides_path.lower() slides_dir = slides_json_path.parent for slide_id, slide_def in slides.items(): image_path = slides_dir / slide_def.image image_path, _ = resolve_with_cache(image_path, project_path) if not image_path.exists(): issues.append( ValidationIssue( f"Slide image not found: {slide_def.image}", slides_json_path ) ) # Check slide type is valid if slide_def.type not in SLIDE_LAYOUTS: issues.append( ValidationIssue( f"Unknown slide type '{slide_def.type}' for slide {slide_id}. " f"Valid types: {list(SLIDE_LAYOUTS.keys())}", project_path / "slides.json", ) ) # Check all video files exist (paths relative to videos_dir or shared_assets) videos_json_path = project_path / config.videos_path # Find shared_assets directory shared_assets_dir = None if (project_path / "shared_assets").exists(): shared_assets_dir = project_path / "shared_assets" elif (project_path.parent / "shared_assets").exists(): shared_assets_dir = project_path.parent / "shared_assets" for video_id, video_source in videos.items(): # Only check files for videos actually used in this manuscript if video_id not in referenced_video_ids: continue # Determine base directory based on is_shared flag if video_source.is_shared: if shared_assets_dir: base_dir = shared_assets_dir else: issues.append( ValidationIssue( f"Video '{video_id}' has is_shared=true but shared_assets directory not found", videos_json_path, ) ) continue else: base_dir = videos_dir video_path = base_dir / video_source.source_file video_path, _ = resolve_with_cache(video_path, project_path) if not video_path.exists(): sf = video_source.source_file hint = ( " — run 'gnommo pexels' to download" if sf.startswith("pexels/") else " — falling back to PlaceholderVideo" ) warnings.append( ValidationIssue( f"Video file not found: {sf}{hint}", videos_json_path, ) ) # Check preprocessed output exists if filters are defined if video_source.filter and video_source.output_file: output_path = base_dir / video_source.output_file output_path, _ = resolve_with_cache(output_path, project_path) if not output_path.exists(): issues.append( ValidationIssue( f"Preprocessed output not found: {video_source.output_file}. " f"Run with -a preprocess first.", videos_json_path, ) ) # 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"shared_assets/videos.json not found (needed for background handle '{bg_handle}')", project_path / "project.json", ) ) else: bg_videos = _read_json(videos_json_path_bg) 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"] bg_path, _ = resolve_with_cache(bg_path, project_path) if not bg_path.exists(): issues.append( ValidationIssue( f"Background file not found: {bg_path} (from handle '{bg_handle}')", project_path / "project.json", ) ) # Check videos.json exists (empty is fine — project may not need triggered videos) if not (project_path / config.videos_path).exists(): issues.append( ValidationIssue( "videos.json not found — run 'gnommo import' to create it", project_path / "videos.json", ) ) # Check resolution is reasonable width, height = config.resolution if width < 50 or height < 50: issues.append( ValidationIssue( f"Resolution too small: {width}x{height}", project_path / "project.json" ) ) if width > 7680 or height > 4320: issues.append( ValidationIssue( f"Resolution too large: {width}x{height} (max 8K)", project_path / "project.json", ) ) # Check FPS is reasonable if config.fps < 1 or config.fps > 120: issues.append( ValidationIssue( f"Invalid FPS: {config.fps} (must be 1-120)", project_path / "project.json", ) ) # Check outro videos exist in videos.json or shared_assets if config.outro: missing_outro = [vid_id for vid_id in config.outro if vid_id not in videos] if missing_outro: found = resolve_missing_videos(missing_outro, project_path, config) still_missing = [vid_id for vid_id in missing_outro if vid_id not in found] for vid_id in still_missing: warnings.append( ValidationIssue( f"Outro video '{vid_id}' not found in videos.json or shared_assets — will be skipped at render", project_path / "project.json", ) ) # Check all audio: markers in manuscript exist in audio.json if audio is not None: seen_audio: set[str] = set() manuscript_path = project_path / "manuscript.txt" for marker in manuscript_markers: audio_id = None if marker.startswith("audio:"): audio_id = marker[6:] elif marker.startswith("A") and len(marker) > 1 and marker[1:].isalnum(): audio_id = marker[1:] if audio_id and audio_id not in seen_audio: seen_audio.add(audio_id) if audio_id not in audio: warnings.append( ValidationIssue( f"Audio marker [{marker}] referenced in manuscript but '{audio_id}' not defined in audio.json — will be silent at render", manuscript_path, ) ) # If any issues, raise ValidationError if issues: raise ValidationError(issues) return warnings