358 lines
14 KiB
Python
358 lines
14 KiB
Python
"""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())
|
|
|
|
# (Key-reuse across cutout/layer is legal: presentation is resolved per-occurrence
|
|
# from the shorthand prefix now — see transformer.resolve_video_presentation — so
|
|
# one handle can appear as vst: (above) and vsb: (below) without colliding.)
|
|
|
|
# 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
|