Adding fixes to the pipeline

This commit is contained in:
2026-03-14 21:29:59 +01:00
parent b6bc5a0463
commit 6949124fa7
6 changed files with 236 additions and 201 deletions
+20 -14
View File
@@ -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
```
---
+44 -64
View File
@@ -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 <project> 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:
+127 -100
View File
@@ -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] = []
+13 -7
View File
@@ -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
+1 -3
View File
@@ -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):
+31 -13
View File
@@ -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: