Files
ffmpeg_wrapper/ffwrap/formats.py
2026-10-09 18:58:50 +00:00

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