Adding fixes to the pipeline
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user