#!/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
_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."""
for i, arg in enumerate(cmd[:-1]):
if arg == "-t":
d = _to_seconds(cmd[i + 1])
if d:
return d
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}"
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
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
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
}
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"]
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)
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 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
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."
)
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}"
_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)
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
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
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):
self._render(final=True)
if sys.stdout.isatty():
sys.stdout.write("\n")
sys.stdout.flush()
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
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):
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 "")
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:
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))
rate = args.rate
video_filter = f"setpts={1/rate:.6f}*PTS"
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")
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,
]
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_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
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)
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 = [] # (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))
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,
)
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)
if _VERBOSE and skipped:
for src, out in skipped:
print(f"[skip] {src} (output exists: {out})")
batch = None
if quiet and sys.stdout.isatty() and (plan or skipped) and not args.dry_run:
batch_files = []
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)
for i in range(n_skipped):
batch.status[i] = 2
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)
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 = []
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))
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,
)
def build_cmd(src, out, user_vf):
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 = []
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,
)
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,
],
)
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")
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).",
)
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.",
)
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).")
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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 up.\n"
" * YouTube re-encodes everything you upload to AV1/VP9/H.264 at their own\n"
" bitrates regardless. Up 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 up 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)
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()
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()
source & further reading
gist.github.com β original article
Disable Codex SQLite diagnostic logging on Windows
AI Penetration Tester (Code Behind It)
Gpt