Adding fixes to the pipeline
This commit is contained in:
@@ -37,18 +37,23 @@ gnommo -p myproject youtubeready
|
|||||||
gnommo -p myproject archive
|
gnommo -p myproject archive
|
||||||
```
|
```
|
||||||
|
|
||||||
## Proxying
|
## Resolution modes
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lowres
|
All commands accept `--res` to trade quality for speed during iteration:
|
||||||
Renders the final video in a low-res mode, for faster iteration
|
|
||||||
```
|
| Flag | Resolution | Use case |
|
||||||
gnommo -p myproject render --res low
|
|---|---|---|
|
||||||
|
| `--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
|
## Project Structure
|
||||||
@@ -157,9 +162,10 @@ gnommo -p myproject render
|
|||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
```bash
|
```bash
|
||||||
gnommo -p myproject render --dry-run # Show FFmpeg command without running
|
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 --slides S1:S10 # Render only slides S1 through S10
|
||||||
gnommo -p myproject render --proxy # Fast preview at reduced resolution
|
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 validate Validate only
|
||||||
gnommo -p video1 import Generate slides.json from images
|
gnommo -p video1 import Generate slides.json from images
|
||||||
gnommo -p video1 pre Preprocess videos (chroma key, etc.)
|
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 all Full pipeline: transcribe → align → render
|
||||||
gnommo -p video1 render --dry-run Show FFmpeg command without running
|
gnommo -p video1 render --dry-run Show FFmpeg command without running
|
||||||
gnommo -p video1 description Generate YouTube description file
|
gnommo -p video1 description Generate YouTube description file
|
||||||
@@ -113,9 +113,9 @@ Examples:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--res",
|
"--res",
|
||||||
type=str,
|
type=str,
|
||||||
choices=["low", "full"],
|
choices=["full", "low", "tiny"],
|
||||||
default="full",
|
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(
|
parser.add_argument(
|
||||||
"-w",
|
"-w",
|
||||||
@@ -124,11 +124,6 @@ Examples:
|
|||||||
default=1,
|
default=1,
|
||||||
help="Number of parallel workers for preprocessing (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(
|
parser.add_argument(
|
||||||
"--final",
|
"--final",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
@@ -184,14 +179,14 @@ Examples:
|
|||||||
args.dry_run,
|
args.dry_run,
|
||||||
args.force,
|
args.force,
|
||||||
args.workers,
|
args.workers,
|
||||||
args.proxy,
|
args.res,
|
||||||
)
|
)
|
||||||
elif action in ("stitch"):
|
elif action in ("stitch"):
|
||||||
return cmd_stitch(
|
return cmd_stitch(
|
||||||
project_path,
|
project_path,
|
||||||
args.verbose,
|
args.verbose,
|
||||||
args.force,
|
args.force,
|
||||||
args.proxy,
|
args.res,
|
||||||
)
|
)
|
||||||
elif action == "render":
|
elif action == "render":
|
||||||
return cmd_render(
|
return cmd_render(
|
||||||
@@ -201,10 +196,9 @@ Examples:
|
|||||||
args.slides,
|
args.slides,
|
||||||
args.res,
|
args.res,
|
||||||
args.force,
|
args.force,
|
||||||
args.proxy,
|
|
||||||
)
|
)
|
||||||
elif action == "transcribe":
|
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":
|
elif action == "align":
|
||||||
return cmd_align(project_path, args.verbose)
|
return cmd_align(project_path, args.verbose)
|
||||||
elif action == "all":
|
elif action == "all":
|
||||||
@@ -739,17 +733,18 @@ def cmd_preprocess(
|
|||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
workers: int = 1,
|
workers: int = 1,
|
||||||
proxy: bool = False,
|
res: str = "full",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Run preprocessing pipeline on narration segments."""
|
"""Run preprocessing pipeline on narration segments."""
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from .parser import parse_project_config, parse_narration
|
from .parser import parse_project_config, parse_narration
|
||||||
from .preprocessor import (
|
from .preprocessor import (
|
||||||
preprocess_video,
|
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}")
|
print(f"Preprocessing narration: {project_path.name}{mode_str}")
|
||||||
|
|
||||||
config = parse_project_config(project_path)
|
config = parse_project_config(project_path)
|
||||||
@@ -760,12 +755,11 @@ def cmd_preprocess(
|
|||||||
print(" Run 'gnommo -p <project> import' first to populate narration.json")
|
print(" Run 'gnommo -p <project> import' first to populate narration.json")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Proxy mode: create low-res copies first, then work from proxy dir
|
# Downscale source files first if a preview res was requested
|
||||||
if proxy:
|
if res != "full":
|
||||||
proxy_dir = create_proxies_for_videos(narration_dir, narration, force, verbose)
|
narration_dir = create_downscaled_videos(narration_dir, narration, res, force, verbose)
|
||||||
# Switch to proxy directory for all subsequent operations
|
cfg = RES_CONFIGS[res]
|
||||||
narration_dir = proxy_dir
|
print(f" Working from {res} dir ({cfg[0]}x{cfg[1]}): {narration_dir}")
|
||||||
print(f" Working from proxy dir: {proxy_dir}")
|
|
||||||
|
|
||||||
# Resolve intermediate directory
|
# Resolve intermediate directory
|
||||||
gnommo_scratch = None
|
gnommo_scratch = None
|
||||||
@@ -853,7 +847,7 @@ def cmd_stitch(
|
|||||||
project_path: Path,
|
project_path: Path,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
proxy: bool = False,
|
res: str = "full",
|
||||||
) -> int:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Stitch narration segments from narration.json.
|
Stitch narration segments from narration.json.
|
||||||
@@ -861,15 +855,11 @@ def cmd_stitch(
|
|||||||
Reads segments from media/narration/narration.json, applies begin/end
|
Reads segments from media/narration/narration.json, applies begin/end
|
||||||
trimming during concatenation, and writes output to media/videos/narration_combined.mov.
|
trimming during concatenation, and writes output to media/videos/narration_combined.mov.
|
||||||
Also creates/updates an entry in videos.json with volume property.
|
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 .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}")
|
print(f"Stitching narration: {project_path.name}{mode_str}")
|
||||||
|
|
||||||
config = parse_project_config(project_path)
|
config = parse_project_config(project_path)
|
||||||
@@ -887,15 +877,13 @@ def cmd_stitch(
|
|||||||
else:
|
else:
|
||||||
videos_dir = project_path / "media" / "videos"
|
videos_dir = project_path / "media" / "videos"
|
||||||
|
|
||||||
# Proxy mode: use proxy directory for both input and output
|
# Use downscaled dirs for non-full res
|
||||||
# Create proxy files on-the-fly if they don't exist
|
if res != "full":
|
||||||
if proxy:
|
cfg = RES_CONFIGS[res]
|
||||||
proxy_narration_dir = ensure_proxy_files_exist(narration_dir, force=False, verbose=verbose)
|
narration_dir = ensure_downscaled_files_exist(narration_dir, res, force=False, verbose=verbose)
|
||||||
proxy_videos_dir = videos_dir / "proxy"
|
videos_dir = videos_dir / cfg[2]
|
||||||
proxy_videos_dir.mkdir(parents=True, exist_ok=True)
|
videos_dir.mkdir(parents=True, exist_ok=True)
|
||||||
narration_dir = proxy_narration_dir
|
print(f" Using {res} dirs: {narration_dir}, {videos_dir}")
|
||||||
videos_dir = proxy_videos_dir
|
|
||||||
print(f" Using proxy dirs: {narration_dir}, {videos_dir}")
|
|
||||||
|
|
||||||
# Get segment IDs in sorted order
|
# Get segment IDs in sorted order
|
||||||
segment_ids = sorted(narration.keys())
|
segment_ids = sorted(narration.keys())
|
||||||
@@ -962,7 +950,7 @@ def cmd_stitch(
|
|||||||
print("\n" + "=" * 60)
|
print("\n" + "=" * 60)
|
||||||
print("Auto-running transcribe to sync with new narration...")
|
print("Auto-running transcribe to sync with new narration...")
|
||||||
print("=" * 60 + "\n")
|
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,
|
slides_arg: str = None,
|
||||||
res: str = "full",
|
res: str = "full",
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
proxy: bool = False,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Render final video."""
|
"""Render final video."""
|
||||||
from .parser import (
|
from .parser import (
|
||||||
@@ -1121,7 +1108,7 @@ def cmd_render(
|
|||||||
from .validator import validate_project
|
from .validator import validate_project
|
||||||
from .transformer import build_render_plan
|
from .transformer import build_render_plan
|
||||||
from .renderer import render, generate_ffmpeg_command_string
|
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
|
# Parse slide range if provided
|
||||||
slide_range = None
|
slide_range = None
|
||||||
@@ -1132,10 +1119,9 @@ def cmd_render(
|
|||||||
print(f"Rendering: {project_path.name}")
|
print(f"Rendering: {project_path.name}")
|
||||||
|
|
||||||
# Show resolution mode
|
# Show resolution mode
|
||||||
if proxy:
|
if res != "full":
|
||||||
print(f" Resolution: PROXY ({PROXY_WIDTH}x{PROXY_HEIGHT}) - fast preview mode")
|
cfg = RES_CONFIGS[res]
|
||||||
elif res == "low":
|
print(f" Resolution: {res.upper()} ({cfg[0]}x{cfg[1]})")
|
||||||
print(" Resolution: LOW (490x270) - fast preview mode")
|
|
||||||
|
|
||||||
# Show cache status
|
# Show cache status
|
||||||
cache_info = get_cache_info()
|
cache_info = get_cache_info()
|
||||||
@@ -1152,22 +1138,19 @@ def cmd_render(
|
|||||||
save_citations(citations, citations_path)
|
save_citations(citations, citations_path)
|
||||||
config = parse_project_config(project_path)
|
config = parse_project_config(project_path)
|
||||||
|
|
||||||
# Override resolution for proxy or low-res preview mode
|
# Override resolution for preview modes
|
||||||
if proxy:
|
if res != "full":
|
||||||
config.resolution = (PROXY_WIDTH, PROXY_HEIGHT)
|
cfg = RES_CONFIGS[res]
|
||||||
elif res == "low":
|
config.resolution = (cfg[0], cfg[1])
|
||||||
config.resolution = (490, 270)
|
|
||||||
|
|
||||||
slides = parse_slides(project_path, config)
|
slides = parse_slides(project_path, config)
|
||||||
videos, videos_dir = parse_videos(project_path, config)
|
videos, videos_dir = parse_videos(project_path, config)
|
||||||
|
|
||||||
# Proxy mode: use videos from proxy directory
|
# Non-full res: use downscaled video directory, create on-the-fly if needed
|
||||||
# Create proxy files on-the-fly if they don't exist
|
if res != "full":
|
||||||
if proxy:
|
videos_dir = ensure_downscaled_files_exist(videos_dir, res, force=False, verbose=verbose)
|
||||||
proxy_dir = ensure_proxy_files_exist(videos_dir, force=False, verbose=verbose)
|
|
||||||
videos_dir = proxy_dir
|
|
||||||
if 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)
|
audio, audio_dir = parse_audio(project_path, config)
|
||||||
|
|
||||||
# Load whisper transcription JSON
|
# Load whisper transcription JSON
|
||||||
@@ -1280,7 +1263,6 @@ def cmd_render(
|
|||||||
audio,
|
audio,
|
||||||
audio_dir,
|
audio_dir,
|
||||||
slide_range=slide_range,
|
slide_range=slide_range,
|
||||||
proxy=proxy,
|
|
||||||
)
|
)
|
||||||
if plan.time_offset > 0:
|
if plan.time_offset > 0:
|
||||||
print(f" Time offset: {plan.time_offset:.1f}s (partial render)")
|
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(
|
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:
|
) -> int:
|
||||||
"""Transcribe video audio using Whisper."""
|
"""Transcribe video audio using Whisper."""
|
||||||
from .transcriber import transcribe_video, save_transcript, words_to_srt
|
from .transcriber import transcribe_video, save_transcript, words_to_srt
|
||||||
from .parser import parse_project_config, parse_videos
|
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
|
# Handle --final mode: transcribe the rendered output for YouTube captions
|
||||||
if final:
|
if final:
|
||||||
return _transcribe_final(project_path, verbose)
|
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}")
|
print(f"Transcribing: {project_path.name}{mode_str}")
|
||||||
|
|
||||||
config = parse_project_config(project_path)
|
config = parse_project_config(project_path)
|
||||||
@@ -1403,11 +1385,9 @@ def cmd_transcribe(
|
|||||||
print("Error: No videos defined in videos.json", file=sys.stderr)
|
print("Error: No videos defined in videos.json", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Proxy mode: use videos from proxy directory
|
# Non-full res: use downscaled video directory
|
||||||
# Create proxy files on-the-fly if they don't exist
|
if res != "full":
|
||||||
if proxy:
|
videos_dir = ensure_downscaled_files_exist(videos_dir, res, force=False, verbose=verbose)
|
||||||
proxy_dir = ensure_proxy_files_exist(videos_dir, force=False, verbose=verbose)
|
|
||||||
videos_dir = proxy_dir
|
|
||||||
|
|
||||||
# Check for multi-segment narration (concatenated file)
|
# Check for multi-segment narration (concatenated file)
|
||||||
if isinstance(config.main_video, list) and len(config.main_video) > 1:
|
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 in seconds for parallel filter processing (avoids huge intermediate files)
|
||||||
CHUNK_DURATION = 60
|
CHUNK_DURATION = 60
|
||||||
|
|
||||||
# Proxy resolution for fast preview workflow
|
# Resolution presets for preview/proxy workflow
|
||||||
PROXY_WIDTH = 320
|
# Each entry: (width, height, subdir_name)
|
||||||
PROXY_HEIGHT = 180
|
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:
|
def get_video_duration(video_path: Path) -> float:
|
||||||
@@ -82,88 +89,68 @@ def format_time(seconds: float) -> str:
|
|||||||
return f"{hours}h {mins}m"
|
return f"{hours}h {mins}m"
|
||||||
|
|
||||||
|
|
||||||
def create_proxy_video(
|
def create_downscaled_video(
|
||||||
source_path: Path,
|
source_path: Path,
|
||||||
proxy_dir: Path,
|
out_dir: Path,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""
|
"""Downscale a video to the given resolution, preserving audio."""
|
||||||
Create a low-resolution proxy of a video for fast preview workflow.
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path = out_dir / source_path.name
|
||||||
|
|
||||||
Args:
|
if out_path.exists() and not force:
|
||||||
source_path: Path to the source video file
|
return out_path
|
||||||
proxy_dir: Directory to store proxy files
|
|
||||||
force: Overwrite existing proxy if True
|
|
||||||
|
|
||||||
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 = [
|
cmd = [
|
||||||
"ffmpeg",
|
"ffmpeg", "-y",
|
||||||
"-y",
|
"-i", str(source_path),
|
||||||
"-i",
|
"-vf", f"scale={width}:{height}",
|
||||||
str(source_path),
|
"-c:v", "libx264",
|
||||||
"-vf",
|
"-preset", "ultrafast",
|
||||||
f"scale={PROXY_WIDTH}:{PROXY_HEIGHT}",
|
"-crf", "28",
|
||||||
"-c:v",
|
"-c:a", "copy",
|
||||||
"libx264",
|
str(out_path),
|
||||||
"-preset",
|
|
||||||
"ultrafast",
|
|
||||||
"-crf",
|
|
||||||
"28",
|
|
||||||
"-c:a",
|
|
||||||
"copy", # Keep original audio for transcription
|
|
||||||
str(proxy_path),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
duration = get_video_duration(source_path)
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise PreprocessError(
|
raise PreprocessError(
|
||||||
f"Failed to create proxy for {source_path.name}",
|
f"Failed to downscale {source_path.name} to {width}x{height}",
|
||||||
filter_type="proxy",
|
filter_type="downscale",
|
||||||
command=" ".join(cmd),
|
command=" ".join(cmd),
|
||||||
stderr=result.stderr,
|
stderr=result.stderr,
|
||||||
)
|
)
|
||||||
|
return out_path
|
||||||
return proxy_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_dir: Path,
|
||||||
videos: dict[str, VideoSource],
|
videos: dict[str, VideoSource],
|
||||||
|
res: str,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""
|
"""
|
||||||
Create proxy versions of all source videos.
|
Create downscaled copies of all source videos for the given res preset.
|
||||||
|
Returns the path to the output subdirectory.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
proxy_dir = videos_dir / "proxy"
|
cfg = RES_CONFIGS[res]
|
||||||
proxy_dir.mkdir(parents=True, exist_ok=True)
|
if cfg is None:
|
||||||
|
return videos_dir # full res — no subdir
|
||||||
|
width, height, subdir = cfg
|
||||||
|
|
||||||
# Collect unique source files that need proxies
|
out_dir = videos_dir / subdir
|
||||||
source_files: set[str] = set()
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
for video_id, video_source in videos.items():
|
|
||||||
source_files.add(video_source.source_file)
|
|
||||||
|
|
||||||
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):
|
for source_file in sorted(source_files):
|
||||||
source_path = videos_dir / source_file
|
source_path = videos_dir / source_file
|
||||||
@@ -171,48 +158,49 @@ def create_proxies_for_videos(
|
|||||||
if verbose:
|
if verbose:
|
||||||
print(f" Skipping {source_file} (not found)")
|
print(f" Skipping {source_file} (not found)")
|
||||||
continue
|
continue
|
||||||
|
out_path = out_dir / source_file
|
||||||
proxy_path = proxy_dir / source_file
|
if out_path.exists() and not force:
|
||||||
if proxy_path.exists() and not force:
|
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" {source_file}: exists, skipping")
|
print(f" {source_file}: exists, skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f" {source_file}...", end=" ", flush=True)
|
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")
|
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,
|
source_dir: Path,
|
||||||
|
res: str,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
verbose: bool = False,
|
verbose: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
"""
|
"""
|
||||||
Ensure proxy files exist for all videos in source_dir, creating them on-the-fly if needed.
|
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.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
|
cfg = RES_CONFIGS[res]
|
||||||
|
if cfg is None:
|
||||||
|
return source_dir
|
||||||
|
width, height, subdir = cfg
|
||||||
|
|
||||||
video_extensions = {".mov", ".mp4", ".webm", ".avi", ".mkv", ".m4v"}
|
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 = [
|
video_files = [
|
||||||
f
|
f for f in source_dir.iterdir()
|
||||||
for f in source_dir.iterdir()
|
|
||||||
if f.is_file()
|
if f.is_file()
|
||||||
and f.suffix.lower() in video_extensions
|
and f.suffix.lower() in video_extensions
|
||||||
and "_processed" not in f.stem
|
and "_processed" not in f.stem
|
||||||
@@ -222,27 +210,31 @@ def ensure_proxy_files_exist(
|
|||||||
if not video_files:
|
if not video_files:
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" No video files found in {source_dir}")
|
print(f" No video files found in {source_dir}")
|
||||||
return proxy_dir
|
return out_dir
|
||||||
|
|
||||||
# Check which proxies need to be created
|
missing = [f for f in video_files if not (out_dir / f.name).exists() or force]
|
||||||
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)
|
|
||||||
|
|
||||||
if not missing_proxies:
|
if not missing:
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f" All proxies exist in {proxy_dir}")
|
print(f" All {res} copies exist in {out_dir}")
|
||||||
return proxy_dir
|
return out_dir
|
||||||
|
|
||||||
print(f" Creating {len(missing_proxies)} proxy file(s) on-the-fly...")
|
print(f" Creating {len(missing)} {res} file(s) ({width}x{height})...")
|
||||||
for video_file in missing_proxies:
|
for video_file in missing:
|
||||||
print(f" {video_file.name}...", end=" ", flush=True)
|
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")
|
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
|
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(
|
def preprocess_video(
|
||||||
videos_dir: Path,
|
videos_dir: Path,
|
||||||
video_id: str,
|
video_id: str,
|
||||||
@@ -386,6 +402,17 @@ def preprocess_video(
|
|||||||
filter_type=None,
|
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
|
# Track intermediate files for cleanup
|
||||||
intermediate_files: list[Path] = []
|
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)
|
always_visible_inputs.append(input_idx)
|
||||||
input_idx += 1
|
input_idx += 1
|
||||||
|
|
||||||
# Input: background image/video (if specified)
|
# Input: background — resolved via handle in shared_assets/videos.json
|
||||||
from .cache import resolve_with_cache
|
import json as _json
|
||||||
bg_file = plan.config.background or plan.config.background_video
|
bg_handle = plan.config.background
|
||||||
has_background = bool(bg_file)
|
has_background = bool(bg_handle)
|
||||||
bg_idx = None
|
bg_idx = None
|
||||||
bg_is_image = False
|
bg_is_image = False
|
||||||
if has_background:
|
if has_background:
|
||||||
bg_path = project_path / bg_file
|
shared_assets_dir = project_path.parent / "shared_assets"
|
||||||
bg_path, _ = resolve_with_cache(bg_path, project_path)
|
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():
|
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"}
|
image_extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".webp"}
|
||||||
bg_is_image = bg_path.suffix.lower() in image_extensions
|
bg_is_image = bg_path.suffix.lower() in image_extensions
|
||||||
# Loop background videos infinitely
|
# Loop background videos infinitely
|
||||||
|
|||||||
@@ -442,7 +442,6 @@ def build_render_plan(
|
|||||||
audio: Optional[dict[str, AudioDefinition]] = None,
|
audio: Optional[dict[str, AudioDefinition]] = None,
|
||||||
audio_dir: Optional[Path] = None,
|
audio_dir: Optional[Path] = None,
|
||||||
slide_range: Optional[tuple[str, Optional[str]]] = None,
|
slide_range: Optional[tuple[str, Optional[str]]] = None,
|
||||||
proxy: Optional[bool] = False,
|
|
||||||
) -> tuple[RenderPlan, list[MarkerTiming]]:
|
) -> tuple[RenderPlan, list[MarkerTiming]]:
|
||||||
"""
|
"""
|
||||||
Build a complete render plan from manuscript and transcription.
|
Build a complete render plan from manuscript and transcription.
|
||||||
@@ -462,8 +461,7 @@ def build_render_plan(
|
|||||||
audio_dir = audio_dir or project_path
|
audio_dir = audio_dir or project_path
|
||||||
|
|
||||||
# Find the main narration video first (need skip value for timing adjustment)
|
# Find the main narration video first (need skip value for timing adjustment)
|
||||||
narration_video_id = "narration_combined.mov" # Default narration video ID
|
narration_video_id = config.main_video
|
||||||
# Handle legacy list format - use first element
|
|
||||||
if isinstance(narration_video_id, list):
|
if isinstance(narration_video_id, list):
|
||||||
narration_video_id = narration_video_id[0] if narration_video_id else None
|
narration_video_id = narration_video_id[0] if narration_video_id else None
|
||||||
if not (narration_video_id and narration_video_id in videos):
|
if not (narration_video_id and narration_video_id in videos):
|
||||||
|
|||||||
+31
-13
@@ -84,6 +84,10 @@ def validate_project(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Segment markers are structural annotations, not slide references
|
||||||
|
if marker.startswith("segment:"):
|
||||||
|
continue
|
||||||
|
|
||||||
if marker not in slides:
|
if marker not in slides:
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
ValidationIssue(
|
||||||
@@ -166,23 +170,37 @@ def validate_project(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check background exists (image or video)
|
# Check background exists — must be a handle in shared_assets/videos.json
|
||||||
# Try 'background' first, fall back to deprecated 'background_video'
|
bg_handle = config.background
|
||||||
bg_file = config.background or config.background_video
|
if bg_handle:
|
||||||
if bg_file:
|
shared_assets_dir = project_path.parent / "shared_assets"
|
||||||
# Check in project folder first, then parent (for shared_assets)
|
videos_json_path_bg = shared_assets_dir / "videos.json"
|
||||||
bg_path = project_path / bg_file
|
if not videos_json_path_bg.exists():
|
||||||
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():
|
|
||||||
issues.append(
|
issues.append(
|
||||||
ValidationIssue(
|
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
|
# Check we have at least one video source
|
||||||
if not videos:
|
if not videos:
|
||||||
|
|||||||
Reference in New Issue
Block a user