Video Modification Simplified 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. /usr/bin/env python3 """vid — a friendlier CLI wrapper around ffmpeg for common video tasks.""" import argparse import json import os import re import shlex import shutil import subprocess import sys import time from pathlib import Path Set by main from --verbose. Read by run and batch prep commands to decide whether to print per-file ffmpeg commands and use a per-file bar. VERBOSE = False def fail msg : print f"error: {msg}", file=sys.stderr sys.exit 1 def check ffmpeg : if not shutil.which "ffmpeg" : fail "ffmpeg not found in PATH" def to seconds s : """Parse '30', '1:30', '00:01:30', or '12.5' to a float number of seconds.""" if s is None: return None try: if ":" in s: parts = float p for p in s.split ":" if len parts == 2: return parts 0 60 + parts 1 if len parts == 3: return parts 0 3600 + parts 1 60 + parts 2 return float s except ValueError, TypeError : return None def guess duration cmd : """Estimate expected output duration seconds from an ffmpeg command.""" Explicit -t wins for i, arg in enumerate cmd :-1 : if arg == "-t": d = to seconds cmd i + 1 if d: return d Otherwise probe the first input and subtract -ss if present. Prefer the video stream's duration over the container's — DJI MP4s often have container-level padding past the last video frame, which would make the progress bar stop short of 100% otherwise. for i, arg in enumerate cmd :-1 : if arg == "-i": input path = cmd i + 1 if not Path input path .is file : return None try: data = probe input path v = next s for s in data "streams" if s.get "codec type" == "video" , None full = None if v and "duration" in v: full = float v "duration" if not full: full = float data.get "format", {} .get "duration", 0 if not full: return None except Exception: return None ss = 0.0 for j, a in enumerate cmd :i : if a == "-ss": ss = to seconds cmd j + 1 or 0.0 break return max 0.0, full - ss return None PROGRESS TTY = sys.stdout.isatty def render progress progress, total, complete=False : """Print a one-line in-place progress update. No-op if stdout isn't a TTY. When complete=True ffmpeg reported progress=end , forces the bar to 100% regardless of how out time compares to the estimated total — duration estimates can be slightly off, but 'ffmpeg said done' is authoritative. """ if not PROGRESS TTY: return out time = progress.get "out time", "00:00:00.000000" .split "." 0 fps = progress.get "fps", "?" speed = progress.get "speed", "?" .strip elapsed s = to seconds out time or 0.0 if total and total 0: pct = 100.0 if complete else min 100.0, elapsed s / total 100 bar len = 24 filled = int bar len pct / 100 bar = "█" filled + "░" bar len - filled line = f" {bar} {pct:5.1f}% {out time} fps={fps: 4} speed={speed}" else: line = f" {out time} fps={fps} speed={speed}" \r returns to column 0; \x1b K clears from cursor to end of line sys.stdout.write "\r" + line + "\x1b K" sys.stdout.flush def run cmd, dry run=False, output path=None, on progress=None : """Run an ffmpeg command with a single-line progress display. on progress: optional callable progress dict, total seconds, complete — when provided, the caller takes over progress rendering e.g., a batch driver showing one overall bar . When None, run renders its own per-file bar. Verbose vs quiet behavior is keyed off the module-level VERBOSE flag: - verbose: prints '→ ffmpeg ...' command line; ffmpeg at loglevel warning - quiet: suppresses both; ffmpeg at loglevel error On Ctrl+C: waits for ffmpeg to clean up, removes the partial output if any , then re-raises KeyboardInterrupt for the caller to handle. """ if VERBOSE or dry run: print "→", " ".join shlex.quote c for c in cmd if dry run: return 0 total = guess duration cmd loglevel = "warning" if VERBOSE else "error" proc cmd = cmd 0 , "-hide banner", "-loglevel", loglevel, "-nostats", "-progress", "pipe:1" + cmd 1: proc = subprocess.Popen proc cmd, stdout=subprocess.PIPE, text=True, bufsize=1 interrupted = False progress = {} rc = -1 rendered own bar = False try: for line in proc.stdout: if "=" not in line: continue k, , v = line.strip .partition "=" progress k = v if k == "progress": if on progress: on progress progress, total, v == "end" else: render progress progress, total, complete= v == "end" rendered own bar = True if v == "end": break rc = proc.wait except KeyboardInterrupt: interrupted = True Only advance past the per-file bar if WE rendered it caller's bar persists if rendered own bar and PROGRESS TTY: sys.stdout.write "\n" sys.stdout.flush if interrupted: print "interrupted, waiting for ffmpeg to clean up...", file=sys.stderr try: proc.wait timeout=5 except subprocess.TimeoutExpired: proc.terminate try: proc.wait timeout=2 except subprocess.TimeoutExpired: proc.kill proc.wait if output path and Path output path .exists : try: Path output path .unlink print f" removed partial output: {output path}", file=sys.stderr except OSError as e: print f" could not remove partial output: {e}", file=sys.stderr raise KeyboardInterrupt return rc def default output input path, suffix, ext=None, sample=False : p = Path input path new ext = ext if ext else p.suffix if not new ext.startswith "." : new ext = "." + new ext if sample: suffix = f"{suffix} sample" return str p.with name f"{p.stem} {suffix}{new ext}" def is sample args : return getattr args, "sample", None is not None Shared quality presets — base CRF for libx264. libx265 needs ~5 higher for equivalent quality. QUALITY PRESETS = { "lossless": 0, byte-perfect — files can be huge "pristine": 17, near-lossless, indistinguishable to most viewers "high": 20, excellent default for transforms "medium": 23, good ffmpeg's own default; default for compress "low": 28, noticeably softer "tiny": 32, visible compression artifacts } Maps the --codec choice to the actual ffmpeg encoder name. CODEC ENCODERS = {"h264": "libx264", "h265": "libx265", "hevc": "libx265"} def detect codec input path : """Probe the source and return 'libx264'/'libx265' to match it. Falls back to libx264.""" try: data = probe input path video = next s for s in data "streams" if s "codec type" == "video" , None if video and video.get "codec name" in "hevc", "h265" : return "libx265" except Exception: pass return "libx264" def pick codec args : """Resolve the user's --codec choice or 'auto' to a concrete ffmpeg encoder.""" choice = getattr args, "codec", None or "auto" if choice == "auto": return detect codec args.input return CODEC ENCODERS choice def quality args args, default label, encoder : """Return the ffmpeg quality-control args for the chosen encoder.""" label = getattr args, "quality", None or default label if encoder == "libx265": if label == "lossless": return "-x265-params", "lossless=1" H.265 ≈ H.264 - 5 CRF for similar visual quality return "-crf", str QUALITY PRESETS label + 5 return "-crf", str QUALITY PRESETS label def input args args : """Build ffmpeg input args, honoring --sample / --sample-start if set.""" parts = "-y" if is sample args : parts += "-ss", str args.sample start , "-t", str args.sample parts += "-i", args.input return parts def parse time value : """Accept '90', '1:30', '00:01:30', or '1.5' — return as string ffmpeg accepts.""" return str value def probe input path : cmd = "ffprobe", "-v", "error", "-print format", "json", "-show format", "-show streams", input path, result = subprocess.run cmd, capture output=True, text=True if result.returncode = 0: fail f"ffprobe failed: {result.stderr.strip }" return json.loads result.stdout ---------- disk-space estimation ---------- def format size bytes : """Human-readable size: 631 B / 631 KB / 631 MB / 6.31 GB.""" if bytes is None: return "?" if bytes = 1e9: return f"{bytes / 1e9:.2f} GB" if bytes = 1e6: return f"{bytes / 1e6:.1f} MB" if bytes = 1e3: return f"{bytes / 1e3:.0f} KB" return f"{int bytes } B" def format gb bytes : if bytes is None: return "?" if bytes = 1e9: return f"{bytes / 1e9:.1f} GB" return f"{bytes / 1e6:.0f} MB" def probe video dims input path : """Return width, height, fps, duration sec or None if unprobable.""" try: data = probe input path except SystemExit: return None v = next s for s in data "streams" if s.get "codec type" == "video" , None if not v: return None try: w, h = int v "width" , int v "height" num, den = v.get "r frame rate", "30/1" .split "/" fps = float num / float den if float den else 30.0 duration = float data.get "format", {} .get "duration", 0 return w, h, fps, duration except KeyError, ValueError, ZeroDivisionError : return None def estimate youtube h265 bytes width, height, fps, duration sec : """Estimate libx265 CRF 22 output size yuv420p, AAC 192k for a YouTube target. Calibrated against YouTube's recommended H.264 upload bitrates ~68 Mbps for 4K 60p, ~12 Mbps for 1080p 60p . libx265 CRF 22 reaches comparable visual quality at ~40 Mbps for that 4K 60p baseline. Scales linearly with pixel count and fps. """ ref pixels, ref fps, ref mbps = 3840 2160, 60.0, 40.0 video mbps = max 4.0, ref mbps width height / ref pixels fps / ref fps video bps = video mbps 1 000 000 / 8 audio bps = 192 000 / 8 AAC 192 kbps return int video bps + audio bps duration sec def estimate dnxhr lb bytes width, height, fps, duration sec : """Estimate DNxHR LB output size 8-bit 4:2:2 . Reference rate: ~90 Mbps for 1920x1080 at 59.94 fps. Scales linearly with pixels × fps. Audio = PCM s16 stereo 48k. Calibrated after first sample. """ ref pixels, ref fps, ref mbps = 1920 1080, 60.0, 90.0 video mbps = ref mbps width height / ref pixels fps / ref fps video bps = video mbps 1 000 000 / 8 audio bps = 48000 2 2 PCM s16 stereo: 2 bytes × 2 channels × samplerate return int video bps + audio bps duration sec def estimate dnxhr hqx bytes width, height, fps, duration sec : """Estimate DNxHR HQX output size. Reference rate: ~1750 Mbps for 3840x2160 at 59.94 fps 10-bit 4:2:2 . Calibrated against an actual encode: 10s of 4K UHD 59.94 source produced ~2.19 GB output. Avid's nominal "~880 Mbps" figure is for the lower-bitrate HQ profile, not HQX. Scales linearly with pixel count and frame rate. Audio = PCM s16 stereo 48k. """ ref pixels, ref fps, ref mbps = 3840 2160, 60.0, 1750.0 video mbps = ref mbps width height / ref pixels fps / ref fps video bps = video mbps 1 000 000 / 8 audio bps = 48000 2 2 PCM s16 stereo: 2 bytes × 2 channels × samplerate return int video bps + audio bps duration sec def estimate default bytes args, input path : """Rough output-size estimate for re-encode commands not davinci-prep .""" try: input size = Path input path .stat .st size except OSError: return None If sampling, scale by duration ratio if is sample args : try: data = probe input path full duration = float data "format" "duration" if full duration 0: sample dur = float args.sample input size = input size sample dur / full duration except Exception: pass "lossless" can balloon files esp. libx264 -crf 0 ; other modes shrink or stay similar quality = getattr args, "quality", None if quality == "lossless": return int input size 3.0 if quality == "pristine": return int input size 1.3 return int input size 1.1 small safety margin def check disk space output dir, needed bytes, label="", skip=False : """Verify enough free space at output dir. Aborts on shortfall unless skipped.""" if skip or needed bytes is None or needed bytes <= 0: return try: free = shutil.disk usage str output dir .free except OSError: return can't query, proceed needed with buffer = int needed bytes 1.10 if free < needed with buffer: suffix = f" for {label}" if label else "" fail f"not enough free disk space{suffix}\n" f" output dir: {output dir}\n" f" estimated: ~{ format gb needed bytes } " f" +10% safety = { format gb needed with buffer } \n" f" available: { format gb free }\n" f"\nfree up space and try again, or pass --skip-space-check to override." Tight-but-OK note if free < needed bytes 1.5: suffix = f" for {label}" if label else "" print f"note: disk getting tight{suffix} — " f"~{ format gb needed bytes } estimated, { format gb free } free", file=sys.stderr, def format mmss seconds : """Format a number of seconds as MM:SS, or HH:MM:SS if ≥ 1 hour.""" s = int max 0, seconds or 0 if s = 3600: return f"{s//3600:02d}:{ s%3600 //60:02d}:{s%60:02d}" return f"{s//60:02d}:{s%60:02d}" def format hms seconds : """Format a number of seconds as HH:MM:SS always padded .""" s = int max 0, seconds or 0 return f"{s//3600:02d}:{ s%3600 //60:02d}:{s%60:02d}" Colors. Skip when stdout isn't a TTY or NO COLOR is set. COLOR ENABLED = sys.stdout.isatty and not os.environ.get "NO COLOR" C = { "reset": "\x1b 0m" if COLOR ENABLED else "", "dim": "\x1b 2m" if COLOR ENABLED else "", "bold": "\x1b 1m" if COLOR ENABLED else "", "fill": "\x1b 36m" if COLOR ENABLED else "", cyan: active "done": "\x1b 32m" if COLOR ENABLED else "", green "fail": "\x1b 31m" if COLOR ENABLED else "", red } class BatchProgress: """ipad-util-style multi-line frame: bar + one row per file. File status: 0=pending ·, dim / 1=active braille spinner / 2=done ✓ / 3=failed ✗ . Each render redraws the whole frame using cursor-up; first frame just prints. If the terminal isn't tall enough to fit one row per file, falls back to a single-line bar no file rows — same logic ipad-util uses. Caller flow: batch = BatchProgress files files: src, out, dur sec, src size for idx, f in enumerate files : batch.begin file idx ... encode, calling batch.update current elapsed sec, complete ... batch.finish file success=bool batch.finalize """ SPINNER = "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" def init self, files : self.files = files src, out, duration seconds, src size bytes self.status = 0 len files 0=pending, 1=active, 2=done, 3=failed self.current idx = -1 self.current elapsed = 0.0 source seconds done in the active file self.total seconds = sum f 2 for f in files self.spin idx = 0 self.start time = time.time self.first frame = True Decide multi-line vs single-line fallback try: term lines = shutil.get terminal size 100, 24 .lines except Exception: term lines = 24 n = len files self.multiline = sys.stdout.isatty and n + 3 <= term lines ----- public API ----- def begin file self, idx : self.current idx = idx self.status idx = 1 self.current elapsed = 0.0 self. render def update current self, elapsed seconds, complete=False : if self.current idx < 0: return dur = self.files self.current idx 2 self.current elapsed = dur if complete else min elapsed seconds, dur self. render def finish file self, success=True : if self.current idx = 0: self.status self.current idx = 2 if success else 3 self.current elapsed = 0.0 self.current idx = -1 self. render def finalize self : One last render with no active file, then move cursor past everything. self. render final=True if sys.stdout.isatty : sys.stdout.write "\n" sys.stdout.flush ----- internals ----- def completed secs self : return sum self.files i 2 for i in range len self.files if self.status i = 2 def render self, final=False : if not sys.stdout.isatty : return if self.multiline: self. render multiline final else: self. render oneline def format bar self : done = self. completed secs + self.current elapsed total = self.total seconds pct = min 100.0, done / total 100 if total 0 else 0 bar len = 24 filled = int bar len pct / 100 bar = "█" filled + "░" bar len - filled wall = time.time - self.start time speed = done / wall if wall 0 else 0 if speed 0 and total done: eta str = format hms total - done / speed else: eta str = "--:--:--" done str = format mmss done total str = format mmss total fill color = C "done" if pct = 100 else C "fill" return f" { C 'dim' } { C 'reset' }{fill color}{bar}{ C 'reset' }{ C 'dim' } { C 'reset' }" f" { C 'bold' }{pct:5.1f}%{ C 'reset' } {done str}/{total str}" f" {speed:.2f}x avg ETA {eta str}" def format file line self, idx, spinner : status = self.status idx if status == 0: icon, icon c, name c = "·", C "dim" , C "dim" elif status == 1: icon, icon c, name c = spinner, C "fill" , C "reset" elif status == 2: icon, icon c, name c = "✓", C "done" , C "reset" else: icon, icon c, name c = "✗", C "fail" , C "reset" name = Path self.files idx 0 .name size = format size self.files idx 3 try: cols = shutil.get terminal size 100, 24 .columns except Exception: cols = 100 max name = max 20, cols - 25 if len name max name: name = name : max name - 1 + "…" return f" {icon c}{icon}{ C 'reset' } " f"{name c}{name}{ C 'reset' } " f"{ C 'dim' } {size} { C 'reset' }" def render multiline self, final=False : n lines = len self.files + 1 bar + per-file if not self.first frame: sys.stdout.write f"\x1b {n lines}A\r" else: self.first frame = False advance spinner each frame skip on final to keep last frame static if not final: self.spin idx = self.spin idx + 1 % len self.SPINNER spinner = self.SPINNER self.spin idx sys.stdout.write "\r" + self. format bar + "\x1b K\n" for i in range len self.files : sys.stdout.write "\r" + self. format file line i, spinner + "\x1b K\n" sys.stdout.flush def render oneline self : No room for the per-file list — show just the bar in place sys.stdout.write "\r" + self. format bar + "\x1b K" sys.stdout.flush def check space for args args, input path, output path, estimator=None : """Convenience wrapper used by individual commands before run .""" if args.dry run or getattr args, "skip space check", False : return estimator = estimator or estimate default bytes est = estimator args, input path out dir = Path output path .parent if output path else Path.cwd if not out dir.exists : out dir = Path.cwd check disk space out dir, est, label=Path output path .name if output path else "" ---------- commands ---------- def cmd trim args : out = args.output or default output args.input, "trimmed" cmd = "ffmpeg", "-y", "-i", args.input if args.start is not None: cmd += "-ss", parse time args.start if args.end is not None: cmd += "-to", parse time args.end elif args.duration is not None: cmd += "-t", parse time args.duration cmd += "-c", "copy" if args.fast else "copy" if args.fast else if not args.fast: cmd += "-c:v", "libx264", "-c:a", "aac" cmd += out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd crop args : out = args.output or default output args.input, "cropped", sample=is sample args enc = pick codec args cmd = "ffmpeg", input args args , "-vf", f"crop={args.width}:{args.height}:{args.x}:{args.y}", "-c:v", enc, quality args args, "high", enc , "-preset", "medium", "-c:a", "copy", out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd rotate args : out = args.output or default output args.input, "rotated", sample=is sample args if args.flip: vf = {"horizontal": "hflip", "vertical": "vflip"} args.flip else: ffmpeg transpose: 1=90CW, 2=90CCW, 3=90CW+vflip, 0=90CCW+vflip rotations = { 90: "transpose=1", -90: "transpose=2", 270: "transpose=2", 180: "transpose=2,transpose=2", } if args.degrees not in rotations: fail "--degrees must be one of 90, -90, 180, 270" vf = rotations args.degrees enc = pick codec args cmd = "ffmpeg", input args args , "-vf", vf, "-c:v", enc, quality args args, "high", enc , "-preset", "medium", "-c:a", "copy", out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd resize args : out = args.output or default output args.input, "resized", sample=is sample args if args.scale: vf = f"scale=iw {args.scale}:ih {args.scale}" elif args.width and args.height: vf = f"scale={args.width}:{args.height}" elif args.width: vf = f"scale={args.width}:-2" elif args.height: vf = f"scale=-2:{args.height}" else: fail "provide --scale, --width, and/or --height" enc = pick codec args cmd = "ffmpeg", input args args , "-vf", vf, "-c:v", enc, quality args args, "high", enc , "-preset", "medium", "-c:a", "copy", out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd convert args : out = args.output or default output args.input, "converted", ext=args.to, sample=is sample args cmd = "ffmpeg", input args args , out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd audio args : out = args.output or default output args.input, "audio", ext=args.format, sample=is sample args cmd = "ffmpeg", input args args , "-vn" if args.format == "mp3": cmd += "-c:a", "libmp3lame", "-q:a", "2" elif args.format == "wav": cmd += "-c:a", "pcm s16le" else: cmd += "-c:a", "copy" cmd += out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd mute args : out = args.output or default output args.input, "muted", sample=is sample args codec = "-c:v", "libx264" if is sample args else "-c", "copy" cmd = "ffmpeg", input args args , codec, "-an", out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd speed args : out = args.output or default output args.input, f"{args.rate}x", sample=is sample args video PTS: multiplier is 1/rate; audio atempo accepts 0.5..2.0, chain for more rate = args.rate video filter = f"setpts={1/rate:.6f} PTS" build audio atempo chain audio filters = remaining = rate while remaining 2.0: audio filters.append "atempo=2.0" remaining /= 2.0 while remaining < 0.5: audio filters.append "atempo=0.5" remaining /= 0.5 audio filters.append f"atempo={remaining:.6f}" cmd = "ffmpeg", input args args , "-filter complex", f" 0:v {video filter} v ; 0:a {','.join audio filters } a ", "-map", " v ", "-map", " a ", out, check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd merge args : if len args.inputs < 2: fail "merge needs at least 2 input files" out = args.output or default output args.inputs 0 , "merged" write a temp concat list next to the first input list path = Path args.inputs 0 .with name ".vid concat list.txt" list path.write text "\n".join f"file {shlex.quote str Path i .resolve }" for i in args.inputs cmd = "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str list path , "-c", "copy", out, merge is stream-copy, so output ≈ sum of input sizes plus a tiny mux overhead if not args.dry run and not args.skip space check: try: est = int sum Path p .stat .st size for p in args.inputs 1.02 check disk space Path out .parent, est, label=Path out .name except OSError: pass try: rc = run cmd, args.dry run, output path=out finally: if not args.dry run: list path.unlink missing ok=True return rc def cmd gif args : out = args.output or default output args.input, "anim", ext="gif" vf parts = f"fps={args.fps}" if args.width: vf parts.append f"scale={args.width}:-1:flags=lanczos" vf = ",".join vf parts + ",split s0 s1 ; s0 palettegen p ; s1 p paletteuse" cmd = "ffmpeg", "-y" if args.start is not None: cmd += "-ss", parse time args.start if args.duration is not None: cmd += "-t", parse time args.duration cmd += "-i", args.input, "-filter complex", vf, out check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd info args : data = probe args.input fmt = data.get "format", {} video = next s for s in data "streams" if s "codec type" == "video" , None audio = next s for s in data "streams" if s "codec type" == "audio" , None print f"file: {args.input}" print f"size: {int fmt.get 'size', 0 / 1 000 000:.1f} MB" print f"duration: {float fmt.get 'duration', 0 :.2f} s" print f"format: {fmt.get 'format long name', '?' }" print f"bitrate: {int fmt.get 'bit rate', 0 // 1000} kbps" if video: fps = video.get "r frame rate", "0/1" try: num, den = fps.split "/" fps val = float num / float den if float den else 0 except Exception: fps val = 0 print f"video: {video.get 'codec name' } " f"{video.get 'width' }x{video.get 'height' } @ {fps val:.2f} fps" if audio: print f"audio: {audio.get 'codec name' } " f"{audio.get 'sample rate' } Hz {audio.get 'channels' }ch" return 0 def cmd compress args : out = args.output or default output args.input, "compressed", sample=is sample args enc = pick codec args cmd = "ffmpeg", input args args , "-c:v", enc, quality args args, "medium", enc , "-preset", "medium", "-c:a", "aac", "-b:a", "128k", out, check space for args args, args.input, out return run cmd, args.dry run, output path=out def cmd thumbnail args : out = args.output or default output args.input, "thumb", ext="jpg" cmd = "ffmpeg", "-y", "-ss", parse time args.time , "-i", args.input, "-frames:v", "1", "-q:v", "2", out, check space for args args, args.input, out return run cmd, args.dry run, output path=out ---------- pipeline ops shared by davinci-prep and youtube-prep ---------- Pipeline syntax lets the user write vid davinci-prep clip.MP4 rotate -d 180 crop -W 1920 -H 1080 vid youtube-prep clip.MP4 rotate -d 180 crop -W 1920 -H 1080 instead of stuffing everything into one flat flag set. Each op keyword rotate / crop / flip starts a mini-argparse block; everything not inside an op block inputs, --sample, -o, etc. is passed through to the main prep parser. Ops are applied in the order written, regardless of where the prep keyword appears in argv. PIPELINE COMMANDS = "davinci-prep", "davinci-prep-lite", "youtube-prep" PIPELINE OP KEYWORDS = "rotate", "crop", "flip" def make pipeline op parsers : """Build mini-argparse parsers for each pipeline op. Mirrors the standalone subcommands.""" rotate p = argparse.ArgumentParser prog="davinci-prep rotate", add help=False rotate p.add argument "-d", "--degrees", type=int, choices= 90, -90, 180, 270 , required=True crop p = argparse.ArgumentParser prog="davinci-prep crop", add help=False crop p.add argument "-W", "--width", type=int, required=True crop p.add argument "-H", "--height", type=int, required=True crop p.add argument "-x", type=int, default=0 crop p.add argument "-y", type=int, default=0 flip p = argparse.ArgumentParser prog="davinci-prep flip", add help=False flip p.add argument "direction", choices= "horizontal", "vertical" return {"rotate": rotate p, "crop": crop p, "flip": flip p} def extract pipeline ops argv : """If argv invokes a prep command davinci-prep / youtube-prep , pull out op blocks. Op blocks rotate / crop / flip and their flags can appear before, after, or interleaved with the prep keyword and the input paths — order among the ops themselves is preserved it controls the filter chain , but rotate ... davinci-prep ... crop ... works just like davinci-prep rotate ... crop ... . Returns ops, processed argv : ops is a list of op name, namespace in written order; processed argv has the op tokens stripped so the main prep parser only sees its own flags and positional inputs. If no prep keyword is in argv, this is a no-op so standalone vid rotate et al. keep working — their argv has no prep keyword . """ if not any cmd in argv for cmd in PIPELINE COMMANDS : return , argv op parsers = make pipeline op parsers boundaries = set op parsers | set PIPELINE COMMANDS main tokens = ops = i = 0 while i < len argv : tok = argv i if tok in op parsers: op name = tok i += 1 op args = while i < len argv and argv i not in boundaries: op args.append argv i i += 1 Mini-parser handles its own errors prints to stderr & exits ns, leftover = op parsers op name .parse known args op args ops.append op name, ns main tokens.extend leftover else: main tokens.append tok i += 1 return ops, main tokens def build transform vf args : """Build a -vf filter chain from args.ops. Returns filter string or None.""" parts = for op name, ns in getattr args, "ops", : if op name == "crop": parts.append f"crop={ns.width}:{ns.height}:{ns.x}:{ns.y}" elif op name == "rotate": rot map = { 90: "transpose=1", quarter-turn clockwise -90: "transpose=2", quarter-turn anti-clockwise 270: "transpose=2", same as -90 180: "transpose=2,transpose=2", } parts.append rot map ns.degrees elif op name == "flip": parts.append {"horizontal": "hflip", "vertical": "vflip"} ns.direction return ",".join parts if parts else None def post transform dims args, src w, src h : """Walk args.ops, applying crop/rotate to track final width, height .""" w, h = src w, src h for op name, ns in getattr args, "ops", : if op name == "crop": w, h = ns.width, ns.height elif op name == "rotate" and ns.degrees in 90, -90, 270 : w, h = h, w return w, h def expand video inputs paths, extensions= ".mp4", ".MP4", ".mov", ".MOV", ".mkv", ".MKV" : """Expand a mix of files and directories into a de-duped, ordered list of video files.""" out = seen = set for p in paths: path = Path p if path.is dir : found = for ext in extensions: found.extend path.glob f" {ext}" for f in sorted found : s = str f if s not in seen: seen.add s out.append s elif path.is file : s = str path if s not in seen: seen.add s out.append s else: print f"warning: skipping not found : {p}", file=sys.stderr return out def cmd davinci prep args : """Transcode for DaVinci Resolve Free on Linux: DNxHR HQX 10-bit MOV.""" inputs = expand video inputs args.inputs Filter out our own outputs so directory rescans don't recurse , and any leftover files from the older libx264-based attempts that didn't work. obsolete suffixes = " davinci", " davinci sample", " davinci faststart", " davinci pcm" inputs = p for p in inputs if not any Path p .stem.endswith s for s in obsolete suffixes if not inputs: fail "no input video files found" if args.output and len inputs 1: fail "--output is only valid with a single input file" seek = "-ss", str args.sample start , "-t", str args.sample if is sample args else ---------- plan + disk-space precheck ---------- plan = src, out, est bytes — files we'll actually encode skipped = src, out — files whose output already exists for src in inputs: if args.output: out = args.output if not out.lower .endswith ".mov" : out = str Path out .with suffix ".mov" else: out = default output src, "davinci", ext="mov", sample=is sample args if Path out .exists : skipped.append src, out continue dims = probe video dims src if dims: src w, src h, fps, dur = dims if is sample args : dur = min dur, float args.sample Account for crop/rotate when estimating output size out w, out h = post transform dims args, src w, src h est = estimate dnxhr hqx bytes out w, out h, fps, dur else: est = None plan.append src, out, est print prep plan plan, skipped, label="DNxHR HQX" if plan: total est = sum e for , , e in plan if e or 0 if total est 0: out dir = Path plan 0 1 .parent check disk space out dir, total est, label=f"{len plan } DNxHR HQX file s ", skip=args.skip space check or args.dry run, ---------- encode loop ---------- return run prep batch args, plan, skipped, seek=seek, suffix="davinci", ext="mov", build cmd=lambda src, out, vf: "ffmpeg", "-y", seek, "-i", src, "-map", "0:v:0", "-map", "0:a:0", "-dn", "-vf", vf if vf else , "-c:v", "dnxhd", "-profile:v", "dnxhr hqx", "-pix fmt", "yuv422p10le", "-c:a", "pcm s16le", "-ar", "48000", "-ac", "2", "-write tmcd", "0", out, , def print prep plan plan, skipped, label="" : """Print the 'plan: N to encode, M already done, est ~X GB' header.""" n plan = len plan n skipped = len skipped if n plan == 0 and n skipped == 0: return parts = if n plan: total est = sum e for , , e in plan if e or 0 if total est 0: parts.append f"{n plan} file s to encode, ~{ format gb total est } estimated output" else: parts.append f"{n plan} file s to encode" if n skipped: parts.append f"{n skipped} already done" if not n plan and n skipped: print f"nothing to do — all {n skipped} file s already done" else: print "plan: " + ", ".join parts def run prep batch args, plan, skipped, seek, suffix, ext, build cmd : """Shared encode loop for the prep commands. Handles quiet vs verbose UI. Quiet mode default : ipad-util-style frame UI with per-file status icons · pending / ⠋ active / ✓ done / ✗ failed . Falls back to a single-line bar if the terminal is too short for the file list. Verbose mode -v : per-file ' i/n src → dst' header, full '→ ffmpeg ...' command, per-file progress bar. """ quiet = not VERBOSE n plan = len plan n skipped = len skipped Verbose mode: list each skipped file by name quiet mode shows them as ✓ rows in the batch UI, so no need to duplicate . Helps the user audit what was carried over from a previous run vs. what's about to be re-encoded. if VERBOSE and skipped: for src, out in skipped: print f" skip {src} output exists: {out} " Build the BatchProgress file list: skipped first pre-marked done , then to-encode. Order matches what the user sees in the frame: ✓ on top, · / ⠋ below. batch = None if quiet and sys.stdout.isatty and plan or skipped and not args.dry run: batch files = Skipped files have duration 0 — they don't add to the work counter, but they DO appear in the file list with a ✓ so the user can see why they weren't re-encoded. for src, out in skipped: try: size = Path src .stat .st size except OSError: size = 0 batch files.append src, out, 0.0, size for src, out, est in plan: dims = probe video dims src dur = 0.0 if dims: dur = dims 3 if is sample args : dur = min dur, float args.sample try: size = Path src .stat .st size except OSError: size = 0 batch files.append src, out, dur, size batch = BatchProgress batch files Pre-mark skipped slots as done before any encoding starts for i in range n skipped : batch.status i = 2 Render once so the user sees the initial state immediately batch. render done = 0 failed = 0 last plan idx = -1 try: for plan idx, src, out, est in enumerate plan : last plan idx = plan idx if VERBOSE: print f" {plan idx + 1}/{n plan} {src} → {out}" vf = build transform vf args cmd = build cmd src, out, vf on prog = None if batch is not None: batch.begin file n skipped + plan idx offset past pre-marked ✓s def on prog progress, total, complete, b=batch : out time = progress.get "out time", "00:00:00.000000" .split "." 0 elapsed = to seconds out time or 0.0 b.update current elapsed, complete=complete rc = run cmd, args.dry run, output path=out, on progress=on prog if rc == 0: done += 1 if batch is not None: batch.finish file success=True else: failed += 1 if batch is not None: batch.finish file success=False else: print f" failed exit {rc} : {src}", file=sys.stderr except KeyboardInterrupt: if batch is not None: batch.finalize not started = max 0, n plan - last plan idx + 1 print f"\nstopped at file {last plan idx + 1}/{n plan}: {done} done, " f"{n skipped} skipped, {failed} failed, " f"{not started} not started", file=sys.stderr raise if batch is not None: batch.finalize if n plan + n skipped 1 or failed: print f"summary: {done} done, {n skipped} skipped, {failed} failed" return 0 if failed == 0 else 1 def cmd davinci prep lite args : """Lightweight DaVinci Resolve transcode: DNxHR LB 1080p 8-bit MOV.""" inputs = expand video inputs args.inputs Drop anything that's already a prep-output of any flavor don't re-process obsolete suffixes = " davinci", " davinci sample", " davinci lite", " davinci lite sample", " davinci faststart", " davinci pcm", historical libx264 attempts " youtube", " youtube sample", inputs = p for p in inputs if not any Path p .stem.endswith s for s in obsolete suffixes if not inputs: fail "no input video files found" if args.output and len inputs 1: fail "--output is only valid with a single input file" seek = "-ss", str args.sample start , "-t", str args.sample if is sample args else ---------- plan + disk-space precheck ---------- Output is always 1920x1080 regardless of source — that's the verified-working resolution for DNxHR LB on this Resolve install. Source fps is preserved. plan = skipped = for src in inputs: if args.output: out = args.output if not out.lower .endswith ".mov" : out = str Path out .with suffix ".mov" else: out = default output src, "davinci lite", ext="mov", sample=is sample args if Path out .exists : skipped.append src, out continue dims = probe video dims src if dims: src w, src h, fps, dur = dims if is sample args : dur = min dur, float args.sample Output is fixed at 1920x1080 DNxHR LB profile constraint on this install est = estimate dnxhr lb bytes 1920, 1080, fps, dur else: est = None plan.append src, out, est print prep plan plan, skipped, label="DNxHR LB" if plan: total est = sum e for , , e in plan if e or 0 if total est 0: out dir = Path plan 0 1 .parent check disk space out dir, total est, label=f"{len plan } DNxHR LB file s ", skip=args.skip space check or args.dry run, ---------- encode loop ---------- def build cmd src, out, user vf : Always end the filter chain with scale=1920:1080 LB-on-this-Resolve constraint vf = ",".join p for p in user vf, "scale=1920:1080" if p return "ffmpeg", "-y", seek, "-i", src, "-map", "0:v:0", "-map", "0:a:0", "-dn", "-vf", vf, "-c:v", "dnxhd", "-profile:v", "dnxhr lb", "-pix fmt", "yuv422p", "-c:a", "pcm s16le", "-ar", "48000", "-ac", "2", "-write tmcd", "0", out, return run prep batch args, plan, skipped, seek=seek, suffix="davinci lite", ext="mov", build cmd=build cmd def cmd youtube prep args : """Re-encode for YouTube upload: H.265 .mp4 at high quality with AAC audio.""" inputs = expand video inputs args.inputs obsolete suffixes = " youtube", " youtube sample" inputs = p for p in inputs if not any Path p .stem.endswith s for s in obsolete suffixes if not inputs: fail "no input video files found" if args.output and len inputs 1: fail "--output is only valid with a single input file" seek = "-ss", str args.sample start , "-t", str args.sample if is sample args else ---------- plan + disk-space precheck ---------- plan = skipped = for src in inputs: if args.output: out = args.output if not out.lower .endswith ".mp4" : out = str Path out .with suffix ".mp4" else: out = default output src, "youtube", ext="mp4", sample=is sample args if Path out .exists : skipped.append src, out continue dims = probe video dims src if dims: src w, src h, fps, dur = dims if is sample args : dur = min dur, float args.sample out w, out h = post transform dims args, src w, src h est = estimate youtube h265 bytes out w, out h, fps, dur else: est = None plan.append src, out, est print prep plan plan, skipped, label="YouTube H.265" if plan: total est = sum e for , , e in plan if e or 0 if total est 0: out dir = Path plan 0 1 .parent check disk space out dir, total est, label=f"{len plan } YouTube H.265 file s ", skip=args.skip space check or args.dry run, ---------- encode loop ---------- return run prep batch args, plan, skipped, seek=seek, suffix="youtube", ext="mp4", build cmd=lambda src, out, vf: "ffmpeg", "-y", seek, "-i", src, "-map", "0:v:0", "-map", "0:a:0", "-dn", "-vf", vf if vf else , "-c:v", "libx265", "-preset", "medium", "-crf", "22", "-pix fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", out, , ---------- argparse ---------- def build parser : fmt = argparse.RawDescriptionHelpFormatter p = argparse.ArgumentParser prog="vid", formatter class=fmt, description="A friendlier CLI for common video edits wraps ffmpeg .", epilog= "Each command has its own detailed help — try:\n" " vid crop --help\n" " vid rotate --help\n" " vid trim --help\n" " vid davinci-prep --help DNxHR HQX 4K transcode for DaVinci Resolve Free heavy \n" " vid davinci-prep-lite --help DNxHR LB 1080p transcode for Resolve disk-friendly \n" " vid youtube-prep --help H.265 .mp4 re-encode for YouTube upload\n" "\n" "Common flags most re-encoding commands accept these :\n" " --sample SECONDS render a short preview instead of the full video\n" " works with crop, rotate, resize, convert, audio,\n" " mute, speed, compress, davinci-prep,\n" " davinci-prep-lite, youtube-prep \n" " -q, --quality LABEL visual quality lossless | pristine | high | medium | low | tiny \n" " -c, --codec CODEC output codec auto | h264 | h265 . 'auto' matches the source.\n" " not used by davinci-prep or youtube-prep — codec is fixed \n" " --dry-run print the ffmpeg command without running it\n" "\n" "Most commands auto-name the output e.g. movie.mp4 → movie cropped.mp4 ." , p.add argument "--dry-run", action="store true", help="print the ffmpeg command without running it" p.add argument "--skip-space-check", action="store true", help="don't pre-check available disk space before encoding" p.add argument "-v", "--verbose", action="store true", help="show the full ffmpeg command and per-file progress bars. " "Without this flag, batch runs show one overall bar with a ✓ line " "as each file completes and ffmpeg warnings are suppressed ." sub = p.add subparsers dest="command", required=True, metavar="COMMAND" parent parser: adds --sample / --sample-start to commands that support previews sample parent = argparse.ArgumentParser add help=False sample parent.add argument "--sample", nargs="?", const=10.0, type=float, default=None, metavar="SECONDS", help="render a short preview clip instead of the full video default: 10 seconds . " "Output file gets a ' sample' suffix so previews don't clobber full renders.", sample parent.add argument "--sample-start", default="0", metavar="TIME", help="where in the source to start the sample default: 0 . " "Accepts seconds 30 or H:MM:SS 1:30, 00:01:30 .", parent parser: adds -q / --quality to commands that re-encode quality parent = argparse.ArgumentParser add help=False quality parent.add argument "-q", "--quality", default=None, choices=list QUALITY PRESETS.keys , help="visual quality preset. Lower = better quality but larger file. " "lossless = byte-perfect HUGE files ; pristine = near-lossless; " "high = excellent default for crop/rotate/resize ; " "medium = good default for compress ; low = noticeable softness; " "tiny = visible compression artifacts.", parent parser: adds -c / --codec to commands that re-encode codec parent = argparse.ArgumentParser add help=False codec parent.add argument "-c", "--codec", default="auto", choices= "auto", "h264", "h265" , help="video codec for the output. 'auto' default matches the source — " "if the source is H.265/HEVC the output will be H.265 too, otherwise H.264. " "Use 'h264' for maximum compatibility, 'h265' for ~30%% smaller files at " "the same quality slower to encode, less widely supported .", def add io sp, multi input=False : if multi input: sp.add argument "inputs", nargs="+", help="input video files 2 or more " else: sp.add argument "input", help="input video file" sp.add argument "-o", "--output", help="output file path. If omitted, a sensible name is chosen " "next to the input e.g. movie.mp4 → movie cropped.mp4 ." trim sp = sub.add parser "trim", help="cut a section out by time", formatter class=fmt, description="Keep a section of the video between two timestamps and throw the rest away.", epilog= "Examples:\n" " vid trim movie.mp4 -s 10 -e 30 keep seconds 10–30\n" " vid trim movie.mp4 -s 1:30 -d 15 keep 15 seconds starting at 1:30\n" " vid trim movie.mp4 -s 5 --fast quick cut without re-encoding\n" , add io sp sp.add argument "-s", "--start", help="when to start keeping. Accepts seconds 30 or H:MM:SS 1:30 ." sp.add argument "-e", "--end", help="when to stop keeping. Same time formats as --start." sp.add argument "-d", "--duration", help="how long to keep, instead of providing --end e.g. 15 = 15 seconds ." sp.add argument "--fast", action="store true", help="copy streams instead of re-encoding — much faster, but cuts can only " "land on keyframes so the start/end may be slightly off." sp.set defaults func=cmd trim crop sp = sub.add parser "crop", help="crop a rectangular region", parents= sample parent, quality parent, codec parent , formatter class=fmt, description= "Cut a rectangle out of every frame. You choose the size of the rectangle and where\n" "its top-left corner sits, measured in pixels from the top-left of the source frame.\n" "Coordinates: x grows to the right, y grows down 0,0 is the top-left corner ." , epilog= "Examples:\n" " vid crop movie.mp4 -W 1920 -H 1080 crop from top-left\n" " vid crop movie.mp4 -W 1920 -H 1080 -x 960 -y 540 center crop of 4K source\n" " vid crop movie.mp4 -W 1080 -H 1920 -x 1380 -y 120 --sample\n" " preview a vertical/portrait crop before encoding the whole video\n" " vid crop movie.mp4 -W 1920 -H 1080 -x 960 -y 540 -q pristine\n" " near-lossless re-encode, bigger file\n" "\n" "Tip: 'vid info movie.mp4' shows source resolution and codec. The output codec\n" "defaults to matching the source so HEVC stays HEVC — override with --codec." , add io sp sp.add argument "-W", "--width", type=int, required=True, help="width of the crop rectangle, in pixels." sp.add argument "-H", "--height", type=int, required=True, help="height of the crop rectangle, in pixels." sp.add argument "-x", type=int, default=0, help="pixels from the left edge of the source to the crop's left edge default: 0 ." sp.add argument "-y", type=int, default=0, help="pixels from the top edge of the source to the crop's top edge default: 0 ." sp.set defaults func=cmd crop rotate sp = sub.add parser "rotate", help="rotate or mirror-flip the video", parents= sample parent, quality parent, codec parent , formatter class=fmt, description= "Spin the picture by a quarter-turn, half-turn, or three-quarter turn, or flip\n" "it like a mirror. Useful for footage shot sideways on a phone, or to correct\n" "an upside-down camera mount." , epilog= "Examples:\n" " vid rotate movie.mp4 -d 90 quarter-turn clockwise right side becomes bottom \n" " vid rotate movie.mp4 -d -90 quarter-turn anti-clockwise left side becomes bottom \n" " vid rotate movie.mp4 -d 180 upside down\n" " vid rotate movie.mp4 --flip horizontal mirror left-right like looking in a mirror \n" " vid rotate movie.mp4 --flip vertical mirror top-bottom\n" " vid rotate movie.mp4 -d 90 -q pristine near-lossless re-encode bigger file \n" " vid rotate movie.mp4 -d 90 -c h264 force H.264 even if source is HEVC\n" "\n" "Note: --flip mirrors the image; -d rotates it. They do different things.\n" "Output codec matches the source by default use --codec to override ." , add io sp g = sp.add mutually exclusive group required=True g.add argument "-d", "--degrees", type=int, choices= 90, -90, 180, 270 , help="rotation amount: 90 = quarter-turn clockwise, -90 = quarter-turn anti-clockwise, " "180 = upside down, 270 = same as -90.", g.add argument "--flip", choices= "horizontal", "vertical" , help="mirror the image: 'horizontal' swaps left/right, 'vertical' swaps top/bottom.", sp.set defaults func=cmd rotate resize sp = sub.add parser "resize", help="make the video bigger or smaller", parents= sample parent, quality parent, codec parent , formatter class=fmt, description= "Change the pixel dimensions of the video. Give either a scale factor, a target\n" "width, a target height, or both. If you give only one of width/height, the other\n" "is computed automatically to preserve aspect ratio no stretching ." , epilog= "Examples:\n" " vid resize movie.mp4 -s 0.5 half-size in each direction quarter the pixels \n" " vid resize movie.mp4 -W 1280 1280 wide, height auto to preserve aspect\n" " vid resize movie.mp4 -H 720 720 tall, width auto\n" " vid resize movie.mp4 -W 1920 -H 1080 force exact dimensions may stretch \n" " vid resize movie.mp4 -H 1080 -q pristine near-lossless 1080p downscale\n" " vid resize movie.mp4 -s 0.5 -c h264 --sample preview a half-size H.264 copy\n" "\n" "Output codec matches the source by default use --codec to override ." , add io sp sp.add argument "-W", "--width", type=int, help="target width in pixels." sp.add argument "-H", "--height", type=int, help="target height in pixels." sp.add argument "-s", "--scale", type=float, help="multiplier applied to both dimensions. 0.5 = half size, 2.0 = double size." sp.set defaults func=cmd resize convert sp = sub.add parser "convert", help="change the file format mp4, webm, mkv, ... ", parents= sample parent , formatter class=fmt, description= "Convert the video from one file format to another. Useful when a platform or\n" "program only accepts certain formats e.g. webm for the web, mp4 for most editors ." , epilog= "Examples:\n" " vid convert movie.mov -t mp4 QuickTime → MP4\n" " vid convert movie.mp4 -t webm MP4 → WebM good for web pages \n" " vid convert movie.avi -t mkv AVI → Matroska\n" , add io sp sp.add argument "-t", "--to", required=True, help="target file extension without the dot. Common values: mp4, webm, mkv, mov, avi." sp.set defaults func=cmd convert audio extract sp = sub.add parser "audio", help="pull just the audio out of a video", parents= sample parent , formatter class=fmt, description= "Save the audio track of a video as an audio-only file. Handy for ripping\n" "music or dialogue, or for editing audio separately." , epilog= "Examples:\n" " vid audio movie.mp4 saves to movie audio.mp3\n" " vid audio movie.mp4 -f wav uncompressed WAV large but lossless \n" " vid audio movie.mp4 -f m4a -o song.m4a custom output name\n" , add io sp sp.add argument "-f", "--format", default="mp3", choices= "mp3", "wav", "aac", "m4a", "ogg" , help="audio format. mp3 = small + universal, wav = uncompressed, " "aac/m4a = better quality than mp3 at same size, ogg = open format. " "Default: mp3." sp.set defaults func=cmd audio mute sp = sub.add parser "mute", help="silence the video strip audio ", parents= sample parent , formatter class=fmt, description="Remove the audio track entirely. The video plays in complete silence.", epilog= "Examples:\n" " vid mute movie.mp4 saves to movie muted.mp4\n" " vid mute movie.mp4 -o silent.mp4 custom output name\n" , add io sp sp.set defaults func=cmd mute speed sp = sub.add parser "speed", help="speed up or slow down the video", parents= sample parent , formatter class=fmt, description= "Play back faster or slower. Both video and audio are adjusted together audio\n" "stays at the right pitch, not chipmunk'd . 2.0 makes a 60-second clip into a\n" "30-second one; 0.5 stretches it to 120 seconds." , epilog= "Examples:\n" " vid speed movie.mp4 -r 2.0 twice as fast\n" " vid speed movie.mp4 -r 0.5 half speed slow-mo \n" " vid speed movie.mp4 -r 1.25 subtle 25% speedup good for talks \n" , add io sp sp.add argument "-r", "--rate", type=float, required=True, help="how fast to play. 1.0 = normal, 2.0 = twice as fast, 0.5 = half speed. " "Values from 0.1 to 10 work fine." sp.set defaults func=cmd speed merge sp = sub.add parser "merge", help="join multiple clips into one", formatter class=fmt, description= "Stitch two or more videos together back-to-back into a single file. Clips are\n" "joined in the order you list them. Works best when the clips share the same\n" "resolution, frame rate, and codec — otherwise re-encode them with 'vid convert' first." , epilog= "Examples:\n" " vid merge intro.mp4 main.mp4 outro.mp4\n" " vid merge clip1.mp4 clip2.mp4 -o full video.mp4\n" , add io sp, multi input=True sp.set defaults func=cmd merge gif sp = sub.add parser "gif", help="turn a clip into an animated GIF", formatter class=fmt, description= "Convert a section of video into an animated GIF — the format used for memes,\n" "reactions, and tutorials embedded in docs. GIFs are silent and not great for\n" "long clips file size grows fast , so trim to a few seconds." , epilog= "Examples:\n" " vid gif movie.mp4 GIF of the whole clip may be huge \n" " vid gif movie.mp4 -s 10 -d 3 3-second GIF starting at 0:10\n" " vid gif movie.mp4 -s 5 -d 2 -W 320 --fps 10 smaller/lighter GIF\n" , add io sp sp.add argument "--fps", type=int, default=15, help="frames per second in the GIF. Lower = smaller file but choppier. " "Default: 15." sp.add argument "-W", "--width", type=int, default=480, help="GIF width in pixels height auto-fits aspect ratio . Default: 480." sp.add argument "-s", "--start", help="when to start in the source e.g. 10, 1:30 ." sp.add argument "-d", "--duration", help="how long the GIF should be in seconds ." sp.set defaults func=cmd gif info sp = sub.add parser "info", help="show details about a video size, length, resolution ", formatter class=fmt, description= "Print a readable summary of a video file: how long it is, how big the file is,\n" "what resolution it was shot at, what codec and audio format it uses." , epilog="Example:\n vid info movie.mp4\n", sp.add argument "input", help="video file to inspect." sp.set defaults func=cmd info compress sp = sub.add parser "compress", help="shrink a video's file size", parents= sample parent, quality parent, codec parent , formatter class=fmt, description= "Re-encode the video to make the file smaller. There's always a quality/size\n" "tradeoff: 'pristine'/'high' barely shrink, 'tiny' shrinks aggressively but\n" "looks noticeably worse. Default is 'medium'. Try --sample first to compare.\n\n" "By default the output codec matches the source HEVC stays HEVC . Use\n" "--codec h265 on an H.264 source to get ~30% smaller files for the same quality." , epilog= "Examples:\n" " vid compress movie.mp4 medium quality good balance \n" " vid compress movie.mp4 -q low smaller file, some quality loss\n" " vid compress movie.mp4 -q tiny --sample preview the worst case first\n" " vid compress movie.mp4 -c h265 H.265: ~30% smaller for same quality\n" , add io sp sp.set defaults func=cmd compress davinci-prep sp = sub.add parser "davinci-prep", help="transcode for DaVinci Resolve Free on Linux DNxHR HQX 10-bit MOV ", parents= sample parent , formatter class=fmt, description= "Convert footage into a format DaVinci Resolve 20 Free can actually play on Linux.\n" "\n" "Why this exists the hard-won findings :\n" " Resolve Free on Linux does NOT decode HEVC/H.265 at all — any profile,\n" " any container. So DJI HEVC clips show up black in the viewer.\n" " Resolve Free on Linux does NOT decode ffmpeg-produced H.264 in .mov\n" " either — even conservative 1080p/Main/L4.0/yuv420p settings. The clip\n" " silently imports as audio-only clef icon, no IO.Video log entries .\n" " Online advice claiming 'H.264 in .mov works' is wrong for this install.\n" " AAC audio in those .mov files caused IO.Audio decode errors. PCM works.\n" " DNxHR in .mov with PCM audio is the verified path. So that's what this\n" " command produces — and at the highest profile HQX, 10-bit 4:2:2 since\n" " you stated quality is the priority and disk space is your concern to\n" " manage, not the script's.\n" "\n" "Output specification all hardcoded — no quality/codec flags :\n" " video: DNxHR HQX 10-bit 4:2:2 , source resolution, source frame rate\n" " pixel: yuv422p10le required by the HQX profile \n" " audio: PCM s16le, 48 kHz, stereo\n" " streams: video + audio only DJI MP4s have 6 streams — data, tmcd, mjpeg\n" " thumbnail — all dropped via -dn and explicit -map \n" " container: .mov forced; warning if -o has a different extension \n" "\n" "IMPORTANT — verify the first sample in Resolve before running the full batch.\n" "DNxHR HQX has been verified to PLAY on this Resolve install only at 1080p LB\n" "profile . 4K UHD HQX has not yet been confirmed. Always do one --sample first,\n" "drop it on a Resolve timeline, and confirm the picture decodes. If it doesn't,\n" "stop and report back rather than burning hours on a doomed batch.\n" "\n" "Disk space: DNxHR HQX at 4K UHD 59.94 fps measures ~1.75 Gbps ~220 MB/s,\n" "~13 GB/min in practice. Avid's nominal '~880 Mbps' figure is for the lower-\n" "bitrate HQ profile, not HQX. The script estimates total output size up front\n" "and aborts if your output filesystem doesn't have room. Pass --skip-space-check\n" "to bypass.\n" "\n" "Inline transforms pipeline ops : write rotate , crop , or flip as ops\n" "directly inside the davinci-prep invocation. Each op takes the same flags as\n" "the standalone subcommand of that name rotate -d 180 , crop -W ... -H ... -x ... -y ... ,\n" " flip horizontal . Ops apply in the order written — so crop ... rotate ... \n" "crops first then rotates, while rotate ... crop ... rotates first then crops.\n" "All ops execute in the same ffmpeg pass — no intermediate file, no second encode.\n" "Crop dimensions are also used to recompute the disk-space estimate.\n" "\n" "Batch mode: pass any mix of files and directories. Directories are scanned for\n" ".mp4/.mov/.mkv. Existing davinci.mov outputs are skipped, so a re-run resumes.\n" "\n" "Cleanup before the real batch: any leftover test files from earlier libx264\n" "attempts davinci.mov, davinci faststart.mov, davinci pcm.mov, and the\n" "TEST .mov files in camera/ can be deleted — they don't work in Resolve and\n" "the script will skip them as 'already done' otherwise." , epilog= "Examples:\n" " vid davinci-prep clip.MP4 --sample 10s preview — TEST THIS FIRST\n" " vid davinci-prep clip.MP4 single full encode\n" " vid davinci-prep ~/Videos/DJI\\ Video/DJI 002 C01/ batch every .MP4 in folder\n" " vid davinci-prep file1.MP4 file2.MP4 some dir/ mix files and folders\n" " vid davinci-prep clip.MP4 --skip-space-check bypass disk-space check\n" "\n" "Inline transforms pipeline ops — each op uses the same flags as 'vid