"""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")