Files
videobeaux/experimental/convertersink.py
2025-09-14 17:38:24 -04:00

316 lines
11 KiB
Python

#!/usr/bin/env python3
import argparse, os, shlex, subprocess, sys
from pathlib import Path
# ------------------------------
# Helpers
# ------------------------------
def run(cmd):
print(">>", " ".join(shlex.quote(c) for c in cmd))
proc = subprocess.run(cmd)
if proc.returncode != 0:
sys.exit(proc.returncode)
def ext_lower(path):
return Path(path).suffix.lower().lstrip(".")
def guess_container(output_path, fmt_override=None):
if fmt_override:
return fmt_override.lower()
ext = ext_lower(output_path)
# Map common aliases/extensions to FFmpeg muxer names
alias = {
"mp4":"mp4", "m4v":"mp4", "mov":"mov", "qt":"mov", "mkv":"matroska",
"webm":"webm", "avi":"avi", "wmv":"asf", "asf":"asf",
"flv":"flv", "ts":"mpegts", "m2ts":"mpegts", "mpeg":"mpeg", "mpg":"mpeg",
"mxf":"mxf", "wav":"wav", "mp3":"mp3", "m4a":"ipod", "aac":"adts", "ogg":"ogg",
"oga":"ogg", "opus":"ogg", "flac":"flac", "alac":"ipod",
"gif":"gif",
# image sequences
"jpg":"image2", "jpeg":"image2", "png":"image2", "tif":"image2", "tiff":"image2", "bmp":"image2", "exr":"image2"
}
return alias.get(ext, ext or "mp4")
def is_audio_only(fmt):
return fmt in {"mp3","ipod","adts","ogg","flac","wav"}
def default_codecs_for_container(mux):
"""
Conservative, broadly compatible defaults.
Overridden by profiles or explicit --vcodec/--acodec.
"""
if mux in ("mp4", "mov", "matroska"):
return ("libx264", "aac")
if mux == "webm":
return ("libvpx-vp9", "libopus")
if mux == "gif":
return (None, None) # handled via palette method
if mux in ("mp3","adts","ogg","flac","wav","ipod"):
return (None, None) # audio-only handled separately
if mux == "mxf":
# Default to XDCAM HD 50 unless profile overrides
return ("mpeg2video", "pcm_s16le")
if mux == "mpegts":
return ("libx264", "aac")
if mux == "avi":
# Legacy default: MPEG-4 ASP + MP3
return ("mpeg4", "mp3")
return ("libx264", "aac")
# ------------------------------
# Profiles (curated, battle-tested)
# ------------------------------
PROFILES = {
# Web/General delivery
"mp4_h264": lambda: [
"-c:v","libx264","-preset","veryfast","-crf","18",
"-pix_fmt","yuv420p",
"-movflags","+faststart",
"-c:a","aac","-b:a","192k","-ac","2"
],
"mp4_hevc": lambda: [
"-c:v","libx265","-preset","medium","-crf","22",
"-tag:v","hvc1",
"-pix_fmt","yuv420p",
"-movflags","+faststart",
"-c:a","aac","-b:a","192k","-ac","2"
],
"mp4_av1": lambda: [
"-c:v","libaom-av1","-crf","28","-b:v","0",
"-pix_fmt","yuv420p",
"-movflags","+faststart",
"-c:a","aac","-b:a","192k","-ac","2"
],
# WebM
"webm_vp9": lambda: [
"-c:v","libvpx-vp9","-b:v","0","-crf","30",
"-row-mt","1",
"-pix_fmt","yuv420p",
"-c:a","libopus","-b:a","160k","-ac","2"
],
"webm_av1": lambda: [
"-c:v","libaom-av1","-crf","32","-b:v","0",
"-pix_fmt","yuv420p",
"-c:a","libopus","-b:a","160k","-ac","2"
],
# Professional mezzanine
"prores_422": lambda: [
"-c:v","prores_ks","-profile:v","2",
"-pix_fmt","yuv422p10le",
"-c:a","pcm_s16le"
],
"prores_4444": lambda: [
"-c:v","prores_ks","-profile:v","4",
"-pix_fmt","yuva444p10le",
"-c:a","pcm_s24le"
],
"dnxhr_hq": lambda: [
"-c:v","dnxhd","-profile:v","dnxhr_hq",
"-pix_fmt","yuv422p",
"-c:a","pcm_s16le"
],
# Broadcast MXF (OP1a)
"mxf_xdcamhd50_1080i59": lambda: [
"-c:v","mpeg2video","-b:v","50M","-minrate","50M","-maxrate","50M","-bufsize","17825792",
"-r","30000/1001","-flags","+ildct+ilme","-top","1",
"-pix_fmt","yuv422p",
"-c:a","pcm_s24le","-ar","48000","-ac","2",
"-f","mxf"
],
# Archival lossless
"lossless_ffv1": lambda: [
"-c:v","ffv1","-level","3","-g","1","-slicecrc","1",
"-c:a","pcm_s24le"
],
# GIF
"gif": lambda: ["-filter_complex","[0:v]fps=15,scale=iw:-2:flags=lanczos"],
# Image sequences
"png_seq": lambda: ["-c:v","png"],
"jpg_seq": lambda: ["-qscale:v","2"],
# Audio-only
"mp3_320": lambda: ["-vn","-c:a","libmp3lame","-b:a","320k"],
"aac_192": lambda: ["-vn","-c:a","aac","-b:a","192k"],
"flac": lambda: ["-vn","-c:a","flac"],
# --- NEW: AVI speed-focused presets ---
# Very fast encode, big files; great for quick turnarounds or NLE ingest
"avi_mjpeg_fast": lambda: ["-c:v","mjpeg","-q:v","3","-c:a","pcm_s16le"],
# Legacy-compatible, faster-than-default MPEG-4 ASP
"avi_mpeg4_fast": lambda: ["-c:v","mpeg4","-qscale:v","3","-bf","0","-mbd","0","-c:a","mp3","-b:a","192k"]
}
# ------------------------------
# Command builder
# ------------------------------
def build_ffmpeg_command(args):
in_path = args.input
out_path = args.output
if not out_path:
base = str(Path(in_path).with_suffix(""))
ext = f".{args.format}" if args.format else ".mp4"
out_path = base + ext
mux = guess_container(out_path, args.format)
common = ["ffmpeg", "-hide_banner", "-y" if args.force else "-n", "-i", in_path]
# Stream copy?
if args.copy:
return common + ["-c","copy", out_path]
# Explicit codecs (can be overridden by profile if repeated later)
vcodec = args.vcodec
acodec = args.acodec
# Apply profile if chosen
profile_args = []
if args.profile:
prof = PROFILES.get(args.profile)
if not prof:
sys.exit(f"Unknown profile: {args.profile}")
profile_args = prof()
# If no explicit profile and no explicit codecs, choose container defaults
if not args.profile:
dv, da = default_codecs_for_container(mux)
if not vcodec and dv: vcodec = dv
if not acodec and da: acodec = da
cmd = list(common)
# Special path: GIF palette method
if mux == "gif" or args.profile == "gif":
vf = []
if args.vf: vf.append(args.vf)
else: vf.append("fps=15,scale=iw:-2:flags=lanczos")
palette_chain = f"[0:v]{','.join(vf)},palettegen[p];[0:v]{','.join(vf)}[v];[v][p]paletteuse"
gif_cmd = list(common) + ["-filter_complex", palette_chain, "-gifflags", "+transdiff", out_path]
return gif_cmd
# Video settings
if vcodec:
cmd += ["-c:v", vcodec]
if args.crf is not None:
cmd += ["-crf", str(args.crf)]
if args.bitrate:
cmd += ["-b:v", args.bitrate]
if args.maxrate:
cmd += ["-maxrate", args.maxrate]
if args.bufsize:
cmd += ["-bufsize", args.bufsize]
if args.preset:
cmd += ["-preset", args.preset]
if args.profile_v:
cmd += ["-profile:v", args.profile_v]
if args.level:
cmd += ["-level", args.level]
if args.pix_fmt:
cmd += ["-pix_fmt", args.pix_fmt]
if args.gop:
cmd += ["-g", str(args.gop)]
if args.r:
cmd += ["-r", args.r]
if args.vf:
cmd += ["-vf", args.vf]
if args.tagv:
cmd += ["-tag:v", args.tagv]
# Audio settings
if acodec:
cmd += ["-c:a", acodec]
if args.abitrate:
cmd += ["-b:a", args.abitrate]
if args.ac:
cmd += ["-ac", str(args.ac)]
if args.ar:
cmd += ["-ar", args.ar]
# Container niceties
if mux == "mp4" and "+faststart" not in " ".join(profile_args):
cmd += ["-movflags", "+faststart"]
# Append profile args last so they can set container-specific flags
if profile_args:
cmd += profile_args
# --- NEW: Sensible AVI defaults when user didn't specify quality flags or a profile ---
if mux == "avi" and not args.profile:
# If using MPEG-4 ASP and no quality flags were set, favor speed with decent visual quality
have_quality_flag = any(x in (args.crf, args.bitrate, args.maxrate, args.bufsize) for x in [args.crf, args.bitrate, args.maxrate, args.bufsize])
if (vcodec or "").lower() == "mpeg4" and not have_quality_flag:
# Avoid B-frames for speed; use qscale for constant quality
cmd += ["-qscale:v","3","-bf","0"]
# If user picked MJPEG explicitly without profile and no quality, set q:v
if (vcodec or "").lower() == "mjpeg" and not have_quality_flag:
cmd += ["-q:v","3"]
# Reasonable audio default if none provided
if not acodec:
cmd += ["-c:a","mp3","-b:a","192k"]
# Raw passthrough flags after '--'
if args.ffmpeg_args:
cmd += args.ffmpeg_args
cmd += [out_path]
return cmd
# ------------------------------
# CLI
# ------------------------------
def parse_args():
p = argparse.ArgumentParser(
description="Black-box FFmpeg converter: any format in → any format out.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
# Globals / required
p.add_argument("-i","--input", required=True, help="Input media file")
p.add_argument("-o","--output", help="Output path (extension chooses container unless --format)")
p.add_argument("-F","--force", action="store_true", help="Overwrite output file")
# Container/format control
p.add_argument("--format", help="Force container/muxer (e.g., mp4, mov, webm, matroska, mxf, gif, image2, avi)")
p.add_argument("--profile", choices=sorted(PROFILES.keys()), help="Apply a curated preset")
# Codecs & quality
p.add_argument("--vcodec", help="Video codec (e.g., libx264, libx265, libaom-av1, prores_ks, dnxhd, mpeg2video, mpeg4, mjpeg)")
p.add_argument("--acodec", help="Audio codec (e.g., aac, libopus, libmp3lame, mp3, pcm_s16le)")
p.add_argument("--crf", type=int, help="Constant Rate Factor (quality target for many codecs)")
p.add_argument("--bitrate", help="Video bitrate (e.g., 5M)")
p.add_argument("--maxrate", help="Video maxrate")
p.add_argument("--bufsize", help="Video VBV buffer size")
p.add_argument("--preset", help="Codec speed/efficiency preset (varies by codec)")
p.add_argument("--profile-v", dest="profile_v", help="Video codec profile (e.g., high, main, baseline; or prores profile index)")
p.add_argument("--level", help="Video level (e.g., 4.1)")
p.add_argument("--pix-fmt", dest="pix_fmt", help="Pixel format (e.g., yuv420p, yuv422p10le, yuva444p10le)")
p.add_argument("--gop", type=int, help="GOP/keyframe interval (frames)")
p.add_argument("-r", help="Output frame rate (e.g., 30000/1001, 25, 24)")
p.add_argument("--vf", help="Video filtergraph")
p.add_argument("--tagv", help="Force video fourcc/tag (e.g., hvc1)")
# Audio
p.add_argument("--abitrate", help="Audio bitrate (e.g., 192k)")
p.add_argument("--ac", type=int, help="Audio channels")
p.add_argument("--ar", help="Audio sample rate (e.g., 48000)")
# Stream copy
p.add_argument("--copy", action="store_true", help="Stream copy all streams when compatible (no re-encode)")
# Pass-through extra ffmpeg args after --
p.add_argument("ffmpeg_args", nargs=argparse.REMAINDER, help="Raw args after -- go straight to ffmpeg")
return p.parse_args()
def main():
args = parse_args()
cmd = build_ffmpeg_command(args)
run(cmd)
if __name__ == "__main__":
main()