Skip to content

Instantly share code, notes, and snippets.

@ParkWardRR
Created December 28, 2025 19:03
Show Gist options
  • Select an option

  • Save ParkWardRR/ff1525c30f61ca4bc8a3f444854b4ee9 to your computer and use it in GitHub Desktop.

Select an option

Save ParkWardRR/ff1525c30f61ca4bc8a3f444854b4ee9 to your computer and use it in GitHub Desktop.
This script transforms VR180 (stereo 360-degree, half-equirectangular) video files into standard flat 2D ProRes MOV files suitable for NLE (non-linear editing) systems. It performs batch processing with intelligent resolution selection, optional pitch variants, and comprehensive logging. Tested on MacOS
#!/usr/bin/env python3
"""
VR180 SBS to Flat 2D ProRes Batch Encoder (macOS)
==================================================
README & DOCUMENTATION
======================
OVERVIEW
--------
This script transforms VR180 (stereo 360-degree, half-equirectangular) video
files into standard flat 2D ProRes MOV files suitable for NLE (non-linear
editing) systems. It performs batch processing with intelligent resolution
selection, optional pitch variants, and comprehensive logging.
KEY FEATURES
------------
• Batch processes multiple video files from source directory
• Scans recursively (optional) for all supported video formats
• Automatically detects source resolution and selects optimal output height
from a predefined resolution ladder (no upscaling—preservation of quality)
• Converts VR180 half-equirectangular to standard rectilinear video using FFmpeg v360
• ProRes 422 HQ output (edit-friendly, broadcast-quality)
• Optional pitch variants for creative reframing (-10°, -15°, -27°, -32°)
• Test mode for quick validation (clamped to 480p by default)
• Comprehensive logging with ffmpeg capture
• Safe overwrite protection (skip existing files unless --overwrite)
WHAT HAPPENS
------------
1. Scans source directory for video files (.mkv, .mp4, .mov, .webm, .avi, .ts, .m2ts, .m4v)
2. For each file:
- Probes source with ffprobe to get dimensions and duration
- Detects stereo mode (side-by-side vs. mono) from filename or aspect ratio
- Determines projection (VR180 half-equirect by default)
- Selects output resolution (highest available that doesn't exceed source)
- Encodes to ProRes 422 HQ MOV with professional audio (48kHz PCM 16-bit)
- Optionally creates pitch variants if requested
3. Outputs organized by source file with logs and metadata
4. Final summary report (success/fail counts, total time)
SYSTEM REQUIREMENTS
-------------------
• macOS (tested 10.15+)
• Python 3.7+
• FFmpeg & FFprobe (install: brew install ffmpeg)
• ~50-100 MB disk per minute of 4K output (depends on ProRes profile)
INSTALLATION
------------
1. Install FFmpeg:
brew install ffmpeg
2. Download or copy this script to your project folder
3. Make executable:
chmod +x vr180_to_flat_prores_batch_enhanced.py
USAGE EXAMPLES
--------------
Basic batch encode (one output per file at max resolution):
python3 vr180_to_flat_prores_batch_enhanced.py /path/to/videos --output /path/to/output
Recursive directory scan:
python3 vr180_to_flat_prores_batch_enhanced.py /path/to/videos --recursive --output /path/to/output
With pitch variants (-10°, -15°, -27°, -32°):
python3 vr180_to_flat_prores_batch_enhanced.py /path/to/videos --pitch-variants --output /path/to/output
Test mode (quick 480p preview):
python3 vr180_to_flat_prores_batch_enhanced.py /path/to/videos --test --output /path/to/output
Full 360° input instead of VR180:
python3 vr180_to_flat_prores_batch_enhanced.py /path/to/videos --projection full360 --output /path/to/output
Custom stereo mode:
python3 vr180_to_flat_prores_batch_enhanced.py /path/to/videos --stereo sbs --output /path/to/output
Overwrite existing outputs:
python3 vr180_to_flat_prores_batch_enhanced.py /path/to/videos --output /path/to/output --overwrite
RESOLUTION SELECTION LOGIC
---------------------------
The script uses a resolution ladder (default: [720]). For each input:
1. Reads source height via ffprobe
2. Finds highest ladder rung ≤ source height
3. Outputs at that resolution (never upscales)
4. Examples:
• 1920x1080 source → 720p output
• 3840x2160 (4K) source → 720p output (if ladder is [720])
• 640x480 source → unchanged, no upscaling
Modify RES_LADDER in CONFIGURATION section to use [720, 1080, 1440, 2160, 4320].
OUTPUT STRUCTURE
----------------
Each source file produces:
output_dir/
└── [source_filename]/
├── [source_filename]_FLAT_720p.mov (main output)
├── [source_filename]_FLAT_720p_pitch-10.mov (if --pitch-variants)
├── [source_filename]_FLAT_720p_pitch-15.mov
├── [source_filename]_FLAT_720p_pitch-27.mov
├── [source_filename]_FLAT_720p_pitch-32.mov
├── logs/
│ └── ffmpeg_*.log (encoding logs)
└── _metadata/
└── ffprobe.json (source media info)
CONFIGURATION OPTIONS
---------------------
Edit these constants at the top of the script:
RES_LADDER List of output heights to choose from [720] default
DEFAULT_MAX_ONLY Use only highest resolution per file (True)
TEST_MAX_HEIGHT Cap test mode output to 480p
NORMAL_D_FOV Viewport diagonal FOV for flat output (120°)
PITCH_VARIANTS Angles for pitch variant outputs [-10, -15, -27, -32]
ASSUME_HEQUIRECT VR180 mode (True) or full 360° (False)
PRORES_PROFILE 3 = 422 HQ (recommended for editing)
PRORES_ENCODER prores_ks (Apple's ProRes encoder)
AUDIO_CODEC pcm_s16le (48kHz PCM, edit-friendly)
TROUBLESHOOTING
---------------
Q: "Missing ffmpeg" error
A: Install FFmpeg: brew install ffmpeg
Q: Output is distorted/stretched
A: Check --stereo flag (sbs vs mono) or --projection (hequirect vs full360)
Q: Encoding is too slow
A: Use test mode first (--test), or reduce RES_LADDER to lower resolutions
Q: Files are skipped
A: Use --overwrite flag to replace existing outputs
Q: Wrong aspect ratio detected
A: Use --stereo explicit flag (sbs, mono) to override auto-detection
NOTES
-----
• ProRes 422 HQ provides excellent quality for NLE workflows (broadcast-ready)
• PCM audio is edit-friendly but larger than AAC (re-encode to AAC for delivery if needed)
• Test mode is perfect for previewing framing/pitch before full batch encode
• Logs are stored per-file for debugging encoding issues
• Files are processed sequentially; consider splitting large batches for parallel processing
VERSION
-------
4.0.1-py - Python 3 implementation with enhanced documentation
================================ END README ================================
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import random
import re
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Tuple, Optional
################################################################################
# CONFIGURATION (edit as desired)
################################################################################
# Output resolution ladder (heights in pixels). Script picks highest <= source height.
RES_LADDER = [720]
# Default behavior: create ONE output per file at the maximum available resolution.
DEFAULT_MAX_ONLY = True
# TEST MODE: cap all test-mode outputs to this height (no upscaling still applies).
TEST_MAX_HEIGHT = 480
# Viewport diagonal field-of-view for flat (non-VR) output.
# Typical: 90–120° for standard rectilinear. 120° is immersive without distortion.
NORMAL_D_FOV = 120
# Pitch variants: vertical reframing angles (in degrees).
# Used only when --pitch-variants flag is passed.
PITCH_VARIANTS = [-10, -15, -27, -32]
# VR projection assumption: True = VR180 (half-equirectangular), False = full 360°.
ASSUME_HEQUIRECT = True
# ProRes codec configuration for high-quality editing workflows.
# prores_ks profiles: 0=proxy, 1=lt, 2=standard, 3=hq, 4=4444, 5=4444xq
PRORES_ENCODER = "prores_ks"
DEFAULT_PRORES_PROFILE = 3 # 3 = 422 HQ (broadcast/editing standard)
DEFAULT_PRORES_PIX_FMT = "yuv422p10le" # 10-bit 4:2:2 for ProRes HQ
PRORES_VENDOR = "apl0" # Apple ProRes vendor ID
PRORES_BITS_PER_MB = 8000 # ~8 Mbps per megapixel (quality-dependent)
# Audio configuration: PCM 16-bit 48kHz is edit-friendly and lossless.
AUDIO_CODEC = "pcm_s16le" # Lossless 16-bit PCM
AUDIO_RATE = 48000 # 48 kHz standard for broadcast/NLE
# MOV container optimization for streaming and NLE compatibility.
MOV_FASTSTART = True # Puts metadata at start (for streaming/scrubbing)
# Supported input video file extensions.
VIDEO_EXTS = {".mkv", ".mp4", ".m4v", ".mov", ".webm", ".avi", ".ts", ".m2ts"}
################################################################################
# Internal constants and utility classes
################################################################################
@dataclass(frozen=True)
class TestSegment:
"""Represents a clip segment for test-mode encoding (fast preview)."""
clip: int # Clip/segment number
start: int # Start time in seconds
dur: int # Duration in seconds
region: str # Label/region name (e.g., "head", "tail", "middle")
def iso_utc() -> str:
"""Return current time in ISO 8601 UTC format."""
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def runstamp_compact() -> str:
"""Return compact timestamp for filenames (YYYYMMDDHHmmss)."""
return time.strftime("%Y%m%d%H%M%S", time.localtime())
def format_time(sec: float) -> str:
"""Convert seconds to HH:MM:SS format."""
s = max(0, int(sec))
h = s // 3600
m = (s % 3600) // 60
r = s % 60
return f"{h:02d}:{m:02d}:{r:02d}"
def format_time_for_filename(sec: float) -> str:
"""Convert seconds to HH-MM-SS format suitable for filenames."""
return format_time(sec).replace(":", "-")
def has_cmd(name: str) -> bool:
"""Check if a command-line tool exists in PATH."""
return shutil.which(name) is not None
def run_cmd(
cmd: List[str],
*,
capture: bool = False,
check: bool = True,
stdout=None,
stderr=None
) -> subprocess.CompletedProcess:
"""
Execute a shell command safely.
Args:
cmd: List of command arguments
capture: If True, capture stdout/stderr as strings
check: If True, raise exception on non-zero exit code
stdout: File handle for stdout redirection (if not capturing)
stderr: File handle for stderr redirection (if not capturing)
Returns:
CompletedProcess object with returncode, stdout, stderr
"""
if capture:
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=check)
return subprocess.run(cmd, stdout=stdout, stderr=stderr, check=check)
def check_dependencies() -> None:
"""Verify that required tools (ffmpeg, ffprobe) are installed."""
for tool in ("ffmpeg", "ffprobe"):
if not has_cmd(tool):
raise RuntimeError(f"Missing {tool}. Install via: brew install ffmpeg")
def ffprobe_json(path: Path) -> Dict:
"""
Probe a media file and return full metadata as JSON dict.
Uses ffprobe to extract:
- Format info (duration, bitrate, container)
- All streams (video, audio, subtitles)
- Codec details, dimensions, frame rate, etc.
Args:
path: Path to media file
Returns:
Dict with 'streams' and 'format' keys
Raises:
RuntimeError: If ffprobe fails
"""
p = run_cmd(
[
"ffprobe",
"-v", "error",
"-print_format", "json",
"-show_format",
"-show_streams",
str(path),
],
capture=True,
check=True,
)
return json.loads(p.stdout)
def get_video_wh(probe: Dict) -> Tuple[int, int]:
"""
Extract video width and height from ffprobe output.
Args:
probe: Dict returned by ffprobe_json()
Returns:
Tuple of (width, height) in pixels
Raises:
RuntimeError: If no video stream or invalid dimensions
"""
streams = probe.get("streams") or []
vstreams = [s for s in streams if s.get("codec_type") == "video"]
if not vstreams:
raise RuntimeError("No video stream found.")
w = int(vstreams[0].get("width") or 0)
h = int(vstreams[0].get("height") or 0)
if w <= 0 or h <= 0:
raise RuntimeError("Could not determine width/height via ffprobe.")
return w, h
def get_duration_seconds(path: Path) -> int:
"""
Extract total duration of a media file in seconds.
Args:
path: Path to media file
Returns:
Duration in seconds (rounded down)
"""
p = run_cmd(
[
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
],
capture=True,
check=True,
)
try:
return int(float(p.stdout.strip()))
except Exception:
return 0
def parse_progress_file(progress_path: Path) -> Dict[str, str]:
"""
Parse ffmpeg progress file (key=value pairs written in real-time).
Used to track encoding progress, frame count, time, bitrate, etc.
Args:
progress_path: Path to ffmpeg's progress output file
Returns:
Dict of key-value pairs (e.g., {'frame': '1250', 'fps': '25.3'})
"""
try:
text = progress_path.read_text(encoding="utf-8", errors="ignore")
except Exception:
return {}
kv: Dict[str, str] = {}
for line in text.splitlines():
if "=" in line:
k, v = line.split("=", 1)
kv[k.strip()] = v.strip()
return kv
def sanitize_stem(s: str, *, max_len: int = 80) -> str:
"""
Clean filename stem: remove special chars, replace spaces with underscores.
Args:
s: Original filename stem
max_len: Maximum length of output string
Returns:
Safe filename string
"""
s = s.strip()
s = re.sub(r"\s+", "_", s) # Replace whitespace with underscores
s = re.sub(r"[^A-Za-z0-9._-]+", "", s) # Remove non-alphanumeric except . - _
s = s.strip("._-") # Strip leading/trailing special chars
if not s:
s = "file"
return s[:max_len]
def stem_20pct(s: str, *, min_len: int = 8, max_len: int = 32) -> str:
"""
Extract a short substring from filename (20% of length, bounded).
Used to create compact output names while preserving uniqueness.
Args:
s: Original string
min_len: Minimum substring length
max_len: Maximum substring length
Returns:
Short substring
"""
s = s or "file"
keep = int(len(s) * 0.20)
keep = max(min_len, keep)
keep = min(max_len, keep)
return s[:keep]
def short_hash8(p: Path) -> str:
"""
Generate an 8-character SHA1 hash of file path for uniqueness.
Useful for distinguishing files with identical names in different folders.
Args:
p: Path object
Returns:
First 8 hex characters of SHA1(path)
"""
h = hashlib.sha1(str(p).encode("utf-8")).hexdigest()
return h[:8]
def iter_video_files(src_dir: Path, *, recursive: bool) -> List[Path]:
"""
Scan directory for video files matching VIDEO_EXTS.
Args:
src_dir: Source directory path
recursive: If True, search subdirectories recursively
Returns:
Sorted list of Path objects
"""
if recursive:
files = [p for p in src_dir.rglob("*") if p.is_file() and p.suffix.lower() in VIDEO_EXTS]
else:
files = [p for p in src_dir.iterdir() if p.is_file() and p.suffix.lower() in VIDEO_EXTS]
return sorted(files)
def guess_in_stereo(src_w: int, src_h: int, filename: str, mode: str) -> str:
"""
Auto-detect stereo mode from filename or aspect ratio.
Side-by-side stereo typically has an aspect ratio ~2:1 (nearly square per eye).
Args:
src_w: Source video width
src_h: Source video height
filename: Filename for pattern matching (e.g., "_sbs", "_lr")
mode: User-provided mode ("sbs", "mono", or "auto")
Returns:
"sbs" (side-by-side) or "mono" (monoscopic)
"""
if mode in ("sbs", "mono"):
return mode # Explicit override
name = filename.lower()
# Check filename patterns
if "lr" in name or "_lr" in name or "sbs" in name or "leftright" in name:
return "sbs"
# Check aspect ratio: side-by-side is ~2:1
if src_h > 0:
ar = src_w / float(src_h)
if 1.85 <= ar <= 2.15:
return "sbs"
return "mono" # Default to monoscopic
def pick_output_heights(
src_h: int,
*,
max_only: bool,
allow_all_res: bool,
cap_h: Optional[int]
) -> List[int]:
"""
Select output resolution(s) from ladder based on source height.
Logic:
1. Start with RES_LADDER
2. Apply test-mode cap if provided
3. Filter to heights <= source height (no upscaling)
4. If max_only=True, keep only the highest; else keep all candidates
5. If no candidates exist (source too small), return lowest ladder rung clamped to source
Args:
src_h: Source video height in pixels
max_only: If True, return only highest resolution; else all candidates
allow_all_res: Unused flag (kept for compatibility)
cap_h: Optional cap height (for test mode)
Returns:
List of output heights in pixels
"""
ladder = RES_LADDER[:]
# Apply test-mode cap
if cap_h is not None:
ladder = [h for h in ladder if h <= cap_h]
# Find heights not exceeding source
candidates = [h for h in ladder if h <= src_h]
if not candidates:
# Source is smaller than smallest ladder rung: return clamped rung
return [min(ladder)] if ladder else [src_h]
if max_only:
return [max(candidates)] # Highest available without upscaling
else:
return candidates # All available
def build_ffmpeg_filter_vr2flat(
in_w: int,
in_h: int,
out_w: int,
out_h: int,
stereo_mode: str,
projection: str,
d_fov: float,
pitch: float = 0.0,
) -> str:
"""
Construct FFmpeg filtergraph for VR→flat conversion.
Uses FFmpeg's v360 filter to:
1. Treat input as half-equirectangular (VR180) or full equirectangular (360°)
2. Reproject to flat rectilinear output with specified FOV
3. Optional pitch adjustment for creative framing
4. Extract one eye if stereo side-by-side input
Args:
in_w, in_h: Input video dimensions
out_w, out_h: Output video dimensions
stereo_mode: "sbs" (side-by-side) or "mono" (monoscopic)
projection: "hequirect" (VR180) or "full360" (equirectangular)
d_fov: Diagonal field-of-view for output (typical 90–120°)
pitch: Vertical rotation in degrees (0 = center, -15 = look down, +15 = look up)
Returns:
FFmpeg filtergraph string suitable for -filter_complex argument
"""
# Determine v360 input/output parameters
# "hequirect" = half-equirectangular (VR180)
# "e" = equirectangular (full 360°)
in_proj = "hequirect" if projection == "hequirect" else "e"
# For side-by-side input, crop to left eye first (left half of frame)
if stereo_mode == "sbs":
crop_filter = f"crop=w={in_w // 2}:h={in_h}:x=0:y=0"
else:
crop_filter = "copy"
# Build v360 parameters:
# - in: input projection and interleave (=sbs for stereo)
# - out: output projection (flat rectilinear)
# - d_fov: diagonal field-of-view
# - pitch: vertical rotation
# - yaw: 0 (straight ahead), roll: 0 (no spin)
v360_opts = f"in={in_proj}:out=flat:d_fov={d_fov}:pitch={pitch}:yaw=0:roll=0"
# Compose filtergraph: crop (if stereo) -> v360 projection -> scale to target res
filtergraph = (
f"[0:v]{crop_filter}[crop];"
f"[crop]v360={v360_opts},scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[out];"
f"[out]pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2[padded]"
)
return filtergraph
def encode_one(
src_file: Path,
run_dir: Path,
out_height: int,
pitch_variant: float,
*,
recursive_rel_root: Optional[Path] = None,
overwrite: bool = False,
test_mode: bool = False,
test_seconds: int = 30,
test_segment: Optional[TestSegment] = None,
stereo_mode: str = "auto",
projection: str = "hequirect",
d_fov: float = NORMAL_D_FOV,
prores_profile: int = DEFAULT_PRORES_PROFILE,
prores_pix_fmt: str = DEFAULT_PRORES_PIX_FMT,
) -> None:
"""
Encode a single video file from VR180 to flat 2D ProRes.
Workflow:
1. Probe source (dimensions, duration, codec info)
2. Determine output filename and path
3. Build FFmpeg command with v360 filter
4. Execute encoding, capturing logs
5. Store metadata (ffprobe output)
Args:
src_file: Path to source video file
run_dir: Output root directory
out_height: Output height in pixels
pitch_variant: Pitch angle in degrees (0 = default, -15 = tilt down, etc.)
recursive_rel_root: Source root for relative path tracking
overwrite: If True, replace existing output files
test_mode: If True, limit output length (for quick tests)
test_seconds: Duration of test output in seconds
test_segment: Optional TestSegment describing which part to extract
stereo_mode: "sbs", "mono", or "auto"
projection: "hequirect" (VR180) or "full360" (equirectangular)
d_fov: Diagonal FOV for flat output
prores_profile: ProRes quality profile (0–5, 3 = HQ recommended)
prores_pix_fmt: ProRes pixel format (usually yuv422p10le)
Raises:
RuntimeError: On ffmpeg/ffprobe failures or invalid inputs
"""
# Probe source file for metadata
probe = ffprobe_json(src_file)
src_w, src_h = get_video_wh(probe)
duration = get_duration_seconds(src_file)
# Determine stereo mode (side-by-side or monoscopic)
stereo_mode = guess_in_stereo(src_w, src_h, src_file.name, stereo_mode)
# Build output directory structure
file_stem = sanitize_stem(src_file.stem)
out_subdir = run_dir / file_stem
out_subdir.mkdir(parents=True, exist_ok=True)
# Build output filename
pitch_suffix = f"_pitch{pitch_variant:+.0f}" if pitch_variant != 0 else ""
out_name = f"{file_stem}_FLAT_{out_height}p{pitch_suffix}.mov"
out_file = out_subdir # for directory struct
out_file = out_subdir / out_name
# Check if output exists (skip unless overwrite flag)
if out_file.exists() and not overwrite:
print(f"⊘ Skipping {out_file.name} (exists; use --overwrite to replace)")
return
# Create logs and metadata subdirectories
logs_dir = out_subdir / "logs"
logs_dir.mkdir(exist_ok=True)
metadata_dir = out_subdir / "_metadata"
metadata_dir.mkdir(exist_ok=True)
# Save ffprobe output for reference
metadata_file = metadata_dir / "ffprobe.json"
metadata_file.write_text(json.dumps(probe, indent=2))
# Build FFmpeg command
# Calculate output width to match aspect ratio
out_w = int(out_height * src_w / src_h)
if out_w % 2 != 0:
out_w += 1 # Ensure even width (required for some codecs)
# Build filter graph for VR→flat projection
filtergraph = build_ffmpeg_filter_vr2flat(
src_w, src_h,
out_w, out_height,
stereo_mode,
projection,
d_fov,
pitch_variant,
)
# Log file for ffmpeg output
log_file = logs_dir / f"ffmpeg_{runstamp_compact()}.log"
# Build ffmpeg command
ffmpeg_cmd = ["ffmpeg", "-y", "-i", str(src_file)]
# Add test-mode trimming if needed
if test_mode:
if test_segment:
# Encode specific segment from test clip
ffmpeg_cmd.extend(["-ss", str(test_segment.start), "-t", str(test_segment.dur)])
else:
# Default: first N seconds
ffmpeg_cmd.extend(["-t", str(test_seconds)])
# Video encoding with v360 filter and ProRes codec
ffmpeg_cmd.extend([
"-filter_complex", filtergraph,
"-c:v", PRORES_ENCODER,
"-q:v", str(prores_profile),
"-pix_fmt", prores_pix_fmt,
"-color_range", "tv", # Video range (16-235 on 8-bit scale)
])
# Audio: PCM 16-bit 48kHz (lossless, edit-friendly)
ffmpeg_cmd.extend([
"-c:a", AUDIO_CODEC,
"-ar", str(AUDIO_RATE),
])
# MOV container: optimize for streaming/scrubbing
if MOV_FASTSTART:
ffmpeg_cmd.extend(["-movflags", "+faststart"])
# Output file
ffmpeg_cmd.append(str(out_file))
# Execute encoding with logging
try:
with open(log_file, "w") as log_fh:
print(f"▶ Encoding {src_file.name} → {out_name}...")
run_cmd(ffmpeg_cmd, stdout=log_fh, stderr=log_fh, check=True)
print(f"✓ {out_name}")
except subprocess.CalledProcessError as e:
try:
with open(log_file, "a") as log_fh:
log_fh.write(f"\n\nEncoding failed: {e}\n")
except Exception:
pass
raise RuntimeError(f"FFmpeg error (see {log_file} for details)")
def main() -> int:
"""
Main entry point: parse arguments, scan files, dispatch encoding jobs.
Returns:
0 on success, 1 if any encodes failed
"""
# Parse command-line arguments
parser = argparse.ArgumentParser(
description="VR180 SBS → Flat 2D ProRes Batch Encoder (macOS)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s /input/folder --output /output/folder
%(prog)s /input --output /output --recursive --pitch-variants
%(prog)s /input --output /output --test --stereo sbs
""",
)
parser.add_argument(
"input",
type=Path,
help="Source directory containing video files",
)
parser.add_argument(
"-o", "--output",
type=Path,
required=True,
help="Output directory for encoded files",
)
parser.add_argument(
"-r", "--recursive",
action="store_true",
help="Scan subdirectories recursively",
)
parser.add_argument(
"-p", "--pitch-variants",
action="store_true",
help="Create pitch variant outputs (default: max res only)",
)
parser.add_argument(
"--stereo",
choices=["auto", "sbs", "mono"],
default="auto",
help="Stereo mode: auto-detect (default), side-by-side, or monoscopic",
)
parser.add_argument(
"--projection",
choices=["hequirect", "full360"],
default="hequirect",
help="Input projection: VR180 half-equirect (default) or full equirectangular",
)
parser.add_argument(
"-t", "--test",
action="store_true",
help="Test mode: limit output to 480p and first N seconds",
)
parser.add_argument(
"--test-seconds",
type=int,
default=30,
help="Duration of test output in seconds (default: 30)",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Overwrite existing output files",
)
parser.add_argument(
"--prores-profile",
type=int,
choices=[0, 1, 2, 3, 4, 5],
default=DEFAULT_PRORES_PROFILE,
help="ProRes quality: 0=proxy, 1=lt, 2=standard, 3=hq (default), 4=4444, 5=4444xq",
)
args = parser.parse_args()
# Validate inputs
if not args.input.is_dir():
print(f"✗ Input directory not found: {args.input}", file=sys.stderr)
return 1
args.output.mkdir(parents=True, exist_ok=True)
# Check dependencies
try:
check_dependencies()
except RuntimeError as e:
print(f"✗ {e}", file=sys.stderr)
return 1
# Set up output directories
run_dir = args.output / f"vr_to_flat_{runstamp_compact()}"
run_dir.mkdir(parents=True, exist_ok=True)
# Find video files
video_files = iter_video_files(args.input, recursive=args.recursive)
if not video_files:
print(f"⊘ No video files found in {args.input}", file=sys.stderr)
return 1
print(f"ℹ Found {len(video_files)} video file(s)")
print(f"ℹ Output: {run_dir}\n")
# Encoding loop: process each file
start_all = time.time()
ok, fail = 0, 0
for f in video_files:
try:
# Probe to get source dimensions for resolution selection
probe = ffprobe_json(f)
src_w, src_h = get_video_wh(probe)
# Determine output resolution(s)
cap = TEST_MAX_HEIGHT if args.test else None
heights = pick_output_heights(
src_h,
max_only=DEFAULT_MAX_ONLY,
allow_all_res=False,
cap_h=cap,
)
# Process each resolution
for h in heights:
# Main encoding (pitch = 0°)
encode_one(
f, run_dir, h, 0.0,
recursive_rel_root=args.input,
overwrite=args.overwrite,
test_mode=args.test,
test_seconds=args.test_seconds,
test_segment=None,
stereo_mode=args.stereo,
projection=args.projection,
d_fov=NORMAL_D_FOV,
prores_profile=args.prores_profile,
prores_pix_fmt=DEFAULT_PRORES_PIX_FMT,
)
# Pitch variants if requested
if args.pitch_variants:
for pitch_v in PITCH_VARIANTS:
encode_one(
f, run_dir, h, pitch_v,
recursive_rel_root=args.input,
overwrite=args.overwrite,
test_mode=args.test,
test_seconds=args.test_seconds,
test_segment=None,
stereo_mode=args.stereo,
projection=args.projection,
d_fov=NORMAL_D_FOV,
prores_profile=args.prores_profile,
prores_pix_fmt=DEFAULT_PRORES_PIX_FMT,
)
ok += 1
except Exception as e:
fail += 1
print(f"✗ {e}", file=sys.stderr)
# Final summary
total = format_time(time.time() - start_all)
print(f"\n✓ Done in {total} | Files: OK={ok} FAIL={fail}")
print(f"✓ Logs and outputs: {run_dir}")
print(f"\nℹ Output structure per file:")
print(f" __/")
print(f" ├── *.mov (ProRes outputs)")
print(f" ├── logs/ (ffmpeg logs)")
print(f" └── _metadata/ (ffprobe.json)")
return 0 if fail == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment