Files
Server-scripts/ffmpeg_handling.py
T

871 lines
28 KiB
Python
Raw Normal View History

2026-04-14 23:56:03 +02:00
#!/home/lucas/.venv_general/bin/python3
"""
FFmpeg Python CLI Tool
A wrapper for ffmpeg-python to perform common media tasks.
Parameters are documented with their equivalent FFmpeg command-line flags.
"""
import argparse
import os
import sys
import json
import ffmpeg
import re
REQUIRES_OUTPUT = {
"convert-av",
"convert-audio",
"convert-video",
"remove-subs",
"remove-audio",
"downsize",
"filter-audio",
"cut",
"set-primary-audio",
}
# ---------- Utilities ---------- #
def run_ffmpeg(stream, overwrite=True):
"""
Run FFmpeg and stream output directly to terminal.
- No log buffering
- Real-time progress/errors visible
- Easier debugging than capture_stderr=True
"""
try:
ffmpeg.run(
stream,
overwrite_output=overwrite
)
except ffmpeg.Error as e:
print("FFmpeg failed.\n")
if e.stderr:
print(e.stderr.decode(errors="ignore"))
sys.exit(1)
def probe_audio_streams(file):
"""
Probes metadata to identify audio tracks.
FFmpeg Equivalent:
ffprobe -v error -select_streams a -show_entries stream=index:tags=language -of json
"""
try:
probe = ffmpeg.probe(file)
except ffmpeg.Error as e:
print("ffprobe failed:", e)
sys.exit(1)
return [
(s.get("index"), s.get("tags", {}).get("language", "unknown"))
for s in probe.get("streams", [])
if s.get("codec_type") == "audio"
]
def select_audio_stream(file, provided=None):
"""
Logical handler for stream selection. Ensures we target the correct
absolute stream index when building filter graphs.
"""
streams = probe_audio_streams(file)
if not streams:
print("No audio streams found.")
sys.exit(1)
if provided is not None:
if any(s[0] == provided for s in streams):
return provided
print(f"Provided index {provided} is not a valid audio stream.")
sys.exit(1)
if len(streams) == 1:
return streams[0][0]
print(f"Found {len(streams)} audio streams:")
for i, (idx, lang) in enumerate(streams):
print(f"[{i}] Index: {idx}, Language: {lang}")
try:
choice = int(input("Select stream number (the list index [x]): "))
return streams[choice][0]
except (ValueError, IndexError):
print("Invalid selection.")
sys.exit(1)
def parse_time_to_seconds(value: str) -> float:
"""
Convert human-readable time formats into seconds.
Supported formats:
- "100s" → 100
- "1m20s" → 80
- "2h10m5s" → 7805
- "0h1m5s" → 65
- "0:1:5" → 65 (hh:mm:ss or mm:ss)
"""
if value is None:
return None
value = str(value).strip()
# Case 1: pure seconds (e.g. 100s)
if value.endswith("s") and value[:-1].isdigit():
return float(value[:-1])
# Case 2: h/m/s format (e.g. 1h2m3s)
pattern = re.compile(
r"(?:(?P<h>\d+)h)?(?:(?P<m>\d+)m)?(?:(?P<s>\d+)s)?"
)
match = pattern.fullmatch(value)
if match and any(match.groups()):
h = int(match.group("h") or 0)
m = int(match.group("m") or 0)
s = int(match.group("s") or 0)
return h * 3600 + m * 60 + s
# Case 3: colon format (hh:mm:ss or mm:ss)
if ":" in value:
parts = list(map(int, value.split(":")))
if len(parts) == 2:
m, s = parts
return m * 60 + s
elif len(parts) == 3:
h, m, s = parts
return h * 3600 + m * 60 + s
# fallback: raw number
try:
return float(value)
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid time format: {value}")
def get_scodec(output_path: str):
ext = output_path.lower().split(".")[-1]
if ext == "mp4":
return "mov_text"
if ext == "mkv":
return "srt"
return "copy"
def get_vcodec(output_path: str):
ext = output_path.lower().split(".")[-1]
if ext == "mp4":
return "libx264"
return "copy"
# ---------- Commands ---------- #
def cmd_info(args):
"""
Retrieves and prints metadata.
FFmpeg Equivalent:
ffprobe -show_format -show_streams -of json [input]
"""
try:
info = ffmpeg.probe(args.input)
print(json.dumps(info, indent=2))
except ffmpeg.Error as e:
print("Error probing file:", e)
def cmd_convert_av(args):
"""
Re-encodes both video and audio.
FFmpeg Equivalent:
ffmpeg -i [input] -c:v [video_codec] -c:a [audio_codec] -preset [preset] -crf [quality] [output]
Parameters:
- vcodec/c:v: Sets the video encoder (e.g., libx264).
- acodec/c:a: Sets the audio encoder (e.g., aac).
- preset: Trade-off between compression speed and efficiency (ultrafast to veryslow).
- qp/crf: Constant Rate Factor. Lower is higher quality (18-28 is standard).
"""
inp = ffmpeg.input(args.input)
out = ffmpeg.output(
inp,
args.output,
vcodec=args.video_codec,
acodec=args.audio_codec,
scodec=get_scodec(args.output),
preset=args.preset,
qp=args.quality,
# This maps all streams from the first input once
map='0',
# Prevents FFmpeg from trying to guess/add extra default streams
map_metadata=0
)
return [out]
def cmd_convert_audio(args):
"""
Transcodes audio while keeping video intact.
FFmpeg Equivalent:
ffmpeg -i [input] -map 0:v -map 0:a -c:v copy -c:a [audio_codec] [output]
Parameters:
- vcodec='copy': Enables 'stream copy' mode, bypassing the video decoder/encoder.
"""
inp = ffmpeg.input(args.input)
out = ffmpeg.output(
inp,
args.output,
vcodec="copy",
acodec=args.audio_codec,
scodec=get_scodec(args.output),
map='0',
map_metadata=0
)
return [out]
def cmd_convert_video(args):
"""
Transcodes video while keeping audio intact.
FFmpeg Equivalent:
ffmpeg -i [input] -map 0:v -map 0:a -c:v [video_codec] -c:a copy [output]
"""
inp = ffmpeg.input(args.input)
out = ffmpeg.output(
inp,
args.output,
vcodec=args.video_codec,
acodec="copy",
scodec=get_scodec(args.output),
map='0',
map_metadata=0
)
return [out]
def cmd_remove_subs(args):
"""
Strips all subtitle data.
FFmpeg Equivalent:
ffmpeg -i [input] -sn -c copy [output]
Parameters:
- sn: Disables subtitle recording.
"""
inp = ffmpeg.input(args.input)
out = ffmpeg.output(
inp,
args.output,
sn=None,
c="copy",
map='0',
map_metadata=0
)
return [out]
def cmd_remove_audio_track(args):
"""
1. Removes a specific audio track from the video (Main Output).
2. Saves that specific audio track as a standalone MP3 (Extra Output).
"""
inp = ffmpeg.input(args.input)
probe = ffmpeg.probe(args.input)
remove_idx = select_audio_stream(args.input, args.audio_index)
streams_to_keep = []
extracted_audio = None
for s in probe["streams"]:
idx = s["index"]
if idx == remove_idx:
extracted_audio = inp[str(idx)]
else:
streams_to_keep.append(inp[str(idx)])
video_out = ffmpeg.output(
*streams_to_keep,
args.output,
vcodec="copy",
acodec="copy",
scodec= get_scodec(args.output),
)
outputs = [video_out]
if args.output_audio:
audio_out = ffmpeg.output(
extracted_audio,
args.output_audio,
acodec='libmp3lame',
audio_bitrate='192k'
)
outputs.append(audio_out)
return outputs
def cmd_downsize(args):
"""
Scales resolution and compresses.
FFmpeg Equivalent:
ffmpeg -i [input] -vf scale=[w]:[h] -c:v libx264 -crf [crf] -c:a copy [output]
Parameters:
- filter('scale'): Invokes the video filter (vf) for resizing.
- crf: Set to 23 (default) for balanced quality.
"""
inp = ffmpeg.input(args.input)
video = inp.video.filter("scale", args.width, args.height)
info = ffmpeg.probe(args.input)
streams = info.get("streams", [])
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
subs = {}
if len(subtitle_streams) > 0:
subs = {inp["s"]}
out = ffmpeg.output(
video,
inp.audio,
*subs,
args.output,
vcodec="libx264",
crf=args.crf,
acodec="copy",
scodec=get_scodec(args.output)
)
return [out]
def cmd_filter_audio(args):
"""
Multi-stage audio mastering chain.
FFmpeg Equivalent:
ffmpeg -i [input] -filter_complex "[0:a:X]highpass...,acompressor...,equalizer...[out]" \
-map 0:v -map [out] -c:v copy -c:a libvorbis [output]
Filter Descriptions:
1. highpass: Cuts frequencies below 100Hz (removes low-end rumble).
2. acompressor: Reduces volume of loud peaks to increase overall clarity.
3. equalizer: Adjusts specific frequency bands (2.5kHz and 4kHz for presence).
4. deesser: Dampens harsh 'S' sounds.
5. alimiter: Hard limit at 0.95 to prevent digital clipping (distortion).
6. dynaudnorm: Smooths out loudness levels over time.
"""
idx_filt = select_audio_stream(args.input, args.audio_index)
inp = ffmpeg.input(args.input)
probe = ffmpeg.probe(args.input)
audio_streams = [
s["index"]
for s in probe["streams"]
if s.get("codec_type") == "audio"
]
filtered_audios = []
for idx in audio_streams:
audio = inp[str(idx)]
if idx == idx_filt:
audio_filtered = (
audio
.filter("highpass", f=100)
.filter("acompressor", threshold="-22dB", ratio=4, attack=10, release=150)
.filter("equalizer", f=2500, t="q", w=1, g=5)
.filter("equalizer", f=4000, t="q", w=1, g=4)
.filter("deesser", i=0.4, f=0.25)
.filter("alimiter", limit=0.95)
.filter("dynaudnorm", f=200, g=15)
)
filtered_audios.append(audio_filtered)
else:
filtered_audios.append(audio)
info = ffmpeg.probe(args.input)
streams = info.get("streams", [])
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
subs = {}
if len(subtitle_streams) > 0:
subs = {inp["s"]}
out = ffmpeg.output(
inp.video,
*filtered_audios,
*subs,
args.output,
vcodec="copy",
acodec="libvorbis",
scodec=get_scodec(args.output),
)
return [out]
def cmd_cut_segment(args):
"""
Cut a segment from a video or audio file.
FFmpeg parameters:
ss (start time): where to begin cutting (seconds or timestamp)
t (duration): length of segment to extract
c=copy: stream copy (fast, no re-encoding)
Notes:
- Using ss before input enables fast seek
- Copy mode is fast but may be less frame-accurate
"""
start = parse_time_to_seconds(args.start)
duration = parse_time_to_seconds(args.duration)
inp = ffmpeg.input(args.input)
out = ffmpeg.output(
inp,
args.output,
ss=start,
t=duration,
vcodec=get_vcodec(args.output),
acodec="copy",
scodec=get_scodec(args.output),
map="0"
)
return [out]
def cmd_set_primary_audio(args):
"""
Set a chosen audio stream as the PRIMARY (first/default) audio track.
Uses select_audio_stream() to ensure correct stream selection.
FFmpeg behavior:
- We explicitly reorder audio streams
- Selected stream becomes first audio track
- All other audio streams are preserved
- Video and subtitles are kept
Important:
FFmpeg does NOT support "set default audio track" directly in MP4.
So we simulate it by stream reordering.
"""
inp = ffmpeg.input(args.input)
primary_audio = select_audio_stream(args.input, args.audio_index)
probe = ffmpeg.probe(args.input)
audio_streams = [
s["index"]
for s in probe["streams"]
if s.get("codec_type") == "audio"
]
reordered_audio = [primary_audio] + [
a for a in audio_streams if a != primary_audio
]
audio_inputs = [inp[f"a:{i - 1}"] for i in reordered_audio]
out_kwargs={}
for i, stream in enumerate(reordered_audio):
if stream == primary_audio:
out_kwargs[f"disposition:a:{i}"] = "default"
else:
out_kwargs[f"disposition:a:{i}"] = "0"
info = ffmpeg.probe(args.input)
streams = info.get("streams", [])
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
subs = {}
if len(subtitle_streams) > 0:
subs = {inp["s"]}
out = ffmpeg.output(
inp.video,
*audio_inputs,
*subs,
args.output,
vcodec="copy",
acodec="copy",
scodec=get_scodec(args.output),
**out_kwargs
)
return [out]
def cmd_test(args):
"""
TEST MODE:
Runs each command individually on test.mp4 and probes outputs.
"""
script_dir = os.path.dirname(os.path.abspath(__file__))
test_files = [ "test_auto.mkv", "test_auto.mp4","test_auto_no_subs.mkv", "test_auto_no_subs.mp4"]
test_files = [ os.path.join(script_dir,file) for file in test_files ]
test_dir = "/tmp/"
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
RESET = "\033[0m"
def red_tag(tag: str) -> str:
return f"{RED}[{tag}]{RESET}"
def green_tag(tag: str) -> str:
return f"{GREEN}[{tag}]{RESET}"
def run_and_probe(name, cmd_func, args, test_dict: list=[]):
print(f"TEST: {name}")
# Run FFmpeg for this single command only
outputs = cmd_func(args)
if not outputs:
print("No outputs returned.")
return
try:
ffmpeg.run(
outputs,
overwrite_output=True,
quiet=True,
capture_stderr=True
)
except ffmpeg.Error as e:
print("FFmpeg failed:")
if e.stderr:
print(e.stderr.decode(errors="ignore"))
return
# Probe result file(s)
out_files = []
if hasattr(args, "output") and args.output:
out_files.append(args.output)
if hasattr(args, "output_audio") and args.output_audio:
out_files.append(args.output_audio)
for i, f in enumerate(out_files):
if not os.path.exists(f):
print(f"{red_tag("FAIL")} Missing output: {f}")
continue
try:
validate_ffprobe(f,test_dict[i])
except ffmpeg.Error as e:
print(f"{red_tag("FAIL")} Probe error: {f}")
print(e)
def get_streams(test_file):
info = ffmpeg.probe(test_file)
streams = info.get("streams", [])
video_streams = [s for s in streams if s.get("codec_type") == "video"]
audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
if not os.path.exists(test_file):
print("test file not found in script directory.")
sys.exit(1)
return video_streams,audio_streams,subtitle_streams
def validate_ffprobe(file_path, expected: dict):
"""
Validate ffprobe output using a dictionary-based expectation.
Expected dict format:
{
"video_count": int,
"audio_count": int,
"subtitle_count": int,
"audio_codecs": list[str],
"video_codecs": list[str]
}
"""
info = ffmpeg.probe(file_path)
streams = info.get("streams", [])
video_streams = [s for s in streams if s.get("codec_type") == "video"]
audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
subtitle_streams = [s for s in streams if s.get("codec_type") == "subtitle"]
# ---------- COUNT CHECKS ----------
if "video_count" in expected:
if len(video_streams) != expected["video_count"]:
print(f"{red_tag("VIDEO COUNT FAIL")} expected {expected['video_count']}, got {len(video_streams)}")
if "audio_count" in expected:
if len(audio_streams) != expected["audio_count"]:
print(f"{red_tag("AUDIO COUNT FAIL")} expected {expected['audio_count']}, got {len(audio_streams)}")
if "subtitle_count" in expected:
if len(subtitle_streams) != expected["subtitle_count"]:
print(f"{red_tag("SUBTITLE COUNT FAIL")} expected {expected['subtitle_count']}, got {len(subtitle_streams)}")
# ---------- CODEC CHECKS ----------
if "audio_codecs" in expected:
actual = sorted([s.get("codec_name") for s in audio_streams])
expected_codecs = sorted(expected["audio_codecs"])
if len(expected_codecs) < len(actual):
last = expected_codecs[-1] if expected_codecs else None
expected_codecs = expected_codecs + [last] * (len(actual) - len(expected_codecs))
if actual != expected_codecs:
print(f"{red_tag("AUDIO CODEC FAIL")}\nExpected: {expected_codecs}\nGot: {actual}")
if "video_codecs" in expected:
actual = sorted([s.get("codec_name") for s in video_streams])
expected_codecs = sorted(expected["video_codecs"])
if len(expected_codecs) < len(actual):
last = expected_codecs[-1] if expected_codecs else None
expected_codecs = expected_codecs + [last] * (len(actual) - len(expected_codecs))
if actual != expected_codecs:
print(f"{red_tag("VIDEO CODEC FAIL")}\nExpected: {expected_codecs}\nGot: {actual}")
# ---------- DURATION CHECKS ----------
if "duration" in expected:
format_info = info.get("format", {})
actual_duration = float(format_info.get("duration", 0))
expected_duration = parse_time_to_seconds(expected["duration"])
# allow small ffmpeg timing variance (important!)
tolerance = expected.get("duration_tolerance", 0.2)
if abs(actual_duration - expected_duration) <= tolerance:
print(f"{red_tag("DURATION FAIL")} expected ~{expected_duration} (±{tolerance}), got {actual_duration}s")
print(f"{green_tag("PASS")} {file_path}")
return True
class Args:
pass
# ---------- INFO ----------
#args = Args()
#args.input = test_file_mp4
#test_dict = {}
#run_and_probe("info", cmd_info, args, test_dict)
#args.input = test_file_mkv
#run_and_probe("info", cmd_info, args, test_dict)
# ---------- DOWNSIZE ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
args.output = os.path.join(test_dir, "out_downsize" + os.path.splitext(test_file)[1])
args.width = 640
args.height = 360
args.crf = 28
test_dict = [{"video_count": len(video_streams), "audio_count": len(audio_streams),
"subtitle_count": len(subtitle_streams), "video_codecs": ["h264"]}]
run_and_probe("downsize", cmd_downsize, args, test_dict)
# ---------- REMOVE AUDIO TRACK ----------
args = Args()
args.audio_index = 1
for test_file in test_files:
video_streams,audio_streams,subtitle_streams = get_streams(test_file)
args.input = test_file
test_dict = [{"video_count": len(video_streams), "audio_count": 1, "subtitle_count": len(subtitle_streams)},
{"video_count": 0, "audio_count": 1, "subtitle_count": 0}]
args.output = os.path.join(test_dir, "out_remove_audio" + os.path.splitext(test_file)[1])
args.output_audio = os.path.join(test_dir, "extracted_audio_" + os.path.splitext(test_file)[1][1:] + ".mp3")
run_and_probe("remove-audio-track", cmd_remove_audio_track, args, test_dict)
# ---------- CONVERT AV ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
args.output = os.path.join(test_dir, "out_convert_av" + os.path.splitext(test_file)[1])
args.video_codec = "libx264"
args.audio_codec = "aac"
args.preset = "fast"
args.quality = 23
test_dict = [ {"video_count": len(video_streams),"audio_count": len(audio_streams),
"subtitle_count": len(subtitle_streams), "video_codecs": ["h264"],"audio_codecs": ["aac"]}]
run_and_probe("convert-av", cmd_convert_av, args, test_dict)
# ---------- CONVERT AUDIO ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
args.output = os.path.join(test_dir, "out_convert_audio" + os.path.splitext(test_file)[1])
args.audio_codec = "aac"
test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams),
"subtitle_count": len(subtitle_streams), "audio_codecs": ["aac"]}]
run_and_probe("convert-audio", cmd_convert_audio, args, test_dict)
# ---------- CONVERT VIDEO ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
args.output = os.path.join(test_dir, "out_convert_video" + os.path.splitext(test_file)[1])
args.video_codec = "libx264"
test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams),
"subtitle_count": len(subtitle_streams), "video_codecs": ["h264"]}]
run_and_probe("convert-video", cmd_convert_video, args, test_dict)
# ---------- REMOVE SUBS ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams), "subtitle_count": 0}]
args.output = os.path.join(test_dir, "out_no_subs" + os.path.splitext(test_file)[1])
run_and_probe("remove-subs", cmd_remove_subs, args, test_dict)
# ---------- FILTER AUDIO ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
args.output = os.path.join(test_dir, "out_filter_audio" + os.path.splitext(test_file)[1])
args.audio_index = 1
test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams),
"subtitle_count": len(subtitle_streams), "audio_codecs": ["vorbis"]}]
run_and_probe("filter-audio", cmd_filter_audio, args, test_dict)
# ---------- CUT ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
args.output = os.path.join(test_dir, "out_cut" + os.path.splitext(test_file)[1])
args.start = "0:0:5"
args.duration = "10s"
test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams),
"subtitle_count": len(subtitle_streams), "duration": "10s"}]
run_and_probe("cut", cmd_cut_segment, args, test_dict)
# ---------- SET PRIMARY AUDIO ----------
args = Args()
for test_file in test_files:
video_streams, audio_streams, subtitle_streams = get_streams(test_file)
args.input = test_file
args.output = os.path.join(test_dir, "out_primary_audio" + os.path.splitext(test_file)[1])
args.audio_index = 2
test_dict = [ {"video_count": len(video_streams), "audio_count": len(audio_streams),
"subtitle_count": len(subtitle_streams)}]
run_and_probe("set-primary-audio", cmd_set_primary_audio, args, test_dict)
print("\nALL TESTS COMPLETED")
# ---------- CLI Setup ---------- #
def main():
parser = argparse.ArgumentParser(
description="Media processing tool using ffmpeg-python",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
# Base parser shared across all subcommands
base_p = argparse.ArgumentParser(add_help=False)
base_p.add_argument("input", help="Path to input media file")
base_p.add_argument("-o", "--output", help="Path for output file")
sub = parser.add_subparsers(dest="command", required=True)
# Info Command
p_info = sub.add_parser("info", parents=[base_p], help="Inspect media metadata")
p_info.set_defaults(func=cmd_info)
# Info Command
p_info = sub.add_parser("test", help="Inspect media metadata")
p_info.set_defaults(func=cmd_test)
# Convert AV Command
p_av = sub.add_parser("convert-av", parents=[base_p], help="Full re-encode of video/audio")
p_av.add_argument("--video-codec", default="libx264", help="FFmpeg -c:v flag")
p_av.add_argument("--audio-codec", default="aac", help="FFmpeg -c:a flag")
p_av.add_argument("--preset", default="medium", help="FFmpeg -preset flag")
p_av.add_argument("--quality", type=int, default=23, help="FFmpeg -crf value")
p_av.set_defaults(func=cmd_convert_av)
# Convert Audio Command
p_a = sub.add_parser("convert-audio", parents=[base_p], help="Change audio format only")
p_a.add_argument("--audio-codec", default="mp3", help="FFmpeg -c:a flag")
p_a.set_defaults(func=cmd_convert_audio)
# Convert Video Command
p_v = sub.add_parser("convert-video", parents=[base_p], help="Change video format only")
p_v.add_argument("--video-codec", default="libx264", help="FFmpeg -c:v flag")
p_v.set_defaults(func=cmd_convert_video)
# Remove Subs Command
p_rs = sub.add_parser("remove-subs", parents=[base_p], help="Strip all subtitle streams (-sn)")
p_rs.set_defaults(func=cmd_remove_subs)
# Remove Audio Command
p_ra = sub.add_parser("remove-audio", parents=[base_p], help="Remove one audio track via map exclusion")
p_ra.add_argument("-oa", "--output-audio", required=False)
p_ra.add_argument("-a", "--audio-index", type=int, help="Track index within audio streams")
p_ra.set_defaults(func=cmd_remove_audio_track)
# Downsize Command
p_ds = sub.add_parser("downsize", parents=[base_p], help="Scale video dimensions (-vf scale)")
p_ds.add_argument("--width", type=int, default=1280)
p_ds.add_argument("--height", type=int, default=720)
p_ds.add_argument("--crf", type=int, default=23)
p_ds.set_defaults(func=cmd_downsize)
# Filter Audio Command
p_fa = sub.add_parser("filter-audio", parents=[base_p], help="Apply complex audio enhancement chain")
p_fa.add_argument("-a", "--audio-index", type=int, help="Index of audio stream to filter (e.g. 0, 1, 2...)")
p_fa.set_defaults(func=cmd_filter_audio)
# Cut Video Segment
p_c = sub.add_parser("cut", parents=[base_p], help="Cut X sec out of the video stream")
p_c.add_argument("--start",required=True,help="Start time (e.g. 10, 10s, 1m5s, 0:1:5)")
p_c.add_argument("--duration",required=True,help="Duration (e.g. 30, 30s, 1m5s, 0:1:5)")
p_c.set_defaults(func=cmd_cut_segment)
# Set audio track as primary
p_pa = sub.add_parser("set-primary-audio", parents=[base_p], help="Set audiotrack X as primary track")
p_pa.add_argument("-a", "--audio-index",type=int,help="Index of audio stream to make primary (e.g. 0, 1, 2...)")
p_pa.set_defaults(func=cmd_set_primary_audio)
# Switch primary audio
args = parser.parse_args()
if not hasattr(args, "func"):
parser.error("No command function bound.")
# enforce output only for convert-av
if args.command in REQUIRES_OUTPUT and not args.output:
parser.error("--output is required for this command")
# Default output path generation logic
outputs = args.func(args)
if not isinstance(outputs, list):
outputs = [outputs]
if args.command != "test":
try:
ffmpeg.run(
ffmpeg.merge_outputs(*outputs),
overwrite_output=True
)
except ffmpeg.Error as e:
print("FFmpeg failed.\n")
if e.stderr:
print(e.stderr.decode(errors="ignore"))
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()