249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""Static knowledge about containers and encoders.
|
|
|
|
Everything in here is plain data so it can be used (and tested) without Qt.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Containers
|
|
# --------------------------------------------------------------------------
|
|
|
|
TEXT_SUBTITLE_CODECS = {
|
|
"subrip", "srt", "ass", "ssa", "mov_text", "webvtt", "text",
|
|
"subviewer", "subviewer1", "microdvd", "sami", "realtext", "jacosub",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Container:
|
|
key: str
|
|
label: str
|
|
ext: str
|
|
# Codec names (as reported by ffprobe) that can be stream-copied into this
|
|
# container. ``None`` means "anything goes" (Matroska).
|
|
video_codecs: frozenset[str] | None
|
|
audio_codecs: frozenset[str] | None
|
|
# How text subtitles are stored: a target codec name, "copy", or None
|
|
# (container cannot hold text subtitles).
|
|
text_subtitles: str | None
|
|
# Image subtitles (PGS, VobSub, DVB) can only be copied, never converted
|
|
# to text, so a container either accepts them or not.
|
|
image_subtitles: frozenset[str] | None = frozenset()
|
|
extra_args: tuple[str, ...] = ()
|
|
|
|
|
|
_MP4_VIDEO = frozenset({"h264", "hevc", "av1", "vp9", "mpeg4", "mpeg2video", "mpeg1video", "mjpeg"})
|
|
_MP4_AUDIO = frozenset({"aac", "mp3", "ac3", "eac3", "opus", "flac", "alac", "mp2"})
|
|
_FASTSTART = ("-movflags", "+faststart")
|
|
|
|
CONTAINERS: dict[str, Container] = {
|
|
c.key: c
|
|
for c in (
|
|
Container("mp4", "MP4 (.mp4)", ".mp4", _MP4_VIDEO, _MP4_AUDIO, "mov_text", extra_args=_FASTSTART),
|
|
Container("mkv", "Matroska (.mkv)", ".mkv", None, None, "copy", image_subtitles=None),
|
|
Container(
|
|
"mov", "QuickTime (.mov)", ".mov",
|
|
frozenset({"h264", "hevc", "mpeg4", "prores", "mjpeg", "dnxhd", "mpeg2video"}),
|
|
frozenset({"aac", "mp3", "ac3", "eac3", "alac", "pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be"}),
|
|
"mov_text", extra_args=_FASTSTART,
|
|
),
|
|
Container(
|
|
"m4v", "MPEG-4 Video (.m4v)", ".m4v",
|
|
frozenset({"h264", "hevc"}), frozenset({"aac", "ac3", "eac3", "alac"}),
|
|
"mov_text", extra_args=_FASTSTART,
|
|
),
|
|
Container(
|
|
"webm", "WebM (.webm)", ".webm",
|
|
frozenset({"vp8", "vp9", "av1"}), frozenset({"opus", "vorbis"}), "webvtt",
|
|
),
|
|
Container(
|
|
"avi", "AVI (.avi)", ".avi",
|
|
frozenset({"mpeg4", "h264", "mjpeg", "msmpeg4v3", "mpeg2video", "mpeg1video"}),
|
|
frozenset({"mp3", "ac3", "pcm_s16le", "mp2", "aac"}), None,
|
|
),
|
|
Container(
|
|
"ts", "MPEG-TS (.ts)", ".ts",
|
|
frozenset({"h264", "hevc", "mpeg2video", "mpeg1video", "av1"}),
|
|
frozenset({"aac", "ac3", "eac3", "mp3", "mp2", "opus"}),
|
|
None, image_subtitles=frozenset({"dvb_subtitle"}),
|
|
),
|
|
)
|
|
}
|
|
|
|
|
|
def container_accepts_video(container: Container, codec: str | None) -> bool:
|
|
return container.video_codecs is None or codec in container.video_codecs
|
|
|
|
|
|
def container_accepts_audio(container: Container, codec: str | None) -> bool:
|
|
return container.audio_codecs is None or codec in container.audio_codecs
|
|
|
|
|
|
def subtitle_plan(container: Container, codec: str | None) -> str | None:
|
|
"""How a subtitle stream with ``codec`` goes into ``container``.
|
|
|
|
Returns "copy", a target encoder name, or None if it must be dropped.
|
|
"""
|
|
if codec in TEXT_SUBTITLE_CODECS:
|
|
target = container.text_subtitles
|
|
if target == "copy":
|
|
# Matroska can hold every text format except mov_text.
|
|
return "srt" if codec == "mov_text" else "copy"
|
|
if target is not None and target == codec:
|
|
return "copy"
|
|
return target
|
|
# Image based (or unknown) subtitles.
|
|
if container.image_subtitles is None or codec in container.image_subtitles:
|
|
return "copy"
|
|
return None
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Video encoders
|
|
# --------------------------------------------------------------------------
|
|
|
|
SPEED_LEVELS = ("Fastest", "Fast", "Balanced", "Slow", "Slowest")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VideoEncoder:
|
|
name: str # ffmpeg encoder name
|
|
label: str
|
|
codec: str # resulting codec name (as ffprobe reports it)
|
|
quality_range: tuple[int, int]
|
|
quality_default: int
|
|
quality_args: tuple[str, ...] # "{q}" is replaced by the quality value
|
|
speed_values: tuple[str, ...] # one entry per SPEED_LEVELS
|
|
speed_option: str | None
|
|
extra_args: tuple[str, ...] = ()
|
|
# Force 8-bit 4:2:0 output; most H.264 players cannot decode High10.
|
|
pix_fmt: str | None = None
|
|
|
|
def args(self, quality: int, speed: int) -> list[str]:
|
|
q = max(self.quality_range[0], min(self.quality_range[1], quality))
|
|
out = ["-c:v", self.name]
|
|
out += [a.replace("{q}", str(q)) for a in self.quality_args]
|
|
if self.speed_option:
|
|
out += [self.speed_option, self.speed_values[max(0, min(4, speed))]]
|
|
out += list(self.extra_args)
|
|
if self.pix_fmt:
|
|
out += ["-pix_fmt", self.pix_fmt]
|
|
return out
|
|
|
|
|
|
_X26X_SPEEDS = ("ultrafast", "veryfast", "medium", "slow", "veryslow")
|
|
_NVENC_SPEEDS = ("p1", "p3", "p4", "p6", "p7")
|
|
_QSV_SPEEDS = ("veryfast", "faster", "medium", "slow", "veryslow")
|
|
_AMF_SPEEDS = ("speed", "speed", "balanced", "quality", "quality")
|
|
_NVENC_Q = ("-rc", "vbr", "-cq", "{q}", "-b:v", "0")
|
|
_AMF_Q = ("-rc", "cqp", "-qp_i", "{q}", "-qp_p", "{q}")
|
|
|
|
VIDEO_ENCODERS: list[VideoEncoder] = [
|
|
VideoEncoder("libx264", "H.264 / AVC (x264)", "h264", (0, 51), 20, ("-crf", "{q}"), _X26X_SPEEDS, "-preset", pix_fmt="yuv420p"),
|
|
VideoEncoder("libx265", "H.265 / HEVC (x265)", "hevc", (0, 51), 22, ("-crf", "{q}"), _X26X_SPEEDS, "-preset"),
|
|
VideoEncoder("libsvtav1", "AV1 (SVT-AV1)", "av1", (0, 63), 30, ("-crf", "{q}"), ("12", "10", "8", "6", "4"), "-preset"),
|
|
VideoEncoder("libvpx-vp9", "VP9 (libvpx)", "vp9", (0, 63), 31, ("-crf", "{q}", "-b:v", "0"), ("5", "4", "3", "1", "0"), "-cpu-used", ("-row-mt", "1")),
|
|
VideoEncoder("mpeg4", "MPEG-4 Part 2 (for AVI)", "mpeg4", (2, 31), 4, ("-q:v", "{q}"), ("",) * 5, None),
|
|
VideoEncoder("h264_nvenc", "H.264 - NVIDIA NVENC", "h264", (0, 51), 23, _NVENC_Q, _NVENC_SPEEDS, "-preset", pix_fmt="yuv420p"),
|
|
VideoEncoder("hevc_nvenc", "H.265 - NVIDIA NVENC", "hevc", (0, 51), 25, _NVENC_Q, _NVENC_SPEEDS, "-preset"),
|
|
VideoEncoder("av1_nvenc", "AV1 - NVIDIA NVENC", "av1", (0, 51), 30, _NVENC_Q, _NVENC_SPEEDS, "-preset"),
|
|
VideoEncoder("h264_qsv", "H.264 - Intel QuickSync", "h264", (1, 51), 23, ("-global_quality", "{q}"), _QSV_SPEEDS, "-preset", pix_fmt="nv12"),
|
|
VideoEncoder("hevc_qsv", "H.265 - Intel QuickSync", "hevc", (1, 51), 25, ("-global_quality", "{q}"), _QSV_SPEEDS, "-preset"),
|
|
VideoEncoder("av1_qsv", "AV1 - Intel QuickSync", "av1", (1, 51), 30, ("-global_quality", "{q}"), _QSV_SPEEDS, "-preset"),
|
|
VideoEncoder("h264_amf", "H.264 - AMD AMF", "h264", (0, 51), 23, _AMF_Q, _AMF_SPEEDS, "-quality", pix_fmt="nv12"),
|
|
VideoEncoder("hevc_amf", "H.265 - AMD AMF", "hevc", (0, 51), 25, _AMF_Q, _AMF_SPEEDS, "-quality"),
|
|
]
|
|
VIDEO_ENCODERS_BY_NAME = {e.name: e for e in VIDEO_ENCODERS}
|
|
|
|
# (label, height) - height 0 means "keep original"
|
|
RESOLUTIONS: list[tuple[str, int]] = [
|
|
("Original", 0),
|
|
("4320p (8K)", 4320),
|
|
("2160p (4K)", 2160),
|
|
("1440p", 1440),
|
|
("1080p", 1080),
|
|
("720p", 720),
|
|
("576p", 576),
|
|
("480p", 480),
|
|
("360p", 360),
|
|
]
|
|
CUSTOM_RESOLUTION = -1
|
|
|
|
FRAME_RATES = ["Original", "23.976", "24", "25", "29.97", "30", "50", "59.94", "60"]
|
|
_FPS_EXPR = {"23.976": "24000/1001", "29.97": "30000/1001", "59.94": "60000/1001"}
|
|
|
|
|
|
def fps_expr(label: str) -> str | None:
|
|
if not label or label == "Original":
|
|
return None
|
|
return _FPS_EXPR.get(label, label)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Audio encoders
|
|
# --------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True)
|
|
class AudioEncoder:
|
|
name: str
|
|
label: str
|
|
codec: str
|
|
max_channels: int = 8
|
|
sample_rates: frozenset[int] | None = None # None = anything
|
|
lossless: bool = False
|
|
# Default bitrate (kbps) for stereo and for 5.1; scaled for other layouts.
|
|
default_kbps: tuple[int, int] = (192, 384)
|
|
max_kbps: int = 1536
|
|
extra_args: tuple[str, ...] = field(default=())
|
|
|
|
def auto_bitrate(self, channels: int) -> int:
|
|
stereo, surround = self.default_kbps
|
|
if channels <= 1:
|
|
kbps = stereo // 2
|
|
elif channels <= 2:
|
|
kbps = stereo
|
|
else:
|
|
kbps = surround * channels // 6
|
|
return min(kbps, self.max_kbps)
|
|
|
|
def nearest_sample_rate(self, rate: int) -> int:
|
|
if self.sample_rates is None or rate in self.sample_rates:
|
|
return rate
|
|
higher = sorted(r for r in self.sample_rates if r >= rate)
|
|
return higher[0] if higher else max(self.sample_rates)
|
|
|
|
|
|
_MP3_RATES = frozenset({8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000})
|
|
|
|
AUDIO_ENCODERS: list[AudioEncoder] = [
|
|
AudioEncoder("aac", "AAC", "aac", default_kbps=(192, 384), max_kbps=768),
|
|
AudioEncoder("ac3", "Dolby Digital (AC-3)", "ac3", max_channels=6,
|
|
sample_rates=frozenset({32000, 44100, 48000}), default_kbps=(224, 448), max_kbps=640),
|
|
AudioEncoder("eac3", "Dolby Digital Plus (E-AC-3)", "eac3", max_channels=6,
|
|
sample_rates=frozenset({32000, 44100, 48000}), default_kbps=(224, 640), max_kbps=6144),
|
|
AudioEncoder("libopus", "Opus", "opus", sample_rates=frozenset({8000, 12000, 16000, 24000, 48000}),
|
|
default_kbps=(128, 320), max_kbps=512),
|
|
AudioEncoder("libmp3lame", "MP3", "mp3", max_channels=2, sample_rates=_MP3_RATES,
|
|
default_kbps=(192, 192), max_kbps=320),
|
|
AudioEncoder("libvorbis", "Vorbis", "vorbis", default_kbps=(192, 448), max_kbps=500),
|
|
AudioEncoder("flac", "FLAC (lossless)", "flac", lossless=True),
|
|
AudioEncoder("alac", "ALAC (lossless)", "alac", lossless=True),
|
|
AudioEncoder("pcm_s16le", "PCM 16-bit (uncompressed)", "pcm_s16le", lossless=True),
|
|
]
|
|
AUDIO_ENCODERS_BY_NAME = {e.name: e for e in AUDIO_ENCODERS}
|
|
|
|
AUDIO_BITRATES = [0, 64, 96, 128, 160, 192, 224, 256, 320, 384, 448, 512, 640] # 0 = auto
|
|
AUDIO_CHANNELS: list[tuple[str, int]] = [
|
|
("Original", 0), ("Mono (1.0)", 1), ("Stereo (2.0)", 2), ("5.1", 6), ("7.1", 8),
|
|
]
|
|
AUDIO_SAMPLE_RATES = [0, 22050, 32000, 44100, 48000, 88200, 96000] # 0 = original
|
|
|
|
|
|
def describe_channels(n: int | None, layout: str | None = None) -> str:
|
|
if layout and layout not in ("unknown", ""):
|
|
return {"mono": "1.0", "stereo": "2.0"}.get(layout, layout.replace("(side)", ""))
|
|
return {1: "1.0", 2: "2.0", 6: "5.1", 8: "7.1"}.get(n or 0, f"{n}ch" if n else "?ch")
|