Created
December 28, 2025 19:03
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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