292 lines
14 KiB
Python
Executable File
292 lines
14 KiB
Python
Executable File
"""Codec / audio / container profile tables and helpers.
|
||
|
||
Data-driven configuration that replaces the v1 if/else codec chains.
|
||
Pure data + pure functions — no PySide6, no I/O, no internal package
|
||
dependencies. Safe to import from any context (incl. unit tests and
|
||
the CLI --version path).
|
||
"""
|
||
|
||
from collections.abc import Callable
|
||
from dataclasses import dataclass
|
||
|
||
# ──────────────────────────────────────────────
|
||
# CONFIG-DRIVEN PROFILES (replaces all if/else chains)
|
||
# ──────────────────────────────────────────────
|
||
|
||
@dataclass
|
||
class VideoCodecProfile:
|
||
label: str # Display name in combo box
|
||
av1an_encoder: str # Encoder name passed to --encoder
|
||
ffmpeg_encoder: str # Encoder name for pure-ffmpeg fallback (e.g. "libsvtav1")
|
||
container: str # Default container extension (mkv or webm)
|
||
crf_range: tuple[int, int] # (min, max) valid CRF values
|
||
default_crf: int
|
||
# (crf, preset) -> av1an --video-params string. Passed to SvtAv1EncApp /
|
||
# vpxenc / x265 as a CLI invocation, so ONLY CLI-accepted flags may
|
||
# appear here. v4.1.0–v4.1.1 appended `--threads N` here, which
|
||
# SvtAv1EncApp rejects with "Unprocessed tokens: --threads" → every
|
||
# chunk fails 3x → no av1an output. Thread capping now lives in
|
||
# ffmpeg_vargs_fn (where libsvtav1 is invoked as a library and
|
||
# accepts -threads) and in EncoderWorker's --workers count (av1an's
|
||
# own chunk-parallel knob).
|
||
params_fn: Callable[[int, int], str]
|
||
ffmpeg_vargs_fn: Callable[[int, int], list[str]] # (crf, preset) -> ffmpeg -c:v args
|
||
presets: list[str] # Human-readable preset labels
|
||
preset_map: dict[str, int] # label -> internal preset value
|
||
# v4.3.0: the codec_name ffprobe returns for files encoded with this
|
||
# profile. Used by _output_already_encoded() to detect skip-existing.
|
||
# av1 → "av1", vp9 → "vp9", hevc → "hevc". Verified against ffprobe
|
||
# output for each encoder; this is the codec_name field in the video
|
||
# stream's JSON, NOT the encoder_name (which would be "libsvtav1" etc).
|
||
ffprobe_codec_name: str = ""
|
||
|
||
@dataclass
|
||
class AudioProfile:
|
||
label: str
|
||
params: list[str] # Tokens passed to --audio-params (joined with space)
|
||
# v3 (OTC-012, SEI CERT STR09-C): the ffmpeg audio encoder name this
|
||
# profile depends on, e.g. "libopus", "libvorbis", "flac", "libiamf".
|
||
# Used by _check_combo_compatibility and _disable_unavailable_codecs
|
||
# to look up the encoder directly in EnvProbe.ffmpeg_libs — replacing
|
||
# the v2 substring match (`"libiamf" in ap.params`) which would
|
||
# falsely match a hypothetical `-libiamf-mode` argument.
|
||
# Empty string means "no ffmpeg encoder dependency" (rare; only used
|
||
# by passthrough profiles that don't transcode audio).
|
||
ffmpeg_encoder_name: str = ""
|
||
# v4.3.0: the codec_name ffprobe returns for files encoded with this
|
||
# profile. Used by _output_already_encoded() to detect skip-existing.
|
||
# opus → "opus", vorbis → "vorbis", flac → "flac", iamf → "iamf".
|
||
ffprobe_codec_name: str = ""
|
||
|
||
@dataclass
|
||
class ContainerProfile:
|
||
label: str
|
||
ext: str # e.g. "mkv", "webm"
|
||
|
||
|
||
def _av1_params(crf: int, preset: int) -> str:
|
||
"""SVT-AV1 encoder params for av1an's --video-params.
|
||
|
||
av1an splits the --video-params value by whitespace (``split_whitespace()``)
|
||
and passes each resulting token as a separate argument to SvtAv1EncApp.
|
||
Therefore the string must contain space-separated ``--flag value`` pairs
|
||
that SvtAv1EncApp can parse natively.
|
||
|
||
Colon-separated ``key=value:key=value`` does NOT work because there are
|
||
no whitespace boundaries for av1an to split on — the entire string reaches
|
||
SvtAv1EncApp as one opaque argument, producing:
|
||
``Maybe missing spacing between tokens``.
|
||
|
||
v4.1.0–v4.1.1 appended `--threads N` here. SVT-AV1's standalone CLI
|
||
(SvtAv1EncApp) does NOT accept `--threads` — it uses `--lp N`
|
||
(logical processors) instead. The result was "Unprocessed tokens:
|
||
--threads" → every chunk failed 3x → no av1an output → ffmpeg fallback
|
||
→ looked "borked". v4.1.2 reverts this; thread capping is now done
|
||
via av1an's `--workers` flag (chunk-parallel) and via `-threads` in
|
||
the ffmpeg fallback path (where libsvtav1 is a library and accepts it).
|
||
"""
|
||
return f"--preset {preset} --crf {crf} --keyint 240"
|
||
|
||
|
||
def _vp9_params(crf: int, preset: int) -> str:
|
||
"""VP9 encoder params for av1an's --video-params.
|
||
|
||
av1an splits by whitespace, so we use space-separated --flag=value tokens
|
||
that vpxenc parses natively.
|
||
"""
|
||
cpu_used = max(0, 8 - preset)
|
||
return f"--end-usage=q --cq-level={crf} --cpu-used={cpu_used}"
|
||
|
||
|
||
def _x265_params(crf: int, preset: int) -> str:
|
||
"""x265 encoder params for av1an's --video-params.
|
||
|
||
av1an splits by whitespace, so we use space-separated --flag value tokens
|
||
that x265 parses natively.
|
||
"""
|
||
return f"--crf {crf} --preset {preset}"
|
||
|
||
def _svtav1_ffmpeg_args(crf: int, preset: int) -> list[str]:
|
||
"""FFmpeg args for SVT-AV1 (maps av1an preset=0..8 → svtav1 -preset 0..13)."""
|
||
# av1an preset range 0-8 maps to SVT-AV1 preset range 0-13
|
||
# Scale roughly: 8→0, 6→4, 4→7, 2→10
|
||
svt_preset = max(0, min(13, round((8 - preset) * 13 / 8)))
|
||
return ["-c:v", "libsvtav1", "-preset", str(svt_preset), "-crf", str(crf),
|
||
"-pix_fmt", "yuv420p10le", "-g", "240"]
|
||
|
||
def _vp9_ffmpeg_args(crf: int, preset: int) -> list[str]:
|
||
"""FFmpeg args for VP9 (maps av1an cpu-used 0..8 → -cpu-used 0..8)."""
|
||
cpu_used = max(0, min(8, preset))
|
||
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0",
|
||
"-cpu-used", str(cpu_used), "-pix_fmt", "yuv420p", "-g", "240",
|
||
"-row-mt", "1", "-tiles", "2x2"]
|
||
|
||
def _x265_ffmpeg_args(crf: int, preset: int) -> list[str]:
|
||
"""FFmpeg args for x265 (maps av1an preset 5..10 → x265 -preset)."""
|
||
# av1an x265 preset range 5-10 maps to x265 preset names
|
||
preset_names = {5: "slow", 7: "medium", 9: "fast", 10: "faster"}
|
||
p = preset_names.get(preset, "medium")
|
||
return ["-c:v", "libx265", "-preset", p, "-crf", str(crf),
|
||
"-pix_fmt", "yuv420p10le", "-g", "240"]
|
||
|
||
|
||
VIDEO_CODECS: list[VideoCodecProfile] = [
|
||
VideoCodecProfile(
|
||
label="AV1 (SVT-AV1)",
|
||
av1an_encoder="svt_av1",
|
||
ffmpeg_encoder="libsvtav1",
|
||
container="mkv",
|
||
crf_range=(18, 52),
|
||
default_crf=32,
|
||
params_fn=_av1_params,
|
||
ffmpeg_vargs_fn=_svtav1_ffmpeg_args,
|
||
presets=["Slow (8)", "Medium (6)", "Fast (4)", "Faster (2)"],
|
||
preset_map={"Slow (8)": 8, "Medium (6)": 6, "Fast (4)": 4, "Faster (2)": 2},
|
||
ffprobe_codec_name="av1", # v4.3.0: skip-existing detection
|
||
),
|
||
VideoCodecProfile(
|
||
label="VP9",
|
||
av1an_encoder="vpx",
|
||
ffmpeg_encoder="libvpx-vp9",
|
||
container="webm",
|
||
crf_range=(18, 52),
|
||
default_crf=32,
|
||
params_fn=_vp9_params,
|
||
ffmpeg_vargs_fn=_vp9_ffmpeg_args,
|
||
presets=["Slow (0)", "Medium (2)", "Fast (4)", "Faster (6)"],
|
||
preset_map={"Slow (0)": 0, "Medium (2)": 2, "Fast (4)": 4, "Faster (6)": 6},
|
||
ffprobe_codec_name="vp9", # v4.3.0: skip-existing detection
|
||
),
|
||
VideoCodecProfile(
|
||
label="x265 (HEVC)",
|
||
av1an_encoder="x265",
|
||
ffmpeg_encoder="libx265",
|
||
container="mkv",
|
||
crf_range=(18, 40),
|
||
default_crf=28,
|
||
params_fn=_x265_params,
|
||
ffmpeg_vargs_fn=_x265_ffmpeg_args,
|
||
presets=["Slow (5)", "Medium (7)", "Fast (9)", "Faster (10)"],
|
||
preset_map={"Slow (5)": 5, "Medium (7)": 7, "Fast (9)": 9, "Faster (10)": 10},
|
||
ffprobe_codec_name="hevc", # v4.3.0: skip-existing detection
|
||
),
|
||
]
|
||
|
||
AUDIO_PROFILES: list[AudioProfile] = [
|
||
AudioProfile(label="Opus (96k)", params=["-c:a", "libopus", "-b:a", "96k"],
|
||
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
|
||
AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"],
|
||
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
|
||
AudioProfile(label="Opus (64k)", params=["-c:a", "libopus", "-b:a", "64k"],
|
||
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
|
||
AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"],
|
||
ffmpeg_encoder_name="libvorbis", ffprobe_codec_name="vorbis"),
|
||
AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"],
|
||
ffmpeg_encoder_name="libvorbis", ffprobe_codec_name="vorbis"),
|
||
AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"],
|
||
ffmpeg_encoder_name="flac", ffprobe_codec_name="flac"),
|
||
# IAMF — AOMedia Immersive Audio Model and Formats (RFC 9454 family).
|
||
# Built on Opus internally; requires ffmpeg compiled with --enable-libiamf.
|
||
# CANNOT be muxed into MKV/WebM — must use the MP4 container (see below).
|
||
# The -strict experimental flag is harmless on ffmpeg builds where libiamf
|
||
# is already stable, and required on builds where it's still flagged
|
||
# experimental, so we always pass it for forward compatibility.
|
||
AudioProfile(
|
||
label="IAMF (128k)",
|
||
params=["-c:a", "libiamf", "-b:a", "128k", "-strict", "experimental"],
|
||
ffmpeg_encoder_name="libiamf",
|
||
ffprobe_codec_name="iamf",
|
||
),
|
||
]
|
||
|
||
CONTAINER_PROFILES: list[ContainerProfile] = [
|
||
ContainerProfile(label="MKV (Matroska)", ext="mkv"),
|
||
ContainerProfile(label="WebM", ext="webm"),
|
||
# MP4 is required for IAMF audio (MKV/WebM cannot mux the IAMF codec).
|
||
# Also useful as a more universally compatible output container.
|
||
ContainerProfile(label="MP4", ext="mp4"),
|
||
]
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
# FFMPEG_LIB_KEY_MAP — single source of truth (OTC-007, SEI CERT MSC04-C).
|
||
#
|
||
# Maps the `ffmpeg_encoder` field of a VideoCodecProfile (e.g. "libsvtav1",
|
||
# "libvpx-vp9") to the corresponding key in EnvProbe.ffmpeg_libs (which is
|
||
# populated by _probe_ffmpeg_libs()).
|
||
#
|
||
# v2 had this map duplicated in three call sites:
|
||
# - _ffmpeg_fallback_encode (around line 2075)
|
||
# - _probe_and_init status bar (around line 4309)
|
||
# - _handle_vs_incompat fallback check (around line 4532)
|
||
# Adding a new codec required updating all three in sync — a classic
|
||
# MSC04-C violation. v3 hoists it to one module-level constant.
|
||
# ──────────────────────────────────────────────────────────────────────────────
|
||
FFMPEG_LIB_KEY_MAP: dict[str, str] = {
|
||
"libsvtav1": "libsvtav1",
|
||
"libaom-av1": "libaom",
|
||
"libvpx-vp9": "libvpx",
|
||
"libx265": "libx265",
|
||
}
|
||
|
||
|
||
def ffmpeg_lib_key_for(ffmpeg_encoder: str) -> str:
|
||
"""Look up the ffmpeg_libs key for a given ffmpeg encoder name.
|
||
|
||
Returns the encoder name itself if no mapping is known — this preserves
|
||
forward compatibility with encoders added after this map was last
|
||
updated (the caller's .get() will then return False, which is the
|
||
safe default for an unknown encoder).
|
||
"""
|
||
return FFMPEG_LIB_KEY_MAP.get(ffmpeg_encoder, ffmpeg_encoder)
|
||
|
||
|
||
# ── Resolution presets ──
|
||
# Aspect ratios:
|
||
# Standard 16:9 -> w/h = 1.778
|
||
# Wide 21:9 -> w/h = 2.333
|
||
# Ultrawide 32:9 -> w/h = 3.556
|
||
|
||
@dataclass
|
||
class ResolutionProfile:
|
||
label: str # Display label in dropdown, e.g. "1080p Wide (2560x1080)"
|
||
category: str # Grouping key: "standard", "wide", "ultrawide", "original"
|
||
width: int | None # None for "original" (no scaling)
|
||
height: int | None # None for "original"
|
||
aspect_label: str # "16:9", "21:9", "32:9", "Source"
|
||
|
||
|
||
RESOLUTION_PRESETS: list[ResolutionProfile] = [
|
||
# ── Original (no scaling) ──
|
||
ResolutionProfile("Original (No Scaling)", "original", None, None, "Source"),
|
||
|
||
# ── Standard 16:9 ──
|
||
ResolutionProfile("480p ( 854x 480)", "standard", 854, 480, "16:9"),
|
||
ResolutionProfile("720p (1280x 720)", "standard", 1280, 720, "16:9"),
|
||
ResolutionProfile("1080p (1920x1080)", "standard", 1920, 1080, "16:9"),
|
||
ResolutionProfile("2K (2560x1440)", "standard", 2560, 1440, "16:9"),
|
||
ResolutionProfile("4K (3840x2160)", "standard", 3840, 2160, "16:9"),
|
||
|
||
# ── Wide 21:9 ──
|
||
ResolutionProfile("480p Wide ( 854x 366)", "wide", 854, 366, "21:9"),
|
||
ResolutionProfile("720p Wide (1280x 549)", "wide", 1280, 549, "21:9"),
|
||
ResolutionProfile("1080p Wide (2560x1080)", "wide", 2560, 1080, "21:9"),
|
||
ResolutionProfile("2K Wide (3440x1440)", "wide", 3440, 1440, "21:9"),
|
||
ResolutionProfile("4K Wide (5120x2160)", "wide", 5120, 2160, "21:9"),
|
||
|
||
# ── Ultrawide 32:9 ──
|
||
ResolutionProfile("480p UW (1706x 480)", "ultrawide", 1706, 480, "32:9"),
|
||
ResolutionProfile("1080p UW (3840x1080)", "ultrawide", 3840, 1080, "32:9"),
|
||
ResolutionProfile("2K UW (5120x1440)", "ultrawide", 5120, 1440, "32:9"),
|
||
ResolutionProfile("4K UW (7680x2160)", "ultrawide", 7680, 2160, "32:9"),
|
||
]
|
||
|
||
|
||
SUBTITLE_OPTIONS = [
|
||
("None", None),
|
||
("English", "eng"),
|
||
]
|
||
|
||
|
||
DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"}
|