{"slug": "video-modification-simplified", "title": "Video Modification Simplified", "summary": "The article describes **vid.py**, a Python command-line tool that acts as a simplified wrapper around ffmpeg for common video tasks. It provides functions for parsing timecodes, estimating video duration by probing input files, and rendering a real-time progress bar in the terminal during video processing. The script includes error handling, ffmpeg dependency checking, and verbose output options for user feedback.", "body_md": "#!/usr/bin/env python3\n\"\"\"vid — a friendlier CLI wrapper around ffmpeg for common video tasks.\"\"\"\n\nimport argparse\nimport json\nimport os\nimport re\nimport shlex\nimport shutil\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\n# Set by main() from --verbose. Read by run() and batch prep commands to\n# decide whether to print per-file ffmpeg commands and use a per-file bar.\n_VERBOSE = False\n\n\ndef fail(msg):\n    print(f\"error: {msg}\", file=sys.stderr)\n    sys.exit(1)\n\n\ndef check_ffmpeg():\n    if not shutil.which(\"ffmpeg\"):\n        fail(\"ffmpeg not found in PATH\")\n\n\ndef _to_seconds(s):\n    \"\"\"Parse '30', '1:30', '00:01:30', or '12.5' to a float number of seconds.\"\"\"\n    if s is None:\n        return None\n    try:\n        if \":\" in s:\n            parts = [float(p) for p in s.split(\":\")]\n            if len(parts) == 2:\n                return parts[0] * 60 + parts[1]\n            if len(parts) == 3:\n                return parts[0] * 3600 + parts[1] * 60 + parts[2]\n        return float(s)\n    except (ValueError, TypeError):\n        return None\n\n\ndef _guess_duration(cmd):\n    \"\"\"Estimate expected output duration (seconds) from an ffmpeg command.\"\"\"\n    # Explicit -t wins\n    for i, arg in enumerate(cmd[:-1]):\n        if arg == \"-t\":\n            d = _to_seconds(cmd[i + 1])\n            if d:\n                return d\n    # Otherwise probe the first input and subtract -ss if present.\n    # Prefer the video stream's duration over the container's — DJI MP4s often\n    # have container-level padding past the last video frame, which would make\n    # the progress bar stop short of 100% otherwise.\n    for i, arg in enumerate(cmd[:-1]):\n        if arg == \"-i\":\n            input_path = cmd[i + 1]\n            if not Path(input_path).is_file():\n                return None\n            try:\n                data = probe(input_path)\n                v = next((s for s in data[\"streams\"] if s.get(\"codec_type\") == \"video\"), None)\n                full = None\n                if v and \"duration\" in v:\n                    full = float(v[\"duration\"])\n                if not full:\n                    full = float(data.get(\"format\", {}).get(\"duration\", 0))\n                if not full:\n                    return None\n            except Exception:\n                return None\n            ss = 0.0\n            for j, a in enumerate(cmd[:i]):\n                if a == \"-ss\":\n                    ss = _to_seconds(cmd[j + 1]) or 0.0\n                    break\n            return max(0.0, full - ss)\n    return None\n\n\n_PROGRESS_TTY = sys.stdout.isatty()\n\n\ndef _render_progress(progress, total, complete=False):\n    \"\"\"Print a one-line in-place progress update. No-op if stdout isn't a TTY.\n\n    When complete=True (ffmpeg reported progress=end), forces the bar to 100%\n    regardless of how out_time compares to the estimated total — duration\n    estimates can be slightly off, but 'ffmpeg said done' is authoritative.\n    \"\"\"\n    if not _PROGRESS_TTY:\n        return\n    out_time = progress.get(\"out_time\", \"00:00:00.000000\").split(\".\")[0]\n    fps = progress.get(\"fps\", \"?\")\n    speed = progress.get(\"speed\", \"?\").strip()\n    elapsed_s = _to_seconds(out_time) or 0.0\n    if total and total > 0:\n        pct = 100.0 if complete else min(100.0, elapsed_s / total * 100)\n        bar_len = 24\n        filled = int(bar_len * pct / 100)\n        bar = \"█\" * filled + \"░\" * (bar_len - filled)\n        line = f\"  [{bar}] {pct:5.1f}%  {out_time}  fps={fps:>4}  speed={speed}\"\n    else:\n        line = f\"  {out_time}  fps={fps}  speed={speed}\"\n    # \\r returns to column 0; \\x1b[K clears from cursor to end of line\n    sys.stdout.write(\"\\r\" + line + \"\\x1b[K\")\n    sys.stdout.flush()\n\n\ndef run(cmd, dry_run=False, output_path=None, on_progress=None):\n    \"\"\"Run an ffmpeg command with a single-line progress display.\n\n    on_progress: optional callable(progress_dict, total_seconds, complete) — when\n        provided, the caller takes over progress rendering (e.g., a batch driver\n        showing one overall bar). When None, run() renders its own per-file bar.\n\n    Verbose vs quiet behavior is keyed off the module-level _VERBOSE flag:\n      - verbose: prints '→ ffmpeg ...' command line; ffmpeg at loglevel warning\n      - quiet:   suppresses both; ffmpeg at loglevel error\n\n    On Ctrl+C: waits for ffmpeg to clean up, removes the partial output (if any),\n    then re-raises KeyboardInterrupt for the caller to handle.\n    \"\"\"\n    if _VERBOSE or dry_run:\n        print(\"→\", \" \".join(shlex.quote(c) for c in cmd))\n    if dry_run:\n        return 0\n\n    total = _guess_duration(cmd)\n    loglevel = \"warning\" if _VERBOSE else \"error\"\n    proc_cmd = (\n        [cmd[0], \"-hide_banner\", \"-loglevel\", loglevel,\n         \"-nostats\", \"-progress\", \"pipe:1\"]\n        + cmd[1:]\n    )\n    proc = subprocess.Popen(proc_cmd, stdout=subprocess.PIPE, text=True, bufsize=1)\n\n    interrupted = False\n    progress = {}\n    rc = -1\n    rendered_own_bar = False\n    try:\n        for line in proc.stdout:\n            if \"=\" not in line:\n                continue\n            k, _, v = line.strip().partition(\"=\")\n            progress[k] = v\n            if k == \"progress\":\n                if on_progress:\n                    on_progress(progress, total, v == \"end\")\n                else:\n                    _render_progress(progress, total, complete=(v == \"end\"))\n                    rendered_own_bar = True\n                if v == \"end\":\n                    break\n        rc = proc.wait()\n    except KeyboardInterrupt:\n        interrupted = True\n\n    # Only advance past the per-file bar if WE rendered it (caller's bar persists)\n    if rendered_own_bar and _PROGRESS_TTY:\n        sys.stdout.write(\"\\n\")\n        sys.stdout.flush()\n\n    if interrupted:\n        print(\"interrupted, waiting for ffmpeg to clean up...\", file=sys.stderr)\n        try:\n            proc.wait(timeout=5)\n        except subprocess.TimeoutExpired:\n            proc.terminate()\n            try:\n                proc.wait(timeout=2)\n            except subprocess.TimeoutExpired:\n                proc.kill()\n                proc.wait()\n        if output_path and Path(output_path).exists():\n            try:\n                Path(output_path).unlink()\n                print(f\"  removed partial output: {output_path}\", file=sys.stderr)\n            except OSError as e:\n                print(f\"  could not remove partial output: {e}\", file=sys.stderr)\n        raise KeyboardInterrupt\n\n    return rc\n\n\ndef default_output(input_path, suffix, ext=None, sample=False):\n    p = Path(input_path)\n    new_ext = ext if ext else p.suffix\n    if not new_ext.startswith(\".\"):\n        new_ext = \".\" + new_ext\n    if sample:\n        suffix = f\"{suffix}_sample\"\n    return str(p.with_name(f\"{p.stem}_{suffix}{new_ext}\"))\n\n\ndef is_sample(args):\n    return getattr(args, \"sample\", None) is not None\n\n\n# Shared quality presets — base CRF for libx264. libx265 needs ~5 higher for equivalent quality.\nQUALITY_PRESETS = {\n    \"lossless\": 0,    # byte-perfect — files can be huge\n    \"pristine\": 17,   # near-lossless, indistinguishable to most viewers\n    \"high\": 20,       # excellent (default for transforms)\n    \"medium\": 23,     # good (ffmpeg's own default; default for compress)\n    \"low\": 28,        # noticeably softer\n    \"tiny\": 32,       # visible compression artifacts\n}\n\n# Maps the --codec choice to the actual ffmpeg encoder name.\nCODEC_ENCODERS = {\"h264\": \"libx264\", \"h265\": \"libx265\", \"hevc\": \"libx265\"}\n\n\ndef detect_codec(input_path):\n    \"\"\"Probe the source and return 'libx264'/'libx265' to match it. Falls back to libx264.\"\"\"\n    try:\n        data = probe(input_path)\n        video = next((s for s in data[\"streams\"] if s[\"codec_type\"] == \"video\"), None)\n        if video and video.get(\"codec_name\") in (\"hevc\", \"h265\"):\n            return \"libx265\"\n    except Exception:\n        pass\n    return \"libx264\"\n\n\ndef pick_codec(args):\n    \"\"\"Resolve the user's --codec choice (or 'auto') to a concrete ffmpeg encoder.\"\"\"\n    choice = getattr(args, \"codec\", None) or \"auto\"\n    if choice == \"auto\":\n        return detect_codec(args.input)\n    return CODEC_ENCODERS[choice]\n\n\ndef quality_args(args, default_label, encoder):\n    \"\"\"Return the ffmpeg quality-control args for the chosen encoder.\"\"\"\n    label = getattr(args, \"quality\", None) or default_label\n    if encoder == \"libx265\":\n        if label == \"lossless\":\n            return [\"-x265-params\", \"lossless=1\"]\n        # H.265 ≈ H.264 - 5 CRF for similar visual quality\n        return [\"-crf\", str(QUALITY_PRESETS[label] + 5)]\n    return [\"-crf\", str(QUALITY_PRESETS[label])]\n\n\ndef input_args(args):\n    \"\"\"Build ffmpeg input args, honoring --sample / --sample-start if set.\"\"\"\n    parts = [\"-y\"]\n    if is_sample(args):\n        parts += [\"-ss\", str(args.sample_start), \"-t\", str(args.sample)]\n    parts += [\"-i\", args.input]\n    return parts\n\n\ndef parse_time(value):\n    \"\"\"Accept '90', '1:30', '00:01:30', or '1.5' — return as string ffmpeg accepts.\"\"\"\n    return str(value)\n\n\ndef probe(input_path):\n    cmd = [\n        \"ffprobe\", \"-v\", \"error\", \"-print_format\", \"json\",\n        \"-show_format\", \"-show_streams\", input_path,\n    ]\n    result = subprocess.run(cmd, capture_output=True, text=True)\n    if result.returncode != 0:\n        fail(f\"ffprobe failed: {result.stderr.strip()}\")\n    return json.loads(result.stdout)\n\n\n# ---------- disk-space estimation ----------\n\ndef _format_size(bytes_):\n    \"\"\"Human-readable size: 631 B / 631 KB / 631 MB / 6.31 GB.\"\"\"\n    if bytes_ is None:\n        return \"?\"\n    if bytes_ >= 1e9:\n        return f\"{bytes_ / 1e9:.2f} GB\"\n    if bytes_ >= 1e6:\n        return f\"{bytes_ / 1e6:.1f} MB\"\n    if bytes_ >= 1e3:\n        return f\"{bytes_ / 1e3:.0f} KB\"\n    return f\"{int(bytes_)} B\"\n\n\ndef _format_gb(bytes_):\n    if bytes_ is None:\n        return \"?\"\n    if bytes_ >= 1e9:\n        return f\"{bytes_ / 1e9:.1f} GB\"\n    return f\"{bytes_ / 1e6:.0f} MB\"\n\n\ndef _probe_video_dims(input_path):\n    \"\"\"Return (width, height, fps, duration_sec) or None if unprobable.\"\"\"\n    try:\n        data = probe(input_path)\n    except SystemExit:\n        return None\n    v = next((s for s in data[\"streams\"] if s.get(\"codec_type\") == \"video\"), None)\n    if not v:\n        return None\n    try:\n        w, h = int(v[\"width\"]), int(v[\"height\"])\n        num, den = v.get(\"r_frame_rate\", \"30/1\").split(\"/\")\n        fps = float(num) / float(den) if float(den) else 30.0\n        duration = float(data.get(\"format\", {}).get(\"duration\", 0))\n        return w, h, fps, duration\n    except (KeyError, ValueError, ZeroDivisionError):\n        return None\n\n\ndef _estimate_youtube_h265_bytes(width, height, fps, duration_sec):\n    \"\"\"Estimate libx265 CRF 22 output size (yuv420p, AAC 192k) for a YouTube target.\n\n    Calibrated against YouTube's recommended H.264 upload bitrates (~68 Mbps for 4K\n    60p, ~12 Mbps for 1080p 60p). libx265 CRF 22 reaches comparable visual quality\n    at ~40 Mbps for that 4K 60p baseline. Scales linearly with pixel count and fps.\n    \"\"\"\n    ref_pixels, ref_fps, ref_mbps = 3840 * 2160, 60.0, 40.0\n    video_mbps = max(4.0, ref_mbps * (width * height) / ref_pixels * (fps / ref_fps))\n    video_bps = video_mbps * 1_000_000 / 8\n    audio_bps = 192_000 / 8  # AAC 192 kbps\n    return int((video_bps + audio_bps) * duration_sec)\n\n\ndef _estimate_dnxhr_lb_bytes(width, height, fps, duration_sec):\n    \"\"\"Estimate DNxHR LB output size (8-bit 4:2:2).\n\n    Reference rate: ~90 Mbps for 1920x1080 at 59.94 fps. Scales linearly with\n    pixels × fps. Audio = PCM s16 stereo 48k. Calibrated after first sample.\n    \"\"\"\n    ref_pixels, ref_fps, ref_mbps = 1920 * 1080, 60.0, 90.0\n    video_mbps = ref_mbps * (width * height) / ref_pixels * (fps / ref_fps)\n    video_bps = video_mbps * 1_000_000 / 8\n    audio_bps = 48000 * 2 * 2  # PCM s16 stereo: 2 bytes × 2 channels × samplerate\n    return int((video_bps + audio_bps) * duration_sec)\n\n\ndef _estimate_dnxhr_hqx_bytes(width, height, fps, duration_sec):\n    \"\"\"Estimate DNxHR HQX output size.\n\n    Reference rate: ~1750 Mbps for 3840x2160 at 59.94 fps (10-bit 4:2:2).\n    Calibrated against an actual encode: 10s of 4K UHD 59.94 source produced\n    ~2.19 GB output. Avid's nominal \"~880 Mbps\" figure is for the lower-bitrate\n    HQ profile, not HQX. Scales linearly with pixel count and frame rate.\n    Audio = PCM s16 stereo 48k.\n    \"\"\"\n    ref_pixels, ref_fps, ref_mbps = 3840 * 2160, 60.0, 1750.0\n    video_mbps = ref_mbps * (width * height) / ref_pixels * (fps / ref_fps)\n    video_bps = video_mbps * 1_000_000 / 8\n    audio_bps = 48000 * 2 * 2  # PCM s16 stereo: 2 bytes × 2 channels × samplerate\n    return int((video_bps + audio_bps) * duration_sec)\n\n\ndef _estimate_default_bytes(args, input_path):\n    \"\"\"Rough output-size estimate for re-encode commands (not davinci-prep).\"\"\"\n    try:\n        input_size = Path(input_path).stat().st_size\n    except OSError:\n        return None\n    # If sampling, scale by duration ratio\n    if is_sample(args):\n        try:\n            data = probe(input_path)\n            full_duration = float(data[\"format\"][\"duration\"])\n            if full_duration > 0:\n                sample_dur = float(args.sample)\n                input_size = input_size * sample_dur / full_duration\n        except Exception:\n            pass\n    # \"lossless\" can balloon files (esp. libx264 -crf 0); other modes shrink or stay similar\n    quality = getattr(args, \"quality\", None)\n    if quality == \"lossless\":\n        return int(input_size * 3.0)\n    if quality == \"pristine\":\n        return int(input_size * 1.3)\n    return int(input_size * 1.1)  # small safety margin\n\n\ndef _check_disk_space(output_dir, needed_bytes, label=\"\", skip=False):\n    \"\"\"Verify enough free space at output_dir. Aborts on shortfall unless skipped.\"\"\"\n    if skip or needed_bytes is None or needed_bytes <= 0:\n        return\n    try:\n        free = shutil.disk_usage(str(output_dir)).free\n    except OSError:\n        return  # can't query, proceed\n    needed_with_buffer = int(needed_bytes * 1.10)\n    if free < needed_with_buffer:\n        suffix = f\" for {label}\" if label else \"\"\n        fail(\n            f\"not enough free disk space{suffix}\\n\"\n            f\"  output dir: {output_dir}\\n\"\n            f\"  estimated:  ~{_format_gb(needed_bytes)} \"\n            f\"(+10% safety = {_format_gb(needed_with_buffer)})\\n\"\n            f\"  available:  {_format_gb(free)}\\n\"\n            f\"\\nfree up space and try again, or pass --skip-space-check to override.\"\n        )\n    # Tight-but-OK note\n    if free < needed_bytes * 1.5:\n        suffix = f\" for {label}\" if label else \"\"\n        print(\n            f\"note: disk getting tight{suffix} — \"\n            f\"~{_format_gb(needed_bytes)} estimated, {_format_gb(free)} free\",\n            file=sys.stderr,\n        )\n\n\ndef _format_mmss(seconds):\n    \"\"\"Format a number of seconds as MM:SS, or HH:MM:SS if ≥ 1 hour.\"\"\"\n    s = int(max(0, seconds or 0))\n    if s >= 3600:\n        return f\"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}\"\n    return f\"{s//60:02d}:{s%60:02d}\"\n\n\ndef _format_hms(seconds):\n    \"\"\"Format a number of seconds as HH:MM:SS (always padded).\"\"\"\n    s = int(max(0, seconds or 0))\n    return f\"{s//3600:02d}:{(s%3600)//60:02d}:{s%60:02d}\"\n\n\n# Colors. Skip when stdout isn't a TTY or NO_COLOR is set.\n_COLOR_ENABLED = sys.stdout.isatty() and not os.environ.get(\"NO_COLOR\")\n_C = {\n    \"reset\": \"\\x1b[0m\" if _COLOR_ENABLED else \"\",\n    \"dim\":   \"\\x1b[2m\" if _COLOR_ENABLED else \"\",\n    \"bold\":  \"\\x1b[1m\" if _COLOR_ENABLED else \"\",\n    \"fill\":  \"\\x1b[36m\" if _COLOR_ENABLED else \"\",   # cyan: active\n    \"done\":  \"\\x1b[32m\" if _COLOR_ENABLED else \"\",   # green\n    \"fail\":  \"\\x1b[31m\" if _COLOR_ENABLED else \"\",   # red\n}\n\n\nclass BatchProgress:\n    \"\"\"ipad-util-style multi-line frame: bar + one row per file.\n\n    File status: 0=pending (·, dim) / 1=active (braille spinner) / 2=done (✓) / 3=failed (✗).\n    Each render redraws the whole frame using cursor-up; first frame just prints.\n    If the terminal isn't tall enough to fit one row per file, falls back to a\n    single-line bar (no file rows) — same logic ipad-util uses.\n\n    Caller flow:\n        batch = BatchProgress(files)          # files: [(src, out, dur_sec, src_size)]\n        for idx, f in enumerate(files):\n            batch.begin_file(idx)\n            # ... encode, calling batch.update_current(elapsed_sec, complete) ...\n            batch.finish_file(success=bool)\n        batch.finalize()\n    \"\"\"\n\n    SPINNER = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"]\n\n    def __init__(self, files):\n        self.files = files  # [(src, out, duration_seconds, src_size_bytes)]\n        self.status = [0] * len(files)  # 0=pending, 1=active, 2=done, 3=failed\n        self.current_idx = -1\n        self.current_elapsed = 0.0  # source seconds done in the active file\n        self.total_seconds = sum(f[2] for f in files)\n        self.spin_idx = 0\n        self.start_time = time.time()\n        self.first_frame = True\n        # Decide multi-line vs single-line fallback\n        try:\n            term_lines = shutil.get_terminal_size((100, 24)).lines\n        except Exception:\n            term_lines = 24\n        n = len(files)\n        self.multiline = sys.stdout.isatty() and (n + 3) <= term_lines\n\n    # ----- public API -----\n\n    def begin_file(self, idx):\n        self.current_idx = idx\n        self.status[idx] = 1\n        self.current_elapsed = 0.0\n        self._render()\n\n    def update_current(self, elapsed_seconds, complete=False):\n        if self.current_idx < 0:\n            return\n        dur = self.files[self.current_idx][2]\n        self.current_elapsed = dur if complete else min(elapsed_seconds, dur)\n        self._render()\n\n    def finish_file(self, success=True):\n        if self.current_idx >= 0:\n            self.status[self.current_idx] = 2 if success else 3\n            self.current_elapsed = 0.0\n            self.current_idx = -1\n        self._render()\n\n    def finalize(self):\n        # One last render with no active file, then move cursor past everything.\n        self._render(final=True)\n        if sys.stdout.isatty():\n            sys.stdout.write(\"\\n\")\n            sys.stdout.flush()\n\n    # ----- internals -----\n\n    def _completed_secs(self):\n        return sum(self.files[i][2] for i in range(len(self.files)) if self.status[i] >= 2)\n\n    def _render(self, final=False):\n        if not sys.stdout.isatty():\n            return\n        if self.multiline:\n            self._render_multiline(final)\n        else:\n            self._render_oneline()\n\n    def _format_bar(self):\n        done = self._completed_secs() + self.current_elapsed\n        total = self.total_seconds\n        pct = min(100.0, (done / total * 100) if total > 0 else 0)\n        bar_len = 24\n        filled = int(bar_len * pct / 100)\n        bar = \"█\" * filled + \"░\" * (bar_len - filled)\n        wall = time.time() - self.start_time\n        speed = (done / wall) if wall > 0 else 0\n        if speed > 0 and total > done:\n            eta_str = _format_hms((total - done) / speed)\n        else:\n            eta_str = \"--:--:--\"\n        done_str = _format_mmss(done)\n        total_str = _format_mmss(total)\n        fill_color = _C[\"done\"] if pct >= 100 else _C[\"fill\"]\n        return (\n            f\"  {_C['dim']}[{_C['reset']}{fill_color}{bar}{_C['reset']}{_C['dim']}]{_C['reset']}\"\n            f\"  {_C['bold']}{pct:5.1f}%{_C['reset']}  {done_str}/{total_str}\"\n            f\"  {speed:.2f}x avg  ETA {eta_str}\"\n        )\n\n    def _format_file_line(self, idx, spinner):\n        status = self.status[idx]\n        if status == 0:\n            icon, icon_c, name_c = \"·\", _C[\"dim\"], _C[\"dim\"]\n        elif status == 1:\n            icon, icon_c, name_c = spinner, _C[\"fill\"], _C[\"reset\"]\n        elif status == 2:\n            icon, icon_c, name_c = \"✓\", _C[\"done\"], _C[\"reset\"]\n        else:\n            icon, icon_c, name_c = \"✗\", _C[\"fail\"], _C[\"reset\"]\n        name = Path(self.files[idx][0]).name\n        size = _format_size(self.files[idx][3])\n        try:\n            cols = shutil.get_terminal_size((100, 24)).columns\n        except Exception:\n            cols = 100\n        max_name = max(20, cols - 25)\n        if len(name) > max_name:\n            name = name[: max_name - 1] + \"…\"\n        return (\n            f\"    {icon_c}{icon}{_C['reset']}  \"\n            f\"{name_c}{name}{_C['reset']}  \"\n            f\"{_C['dim']}({size}){_C['reset']}\"\n        )\n\n    def _render_multiline(self, final=False):\n        n_lines = len(self.files) + 1  # bar + per-file\n        if not self.first_frame:\n            sys.stdout.write(f\"\\x1b[{n_lines}A\\r\")\n        else:\n            self.first_frame = False\n        # advance spinner each frame (skip on final to keep last frame static)\n        if not final:\n            self.spin_idx = (self.spin_idx + 1) % len(self.SPINNER)\n        spinner = self.SPINNER[self.spin_idx]\n        sys.stdout.write(\"\\r\" + self._format_bar() + \"\\x1b[K\\n\")\n        for i in range(len(self.files)):\n            sys.stdout.write(\"\\r\" + self._format_file_line(i, spinner) + \"\\x1b[K\\n\")\n        sys.stdout.flush()\n\n    def _render_oneline(self):\n        # No room for the per-file list — show just the bar (in place)\n        sys.stdout.write(\"\\r\" + self._format_bar() + \"\\x1b[K\")\n        sys.stdout.flush()\n\n\ndef _check_space_for_args(args, input_path, output_path, estimator=None):\n    \"\"\"Convenience wrapper used by individual commands before run().\"\"\"\n    if args.dry_run or getattr(args, \"skip_space_check\", False):\n        return\n    estimator = estimator or _estimate_default_bytes\n    est = estimator(args, input_path)\n    out_dir = Path(output_path).parent if output_path else Path.cwd()\n    if not out_dir.exists():\n        out_dir = Path.cwd()\n    _check_disk_space(out_dir, est, label=Path(output_path).name if output_path else \"\")\n\n\n# ---------- commands ----------\n\ndef cmd_trim(args):\n    out = args.output or default_output(args.input, \"trimmed\")\n    cmd = [\"ffmpeg\", \"-y\", \"-i\", args.input]\n    if args.start is not None:\n        cmd += [\"-ss\", parse_time(args.start)]\n    if args.end is not None:\n        cmd += [\"-to\", parse_time(args.end)]\n    elif args.duration is not None:\n        cmd += [\"-t\", parse_time(args.duration)]\n    cmd += [\"-c\", \"copy\" if args.fast else \"copy\"] if args.fast else []\n    if not args.fast:\n        cmd += [\"-c:v\", \"libx264\", \"-c:a\", \"aac\"]\n    cmd += [out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_crop(args):\n    out = args.output or default_output(args.input, \"cropped\", sample=is_sample(args))\n    enc = pick_codec(args)\n    cmd = [\"ffmpeg\", *input_args(args),\n           \"-vf\", f\"crop={args.width}:{args.height}:{args.x}:{args.y}\",\n           \"-c:v\", enc, *quality_args(args, \"high\", enc), \"-preset\", \"medium\",\n           \"-c:a\", \"copy\", out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_rotate(args):\n    out = args.output or default_output(args.input, \"rotated\", sample=is_sample(args))\n    if args.flip:\n        vf = {\"horizontal\": \"hflip\", \"vertical\": \"vflip\"}[args.flip]\n    else:\n        # ffmpeg transpose: 1=90CW, 2=90CCW, 3=90CW+vflip, 0=90CCW+vflip\n        rotations = {\n            90: \"transpose=1\",\n            -90: \"transpose=2\",\n            270: \"transpose=2\",\n            180: \"transpose=2,transpose=2\",\n        }\n        if args.degrees not in rotations:\n            fail(\"--degrees must be one of 90, -90, 180, 270\")\n        vf = rotations[args.degrees]\n    enc = pick_codec(args)\n    cmd = [\"ffmpeg\", *input_args(args), \"-vf\", vf,\n           \"-c:v\", enc, *quality_args(args, \"high\", enc), \"-preset\", \"medium\",\n           \"-c:a\", \"copy\", out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_resize(args):\n    out = args.output or default_output(args.input, \"resized\", sample=is_sample(args))\n    if args.scale:\n        vf = f\"scale=iw*{args.scale}:ih*{args.scale}\"\n    elif args.width and args.height:\n        vf = f\"scale={args.width}:{args.height}\"\n    elif args.width:\n        vf = f\"scale={args.width}:-2\"\n    elif args.height:\n        vf = f\"scale=-2:{args.height}\"\n    else:\n        fail(\"provide --scale, --width, and/or --height\")\n    enc = pick_codec(args)\n    cmd = [\"ffmpeg\", *input_args(args), \"-vf\", vf,\n           \"-c:v\", enc, *quality_args(args, \"high\", enc), \"-preset\", \"medium\",\n           \"-c:a\", \"copy\", out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_convert(args):\n    out = args.output or default_output(args.input, \"converted\", ext=args.to, sample=is_sample(args))\n    cmd = [\"ffmpeg\", *input_args(args), out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_audio(args):\n    out = args.output or default_output(args.input, \"audio\", ext=args.format, sample=is_sample(args))\n    cmd = [\"ffmpeg\", *input_args(args), \"-vn\"]\n    if args.format == \"mp3\":\n        cmd += [\"-c:a\", \"libmp3lame\", \"-q:a\", \"2\"]\n    elif args.format == \"wav\":\n        cmd += [\"-c:a\", \"pcm_s16le\"]\n    else:\n        cmd += [\"-c:a\", \"copy\"]\n    cmd += [out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_mute(args):\n    out = args.output or default_output(args.input, \"muted\", sample=is_sample(args))\n    codec = [\"-c:v\", \"libx264\"] if is_sample(args) else [\"-c\", \"copy\"]\n    cmd = [\"ffmpeg\", *input_args(args), *codec, \"-an\", out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_speed(args):\n    out = args.output or default_output(args.input, f\"{args.rate}x\", sample=is_sample(args))\n    # video PTS: multiplier is 1/rate; audio atempo accepts 0.5..2.0, chain for more\n    rate = args.rate\n    video_filter = f\"setpts={1/rate:.6f}*PTS\"\n    # build audio atempo chain\n    audio_filters = []\n    remaining = rate\n    while remaining > 2.0:\n        audio_filters.append(\"atempo=2.0\")\n        remaining /= 2.0\n    while remaining < 0.5:\n        audio_filters.append(\"atempo=0.5\")\n        remaining /= 0.5\n    audio_filters.append(f\"atempo={remaining:.6f}\")\n    cmd = [\n        \"ffmpeg\", *input_args(args),\n        \"-filter_complex\",\n        f\"[0:v]{video_filter}[v];[0:a]{','.join(audio_filters)}[a]\",\n        \"-map\", \"[v]\", \"-map\", \"[a]\", out,\n    ]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_merge(args):\n    if len(args.inputs) < 2:\n        fail(\"merge needs at least 2 input files\")\n    out = args.output or default_output(args.inputs[0], \"merged\")\n    # write a temp concat list next to the first input\n    list_path = Path(args.inputs[0]).with_name(\".vid_concat_list.txt\")\n    list_path.write_text(\n        \"\\n\".join(f\"file {shlex.quote(str(Path(i).resolve()))}\" for i in args.inputs)\n    )\n    cmd = [\n        \"ffmpeg\", \"-y\", \"-f\", \"concat\", \"-safe\", \"0\",\n        \"-i\", str(list_path), \"-c\", \"copy\", out,\n    ]\n    # merge is stream-copy, so output ≈ sum of input sizes (plus a tiny mux overhead)\n    if not args.dry_run and not args.skip_space_check:\n        try:\n            est = int(sum(Path(p).stat().st_size for p in args.inputs) * 1.02)\n            _check_disk_space(Path(out).parent, est, label=Path(out).name)\n        except OSError:\n            pass\n    try:\n        rc = run(cmd, args.dry_run, output_path=out)\n    finally:\n        if not args.dry_run:\n            list_path.unlink(missing_ok=True)\n    return rc\n\n\ndef cmd_gif(args):\n    out = args.output or default_output(args.input, \"anim\", ext=\"gif\")\n    vf_parts = [f\"fps={args.fps}\"]\n    if args.width:\n        vf_parts.append(f\"scale={args.width}:-1:flags=lanczos\")\n    vf = \",\".join(vf_parts) + \",split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse\"\n    cmd = [\"ffmpeg\", \"-y\"]\n    if args.start is not None:\n        cmd += [\"-ss\", parse_time(args.start)]\n    if args.duration is not None:\n        cmd += [\"-t\", parse_time(args.duration)]\n    cmd += [\"-i\", args.input, \"-filter_complex\", vf, out]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_info(args):\n    data = probe(args.input)\n    fmt = data.get(\"format\", {})\n    video = next((s for s in data[\"streams\"] if s[\"codec_type\"] == \"video\"), None)\n    audio = next((s for s in data[\"streams\"] if s[\"codec_type\"] == \"audio\"), None)\n    print(f\"file:     {args.input}\")\n    print(f\"size:     {int(fmt.get('size', 0)) / 1_000_000:.1f} MB\")\n    print(f\"duration: {float(fmt.get('duration', 0)):.2f} s\")\n    print(f\"format:   {fmt.get('format_long_name', '?')}\")\n    print(f\"bitrate:  {int(fmt.get('bit_rate', 0)) // 1000} kbps\")\n    if video:\n        fps = video.get(\"r_frame_rate\", \"0/1\")\n        try:\n            num, den = fps.split(\"/\")\n            fps_val = float(num) / float(den) if float(den) else 0\n        except Exception:\n            fps_val = 0\n        print(\n            f\"video:    {video.get('codec_name')} \"\n            f\"{video.get('width')}x{video.get('height')} @ {fps_val:.2f} fps\"\n        )\n    if audio:\n        print(\n            f\"audio:    {audio.get('codec_name')} \"\n            f\"{audio.get('sample_rate')} Hz {audio.get('channels')}ch\"\n        )\n    return 0\n\n\ndef cmd_compress(args):\n    out = args.output or default_output(args.input, \"compressed\", sample=is_sample(args))\n    enc = pick_codec(args)\n    cmd = [\n        \"ffmpeg\", *input_args(args),\n        \"-c:v\", enc, *quality_args(args, \"medium\", enc), \"-preset\", \"medium\",\n        \"-c:a\", \"aac\", \"-b:a\", \"128k\", out,\n    ]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\ndef cmd_thumbnail(args):\n    out = args.output or default_output(args.input, \"thumb\", ext=\"jpg\")\n    cmd = [\n        \"ffmpeg\", \"-y\", \"-ss\", parse_time(args.time),\n        \"-i\", args.input, \"-frames:v\", \"1\", \"-q:v\", \"2\", out,\n    ]\n    _check_space_for_args(args, args.input, out)\n    return run(cmd, args.dry_run, output_path=out)\n\n\n# ---------- pipeline ops (shared by davinci-prep and youtube-prep) ----------\n#\n# Pipeline syntax lets the user write\n#   vid davinci-prep clip.MP4 rotate -d 180 crop -W 1920 -H 1080\n#   vid youtube-prep  clip.MP4 rotate -d 180 crop -W 1920 -H 1080\n# instead of stuffing everything into one flat flag set. Each op keyword\n# (rotate / crop / flip) starts a mini-argparse block; everything not\n# inside an op block (inputs, --sample, -o, etc.) is passed through to\n# the main prep parser. Ops are applied in the order written, regardless\n# of where the prep keyword appears in argv.\n\nPIPELINE_COMMANDS = (\"davinci-prep\", \"davinci-prep-lite\", \"youtube-prep\")\nPIPELINE_OP_KEYWORDS = (\"rotate\", \"crop\", \"flip\")\n\n\ndef _make_pipeline_op_parsers():\n    \"\"\"Build mini-argparse parsers for each pipeline op. Mirrors the standalone subcommands.\"\"\"\n    rotate_p = argparse.ArgumentParser(prog=\"davinci-prep rotate\", add_help=False)\n    rotate_p.add_argument(\"-d\", \"--degrees\", type=int,\n                          choices=[90, -90, 180, 270], required=True)\n\n    crop_p = argparse.ArgumentParser(prog=\"davinci-prep crop\", add_help=False)\n    crop_p.add_argument(\"-W\", \"--width\", type=int, required=True)\n    crop_p.add_argument(\"-H\", \"--height\", type=int, required=True)\n    crop_p.add_argument(\"-x\", type=int, default=0)\n    crop_p.add_argument(\"-y\", type=int, default=0)\n\n    flip_p = argparse.ArgumentParser(prog=\"davinci-prep flip\", add_help=False)\n    flip_p.add_argument(\"direction\", choices=[\"horizontal\", \"vertical\"])\n\n    return {\"rotate\": rotate_p, \"crop\": crop_p, \"flip\": flip_p}\n\n\ndef _extract_pipeline_ops(argv):\n    \"\"\"If argv invokes a prep command (davinci-prep / youtube-prep), pull out op blocks.\n\n    Op blocks (rotate / crop / flip and their flags) can appear before, after,\n    or interleaved with the prep keyword and the input paths — order among\n    the ops themselves is preserved (it controls the filter chain), but\n    `rotate ... davinci-prep ... crop ...` works just like\n    `davinci-prep rotate ... crop ...`.\n\n    Returns (ops, processed_argv): ops is a list of (op_name, namespace) in\n    written order; processed_argv has the op tokens stripped so the main\n    prep parser only sees its own flags and positional inputs.\n\n    If no prep keyword is in argv, this is a no-op (so standalone `vid rotate`\n    et al. keep working — their argv has no prep keyword).\n    \"\"\"\n    if not any(cmd in argv for cmd in PIPELINE_COMMANDS):\n        return [], argv\n\n    op_parsers = _make_pipeline_op_parsers()\n    boundaries = set(op_parsers) | set(PIPELINE_COMMANDS)\n    main_tokens = []\n    ops = []\n\n    i = 0\n    while i < len(argv):\n        tok = argv[i]\n        if tok in op_parsers:\n            op_name = tok\n            i += 1\n            op_args = []\n            while i < len(argv) and argv[i] not in boundaries:\n                op_args.append(argv[i])\n                i += 1\n            # Mini-parser handles its own errors (prints to stderr & exits)\n            ns, leftover = op_parsers[op_name].parse_known_args(op_args)\n            ops.append((op_name, ns))\n            main_tokens.extend(leftover)\n        else:\n            main_tokens.append(tok)\n            i += 1\n\n    return ops, main_tokens\n\n\ndef _build_transform_vf(args):\n    \"\"\"Build a -vf filter chain from args.ops. Returns filter string or None.\"\"\"\n    parts = []\n    for op_name, ns in getattr(args, \"ops\", []):\n        if op_name == \"crop\":\n            parts.append(f\"crop={ns.width}:{ns.height}:{ns.x}:{ns.y}\")\n        elif op_name == \"rotate\":\n            rot_map = {\n                90: \"transpose=1\",      # quarter-turn clockwise\n                -90: \"transpose=2\",     # quarter-turn anti-clockwise\n                270: \"transpose=2\",     # same as -90\n                180: \"transpose=2,transpose=2\",\n            }\n            parts.append(rot_map[ns.degrees])\n        elif op_name == \"flip\":\n            parts.append({\"horizontal\": \"hflip\", \"vertical\": \"vflip\"}[ns.direction])\n    return \",\".join(parts) if parts else None\n\n\ndef _post_transform_dims(args, src_w, src_h):\n    \"\"\"Walk args.ops, applying crop/rotate to track final (width, height).\"\"\"\n    w, h = src_w, src_h\n    for op_name, ns in getattr(args, \"ops\", []):\n        if op_name == \"crop\":\n            w, h = ns.width, ns.height\n        elif op_name == \"rotate\" and ns.degrees in (90, -90, 270):\n            w, h = h, w\n    return w, h\n\n\ndef _expand_video_inputs(paths, extensions=(\".mp4\", \".MP4\", \".mov\", \".MOV\", \".mkv\", \".MKV\")):\n    \"\"\"Expand a mix of files and directories into a de-duped, ordered list of video files.\"\"\"\n    out = []\n    seen = set()\n    for p in paths:\n        path = Path(p)\n        if path.is_dir():\n            found = []\n            for ext in extensions:\n                found.extend(path.glob(f\"*{ext}\"))\n            for f in sorted(found):\n                s = str(f)\n                if s not in seen:\n                    seen.add(s)\n                    out.append(s)\n        elif path.is_file():\n            s = str(path)\n            if s not in seen:\n                seen.add(s)\n                out.append(s)\n        else:\n            print(f\"warning: skipping (not found): {p}\", file=sys.stderr)\n    return out\n\n\ndef cmd_davinci_prep(args):\n    \"\"\"Transcode for DaVinci Resolve Free on Linux: DNxHR HQX 10-bit MOV.\"\"\"\n    inputs = _expand_video_inputs(args.inputs)\n    # Filter out our own outputs (so directory rescans don't recurse), and any\n    # leftover files from the older libx264-based attempts that didn't work.\n    obsolete_suffixes = (\"_davinci\", \"_davinci_sample\",\n                         \"_davinci_faststart\", \"_davinci_pcm\")\n    inputs = [p for p in inputs\n              if not any(Path(p).stem.endswith(s) for s in obsolete_suffixes)]\n    if not inputs:\n        fail(\"no input video files found\")\n    if args.output and len(inputs) > 1:\n        fail(\"--output is only valid with a single input file\")\n\n    seek = [\"-ss\", str(args.sample_start), \"-t\", str(args.sample)] if is_sample(args) else []\n\n    # ---------- plan + disk-space precheck ----------\n    plan = []     # (src, out, est_bytes) — files we'll actually encode\n    skipped = []  # (src, out)            — files whose output already exists\n    for src in inputs:\n        if args.output:\n            out = args.output\n            if not out.lower().endswith(\".mov\"):\n                out = str(Path(out).with_suffix(\".mov\"))\n        else:\n            out = default_output(src, \"davinci\", ext=\"mov\", sample=is_sample(args))\n        if Path(out).exists():\n            skipped.append((src, out))\n            continue\n        dims = _probe_video_dims(src)\n        if dims:\n            src_w, src_h, fps, dur = dims\n            if is_sample(args):\n                dur = min(dur, float(args.sample))\n            # Account for crop/rotate when estimating output size\n            out_w, out_h = _post_transform_dims(args, src_w, src_h)\n            est = _estimate_dnxhr_hqx_bytes(out_w, out_h, fps, dur)\n        else:\n            est = None\n        plan.append((src, out, est))\n\n    _print_prep_plan(plan, skipped, label=\"DNxHR HQX\")\n    if plan:\n        total_est = sum(e for _, _, e in plan if e) or 0\n        if total_est > 0:\n            out_dir = Path(plan[0][1]).parent\n            _check_disk_space(\n                out_dir, total_est,\n                label=f\"{len(plan)} DNxHR HQX file(s)\",\n                skip=args.skip_space_check or args.dry_run,\n            )\n\n    # ---------- encode loop ----------\n    return _run_prep_batch(\n        args, plan, skipped,\n        seek=seek,\n        suffix=\"davinci\",\n        ext=\"mov\",\n        build_cmd=lambda src, out, vf: [\n            \"ffmpeg\", \"-y\", *seek, \"-i\", src,\n            \"-map\", \"0:v:0\", \"-map\", \"0:a:0\", \"-dn\",\n            *([\"-vf\", vf] if vf else []),\n            \"-c:v\", \"dnxhd\", \"-profile:v\", \"dnxhr_hqx\",\n            \"-pix_fmt\", \"yuv422p10le\",\n            \"-c:a\", \"pcm_s16le\", \"-ar\", \"48000\", \"-ac\", \"2\",\n            \"-write_tmcd\", \"0\",\n            out,\n        ],\n    )\n\n\ndef _print_prep_plan(plan, skipped, label=\"\"):\n    \"\"\"Print the 'plan: N to encode, M already done, est ~X GB' header.\"\"\"\n    n_plan = len(plan)\n    n_skipped = len(skipped)\n    if n_plan == 0 and n_skipped == 0:\n        return\n    parts = []\n    if n_plan:\n        total_est = sum(e for _, _, e in plan if e) or 0\n        if total_est > 0:\n            parts.append(f\"{n_plan} file(s) to encode, ~{_format_gb(total_est)} estimated output\")\n        else:\n            parts.append(f\"{n_plan} file(s) to encode\")\n    if n_skipped:\n        parts.append(f\"{n_skipped} already done\")\n    if not n_plan and n_skipped:\n        print(f\"nothing to do — all {n_skipped} file(s) already done\")\n    else:\n        print(\"plan: \" + \", \".join(parts))\n\n\ndef _run_prep_batch(args, plan, skipped, seek, suffix, ext, build_cmd):\n    \"\"\"Shared encode loop for the prep commands. Handles quiet vs verbose UI.\n\n    Quiet mode (default): ipad-util-style frame UI with per-file status icons\n    (· pending / ⠋ active / ✓ done / ✗ failed). Falls back to a single-line bar\n    if the terminal is too short for the file list.\n\n    Verbose mode (-v): per-file '[i/n] src → dst' header, full '→ ffmpeg ...'\n    command, per-file progress bar.\n    \"\"\"\n    quiet = not _VERBOSE\n    n_plan = len(plan)\n    n_skipped = len(skipped)\n\n    # Verbose mode: list each skipped file by name (quiet mode shows them as ✓ rows\n    # in the batch UI, so no need to duplicate). Helps the user audit what was\n    # carried over from a previous run vs. what's about to be re-encoded.\n    if _VERBOSE and skipped:\n        for src, out in skipped:\n            print(f\"[skip] {src}  (output exists: {out})\")\n\n    # Build the BatchProgress file list: skipped first (pre-marked done), then to-encode.\n    # Order matches what the user sees in the frame: ✓ on top, · / ⠋ below.\n    batch = None\n    if quiet and sys.stdout.isatty() and (plan or skipped) and not args.dry_run:\n        batch_files = []\n        # Skipped files have duration 0 — they don't add to the work counter, but\n        # they DO appear in the file list with a ✓ so the user can see why they\n        # weren't re-encoded.\n        for src, out in skipped:\n            try:\n                size = Path(src).stat().st_size\n            except OSError:\n                size = 0\n            batch_files.append((src, out, 0.0, size))\n        for src, out, _est in plan:\n            dims = _probe_video_dims(src)\n            dur = 0.0\n            if dims:\n                dur = dims[3]\n                if is_sample(args):\n                    dur = min(dur, float(args.sample))\n            try:\n                size = Path(src).stat().st_size\n            except OSError:\n                size = 0\n            batch_files.append((src, out, dur, size))\n        batch = BatchProgress(batch_files)\n        # Pre-mark skipped slots as done before any encoding starts\n        for i in range(n_skipped):\n            batch.status[i] = 2\n        # Render once so the user sees the initial state immediately\n        batch._render()\n\n    done = 0\n    failed = 0\n    last_plan_idx = -1\n    try:\n        for plan_idx, (src, out, _est) in enumerate(plan):\n            last_plan_idx = plan_idx\n\n            if _VERBOSE:\n                print(f\"[{plan_idx + 1}/{n_plan}] {src} → {out}\")\n\n            vf = _build_transform_vf(args)\n            cmd = build_cmd(src, out, vf)\n\n            on_prog = None\n            if batch is not None:\n                batch.begin_file(n_skipped + plan_idx)  # offset past pre-marked ✓s\n\n                def on_prog(progress, total, complete, _b=batch):\n                    out_time = progress.get(\"out_time\", \"00:00:00.000000\").split(\".\")[0]\n                    elapsed = _to_seconds(out_time) or 0.0\n                    _b.update_current(elapsed, complete=complete)\n\n            rc = run(cmd, args.dry_run, output_path=out, on_progress=on_prog)\n\n            if rc == 0:\n                done += 1\n                if batch is not None:\n                    batch.finish_file(success=True)\n            else:\n                failed += 1\n                if batch is not None:\n                    batch.finish_file(success=False)\n                else:\n                    print(f\"  failed (exit {rc}): {src}\", file=sys.stderr)\n    except KeyboardInterrupt:\n        if batch is not None:\n            batch.finalize()\n        not_started = max(0, n_plan - (last_plan_idx + 1))\n        print(f\"\\nstopped at file {last_plan_idx + 1}/{n_plan}: {done} done, \"\n              f\"{n_skipped} skipped, {failed} failed, \"\n              f\"{not_started} not started\", file=sys.stderr)\n        raise\n\n    if batch is not None:\n        batch.finalize()\n    if (n_plan + n_skipped) > 1 or failed:\n        print(f\"summary: {done} done, {n_skipped} skipped, {failed} failed\")\n    return 0 if failed == 0 else 1\n\n\ndef cmd_davinci_prep_lite(args):\n    \"\"\"Lightweight DaVinci Resolve transcode: DNxHR LB 1080p 8-bit MOV.\"\"\"\n    inputs = _expand_video_inputs(args.inputs)\n    # Drop anything that's already a prep-output of any flavor (don't re-process)\n    obsolete_suffixes = (\n        \"_davinci\", \"_davinci_sample\",\n        \"_davinci_lite\", \"_davinci_lite_sample\",\n        \"_davinci_faststart\", \"_davinci_pcm\",  # historical libx264 attempts\n        \"_youtube\", \"_youtube_sample\",\n    )\n    inputs = [p for p in inputs\n              if not any(Path(p).stem.endswith(s) for s in obsolete_suffixes)]\n    if not inputs:\n        fail(\"no input video files found\")\n    if args.output and len(inputs) > 1:\n        fail(\"--output is only valid with a single input file\")\n\n    seek = [\"-ss\", str(args.sample_start), \"-t\", str(args.sample)] if is_sample(args) else []\n\n    # ---------- plan + disk-space precheck ----------\n    # Output is always 1920x1080 regardless of source — that's the verified-working\n    # resolution for DNxHR LB on this Resolve install. Source fps is preserved.\n    plan = []\n    skipped = []\n    for src in inputs:\n        if args.output:\n            out = args.output\n            if not out.lower().endswith(\".mov\"):\n                out = str(Path(out).with_suffix(\".mov\"))\n        else:\n            out = default_output(src, \"davinci_lite\", ext=\"mov\", sample=is_sample(args))\n        if Path(out).exists():\n            skipped.append((src, out))\n            continue\n        dims = _probe_video_dims(src)\n        if dims:\n            _src_w, _src_h, fps, dur = dims\n            if is_sample(args):\n                dur = min(dur, float(args.sample))\n            # Output is fixed at 1920x1080 (DNxHR LB profile constraint on this install)\n            est = _estimate_dnxhr_lb_bytes(1920, 1080, fps, dur)\n        else:\n            est = None\n        plan.append((src, out, est))\n\n    _print_prep_plan(plan, skipped, label=\"DNxHR LB\")\n    if plan:\n        total_est = sum(e for _, _, e in plan if e) or 0\n        if total_est > 0:\n            out_dir = Path(plan[0][1]).parent\n            _check_disk_space(\n                out_dir, total_est,\n                label=f\"{len(plan)} DNxHR LB file(s)\",\n                skip=args.skip_space_check or args.dry_run,\n            )\n\n    # ---------- encode loop ----------\n    def build_cmd(src, out, user_vf):\n        # Always end the filter chain with scale=1920:1080 (LB-on-this-Resolve constraint)\n        vf = \",\".join(p for p in (user_vf, \"scale=1920:1080\") if p)\n        return [\n            \"ffmpeg\", \"-y\", *seek, \"-i\", src,\n            \"-map\", \"0:v:0\", \"-map\", \"0:a:0\", \"-dn\",\n            \"-vf\", vf,\n            \"-c:v\", \"dnxhd\", \"-profile:v\", \"dnxhr_lb\",\n            \"-pix_fmt\", \"yuv422p\",\n            \"-c:a\", \"pcm_s16le\", \"-ar\", \"48000\", \"-ac\", \"2\",\n            \"-write_tmcd\", \"0\",\n            out,\n        ]\n    return _run_prep_batch(args, plan, skipped,\n                           seek=seek, suffix=\"davinci_lite\", ext=\"mov\",\n                           build_cmd=build_cmd)\n\n\ndef cmd_youtube_prep(args):\n    \"\"\"Re-encode for YouTube upload: H.265 .mp4 at high quality with AAC audio.\"\"\"\n    inputs = _expand_video_inputs(args.inputs)\n    obsolete_suffixes = (\"_youtube\", \"_youtube_sample\")\n    inputs = [p for p in inputs\n              if not any(Path(p).stem.endswith(s) for s in obsolete_suffixes)]\n    if not inputs:\n        fail(\"no input video files found\")\n    if args.output and len(inputs) > 1:\n        fail(\"--output is only valid with a single input file\")\n\n    seek = [\"-ss\", str(args.sample_start), \"-t\", str(args.sample)] if is_sample(args) else []\n\n    # ---------- plan + disk-space precheck ----------\n    plan = []\n    skipped = []\n    for src in inputs:\n        if args.output:\n            out = args.output\n            if not out.lower().endswith(\".mp4\"):\n                out = str(Path(out).with_suffix(\".mp4\"))\n        else:\n            out = default_output(src, \"youtube\", ext=\"mp4\", sample=is_sample(args))\n        if Path(out).exists():\n            skipped.append((src, out))\n            continue\n        dims = _probe_video_dims(src)\n        if dims:\n            src_w, src_h, fps, dur = dims\n            if is_sample(args):\n                dur = min(dur, float(args.sample))\n            out_w, out_h = _post_transform_dims(args, src_w, src_h)\n            est = _estimate_youtube_h265_bytes(out_w, out_h, fps, dur)\n        else:\n            est = None\n        plan.append((src, out, est))\n\n    _print_prep_plan(plan, skipped, label=\"YouTube H.265\")\n    if plan:\n        total_est = sum(e for _, _, e in plan if e) or 0\n        if total_est > 0:\n            out_dir = Path(plan[0][1]).parent\n            _check_disk_space(\n                out_dir, total_est,\n                label=f\"{len(plan)} YouTube H.265 file(s)\",\n                skip=args.skip_space_check or args.dry_run,\n            )\n\n    # ---------- encode loop ----------\n    return _run_prep_batch(\n        args, plan, skipped,\n        seek=seek, suffix=\"youtube\", ext=\"mp4\",\n        build_cmd=lambda src, out, vf: [\n            \"ffmpeg\", \"-y\", *seek, \"-i\", src,\n            \"-map\", \"0:v:0\", \"-map\", \"0:a:0\", \"-dn\",\n            *([\"-vf\", vf] if vf else []),\n            \"-c:v\", \"libx265\", \"-preset\", \"medium\", \"-crf\", \"22\",\n            \"-pix_fmt\", \"yuv420p\",\n            \"-c:a\", \"aac\", \"-b:a\", \"192k\",\n            \"-movflags\", \"+faststart\",\n            out,\n        ],\n    )\n\n\n# ---------- argparse ----------\n\ndef build_parser():\n    fmt = argparse.RawDescriptionHelpFormatter\n    p = argparse.ArgumentParser(\n        prog=\"vid\",\n        formatter_class=fmt,\n        description=\"A friendlier CLI for common video edits (wraps ffmpeg).\",\n        epilog=(\n            \"Each command has its own detailed help — try:\\n\"\n            \"  vid crop --help\\n\"\n            \"  vid rotate --help\\n\"\n            \"  vid trim --help\\n\"\n            \"  vid davinci-prep --help       # DNxHR HQX 4K transcode for DaVinci Resolve Free (heavy)\\n\"\n            \"  vid davinci-prep-lite --help  # DNxHR LB 1080p transcode for Resolve (disk-friendly)\\n\"\n            \"  vid youtube-prep --help       # H.265 .mp4 re-encode for YouTube upload\\n\"\n            \"\\n\"\n            \"Common flags (most re-encoding commands accept these):\\n\"\n            \"  --sample [SECONDS]      render a short preview instead of the full video\\n\"\n            \"                          (works with crop, rotate, resize, convert, audio,\\n\"\n            \"                           mute, speed, compress, davinci-prep,\\n\"\n            \"                           davinci-prep-lite, youtube-prep)\\n\"\n            \"  -q, --quality LABEL     visual quality (lossless | pristine | high | medium | low | tiny)\\n\"\n            \"  -c, --codec CODEC       output codec (auto | h264 | h265). 'auto' matches the source.\\n\"\n            \"                          (not used by davinci-prep or youtube-prep — codec is fixed)\\n\"\n            \"  --dry-run               print the ffmpeg command without running it\\n\"\n            \"\\n\"\n            \"Most commands auto-name the output (e.g. movie.mp4 → movie_cropped.mp4).\"\n        ),\n    )\n    p.add_argument(\"--dry-run\", action=\"store_true\",\n                   help=\"print the ffmpeg command without running it\")\n    p.add_argument(\"--skip-space-check\", action=\"store_true\",\n                   help=\"don't pre-check available disk space before encoding\")\n    p.add_argument(\"-v\", \"--verbose\", action=\"store_true\",\n                   help=\"show the full ffmpeg command and per-file progress bars. \"\n                        \"Without this flag, batch runs show one overall bar with a ✓ line \"\n                        \"as each file completes (and ffmpeg warnings are suppressed).\")\n    sub = p.add_subparsers(dest=\"command\", required=True, metavar=\"COMMAND\")\n\n    # parent parser: adds --sample / --sample-start to commands that support previews\n    sample_parent = argparse.ArgumentParser(add_help=False)\n    sample_parent.add_argument(\n        \"--sample\", nargs=\"?\", const=10.0, type=float, default=None,\n        metavar=\"SECONDS\",\n        help=\"render a short preview clip instead of the full video (default: 10 seconds). \"\n             \"Output file gets a '_sample' suffix so previews don't clobber full renders.\",\n    )\n    sample_parent.add_argument(\n        \"--sample-start\", default=\"0\", metavar=\"TIME\",\n        help=\"where in the source to start the sample (default: 0). \"\n             \"Accepts seconds (30) or H:MM:SS (1:30, 00:01:30).\",\n    )\n\n    # parent parser: adds -q / --quality to commands that re-encode\n    quality_parent = argparse.ArgumentParser(add_help=False)\n    quality_parent.add_argument(\n        \"-q\", \"--quality\", default=None,\n        choices=list(QUALITY_PRESETS.keys()),\n        help=\"visual quality preset. Lower = better quality but larger file. \"\n             \"lossless = byte-perfect (HUGE files); pristine = near-lossless; \"\n             \"high = excellent (default for crop/rotate/resize); \"\n             \"medium = good (default for compress); low = noticeable softness; \"\n             \"tiny = visible compression artifacts.\",\n    )\n\n    # parent parser: adds -c / --codec to commands that re-encode\n    codec_parent = argparse.ArgumentParser(add_help=False)\n    codec_parent.add_argument(\n        \"-c\", \"--codec\", default=\"auto\", choices=[\"auto\", \"h264\", \"h265\"],\n        help=\"video codec for the output. 'auto' (default) matches the source — \"\n             \"if the source is H.265/HEVC the output will be H.265 too, otherwise H.264. \"\n             \"Use 'h264' for maximum compatibility, 'h265' for ~30%% smaller files at \"\n             \"the same quality (slower to encode, less widely supported).\",\n    )\n\n    def add_io(sp, multi_input=False):\n        if multi_input:\n            sp.add_argument(\"inputs\", nargs=\"+\", help=\"input video files (2 or more)\")\n        else:\n            sp.add_argument(\"input\", help=\"input video file\")\n        sp.add_argument(\"-o\", \"--output\",\n                        help=\"output file path. If omitted, a sensible name is chosen \"\n                             \"next to the input (e.g. movie.mp4 → movie_cropped.mp4).\")\n\n    # trim\n    sp = sub.add_parser(\n        \"trim\",\n        help=\"cut a section out by time\",\n        formatter_class=fmt,\n        description=\"Keep a section of the video between two timestamps and throw the rest away.\",\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid trim movie.mp4 -s 10 -e 30           # keep seconds 10–30\\n\"\n            \"  vid trim movie.mp4 -s 1:30 -d 15         # keep 15 seconds starting at 1:30\\n\"\n            \"  vid trim movie.mp4 -s 5 --fast           # quick cut without re-encoding\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"-s\", \"--start\",\n                    help=\"when to start keeping. Accepts seconds (30) or H:MM:SS (1:30).\")\n    sp.add_argument(\"-e\", \"--end\",\n                    help=\"when to stop keeping. Same time formats as --start.\")\n    sp.add_argument(\"-d\", \"--duration\",\n                    help=\"how long to keep, instead of providing --end (e.g. 15 = 15 seconds).\")\n    sp.add_argument(\"--fast\", action=\"store_true\",\n                    help=\"copy streams instead of re-encoding — much faster, but cuts can only \"\n                         \"land on keyframes so the start/end may be slightly off.\")\n    sp.set_defaults(func=cmd_trim)\n\n    # crop\n    sp = sub.add_parser(\n        \"crop\", help=\"crop a rectangular region\",\n        parents=[sample_parent, quality_parent, codec_parent],\n        formatter_class=fmt,\n        description=(\n            \"Cut a rectangle out of every frame. You choose the size of the rectangle and where\\n\"\n            \"its top-left corner sits, measured in pixels from the top-left of the source frame.\\n\"\n            \"Coordinates: x grows to the right, y grows down (0,0 is the top-left corner).\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid crop movie.mp4 -W 1920 -H 1080                      # crop from top-left\\n\"\n            \"  vid crop movie.mp4 -W 1920 -H 1080 -x 960 -y 540        # center crop of 4K source\\n\"\n            \"  vid crop movie.mp4 -W 1080 -H 1920 -x 1380 -y 120 --sample\\n\"\n            \"      # preview a vertical/portrait crop before encoding the whole video\\n\"\n            \"  vid crop movie.mp4 -W 1920 -H 1080 -x 960 -y 540 -q pristine\\n\"\n            \"      # near-lossless re-encode, bigger file\\n\"\n            \"\\n\"\n            \"Tip: 'vid info movie.mp4' shows source resolution and codec. The output codec\\n\"\n            \"defaults to matching the source (so HEVC stays HEVC) — override with --codec.\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"-W\", \"--width\", type=int, required=True,\n                    help=\"width of the crop rectangle, in pixels.\")\n    sp.add_argument(\"-H\", \"--height\", type=int, required=True,\n                    help=\"height of the crop rectangle, in pixels.\")\n    sp.add_argument(\"-x\", type=int, default=0,\n                    help=\"pixels from the left edge of the source to the crop's left edge (default: 0).\")\n    sp.add_argument(\"-y\", type=int, default=0,\n                    help=\"pixels from the top edge of the source to the crop's top edge (default: 0).\")\n    sp.set_defaults(func=cmd_crop)\n\n    # rotate\n    sp = sub.add_parser(\n        \"rotate\", help=\"rotate or mirror-flip the video\",\n        parents=[sample_parent, quality_parent, codec_parent],\n        formatter_class=fmt,\n        description=(\n            \"Spin the picture by a quarter-turn, half-turn, or three-quarter turn, or flip\\n\"\n            \"it like a mirror. Useful for footage shot sideways on a phone, or to correct\\n\"\n            \"an upside-down camera mount.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid rotate movie.mp4 -d 90          # quarter-turn clockwise (right side becomes bottom)\\n\"\n            \"  vid rotate movie.mp4 -d -90         # quarter-turn anti-clockwise (left side becomes bottom)\\n\"\n            \"  vid rotate movie.mp4 -d 180         # upside down\\n\"\n            \"  vid rotate movie.mp4 --flip horizontal   # mirror left-right (like looking in a mirror)\\n\"\n            \"  vid rotate movie.mp4 --flip vertical     # mirror top-bottom\\n\"\n            \"  vid rotate movie.mp4 -d 90 -q pristine   # near-lossless re-encode (bigger file)\\n\"\n            \"  vid rotate movie.mp4 -d 90 -c h264       # force H.264 even if source is HEVC\\n\"\n            \"\\n\"\n            \"Note: --flip mirrors the image; -d rotates it. They do different things.\\n\"\n            \"Output codec matches the source by default (use --codec to override).\"\n        ),\n    )\n    add_io(sp)\n    g = sp.add_mutually_exclusive_group(required=True)\n    g.add_argument(\n        \"-d\", \"--degrees\", type=int, choices=[90, -90, 180, 270],\n        help=\"rotation amount: 90 = quarter-turn clockwise, -90 = quarter-turn anti-clockwise, \"\n             \"180 = upside down, 270 = same as -90.\",\n    )\n    g.add_argument(\n        \"--flip\", choices=[\"horizontal\", \"vertical\"],\n        help=\"mirror the image: 'horizontal' swaps left/right, 'vertical' swaps top/bottom.\",\n    )\n    sp.set_defaults(func=cmd_rotate)\n\n    # resize\n    sp = sub.add_parser(\n        \"resize\", help=\"make the video bigger or smaller\",\n        parents=[sample_parent, quality_parent, codec_parent],\n        formatter_class=fmt,\n        description=(\n            \"Change the pixel dimensions of the video. Give either a scale factor, a target\\n\"\n            \"width, a target height, or both. If you give only one of width/height, the other\\n\"\n            \"is computed automatically to preserve aspect ratio (no stretching).\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid resize movie.mp4 -s 0.5                  # half-size in each direction (quarter the pixels)\\n\"\n            \"  vid resize movie.mp4 -W 1280                 # 1280 wide, height auto to preserve aspect\\n\"\n            \"  vid resize movie.mp4 -H 720                  # 720 tall, width auto\\n\"\n            \"  vid resize movie.mp4 -W 1920 -H 1080         # force exact dimensions (may stretch)\\n\"\n            \"  vid resize movie.mp4 -H 1080 -q pristine     # near-lossless 1080p downscale\\n\"\n            \"  vid resize movie.mp4 -s 0.5 -c h264 --sample # preview a half-size H.264 copy\\n\"\n            \"\\n\"\n            \"Output codec matches the source by default (use --codec to override).\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"-W\", \"--width\", type=int,\n                    help=\"target width in pixels.\")\n    sp.add_argument(\"-H\", \"--height\", type=int,\n                    help=\"target height in pixels.\")\n    sp.add_argument(\"-s\", \"--scale\", type=float,\n                    help=\"multiplier applied to both dimensions. 0.5 = half size, 2.0 = double size.\")\n    sp.set_defaults(func=cmd_resize)\n\n    # convert\n    sp = sub.add_parser(\n        \"convert\", help=\"change the file format (mp4, webm, mkv, ...)\",\n        parents=[sample_parent], formatter_class=fmt,\n        description=(\n            \"Convert the video from one file format to another. Useful when a platform or\\n\"\n            \"program only accepts certain formats (e.g. webm for the web, mp4 for most editors).\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid convert movie.mov -t mp4            # QuickTime → MP4\\n\"\n            \"  vid convert movie.mp4 -t webm           # MP4 → WebM (good for web pages)\\n\"\n            \"  vid convert movie.avi -t mkv            # AVI → Matroska\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"-t\", \"--to\", required=True,\n                    help=\"target file extension without the dot. Common values: mp4, webm, mkv, mov, avi.\")\n    sp.set_defaults(func=cmd_convert)\n\n    # audio extract\n    sp = sub.add_parser(\n        \"audio\", help=\"pull just the audio out of a video\", parents=[sample_parent],\n        formatter_class=fmt,\n        description=(\n            \"Save the audio track of a video as an audio-only file. Handy for ripping\\n\"\n            \"music or dialogue, or for editing audio separately.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid audio movie.mp4                     # saves to movie_audio.mp3\\n\"\n            \"  vid audio movie.mp4 -f wav              # uncompressed WAV (large but lossless)\\n\"\n            \"  vid audio movie.mp4 -f m4a -o song.m4a  # custom output name\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"-f\", \"--format\", default=\"mp3\",\n                    choices=[\"mp3\", \"wav\", \"aac\", \"m4a\", \"ogg\"],\n                    help=\"audio format. mp3 = small + universal, wav = uncompressed, \"\n                         \"aac/m4a = better quality than mp3 at same size, ogg = open format. \"\n                         \"Default: mp3.\")\n    sp.set_defaults(func=cmd_audio)\n\n    # mute\n    sp = sub.add_parser(\n        \"mute\", help=\"silence the video (strip audio)\", parents=[sample_parent],\n        formatter_class=fmt,\n        description=\"Remove the audio track entirely. The video plays in complete silence.\",\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid mute movie.mp4                      # saves to movie_muted.mp4\\n\"\n            \"  vid mute movie.mp4 -o silent.mp4        # custom output name\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.set_defaults(func=cmd_mute)\n\n    # speed\n    sp = sub.add_parser(\n        \"speed\", help=\"speed up or slow down the video\",\n        parents=[sample_parent], formatter_class=fmt,\n        description=(\n            \"Play back faster or slower. Both video and audio are adjusted together (audio\\n\"\n            \"stays at the right pitch, not chipmunk'd). 2.0 makes a 60-second clip into a\\n\"\n            \"30-second one; 0.5 stretches it to 120 seconds.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid speed movie.mp4 -r 2.0              # twice as fast\\n\"\n            \"  vid speed movie.mp4 -r 0.5              # half speed (slow-mo)\\n\"\n            \"  vid speed movie.mp4 -r 1.25             # subtle 25% speedup (good for talks)\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"-r\", \"--rate\", type=float, required=True,\n                    help=\"how fast to play. 1.0 = normal, 2.0 = twice as fast, 0.5 = half speed. \"\n                         \"Values from 0.1 to 10 work fine.\")\n    sp.set_defaults(func=cmd_speed)\n\n    # merge\n    sp = sub.add_parser(\n        \"merge\", help=\"join multiple clips into one\",\n        formatter_class=fmt,\n        description=(\n            \"Stitch two or more videos together back-to-back into a single file. Clips are\\n\"\n            \"joined in the order you list them. Works best when the clips share the same\\n\"\n            \"resolution, frame rate, and codec — otherwise re-encode them with 'vid convert' first.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid merge intro.mp4 main.mp4 outro.mp4\\n\"\n            \"  vid merge clip1.mp4 clip2.mp4 -o full_video.mp4\\n\"\n        ),\n    )\n    add_io(sp, multi_input=True)\n    sp.set_defaults(func=cmd_merge)\n\n    # gif\n    sp = sub.add_parser(\n        \"gif\", help=\"turn a clip into an animated GIF\",\n        formatter_class=fmt,\n        description=(\n            \"Convert a section of video into an animated GIF — the format used for memes,\\n\"\n            \"reactions, and tutorials embedded in docs. GIFs are silent and not great for\\n\"\n            \"long clips (file size grows fast), so trim to a few seconds.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid gif movie.mp4                       # GIF of the whole clip (may be huge!)\\n\"\n            \"  vid gif movie.mp4 -s 10 -d 3            # 3-second GIF starting at 0:10\\n\"\n            \"  vid gif movie.mp4 -s 5 -d 2 -W 320 --fps 10   # smaller/lighter GIF\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"--fps\", type=int, default=15,\n                    help=\"frames per second in the GIF. Lower = smaller file but choppier. \"\n                         \"Default: 15.\")\n    sp.add_argument(\"-W\", \"--width\", type=int, default=480,\n                    help=\"GIF width in pixels (height auto-fits aspect ratio). Default: 480.\")\n    sp.add_argument(\"-s\", \"--start\", help=\"when to start in the source (e.g. 10, 1:30).\")\n    sp.add_argument(\"-d\", \"--duration\", help=\"how long the GIF should be (in seconds).\")\n    sp.set_defaults(func=cmd_gif)\n\n    # info\n    sp = sub.add_parser(\n        \"info\", help=\"show details about a video (size, length, resolution)\",\n        formatter_class=fmt,\n        description=(\n            \"Print a readable summary of a video file: how long it is, how big the file is,\\n\"\n            \"what resolution it was shot at, what codec and audio format it uses.\"\n        ),\n        epilog=\"Example:\\n  vid info movie.mp4\\n\",\n    )\n    sp.add_argument(\"input\", help=\"video file to inspect.\")\n    sp.set_defaults(func=cmd_info)\n\n    # compress\n    sp = sub.add_parser(\n        \"compress\", help=\"shrink a video's file size\",\n        parents=[sample_parent, quality_parent, codec_parent], formatter_class=fmt,\n        description=(\n            \"Re-encode the video to make the file smaller. There's always a quality/size\\n\"\n            \"tradeoff: 'pristine'/'high' barely shrink, 'tiny' shrinks aggressively but\\n\"\n            \"looks noticeably worse. Default is 'medium'. Try --sample first to compare.\\n\\n\"\n            \"By default the output codec matches the source (HEVC stays HEVC). Use\\n\"\n            \"--codec h265 on an H.264 source to get ~30% smaller files for the same quality.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid compress movie.mp4                       # medium quality (good balance)\\n\"\n            \"  vid compress movie.mp4 -q low                # smaller file, some quality loss\\n\"\n            \"  vid compress movie.mp4 -q tiny --sample      # preview the worst case first\\n\"\n            \"  vid compress movie.mp4 -c h265               # H.265: ~30% smaller for same quality\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.set_defaults(func=cmd_compress)\n\n    # davinci-prep\n    sp = sub.add_parser(\n        \"davinci-prep\",\n        help=\"transcode for DaVinci Resolve Free on Linux (DNxHR HQX 10-bit MOV)\",\n        parents=[sample_parent],\n        formatter_class=fmt,\n        description=(\n            \"Convert footage into a format DaVinci Resolve 20 Free can actually play on Linux.\\n\"\n            \"\\n\"\n            \"Why this exists (the hard-won findings):\\n\"\n            \"  * Resolve Free on Linux does NOT decode HEVC/H.265 at all — any profile,\\n\"\n            \"    any container. So DJI HEVC clips show up black in the viewer.\\n\"\n            \"  * Resolve Free on Linux does NOT decode ffmpeg-produced H.264 in .mov\\n\"\n            \"    either — even conservative 1080p/Main/L4.0/yuv420p settings. The clip\\n\"\n            \"    silently imports as audio-only (clef icon, no IO.Video log entries).\\n\"\n            \"    Online advice claiming 'H.264 in .mov works' is wrong for this install.\\n\"\n            \"  * AAC audio in those .mov files caused IO.Audio decode errors. PCM works.\\n\"\n            \"  * DNxHR in .mov with PCM audio is the verified path. So that's what this\\n\"\n            \"    command produces — and at the highest profile (HQX, 10-bit 4:2:2) since\\n\"\n            \"    you stated quality is the priority and disk space is your concern to\\n\"\n            \"    manage, not the script's.\\n\"\n            \"\\n\"\n            \"Output specification (all hardcoded — no quality/codec flags):\\n\"\n            \"  video:  DNxHR HQX (10-bit 4:2:2), source resolution, source frame rate\\n\"\n            \"  pixel:  yuv422p10le (required by the HQX profile)\\n\"\n            \"  audio:  PCM s16le, 48 kHz, stereo\\n\"\n            \"  streams: video + audio only (DJI MP4s have 6 streams — data, tmcd, mjpeg\\n\"\n            \"          thumbnail — all dropped via -dn and explicit -map)\\n\"\n            \"  container: .mov (forced; warning if -o has a different extension)\\n\"\n            \"\\n\"\n            \"IMPORTANT — verify the first sample in Resolve before running the full batch.\\n\"\n            \"DNxHR HQX has been verified to PLAY on this Resolve install only at 1080p (LB\\n\"\n            \"profile). 4K UHD HQX has not yet been confirmed. Always do one --sample first,\\n\"\n            \"drop it on a Resolve timeline, and confirm the picture decodes. If it doesn't,\\n\"\n            \"stop and report back rather than burning hours on a doomed batch.\\n\"\n            \"\\n\"\n            \"Disk space: DNxHR HQX at 4K UHD 59.94 fps measures ~1.75 Gbps (~220 MB/s,\\n\"\n            \"~13 GB/min) in practice. (Avid's nominal '~880 Mbps' figure is for the lower-\\n\"\n            \"bitrate HQ profile, not HQX.) The script estimates total output size up front\\n\"\n            \"and aborts if your output filesystem doesn't have room. Pass --skip-space-check\\n\"\n            \"to bypass.\\n\"\n            \"\\n\"\n            \"Inline transforms (pipeline ops): write `rotate`, `crop`, or `flip` as ops\\n\"\n            \"directly inside the davinci-prep invocation. Each op takes the same flags as\\n\"\n            \"the standalone subcommand of that name (`rotate -d 180`, `crop -W ... -H ... -x ... -y ...`,\\n\"\n            \"`flip horizontal`). Ops apply in the order written — so `crop ... rotate ...`\\n\"\n            \"crops first then rotates, while `rotate ... crop ...` rotates first then crops.\\n\"\n            \"All ops execute in the same ffmpeg pass — no intermediate file, no second encode.\\n\"\n            \"Crop dimensions are also used to recompute the disk-space estimate.\\n\"\n            \"\\n\"\n            \"Batch mode: pass any mix of files and directories. Directories are scanned for\\n\"\n            \".mp4/.mov/.mkv. Existing _davinci.mov outputs are skipped, so a re-run resumes.\\n\"\n            \"\\n\"\n            \"Cleanup before the real batch: any leftover test files from earlier libx264\\n\"\n            \"attempts (*_davinci.mov, *_davinci_faststart.mov, *_davinci_pcm.mov, and the\\n\"\n            \"TEST_*.mov files in camera/) can be deleted — they don't work in Resolve and\\n\"\n            \"the script will skip them as 'already done' otherwise.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid davinci-prep clip.MP4 --sample                      # 10s preview — TEST THIS FIRST\\n\"\n            \"  vid davinci-prep clip.MP4                               # single full encode\\n\"\n            \"  vid davinci-prep ~/Videos/DJI\\\\ Video/DJI_002_C01/        # batch every .MP4 in folder\\n\"\n            \"  vid davinci-prep file1.MP4 file2.MP4 some_dir/          # mix files and folders\\n\"\n            \"  vid davinci-prep clip.MP4 --skip-space-check            # bypass disk-space check\\n\"\n            \"\\n\"\n            \"Inline transforms (pipeline ops — each op uses the same flags as 'vid <op>'):\\n\"\n            \"  vid davinci-prep clip.MP4 rotate -d 180                         # upside-down camera fix\\n\"\n            \"  vid davinci-prep clip.MP4 rotate -d -90                         # phone shot sideways\\n\"\n            \"  vid davinci-prep clip.MP4 flip horizontal                       # mirror left-right\\n\"\n            \"  vid davinci-prep clip.MP4 crop -W 3840 -H 1620 -y 270           # 2.39:1 letterbox crop\\n\"\n            \"  vid davinci-prep clip.MP4 crop -W 1920 -H 1080 -x 960 -y 540 rotate -d 180\\n\"\n            \"      # combine ops; order matters — here it crops, then rotates\\n\"\n            \"  vid davinci-prep rotate -d 180 ~/Videos/DJI\\\\ Video/DJI_002_C01/\\n\"\n            \"      # ops + inputs can mix in any order — this rotates every clip in the folder\\n\"\n            \"\\n\"\n            \"Workflow:\\n\"\n            \"  1. Run with --sample on one file.\\n\"\n            \"  2. Import the resulting *_sample.mov into Resolve, drop it on a timeline.\\n\"\n            \"  3. Confirm the picture decodes (not just audio). Scrub a few seconds.\\n\"\n            \"  4. If it plays, kick off the full batch. If not, stop and investigate.\\n\"\n            \"\\n\"\n            \"Note: --quality and --codec are deliberately NOT accepted. The codec, profile,\\n\"\n            \"pixel format, and audio format are all fixed to the verified-working set.\"\n        ),\n    )\n    sp.add_argument(\"inputs\", nargs=\"+\",\n                    help=\"video file(s) and/or director(ies) to transcode. \"\n                         \"Directories are scanned for .mp4/.mov/.mkv files (case-insensitive).\")\n    sp.add_argument(\"-o\", \"--output\",\n                    help=\"output file path (single-input only). Extension is forced to .mov; \"\n                         \"a warning is printed if you supplied a different one.\")\n    sp.set_defaults(func=cmd_davinci_prep)\n\n    # davinci-prep-lite\n    sp = sub.add_parser(\n        \"davinci-prep-lite\",\n        help=\"lightweight Resolve Free transcode (DNxHR LB 8-bit 1080p MOV)\",\n        parents=[sample_parent],\n        formatter_class=fmt,\n        description=(\n            \"Lightweight transcode for DaVinci Resolve Free on Linux. Same compatibility\\n\"\n            \"story as 'vid davinci-prep', but tuned to use much less disk.\\n\"\n            \"\\n\"\n            \"Why this exists alongside davinci-prep:\\n\"\n            \"  * davinci-prep produces DNxHR HQX 10-bit 4:2:2 at source resolution\\n\"\n            \"    (~1.75 Gbps at 4K UHD 60p, ~13 GB/min). Right for color grading or for\\n\"\n            \"    archive-quality intermediates. Wrong if your disk is full.\\n\"\n            \"  * davinci-prep-lite produces DNxHR LB 8-bit 4:2:2 at 1920x1080. The picture\\n\"\n            \"    is downscaled to 1080p and stored at ~90 Mbps (~700 MB/min). That's the\\n\"\n            \"    verified-working format on this Resolve install (LB at 1080p plays cleanly).\\n\"\n            \"  * Use lite for proxy-grade editing, casual project work, or when 4K HQX is\\n\"\n            \"    way more file than the project calls for. Use davinci-prep when you need\\n\"\n            \"    the headroom for grading.\\n\"\n            \"\\n\"\n            \"Why the format is locked (same hard-won findings as davinci-prep):\\n\"\n            \"  * Resolve Free Linux does NOT decode HEVC at all.\\n\"\n            \"  * Resolve Free Linux does NOT decode ffmpeg-produced H.264 in .mov either.\\n\"\n            \"  * AAC in .mov caused IO.Audio decode errors. PCM s16le works.\\n\"\n            \"  * DNxHR LB 8-bit 4:2:2 at 1080p with PCM s16le is the verified-working set.\\n\"\n            \"\\n\"\n            \"Output specification (all hardcoded — no quality/codec flags):\\n\"\n            \"  video:  DNxHR LB (8-bit 4:2:2), 1920x1080, source frame rate preserved\\n\"\n            \"  pixel:  yuv422p (LB profile is 8-bit; will error on 10-bit pix_fmt)\\n\"\n            \"  audio:  PCM s16le, 48 kHz, stereo\\n\"\n            \"  streams: video + audio only (DJI MP4s' extra data/tmcd/mjpeg dropped)\\n\"\n            \"  container: .mov (forced; warning if -o has a different extension)\\n\"\n            \"\\n\"\n            \"Disk space: DNxHR LB at 1080p 59.94 fps measures ~90 Mbps (~11 MB/s,\\n\"\n            \"~675 MB/min). A 72-minute batch lands in the ~50 GB neighborhood — small\\n\"\n            \"enough to keep alongside the originals. Estimate is computed up front;\\n\"\n            \"pass --skip-space-check to bypass.\\n\"\n            \"\\n\"\n            \"Inline transforms (pipeline ops — same syntax as davinci-prep): write\\n\"\n            \"`rotate`, `crop`, or `flip` as ops directly inside the invocation, in any\\n\"\n            \"order, in any position relative to the input paths. The filter chain is\\n\"\n            \"always [your ops] → scale=1920:1080, so the final output is 1080p no matter\\n\"\n            \"what. If you crop to a non-16:9 shape, the scale will stretch it — that's\\n\"\n            \"your call.\\n\"\n            \"\\n\"\n            \"Batch mode: pass any mix of files and directories. Directories are scanned\\n\"\n            \"for .mp4/.mov/.mkv. Existing _davinci_lite.mov outputs are skipped, so a\\n\"\n            \"re-run resumes. Outputs from other prep commands (_davinci, _youtube) are\\n\"\n            \"filtered out of directory scans to avoid double-processing.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid davinci-prep-lite clip.MP4 --sample                      # 10s preview — test in Resolve first\\n\"\n            \"  vid davinci-prep-lite clip.MP4                               # single full encode\\n\"\n            \"  vid davinci-prep-lite ~/Videos/DJI\\\\ Video/DJI_002_C01/        # batch every .MP4 in folder\\n\"\n            \"  vid davinci-prep-lite file1.MP4 file2.MP4 some_dir/          # mix files and folders\\n\"\n            \"  vid davinci-prep-lite clip.MP4 --skip-space-check            # bypass disk-space check\\n\"\n            \"\\n\"\n            \"Inline transforms (pipeline ops — each op uses the same flags as 'vid <op>'):\\n\"\n            \"  vid davinci-prep-lite clip.MP4 rotate -d 180                         # upside-down camera fix\\n\"\n            \"  vid davinci-prep-lite clip.MP4 flip horizontal                       # mirror left-right\\n\"\n            \"  vid davinci-prep-lite clip.MP4 crop -W 3840 -H 1620 -y 270           # 2.39:1 letterbox crop\\n\"\n            \"  vid davinci-prep-lite rotate -d 180 ~/Videos/DJI\\\\ Video/DJI_002_C01/\\n\"\n            \"      # ops + inputs can mix in any order — this rotates every clip in the folder\\n\"\n            \"\\n\"\n            \"Note: --quality and --codec are deliberately NOT accepted. The codec, profile,\\n\"\n            \"resolution, pixel format, and audio format are all fixed to the verified-working\\n\"\n            \"DNxHR LB set. If you need higher quality or 4K, use 'vid davinci-prep' instead.\"\n        ),\n    )\n    sp.add_argument(\"inputs\", nargs=\"+\",\n                    help=\"video file(s) and/or director(ies) to transcode. \"\n                         \"Directories are scanned for .mp4/.mov/.mkv files (case-insensitive).\")\n    sp.add_argument(\"-o\", \"--output\",\n                    help=\"output file path (single-input only). Extension is forced to .mov; \"\n                         \"a warning is printed if you supplied a different one.\")\n    sp.set_defaults(func=cmd_davinci_prep_lite)\n\n    # youtube-prep\n    sp = sub.add_parser(\n        \"youtube-prep\",\n        help=\"re-encode for YouTube upload (H.265 .mp4 at high quality)\",\n        parents=[sample_parent],\n        formatter_class=fmt,\n        description=(\n            \"Re-encode footage into a small, high-quality file suitable for YouTube upload.\\n\"\n            \"\\n\"\n            \"Why this is separate from davinci-prep:\\n\"\n            \"  * davinci-prep produces a DNxHR HQX editing intermediate (~1.75 Gbps for 4K\\n\"\n            \"    60p — huge). That's the right format for editing in Resolve, the wrong\\n\"\n            \"    format for uploading.\\n\"\n            \"  * YouTube re-encodes everything you upload to AV1/VP9/H.264 at their own\\n\"\n            \"    bitrates regardless. Uploading 9 GB of DNxHR HQX gives them nothing extra.\\n\"\n            \"  * This command produces a file in the same quality bracket as YouTube's\\n\"\n            \"    recommended H.264 upload bitrates (~68 Mbps at 4K 60p SDR), but encoded\\n\"\n            \"    with libx265 at CRF 22 — visually transparent for most content, ~70% smaller\\n\"\n            \"    than that H.264 number, and well above the quality of YouTube's final\\n\"\n            \"    encode (so there's no perceptual loss after their re-encode pass).\\n\"\n            \"\\n\"\n            \"Output specification (all hardcoded — no quality/codec flags):\\n\"\n            \"  video:  libx265 CRF 22, source resolution, source frame rate\\n\"\n            \"  pixel:  yuv420p (8-bit; YouTube's delivery is 8-bit, no point uploading more)\\n\"\n            \"  audio:  AAC 192 kbps\\n\"\n            \"  streams: video + audio only (DJI MP4s' extra data/tmcd/mjpeg streams dropped)\\n\"\n            \"  container: .mp4 with +faststart (moov atom up front for streaming)\\n\"\n            \"\\n\"\n            \"Disk space: typical output is ~30–50% of the source HEVC's size. Estimate is\\n\"\n            \"computed up front and the script aborts if your filesystem doesn't have room.\\n\"\n            \"Pass --skip-space-check to bypass.\\n\"\n            \"\\n\"\n            \"Inline transforms (pipeline ops): write `rotate`, `crop`, or `flip` as ops\\n\"\n            \"directly inside the youtube-prep invocation — same syntax as davinci-prep.\\n\"\n            \"Each op uses the standalone subcommand's flags (`rotate -d 180`, `crop -W ... -H ...`,\\n\"\n            \"`flip horizontal`). Ops apply in the order written. All in one ffmpeg pass.\\n\"\n            \"\\n\"\n            \"Batch mode: pass any mix of files and directories. Directories are scanned for\\n\"\n            \".mp4/.mov/.mkv. Existing _youtube.mp4 outputs are skipped, so a re-run resumes.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid youtube-prep clip.MP4 --sample                      # 10s preview\\n\"\n            \"  vid youtube-prep clip.MP4                               # single file → clip_youtube.mp4\\n\"\n            \"  vid youtube-prep ~/Videos/DJI\\\\ Video/DJI_002_C01/        # batch every .MP4 in folder\\n\"\n            \"  vid youtube-prep file1.MP4 file2.MP4 some_dir/          # mix files and folders\\n\"\n            \"  vid youtube-prep clip.MP4 --skip-space-check            # bypass disk-space check\\n\"\n            \"\\n\"\n            \"Inline transforms (pipeline ops — each op uses the same flags as 'vid <op>'):\\n\"\n            \"  vid youtube-prep clip.MP4 rotate -d 180                         # upside-down camera fix\\n\"\n            \"  vid youtube-prep clip.MP4 rotate -d -90                         # phone shot sideways\\n\"\n            \"  vid youtube-prep clip.MP4 flip horizontal                       # mirror left-right\\n\"\n            \"  vid youtube-prep clip.MP4 crop -W 3840 -H 1620 -y 270           # 2.39:1 letterbox crop\\n\"\n            \"  vid youtube-prep clip.MP4 crop -W 1920 -H 1080 -x 960 -y 540 rotate -d 180\\n\"\n            \"      # combine ops; order matters — here it crops, then rotates\\n\"\n            \"  vid youtube-prep rotate -d 180 ~/Videos/DJI\\\\ Video/DJI_002_C01/\\n\"\n            \"      # ops + inputs can mix in any order — this rotates every clip in the folder\\n\"\n            \"\\n\"\n            \"Note: --quality and --codec are deliberately NOT accepted. The codec, CRF, pixel\\n\"\n            \"format, and audio format are fixed to a YouTube-friendly delivery target.\"\n        ),\n    )\n    sp.add_argument(\"inputs\", nargs=\"+\",\n                    help=\"video file(s) and/or director(ies) to transcode. \"\n                         \"Directories are scanned for .mp4/.mov/.mkv files (case-insensitive).\")\n    sp.add_argument(\"-o\", \"--output\",\n                    help=\"output file path (single-input only). Extension is forced to .mp4; \"\n                         \"a warning is printed if you supplied a different one.\")\n    sp.set_defaults(func=cmd_youtube_prep)\n\n    # thumbnail\n    sp = sub.add_parser(\n        \"thumbnail\", help=\"save a single frame as a JPEG image\",\n        formatter_class=fmt,\n        description=(\n            \"Grab one frame from the video at the time you choose and save it as a still image.\\n\"\n            \"Useful for video thumbnails, poster images, or just a quick screenshot.\"\n        ),\n        epilog=(\n            \"Examples:\\n\"\n            \"  vid thumbnail movie.mp4                 # very first frame\\n\"\n            \"  vid thumbnail movie.mp4 -t 30           # frame at 30 seconds\\n\"\n            \"  vid thumbnail movie.mp4 -t 1:15 -o poster.jpg\\n\"\n        ),\n    )\n    add_io(sp)\n    sp.add_argument(\"-t\", \"--time\", default=\"0\",\n                    help=\"timestamp of the frame to grab (e.g. 30, 1:30, 00:01:30). Default: 0 (first frame).\")\n    sp.set_defaults(func=cmd_thumbnail)\n\n    return p\n\n\ndef main():\n    check_ffmpeg()\n    # Pre-process argv to extract pipeline ops (no-op when not invoking a prep command)\n    pipeline_ops, processed_argv = _extract_pipeline_ops(sys.argv[1:])\n    parser = build_parser()\n    args = parser.parse_args(processed_argv)\n    args.ops = pipeline_ops if getattr(args, \"command\", None) in PIPELINE_COMMANDS else []\n    if not hasattr(args, \"dry_run\"):\n        args.dry_run = False\n    if not hasattr(args, \"skip_space_check\"):\n        args.skip_space_check = False\n    if not hasattr(args, \"verbose\"):\n        args.verbose = False\n    global _VERBOSE\n    _VERBOSE = args.verbose\n    try:\n        rc = args.func(args)\n    except KeyboardInterrupt:\n        print(\"aborted by user\", file=sys.stderr)\n        sys.exit(130)\n    sys.exit(rc or 0)\n\n\nif __name__ == \"__main__\":\n    main()\n", "url": "https://wpnews.pro/news/video-modification-simplified", "canonical_source": "https://gist.github.com/addohm/8fee9aece76b65c19311fd0f003ce9f7", "published_at": "2026-05-21 21:16:56+00:00", "updated_at": "2026-05-23 21:33:34.134238+00:00", "lang": "en", "topics": ["developer-tools", "open-source"], "entities": ["ffmpeg", "Python"], "alternates": {"html": "https://wpnews.pro/news/video-modification-simplified", "markdown": "https://wpnews.pro/news/video-modification-simplified.md", "text": "https://wpnews.pro/news/video-modification-simplified.txt", "jsonld": "https://wpnews.pro/news/video-modification-simplified.jsonld"}}