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 <op>'):\n" " vid davinci-prep clip.MP4 rotate -d 180 # upside-down camera fix\n" " vid davinci-prep clip.MP4 rotate -d -90 # phone shot sideways\n" " vid davinci-prep clip.MP4 flip horizontal # mirror left-right\n" " vid davinci-prep clip.MP4 crop -W 3840 -H 1620 -y 270 # 2.39:1 letterbox crop\n" " vid davinci-prep clip.MP4 crop -W 1920 -H 1080 -x 960 -y 540 rotate -d 180\n" " # combine ops; order matters — here it crops, then rotates\n" " vid davinci-prep rotate -d 180 ~/Videos/DJI\\ Video/DJI_002_C01/\n" " # ops + inputs can mix in any order — this rotates every clip in the folder\n" "\n" "Workflow:\n" " 1. Run with --sample on one file.\n" " 2. Import the resulting *_sample.mov into Resolve, drop it on a timeline.\n" " 3. Confirm the picture decodes (not just audio). Scrub a few seconds.\n" " 4. If it plays, kick off the full batch. If not, stop and investigate.\n" "\n" "Note: --quality and --codec are deliberately NOT accepted. The codec, profile,\n" "pixel format, and audio format are all fixed to the verified-working set." ), ) sp.add_argument("inputs", nargs="+", help="video file(s) and/or director(ies) to transcode. " "Directories are scanned for .mp4/.mov/.mkv files (case-insensitive).") sp.add_argument("-o", "--output", help="output file path (single-input only). Extension is forced to .mov; " "a warning is printed if you supplied a different one.") sp.set_defaults(func=cmd_davinci_prep) # davinci-prep-lite sp = sub.add_parser( "davinci-prep-lite", help="lightweight Resolve Free transcode (DNxHR LB 8-bit 1080p MOV)", parents=[sample_parent], formatter_class=fmt, description=( "Lightweight transcode for DaVinci Resolve Free on Linux. Same compatibility\n" "story as 'vid davinci-prep', but tuned to use much less disk.\n" "\n" "Why this exists alongside davinci-prep:\n" " * davinci-prep produces DNxHR HQX 10-bit 4:2:2 at source resolution\n" " (~1.75 Gbps at 4K UHD 60p, ~13 GB/min). Right for color grading or for\n" " archive-quality intermediates. Wrong if your disk is full.\n" " * davinci-prep-lite produces DNxHR LB 8-bit 4:2:2 at 1920x1080. The picture\n" " is downscaled to 1080p and stored at ~90 Mbps (~700 MB/min). That's the\n" " verified-working format on this Resolve install (LB at 1080p plays cleanly).\n" " * Use lite for proxy-grade editing, casual project work, or when 4K HQX is\n" " way more file than the project calls for. Use davinci-prep when you need\n" " the headroom for grading.\n" "\n" "Why the format is locked (same hard-won findings as davinci-prep):\n" " * Resolve Free Linux does NOT decode HEVC at all.\n" " * Resolve Free Linux does NOT decode ffmpeg-produced H.264 in .mov either.\n" " * AAC in .mov caused IO.Audio decode errors. PCM s16le works.\n" " * DNxHR LB 8-bit 4:2:2 at 1080p with PCM s16le is the verified-working set.\n" "\n" "Output specification (all hardcoded — no quality/codec flags):\n" " video: DNxHR LB (8-bit 4:2:2), 1920x1080, source frame rate preserved\n" " pixel: yuv422p (LB profile is 8-bit; will error on 10-bit pix_fmt)\n" " audio: PCM s16le, 48 kHz, stereo\n" " streams: video + audio only (DJI MP4s' extra data/tmcd/mjpeg dropped)\n" " container: .mov (forced; warning if -o has a different extension)\n" "\n" "Disk space: DNxHR LB at 1080p 59.94 fps measures ~90 Mbps (~11 MB/s,\n" "~675 MB/min). A 72-minute batch lands in the ~50 GB neighborhood — small\n" "enough to keep alongside the originals. Estimate is computed up front;\n" "pass --skip-space-check to bypass.\n" "\n" "Inline transforms (pipeline ops — same syntax as davinci-prep): write\n" "`rotate`, `crop`, or `flip` as ops directly inside the invocation, in any\n" "order, in any position relative to the input paths. The filter chain is\n" "always [your ops] → scale=1920:1080, so the final output is 1080p no matter\n" "what. If you crop to a non-16:9 shape, the scale will stretch it — that's\n" "your call.\n" "\n" "Batch mode: pass any mix of files and directories. Directories are scanned\n" "for .mp4/.mov/.mkv. Existing _davinci_lite.mov outputs are skipped, so a\n" "re-run resumes. Outputs from other prep commands (_davinci, _youtube) are\n" "filtered out of directory scans to avoid double-processing." ), epilog=( "Examples:\n" " vid davinci-prep-lite clip.MP4 --sample # 10s preview — test in Resolve first\n" " vid davinci-prep-lite clip.MP4 # single full encode\n" " vid davinci-prep-lite ~/Videos/DJI\\ Video/DJI_002_C01/ # batch every .MP4 in folder\n" " vid davinci-prep-lite file1.MP4 file2.MP4 some_dir/ # mix files and folders\n" " vid davinci-prep-lite clip.MP4 --skip-space-check # bypass disk-space check\n" "\n" "Inline transforms (pipeline ops — each op uses the same flags as 'vid <op>'):\n" " vid davinci-prep-lite clip.MP4 rotate -d 180 # upside-down camera fix\n" " vid davinci-prep-lite clip.MP4 flip horizontal # mirror left-right\n" " vid davinci-prep-lite clip.MP4 crop -W 3840 -H 1620 -y 270 # 2.39:1 letterbox crop\n" " vid davinci-prep-lite rotate -d 180 ~/Videos/DJI\\ Video/DJI_002_C01/\n" " # ops + inputs can mix in any order — this rotates every clip in the folder\n" "\n" "Note: --quality and --codec are deliberately NOT accepted. The codec, profile,\n" "resolution, pixel format, and audio format are all fixed to the verified-working\n" "DNxHR LB set. If you need higher quality or 4K, use 'vid davinci-prep' instead." ), ) sp.add_argument("inputs", nargs="+", help="video file(s) and/or director(ies) to transcode. " "Directories are scanned for .mp4/.mov/.mkv files (case-insensitive).") sp.add_argument("-o", "--output", help="output file path (single-input only). Extension is forced to .mov; " "a warning is printed if you supplied a different one.") sp.set_defaults(func=cmd_davinci_prep_lite) # youtube-prep sp = sub.add_parser( "youtube-prep", help="re-encode for YouTube upload (H.265 .mp4 at high quality)", parents=[sample_parent], formatter_class=fmt, description=( "Re-encode footage into a small, high-quality file suitable for YouTube upload.\n" "\n" "Why this is separate from davinci-prep:\n" " * davinci-prep produces a DNxHR HQX editing intermediate (~1.75 Gbps for 4K\n" " 60p — huge). That's the right format for editing in Resolve, the wrong\n" " format for uploading.\n" " * YouTube re-encodes everything you upload to AV1/VP9/H.264 at their own\n" " bitrates regardless. Uploading 9 GB of DNxHR HQX gives them nothing extra.\n" " * This command produces a file in the same quality bracket as YouTube's\n" " recommended H.264 upload bitrates (~68 Mbps at 4K 60p SDR), but encoded\n" " with libx265 at CRF 22 — visually transparent for most content, ~70% smaller\n" " than that H.264 number, and well above the quality of YouTube's final\n" " encode (so there's no perceptual loss after their re-encode pass).\n" "\n" "Output specification (all hardcoded — no quality/codec flags):\n" " video: libx265 CRF 22, source resolution, source frame rate\n" " pixel: yuv420p (8-bit; YouTube's delivery is 8-bit, no point uploading more)\n" " audio: AAC 192 kbps\n" " streams: video + audio only (DJI MP4s' extra data/tmcd/mjpeg streams dropped)\n" " container: .mp4 with +faststart (moov atom up front for streaming)\n" "\n" "Disk space: typical output is ~30–50% of the source HEVC's size. Estimate is\n" "computed up front and the script aborts if your filesystem doesn't have room.\n" "Pass --skip-space-check to bypass.\n" "\n" "Inline transforms (pipeline ops): write `rotate`, `crop`, or `flip` as ops\n" "directly inside the youtube-prep invocation — same syntax as davinci-prep.\n" "Each op uses the standalone subcommand's flags (`rotate -d 180`, `crop -W ... -H ...`,\n" "`flip horizontal`). Ops apply in the order written. All in one ffmpeg pass.\n" "\n" "Batch mode: pass any mix of files and directories. Directories are scanned for\n" ".mp4/.mov/.mkv. Existing _youtube.mp4 outputs are skipped, so a re-run resumes." ), epilog=( "Examples:\n" " vid youtube-prep clip.MP4 --sample # 10s preview\n" " vid youtube-prep clip.MP4 # single file → clip_youtube.mp4\n" " vid youtube-prep ~/Videos/DJI\\ Video/DJI_002_C01/ # batch every .MP4 in folder\n" " vid youtube-prep file1.MP4 file2.MP4 some_dir/ # mix files and folders\n" " vid youtube-prep clip.MP4 --skip-space-check # bypass disk-space check\n" "\n" "Inline transforms (pipeline ops — each op uses the same flags as 'vid <op>'):\n" " vid youtube-prep clip.MP4 rotate -d 180 # upside-down camera fix\n" " vid youtube-prep clip.MP4 rotate -d -90 # phone shot sideways\n" " vid youtube-prep clip.MP4 flip horizontal # mirror left-right\n" " vid youtube-prep clip.MP4 crop -W 3840 -H 1620 -y 270 # 2.39:1 letterbox crop\n" " vid youtube-prep clip.MP4 crop -W 1920 -H 1080 -x 960 -y 540 rotate -d 180\n" " # combine ops; order matters — here it crops, then rotates\n" " vid youtube-prep rotate -d 180 ~/Videos/DJI\\ Video/DJI_002_C01/\n" " # ops + inputs can mix in any order — this rotates every clip in the folder\n" "\n" "Note: --quality and --codec are deliberately NOT accepted. The codec, CRF, pixel\n" "format, and audio format are fixed to a YouTube-friendly delivery target." ), ) sp.add_argument("inputs", nargs="+", help="video file(s) and/or director(ies) to transcode. " "Directories are scanned for .mp4/.mov/.mkv files (case-insensitive).") sp.add_argument("-o", "--output", help="output file path (single-input only). Extension is forced to .mp4; " "a warning is printed if you supplied a different one.") sp.set_defaults(func=cmd_youtube_prep) # thumbnail sp = sub.add_parser( "thumbnail", help="save a single frame as a JPEG image", formatter_class=fmt, description=( "Grab one frame from the video at the time you choose and save it as a still image.\n" "Useful for video thumbnails, poster images, or just a quick screenshot." ), epilog=( "Examples:\n" " vid thumbnail movie.mp4 # very first frame\n" " vid thumbnail movie.mp4 -t 30 # frame at 30 seconds\n" " vid thumbnail movie.mp4 -t 1:15 -o poster.jpg\n" ), ) add_io(sp) sp.add_argument("-t", "--time", default="0", help="timestamp of the frame to grab (e.g. 30, 1:30, 00:01:30). Default: 0 (first frame).") sp.set_defaults(func=cmd_thumbnail) return p def main(): check_ffmpeg() # Pre-process argv to extract pipeline ops (no-op when not invoking a prep command) pipeline_ops, processed_argv = _extract_pipeline_ops(sys.argv[1:]) parser = build_parser() args = parser.parse_args(processed_argv) args.ops = pipeline_ops if getattr(args, "command", None) in PIPELINE_COMMANDS else [] if not hasattr(args, "dry_run"): args.dry_run = False if not hasattr(args, "skip_space_check"): args.skip_space_check = False if not hasattr(args, "verbose"): args.verbose = False global _VERBOSE _VERBOSE = args.verbose try: rc = args.func(args) except KeyboardInterrupt: print("aborted by user", file=sys.stderr) sys.exit(130) sys.exit(rc or 0) if __name__ == "__main__": main()