Version 1 OneShot

This commit is contained in:
2026-10-09 18:58:50 +00:00
parent 514c13d184
commit bf0d661eed
27 changed files with 3039 additions and 0 deletions

4
ffwrap/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
"""FFmpeg Wrapper - a small GUI front-end for common ffmpeg conversions."""
__version__ = "1.0.0"
APP_NAME = "FFmpeg Wrapper"

5
ffwrap/__main__.py Normal file
View File

@@ -0,0 +1,5 @@
import sys
from .app import main
sys.exit(main())

45
ffwrap/app.py Normal file
View File

@@ -0,0 +1,45 @@
"""Application entry point."""
from __future__ import annotations
import os
import sys
from pathlib import Path
def main() -> int:
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QApplication
from . import APP_NAME
from .gui.main_window import MainWindow
from .tools import app_dir
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setOrganizationName("ffwrap")
app.setStyle("Fusion")
# Bundled by PyInstaller into sys._MEIPASS, or next to the sources.
base = Path(getattr(sys, "_MEIPASS", app_dir()))
icon = base / "assets" / "icon.png"
if icon.exists():
app.setWindowIcon(QIcon(str(icon)))
win = MainWindow()
win.show()
# Files passed on the command line (or dropped onto the executable).
args = [Path(a) for a in sys.argv[1:] if not a.startswith("-")]
if args:
win.add_paths(args)
# Build verification: FFWRAP_SMOKE_TEST=shot.png saves a screenshot after start-up and exits.
shot = os.environ.get("FFWRAP_SMOKE_TEST")
if shot:
from PySide6.QtCore import QTimer
def finish():
win.grab().save(shot)
app.exit(0 if win.tools else 3)
QTimer.singleShot(4000, finish)
return app.exec()

366
ffwrap/command.py Normal file
View File

@@ -0,0 +1,366 @@
"""Turn user options + probe results into an ffmpeg command line.
Pure Python (no Qt) so it can be unit tested.
"""
from __future__ import annotations
import os
import shlex
import subprocess
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
from .formats import (
AUDIO_ENCODERS_BY_NAME,
CONTAINERS,
CUSTOM_RESOLUTION,
VIDEO_ENCODERS_BY_NAME,
container_accepts_audio,
container_accepts_video,
fps_expr,
subtitle_plan,
)
from .probe import MediaInfo, Stream
from .tools import IS_WINDOWS
VIDEO_MODES = {
"copy": "Copy (keep original, no re-encode)",
"auto": "Auto (copy if compatible, else re-encode)",
"encode": "Re-encode",
}
AUDIO_MODES = {
"copy": "Copy (keep original, no re-encode)",
"auto": "Auto (copy if compatible, else re-encode)",
"encode": "Re-encode",
"strip": "Strip audio (no audio in output)",
}
EXISTS_POLICIES = {
"rename": "Add a number (keep both)",
"overwrite": "Overwrite",
"skip": "Skip file",
}
@dataclass
class Options:
container: str = "mp4"
output_dir: str = "" # empty = next to the source file
suffix: str = ""
if_exists: str = "rename"
video_mode: str = "copy"
video_encoder: str = "libx264"
resolution: int = 0 # target height, 0 = original, -1 = custom
custom_width: int = 0
custom_height: int = 0
no_upscale: bool = False
fps: str = "Original"
quality: int = 20
speed: int = 2
audio_mode: str = "auto"
audio_encoder: str = "aac"
audio_bitrate: int = 0 # kbps, 0 = auto
audio_channels: int = 0 # 0 = original
audio_sample_rate: int = 0 # Hz, 0 = original
subtitles: bool = True
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> "Options":
opts = cls()
for f in fields(cls):
if f.name in data:
try:
setattr(opts, f.name, type(getattr(opts, f.name))(data[f.name]))
except (TypeError, ValueError):
pass
return opts
@dataclass
class BuiltCommand:
args: list[str]
notes: list[str] = field(default_factory=list) # human readable decisions / warnings
# --------------------------------------------------------------------------
# Video scaling
# --------------------------------------------------------------------------
def scale_filter(video: Stream, opts: Options) -> str | None:
"""Return an ffmpeg ``scale`` filter for the requested resolution, or None."""
size = video.display_size
sw, sh = size if size else (0, 0)
if opts.resolution == CUSTOM_RESOLUTION:
w, h = max(0, opts.custom_width), max(0, opts.custom_height)
if not w and not h:
return None
if w and h:
factor = min(w / sw, h / sh) if size else 0
expr = f"scale={w}:{h}:force_original_aspect_ratio=decrease:force_divisible_by=2"
elif w:
factor = w / sw if size else 0
expr = f"scale={w - w % 2}:-2"
else:
factor = h / sh if size else 0
expr = f"scale=-2:{h - h % 2}"
elif opts.resolution > 0:
target = opts.resolution
if size and sh > sw:
# Portrait video: "1080p" means 1080 pixels wide.
factor = target / sw
expr = f"scale={target}:-2"
else:
factor = target / sh if size else 0
expr = f"scale=-2:{target}"
else:
return None
if size:
if abs(factor - 1) < 1e-6:
return None
if opts.no_upscale and factor > 1:
return None
return expr + ":flags=lanczos"
# --------------------------------------------------------------------------
# Command builder
# --------------------------------------------------------------------------
def build_command(
ffmpeg: str,
info: MediaInfo,
opts: Options,
audio_indices: list[int],
output: Path,
) -> BuiltCommand:
container = CONTAINERS[opts.container]
notes: list[str] = []
args = [
ffmpeg, "-hide_banner", "-nostdin", "-y",
"-loglevel", "warning",
"-progress", "pipe:1", "-nostats",
"-i", str(info.path),
]
maps: list[str] = []
codec_args: list[str] = []
# ---- video -----------------------------------------------------------
video = info.video
if video is not None:
maps += ["-map", f"0:{video.index}"]
filters = []
scale = scale_filter(video, opts)
if scale:
filters.append(scale)
fps = fps_expr(opts.fps)
if fps:
filters.append(f"fps={fps}")
if opts.video_mode == "copy":
encode = False
if not container_accepts_video(container, video.codec):
notes.append(f"Warning: {video.codec} video may not be supported in {container.ext} when copying")
elif opts.video_mode == "auto":
encode = bool(filters) or not container_accepts_video(container, video.codec)
else:
encode = True
if encode:
enc = VIDEO_ENCODERS_BY_NAME[opts.video_encoder]
if filters:
codec_args += ["-vf", ",".join(filters)]
codec_args += enc.args(opts.quality, opts.speed)
out_codec = enc.codec
notes.append(f"Video: re-encode {video.codec} -> {enc.label}" + (f" [{', '.join(filters)}]" if filters else ""))
else:
codec_args += ["-c:v", "copy"]
out_codec = video.codec
notes.append(f"Video: copy {video.codec}")
if out_codec == "hevc" and opts.container in ("mp4", "mov", "m4v"):
# Apple devices (and some smart TVs) only play HEVC tagged as hvc1.
codec_args += ["-tag:v", "hvc1"]
# ---- audio -----------------------------------------------------------
if opts.audio_mode == "strip":
codec_args.append("-an")
notes.append("Audio: stripped")
else:
selected = [s for s in (info.stream(i) for i in audio_indices) if s is not None and s.kind == "audio"]
if not selected and info.audio:
notes.append("Audio: no tracks selected - output will have no audio")
enc = AUDIO_ENCODERS_BY_NAME[opts.audio_encoder]
for i, s in enumerate(selected):
maps += ["-map", f"0:{s.index}"]
codec_args += _audio_track_args(i, s, opts, container, enc, notes)
codec_args += [f"-disposition:a:{i}", "default" if i == 0 else "0"]
# ---- subtitles -------------------------------------------------------
if opts.subtitles:
j = 0
for s in info.subtitles:
plan = subtitle_plan(container, s.codec)
if plan is None:
notes.append(f"Subtitles: dropped track #{s.index} ({s.codec}) - not supported in {container.ext}")
continue
maps += ["-map", f"0:{s.index}"]
codec_args += [f"-c:s:{j}", plan]
j += 1
if opts.container == "mkv" and any(s.kind == "attachment" for s in info.streams):
# Fonts used by ASS subtitles.
maps += ["-map", "0:t?"]
codec_args += ["-c:t", "copy"]
args += maps + codec_args
args += ["-max_muxing_queue_size", "4096"]
args += list(container.extra_args)
args.append(str(output))
return BuiltCommand(args=args, notes=notes)
def _audio_track_args(i, s: Stream, opts: Options, container, enc, notes: list[str]) -> list[str]:
want_ch, want_sr = opts.audio_channels, opts.audio_sample_rate
label = f"Audio #{s.index}" + (f" [{s.language}]" if s.language else "")
if opts.audio_mode == "copy":
if not container_accepts_audio(container, s.codec):
notes.append(f"Warning: {label}: {s.codec} may not be supported in {container.ext} when copying")
notes.append(f"{label}: copy {s.codec}")
return [f"-c:a:{i}", "copy"]
if opts.audio_mode == "auto":
changes = (want_ch and want_ch != s.channels) or (want_sr and want_sr != s.sample_rate)
if container_accepts_audio(container, s.codec) and not changes:
notes.append(f"{label}: copy {s.codec}")
return [f"-c:a:{i}", "copy"]
out = [f"-c:a:{i}", enc.name]
channels = want_ch or s.channels or 2
if channels > enc.max_channels:
notes.append(f"{label}: {enc.label} supports at most {enc.max_channels} channels - downmixing")
channels = enc.max_channels
out += [f"-ac:a:{i}", str(channels)]
elif want_ch:
out += [f"-ac:a:{i}", str(channels)]
if want_sr:
rate = enc.nearest_sample_rate(want_sr)
if rate != want_sr:
notes.append(f"{label}: {enc.label} does not support {want_sr} Hz - using {rate} Hz")
out += [f"-ar:a:{i}", str(rate)]
if not enc.lossless:
kbps = min(opts.audio_bitrate or enc.auto_bitrate(channels), enc.max_kbps)
out += [f"-b:a:{i}", f"{kbps}k"]
if enc.name == "libopus" and channels > 2:
out += [f"-mapping_family:a:{i}", "1"]
notes.append(f"{label}: re-encode {s.codec} -> {enc.label}")
return out
# --------------------------------------------------------------------------
# Output paths
# --------------------------------------------------------------------------
def _norm(p: Path) -> str:
return os.path.normcase(os.path.abspath(p))
def plan_output_path(src: Path, opts: Options, taken: set[str], exists=os.path.exists) -> Path | None:
"""Pick the output file for ``src``. Returns None if the file should be skipped.
``taken`` holds normalised paths already produced in this batch; they are
never overwritten even with the "overwrite" policy.
"""
ext = CONTAINERS[opts.container].ext
out_dir = Path(opts.output_dir) if opts.output_dir else src.parent
stem = src.stem + opts.suffix
cand = out_dir / f"{stem}{ext}"
if _norm(cand) == _norm(src):
# Never overwrite the input (e.g. mp4 -> mp4 in the same folder).
stem += "_converted"
cand = out_dir / f"{stem}{ext}"
in_batch = _norm(cand) in taken
if not in_batch and not exists(cand):
return cand
if not in_batch and opts.if_exists == "skip":
return None
if not in_batch and opts.if_exists == "overwrite" and _norm(cand) != _norm(src):
return cand
n = 1
while True:
p = out_dir / f"{stem} ({n}){ext}"
if _norm(p) not in taken and not exists(p) and _norm(p) != _norm(src):
return p
n += 1
def temp_output_path(final: Path) -> Path:
"""ffmpeg writes here first; renamed to ``final`` only on success."""
return final.with_name(f"{final.stem}.part{final.suffix}")
# --------------------------------------------------------------------------
# Audio track selection helpers
# --------------------------------------------------------------------------
def default_audio_selection(info: MediaInfo) -> list[int]:
return [s.index for s in info.audio]
def match_audio_selection(ref_all: list[Stream], ref_selected: list[int], target_all: list[Stream]) -> list[int]:
"""Map a track selection made on one file onto another file's audio tracks.
Identical track layouts map by position; otherwise each selected track is
matched by language, then codec / channels / title / position. Falls back to
the target's default (or first) track so audio is never silently lost.
"""
if not target_all:
return []
if not ref_selected:
return []
sig = lambda s: (s.language, s.codec, s.channels) # noqa: E731
ref_pos = {s.index: pos for pos, s in enumerate(ref_all)}
by_index = {s.index: s for s in ref_all}
if [sig(s) for s in ref_all] == [sig(s) for s in target_all]:
return [target_all[ref_pos[i]].index for i in ref_selected if i in ref_pos]
result: list[int] = []
for i in ref_selected:
r = by_index.get(i)
if r is None:
continue
best, best_score = None, 0
for pos, t in enumerate(target_all):
if t.index in result:
continue
if r.language and t.language and r.language != t.language:
continue
score = 1
score += 8 if r.language and r.language == t.language else 0
score += 2 if r.codec == t.codec else 0
score += 2 if r.channels == t.channels else 0
score += 2 if r.title and r.title == t.title else 0
score += 1 if pos == ref_pos[i] else 0
if score > best_score:
best, best_score = t, score
if best is not None:
result.append(best.index)
if not result:
default = next((t for t in target_all if t.default), target_all[0])
result = [default.index]
return result
def format_command(args: list[str]) -> str:
return subprocess.list2cmdline(args) if IS_WINDOWS else shlex.join(args)

248
ffwrap/formats.py Normal file
View File

@@ -0,0 +1,248 @@
"""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")

0
ffwrap/gui/__init__.py Normal file
View File

881
ffwrap/gui/main_window.py Normal file
View File

@@ -0,0 +1,881 @@
"""Main application window."""
from __future__ import annotations
import json
import os
from pathlib import Path
from PySide6.QtCore import QObject, QRunnable, QSettings, Qt, QThreadPool, QUrl, Signal
from PySide6.QtGui import QAction, QDesktopServices, QFontDatabase, QKeySequence
from PySide6.QtWidgets import (
QAbstractItemView, QCheckBox, QComboBox, QFileDialog, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton,
QRadioButton, QSpinBox, QSplitter, QTableView, QToolBar, QVBoxLayout, QWidget,
)
from .. import APP_NAME, __version__
from ..command import (
AUDIO_MODES, EXISTS_POLICIES, VIDEO_MODES, Options, build_command, format_command,
match_audio_selection, plan_output_path,
)
from ..formats import (
AUDIO_BITRATES, AUDIO_CHANNELS, AUDIO_ENCODERS, AUDIO_ENCODERS_BY_NAME, AUDIO_SAMPLE_RATES, CONTAINERS,
CUSTOM_RESOLUTION, FRAME_RATES, RESOLUTIONS, SPEED_LEVELS, TEXT_SUBTITLE_CODECS,
VIDEO_ENCODERS, VIDEO_ENCODERS_BY_NAME,
)
from ..probe import MediaInfo, ProbeError, probe
from ..tools import FFmpegTools, locate_tools
from .queue_model import COL_PROGRESS, RUNNABLE, Job, ProgressDelegate, QueueModel, Status
from .runner import BatchRunner
MEDIA_EXTENSIONS = {
".mkv", ".mp4", ".m4v", ".mov", ".avi", ".wmv", ".flv", ".webm", ".ts", ".m2ts", ".mts",
".mpg", ".mpeg", ".vob", ".3gp", ".ogv", ".divx", ".rmvb", ".asf", ".mxf", ".dv",
".mka", ".mp3", ".flac", ".m4a", ".wav", ".ogg", ".opus", ".ac3", ".dts", ".wma",
}
# --------------------------------------------------------------------------
# Background probing
# --------------------------------------------------------------------------
class _ProbeSignals(QObject):
done = Signal(object, object, str) # job, MediaInfo | None, error
class _ProbeTask(QRunnable):
def __init__(self, ffprobe: str, job: Job, signals: _ProbeSignals):
super().__init__()
self.ffprobe, self.job, self.signals = ffprobe, job, signals
def run(self):
try:
info = probe(self.ffprobe, self.job.path)
self.signals.done.emit(self.job, info, "")
except ProbeError as e:
self.signals.done.emit(self.job, None, str(e))
except Exception as e: # never let a worker thread die silently
self.signals.done.emit(self.job, None, f"{type(e).__name__}: {e}")
class TrackList(QListWidget):
"""Checkable, drag-reorderable list of audio tracks."""
order_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setDragDropMode(QAbstractItemView.InternalMove)
self.setDefaultDropAction(Qt.MoveAction)
self.setSelectionMode(QAbstractItemView.SingleSelection)
def dropEvent(self, event):
super().dropEvent(event)
self.order_changed.emit()
def _combo(items: list[tuple[str, object]]) -> QComboBox:
cb = QComboBox()
for label, data in items:
cb.addItem(label, data)
return cb
def _select_data(cb: QComboBox, value) -> bool:
i = cb.findData(value)
if i >= 0:
cb.setCurrentIndex(i)
return True
return False
# --------------------------------------------------------------------------
# Main window
# --------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle(APP_NAME)
self.resize(1280, 880)
self.setAcceptDrops(True)
self.settings = QSettings("ffwrap", "FFmpegWrapper")
self.tools: FFmpegTools | None = None
self.model = QueueModel(self)
self.runner = BatchRunner(self)
self.pool = QThreadPool(self)
self.pool.setMaxThreadCount(max(2, min(6, os.cpu_count() or 2)))
self.probe_signals = _ProbeSignals()
self.probe_signals.done.connect(self._on_probed)
self._track_job: Job | None = None
self._loading = False
self._build_ui()
self._connect()
self._load_settings()
self._init_tools()
# ------------------------------------------------------------------ UI
def _build_ui(self) -> None:
# Toolbar / menus
tb = QToolBar("Main")
tb.setMovable(False)
tb.setToolButtonStyle(Qt.ToolButtonTextOnly)
self.addToolBar(tb)
self.act_add = QAction("Add Files...", self, shortcut=QKeySequence.Open)
self.act_add_dir = QAction("Add Folder...", self)
self.act_remove = QAction("Remove Selected", self, shortcut=QKeySequence.Delete)
self.act_clear = QAction("Clear Queue", self)
for a in (self.act_add, self.act_add_dir, self.act_remove, self.act_clear):
tb.addAction(a)
def action(text, slot, shortcut=None):
a = QAction(text, self)
a.triggered.connect(slot)
if shortcut is not None:
a.setShortcut(shortcut)
return a
m_file = self.menuBar().addMenu("&File")
m_file.addAction(self.act_add)
m_file.addAction(self.act_add_dir)
m_file.addSeparator()
m_file.addAction(action("Quit", self.close, QKeySequence.Quit))
m_settings = self.menuBar().addMenu("&Settings")
m_settings.addAction(action("Set FFmpeg Location...", self._choose_ffmpeg_dir))
m_settings.addAction(action("Use Automatic FFmpeg Detection", self._reset_ffmpeg_dir))
m_settings.addSeparator()
m_settings.addAction(action("Reset Conversion Options", self._reset_options))
self.menuBar().addMenu("&Help").addAction(action("About", self._about))
# Queue table
self.table = QTableView()
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.table.setAlternatingRowColors(True)
self.table.verticalHeader().setVisible(False)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.setItemDelegateForColumn(COL_PROGRESS, ProgressDelegate(self.table))
hh = self.table.horizontalHeader()
hh.setSectionResizeMode(QHeaderView.Interactive)
hh.setSectionResizeMode(0, QHeaderView.Stretch)
for col, w in ((1, 70), (2, 190), (3, 70), (4, 45), (5, 190), (6, 110)):
self.table.setColumnWidth(col, w)
self.drop_hint = QLabel("Drop video files or folders here, or use Add Files...")
self.drop_hint.setAlignment(Qt.AlignCenter)
self.drop_hint.setStyleSheet("color: gray;")
queue_box = QWidget()
ql = QVBoxLayout(queue_box)
ql.setContentsMargins(0, 0, 0, 0)
ql.addWidget(self.table)
ql.addWidget(self.drop_hint)
# Audio track panel
tracks_box = QGroupBox("Audio tracks")
tl = QVBoxLayout(tracks_box)
self.tracks_title = QLabel("Select a file in the queue")
self.tracks_title.setWordWrap(True)
self.track_list = TrackList()
hint = QLabel("Tick the tracks to keep. Drag to reorder - the first ticked track becomes the default.")
hint.setWordWrap(True)
hint.setStyleSheet("color: gray;")
self.stream_info = QLabel("")
self.stream_info.setWordWrap(True)
row = QHBoxLayout()
self.btn_all = QPushButton("All")
self.btn_none = QPushButton("None")
self.btn_apply_all = QPushButton("Apply to All Files")
self.btn_apply_all.setToolTip(
"Copy this track selection to every other file in the queue.\n"
"Tracks are matched by language, codec and channel layout.")
row.addWidget(self.btn_all)
row.addWidget(self.btn_none)
row.addStretch(1)
row.addWidget(self.btn_apply_all)
tl.addWidget(self.tracks_title)
tl.addWidget(self.track_list, 1)
tl.addWidget(hint)
tl.addLayout(row)
tl.addWidget(self.stream_info)
top = QSplitter(Qt.Horizontal)
top.addWidget(queue_box)
top.addWidget(tracks_box)
top.setStretchFactor(0, 3)
top.setStretchFactor(1, 2)
top.setSizes([800, 460])
# Settings
self.settings_box = QWidget()
sl = QHBoxLayout(self.settings_box)
sl.setContentsMargins(0, 0, 0, 0)
sl.addWidget(self._build_output_group(), 1)
sl.addWidget(self._build_video_group(), 1)
sl.addWidget(self._build_audio_group(), 1)
# Log
self.log_view = QPlainTextEdit()
self.log_view.setReadOnly(True)
self.log_view.setMaximumBlockCount(10000)
self.log_view.setFont(QFontDatabase.systemFont(QFontDatabase.FixedFont))
splitter = QSplitter(Qt.Vertical)
splitter.addWidget(top)
splitter.addWidget(self.settings_box)
splitter.addWidget(self.log_view)
splitter.setStretchFactor(0, 3)
splitter.setStretchFactor(2, 1)
splitter.setCollapsible(1, False)
splitter.setSizes([380, 300, 160])
# Bottom bar
self.overall = QProgressBar()
self.overall.setRange(0, 1000)
self.overall.setFormat("%p%")
self.btn_preview = QPushButton("Show Command")
self.btn_preview.setToolTip("Print the ffmpeg command for the selected file to the log")
self.btn_start = QPushButton("Start")
self.btn_start.setDefault(True)
self.btn_start.setMinimumWidth(110)
self.btn_stop = QPushButton("Stop")
self.btn_stop.setEnabled(False)
bottom = QHBoxLayout()
bottom.addWidget(self.overall, 1)
bottom.addWidget(self.btn_preview)
bottom.addWidget(self.btn_start)
bottom.addWidget(self.btn_stop)
central = QWidget()
cl = QVBoxLayout(central)
cl.addWidget(splitter, 1)
cl.addLayout(bottom)
self.setCentralWidget(central)
self.ffmpeg_label = QLabel()
self.statusBar().addPermanentWidget(self.ffmpeg_label)
def _build_output_group(self) -> QGroupBox:
box = QGroupBox("Output")
f = QFormLayout(box)
self.cb_container = _combo([(c.label, c.key) for c in CONTAINERS.values()])
f.addRow("Container:", self.cb_container)
self.rb_same_dir = QRadioButton("Same folder as source")
self.rb_custom_dir = QRadioButton("Custom folder:")
self.ed_out_dir = QLineEdit()
self.ed_out_dir.setPlaceholderText("Choose an output folder")
self.btn_out_dir = QPushButton("Browse...")
dir_row = QHBoxLayout()
dir_row.addWidget(self.ed_out_dir, 1)
dir_row.addWidget(self.btn_out_dir)
f.addRow("Save to:", self.rb_same_dir)
f.addRow("", self.rb_custom_dir)
f.addRow("", dir_row)
self.ed_suffix = QLineEdit()
self.ed_suffix.setPlaceholderText("e.g. _jellyfin (optional)")
f.addRow("Name suffix:", self.ed_suffix)
self.cb_exists = _combo([(v, k) for k, v in EXISTS_POLICIES.items()])
f.addRow("If file exists:", self.cb_exists)
self.chk_subs = QCheckBox("Keep subtitles (converted where needed)")
self.chk_subs.setToolTip(
"MP4/MOV: text subtitles become mov_text; image subtitles (PGS/VobSub) are dropped.\n"
"MKV: all subtitles are kept as-is. WebM: text becomes WebVTT. AVI: none.")
f.addRow("", self.chk_subs)
return box
def _build_video_group(self) -> QGroupBox:
self.video_box = box = QGroupBox("Video")
f = QFormLayout(box)
self.cb_video_mode = _combo([(v, k) for k, v in VIDEO_MODES.items()])
f.addRow("Mode:", self.cb_video_mode)
self.cb_vcodec = QComboBox()
f.addRow("Encoder:", self.cb_vcodec)
self.cb_resolution = _combo([(label, h) for label, h in RESOLUTIONS] + [("Custom...", CUSTOM_RESOLUTION)])
self.cb_resolution.setToolTip("Target size of the shorter side; aspect ratio is kept.")
f.addRow("Resolution:", self.cb_resolution)
self.sp_width = QSpinBox()
self.sp_height = QSpinBox()
for sp in (self.sp_width, self.sp_height):
sp.setRange(0, 16384)
sp.setSingleStep(2)
sp.setSpecialValueText("auto")
self.custom_row = QWidget()
cr = QHBoxLayout(self.custom_row)
cr.setContentsMargins(0, 0, 0, 0)
cr.addWidget(self.sp_width, 1)
cr.addWidget(QLabel("x"))
cr.addWidget(self.sp_height, 1)
f.addRow("Width x Height:", self.custom_row)
self.custom_row_label = f.labelForField(self.custom_row)
self.chk_no_upscale = QCheckBox("Never upscale smaller videos")
f.addRow("", self.chk_no_upscale)
self.cb_fps = _combo([(v, v) for v in FRAME_RATES])
f.addRow("Frame rate:", self.cb_fps)
self.sp_quality = QSpinBox()
self.sp_quality.setToolTip("CRF / CQ value. Lower = better quality and bigger files.")
f.addRow("Quality (lower=better):", self.sp_quality)
self.cb_speed = _combo([(s, i) for i, s in enumerate(SPEED_LEVELS)])
self.cb_speed.setToolTip("Encoder preset. Slower presets give smaller files at the same quality.")
f.addRow("Speed:", self.cb_speed)
return box
def _build_audio_group(self) -> QGroupBox:
self.audio_box = box = QGroupBox("Audio")
f = QFormLayout(box)
self.cb_audio_mode = _combo([(v, k) for k, v in AUDIO_MODES.items()])
f.addRow("Mode:", self.cb_audio_mode)
self.cb_acodec = QComboBox()
f.addRow("Encoder:", self.cb_acodec)
self.cb_abitrate = _combo([("Auto", 0)] + [(f"{b} kb/s", b) for b in AUDIO_BITRATES if b])
f.addRow("Bitrate:", self.cb_abitrate)
self.cb_channels = _combo(AUDIO_CHANNELS)
self.cb_channels.setToolTip("Up/down-mix the channel layout (e.g. 5.1 to stereo).")
f.addRow("Channels:", self.cb_channels)
self.cb_samplerate = _combo([("Original", 0)] + [(f"{r / 1000:g} kHz", r) for r in AUDIO_SAMPLE_RATES if r])
self.cb_samplerate.setToolTip("Resample audio to a different sample rate.")
f.addRow("Sample rate:", self.cb_samplerate)
note = QLabel("Auto copies tracks the container supports and re-encodes the rest "
"(e.g. DTS/TrueHD -> AAC for MP4).")
note.setWordWrap(True)
note.setStyleSheet("color: gray;")
f.addRow(note)
return box
def _connect(self) -> None:
self.act_add.triggered.connect(self._add_files_dialog)
self.act_add_dir.triggered.connect(self._add_folder_dialog)
self.act_remove.triggered.connect(self._remove_selected)
self.act_clear.triggered.connect(self._clear_queue)
self.table.selectionModel().currentRowChanged.connect(lambda *_: self._show_tracks())
self.table.customContextMenuRequested.connect(self._table_menu)
self.model.rowsInserted.connect(self._update_hint)
self.model.rowsRemoved.connect(self._update_hint)
self.track_list.itemChanged.connect(self._track_item_changed)
self.track_list.order_changed.connect(self._track_order_changed)
self.btn_all.clicked.connect(lambda: self._check_all_tracks(True))
self.btn_none.clicked.connect(lambda: self._check_all_tracks(False))
self.btn_apply_all.clicked.connect(self._apply_tracks_to_all)
self.cb_container.currentIndexChanged.connect(self._container_changed)
self.rb_same_dir.toggled.connect(self._update_enabled)
self.btn_out_dir.clicked.connect(self._browse_out_dir)
self.cb_video_mode.currentIndexChanged.connect(self._update_enabled)
self.cb_audio_mode.currentIndexChanged.connect(self._update_enabled)
self.cb_resolution.currentIndexChanged.connect(self._update_enabled)
self.cb_vcodec.currentIndexChanged.connect(self._video_encoder_changed)
self.cb_acodec.currentIndexChanged.connect(self._update_enabled)
self.btn_start.clicked.connect(self._start)
self.btn_stop.clicked.connect(self.runner.cancel)
self.btn_preview.clicked.connect(self._preview_command)
self.runner.job_changed.connect(self.model.refresh)
self.runner.log.connect(self._log)
self.runner.progress.connect(lambda v: self.overall.setValue(int(v * 1000)))
self.runner.finished.connect(self._batch_finished)
# ------------------------------------------------------------ settings
def _load_settings(self) -> None:
geo = self.settings.value("geometry")
if geo is not None:
self.restoreGeometry(geo)
try:
opts = Options.from_dict(json.loads(self.settings.value("options", "{}")))
except (TypeError, ValueError):
opts = Options()
self._apply_options(opts)
def _save_settings(self) -> None:
self.settings.setValue("geometry", self.saveGeometry())
self.settings.setValue("options", json.dumps(self.current_options().to_dict()))
def _apply_options(self, o: Options) -> None:
self._loading = True
try:
_select_data(self.cb_container, o.container)
self._populate_encoders()
self.rb_custom_dir.setChecked(bool(o.output_dir))
self.rb_same_dir.setChecked(not o.output_dir)
self.ed_out_dir.setText(o.output_dir)
self.ed_suffix.setText(o.suffix)
_select_data(self.cb_exists, o.if_exists)
self.chk_subs.setChecked(o.subtitles)
_select_data(self.cb_video_mode, o.video_mode)
_select_data(self.cb_vcodec, o.video_encoder)
_select_data(self.cb_resolution, o.resolution)
self.sp_width.setValue(o.custom_width)
self.sp_height.setValue(o.custom_height)
self.chk_no_upscale.setChecked(o.no_upscale)
_select_data(self.cb_fps, o.fps)
self._update_quality_range(reset=False)
self.sp_quality.setValue(o.quality)
_select_data(self.cb_speed, o.speed)
_select_data(self.cb_audio_mode, o.audio_mode)
_select_data(self.cb_acodec, o.audio_encoder)
_select_data(self.cb_abitrate, o.audio_bitrate)
_select_data(self.cb_channels, o.audio_channels)
_select_data(self.cb_samplerate, o.audio_sample_rate)
finally:
self._loading = False
self._update_enabled()
def current_options(self) -> Options:
return Options(
container=self.cb_container.currentData(),
output_dir=self.ed_out_dir.text().strip() if self.rb_custom_dir.isChecked() else "",
suffix=self.ed_suffix.text(),
if_exists=self.cb_exists.currentData(),
video_mode=self.cb_video_mode.currentData(),
video_encoder=self.cb_vcodec.currentData() or "libx264",
resolution=self.cb_resolution.currentData(),
custom_width=self.sp_width.value(),
custom_height=self.sp_height.value(),
no_upscale=self.chk_no_upscale.isChecked(),
fps=self.cb_fps.currentData(),
quality=self.sp_quality.value(),
speed=self.cb_speed.currentData(),
audio_mode=self.cb_audio_mode.currentData(),
audio_encoder=self.cb_acodec.currentData() or "aac",
audio_bitrate=self.cb_abitrate.currentData(),
audio_channels=self.cb_channels.currentData(),
audio_sample_rate=self.cb_samplerate.currentData(),
subtitles=self.chk_subs.isChecked(),
)
def _reset_options(self) -> None:
self._apply_options(Options())
# ------------------------------------------------------------- ffmpeg
def _init_tools(self) -> None:
self.tools = locate_tools(self.settings.value("ffmpeg_dir", "") or None)
if self.tools:
self.ffmpeg_label.setText(f"FFmpeg {self.tools.version}")
self.ffmpeg_label.setToolTip(f"{self.tools.ffmpeg}\n{self.tools.ffprobe}")
self._log(f"Using ffmpeg {self.tools.version}: {self.tools.ffmpeg}")
else:
self.ffmpeg_label.setText("FFmpeg not found - Settings > Set FFmpeg Location")
self.ffmpeg_label.setToolTip("")
self._log("FFmpeg was not found. Put ffmpeg and ffprobe next to this program, install them "
"on your PATH, or use Settings > Set FFmpeg Location.")
self._populate_encoders()
# Re-probe anything that was waiting for ffprobe.
for job in self.model.jobs:
if job.status in (Status.PROBING, Status.INVALID) and job.info is None:
self._probe(job)
def _choose_ffmpeg_dir(self) -> None:
d = QFileDialog.getExistingDirectory(self, "Folder containing ffmpeg and ffprobe",
self.settings.value("ffmpeg_dir", ""))
if not d:
return
found = locate_tools(d)
if found is None or Path(found.ffmpeg).parent != Path(d):
QMessageBox.warning(self, APP_NAME, "ffmpeg and ffprobe were not found in that folder.")
return
self.settings.setValue("ffmpeg_dir", d)
self._init_tools()
def _reset_ffmpeg_dir(self) -> None:
self.settings.remove("ffmpeg_dir")
self._init_tools()
# ---------------------------------------------------- dynamic controls
def _populate_encoders(self) -> None:
"""Fill encoder combos with encoders that are available and fit the container."""
container = CONTAINERS[self.cb_container.currentData()]
available = self.tools.encoders if self.tools else None
vcodec_changed = False
def fits(enc, accepted):
return (accepted is None or enc.codec in accepted) and (available is None or enc.name in available)
for cb, encoders, accepted in (
(self.cb_vcodec, VIDEO_ENCODERS, container.video_codecs),
(self.cb_acodec, AUDIO_ENCODERS, container.audio_codecs),
):
current = cb.currentData()
cb.blockSignals(True)
cb.clear()
for enc in encoders:
if fits(enc, accepted):
cb.addItem(enc.label, enc.name)
if not _select_data(cb, current):
cb.setCurrentIndex(0)
cb.blockSignals(False)
if cb is self.cb_vcodec:
vcodec_changed = cb.currentData() != current
self._update_quality_range(reset=vcodec_changed and not self._loading)
def _container_changed(self) -> None:
if self._loading:
return
self._populate_encoders()
self._update_enabled()
def _video_encoder_changed(self) -> None:
if not self._loading:
self._update_quality_range(reset=True)
self._update_enabled()
def _update_quality_range(self, reset: bool) -> None:
enc = VIDEO_ENCODERS_BY_NAME.get(self.cb_vcodec.currentData())
if enc is None:
return
self.sp_quality.setRange(*enc.quality_range)
if reset:
self.sp_quality.setValue(enc.quality_default)
def _update_enabled(self) -> None:
if self._loading:
return
running = self.runner.running
self.settings_box.setEnabled(not running)
self.ed_out_dir.setEnabled(self.rb_custom_dir.isChecked())
self.btn_out_dir.setEnabled(self.rb_custom_dir.isChecked())
container = CONTAINERS[self.cb_container.currentData()]
subs_possible = container.text_subtitles is not None or container.image_subtitles != frozenset()
self.chk_subs.setEnabled(subs_possible)
vencode = self.cb_video_mode.currentData() != "copy"
for w in (self.cb_vcodec, self.cb_resolution, self.chk_no_upscale, self.cb_fps, self.sp_quality):
w.setEnabled(vencode)
enc = VIDEO_ENCODERS_BY_NAME.get(self.cb_vcodec.currentData())
self.cb_speed.setEnabled(vencode and enc is not None and enc.speed_option is not None)
custom = self.cb_resolution.currentData() == CUSTOM_RESOLUTION
self.custom_row.setVisible(custom)
if self.custom_row_label:
self.custom_row_label.setVisible(custom)
self.custom_row.setEnabled(vencode)
amode = self.cb_audio_mode.currentData()
aencode = amode in ("auto", "encode")
for w in (self.cb_acodec, self.cb_channels, self.cb_samplerate):
w.setEnabled(aencode)
aenc = AUDIO_ENCODERS_BY_NAME.get(self.cb_acodec.currentData())
self.cb_abitrate.setEnabled(aencode and aenc is not None and not aenc.lossless)
self.track_list.setEnabled(amode != "strip")
for b in (self.btn_all, self.btn_none, self.btn_apply_all):
b.setEnabled(amode != "strip")
def _browse_out_dir(self) -> None:
d = QFileDialog.getExistingDirectory(self, "Output folder", self.ed_out_dir.text() or str(Path.home()))
if d:
self.ed_out_dir.setText(d)
# --------------------------------------------------------- adding files
def _add_files_dialog(self) -> None:
exts = " ".join(f"*{e}" for e in sorted(MEDIA_EXTENSIONS))
files, _ = QFileDialog.getOpenFileNames(
self, "Add media files", self.settings.value("last_dir", str(Path.home())),
f"Media files ({exts});;All files (*)")
if files:
self.settings.setValue("last_dir", str(Path(files[0]).parent))
self.add_paths([Path(f) for f in files])
def _add_folder_dialog(self) -> None:
d = QFileDialog.getExistingDirectory(self, "Add all media files in folder (including sub-folders)",
self.settings.value("last_dir", str(Path.home())))
if d:
self.settings.setValue("last_dir", d)
self.add_paths([Path(d)])
def add_paths(self, paths: list[Path]) -> None:
files: list[Path] = []
for p in paths:
if p.is_dir():
files += sorted((f for f in p.rglob("*") if f.is_file() and f.suffix.lower() in MEDIA_EXTENSIONS),
key=lambda f: str(f).lower())
elif p.is_file():
files.append(p)
existing = {os.path.normcase(str(j.path.resolve())) for j in self.model.jobs}
new_jobs = []
for f in files:
key = os.path.normcase(str(f.resolve()))
if key in existing or ".part." in f.name:
continue
existing.add(key)
new_jobs.append(Job(path=f.resolve()))
self.model.add(new_jobs)
for job in new_jobs:
self._probe(job)
if new_jobs and not self.table.currentIndex().isValid():
self.table.selectRow(0)
if paths and not new_jobs:
self.statusBar().showMessage("No new media files found", 4000)
def _probe(self, job: Job) -> None:
if not self.tools:
job.status, job.message = Status.INVALID, "ffprobe not found"
self.model.refresh(job)
return
job.status, job.message = Status.PROBING, ""
self.model.refresh(job)
self.pool.start(_ProbeTask(self.tools.ffprobe, job, self.probe_signals))
def _on_probed(self, job: Job, info: MediaInfo | None, error: str) -> None:
if self.model.row_of(job) < 0:
return # removed while probing
if info is None:
job.status, job.message = Status.INVALID, error
self._log(f"Cannot read {job.path.name}: {error}")
else:
job.info = info
job.audio_order = [s.index for s in info.audio]
job.audio_checked = set(job.audio_order)
job.status, job.message = Status.READY, ""
self.model.refresh(job)
if job is self._current_job():
self._show_tracks()
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
paths = [Path(u.toLocalFile()) for u in event.mimeData().urls() if u.isLocalFile()]
self.add_paths(paths)
def _update_hint(self, *_):
self.drop_hint.setVisible(not self.model.jobs)
# ------------------------------------------------------- queue actions
def _selected_rows(self) -> list[int]:
return sorted({i.row() for i in self.table.selectionModel().selectedRows()})
def _current_job(self) -> Job | None:
idx = self.table.currentIndex()
return self.model.job_at(idx.row()) if idx.isValid() else None
def _remove_selected(self) -> None:
rows = [r for r in self._selected_rows() if self.model.jobs[r].status != Status.RUNNING
and self.model.jobs[r].status != Status.QUEUED]
self.model.remove_rows(rows)
self._show_tracks()
def _clear_queue(self) -> None:
if self.runner.running:
QMessageBox.information(self, APP_NAME, "Stop the current batch before clearing the queue.")
return
self.model.remove_rows(list(range(len(self.model.jobs))))
self.overall.setValue(0)
self._show_tracks()
def _table_menu(self, pos) -> None:
job = self.model.job_at(self.table.indexAt(pos).row())
if job is None:
return
menu = QMenu(self)
target = job.output if job.output and job.output.exists() else job.path
menu.addAction("Open Containing Folder").triggered.connect(
lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(target.parent))))
if job.status == Status.DONE:
menu.addAction("Convert Again").triggered.connect(lambda: self._requeue(job))
if job.status == Status.INVALID:
menu.addAction("Retry Reading File").triggered.connect(lambda: self._probe(job))
menu.addSeparator()
menu.addAction(self.act_remove)
menu.exec(self.table.viewport().mapToGlobal(pos))
def _requeue(self, job: Job) -> None:
job.status, job.progress, job.message = Status.READY, 0.0, ""
self.model.refresh(job)
# ---------------------------------------------------------- track panel
def _show_tracks(self) -> None:
job = self._current_job()
self._track_job = job
self.track_list.blockSignals(True)
self.track_list.clear()
if job is None or job.info is None:
self.tracks_title.setText("Select a file in the queue" if job is None
else f"{job.path.name}\n({job.status.value})")
self.stream_info.setText("")
self.track_list.blockSignals(False)
return
info = job.info
self.tracks_title.setText(f"<b>{job.path.name}</b>")
by_index = {s.index: s for s in info.audio}
for n, idx in enumerate(job.audio_order):
s = by_index[idx]
pos = info.audio.index(s) + 1
text = f"Track {pos}: {(s.language or 'und').upper():4} {s.describe()}"
if s.title:
text += f' "{s.title}"'
if s.default:
text += " (default)"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, idx)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsDragEnabled)
item.setCheckState(Qt.Checked if idx in job.audio_checked else Qt.Unchecked)
self.track_list.addItem(item)
if not info.audio:
self.track_list.addItem(QListWidgetItem("(no audio tracks)"))
v = info.video
text_subs = sum(1 for s in info.subtitles if s.codec in TEXT_SUBTITLE_CODECS)
image_subs = len(info.subtitles) - text_subs
parts = [f"Video: {v.describe()}" if v else "Video: none"]
if info.subtitles:
parts.append(f"Subtitles: {text_subs} text, {image_subs} image")
self.stream_info.setText(" | ".join(parts))
self.track_list.blockSignals(False)
def _sync_tracks_from_list(self) -> None:
job = self._track_job
if job is None or job.info is None:
return
order, checked = [], set()
for i in range(self.track_list.count()):
item = self.track_list.item(i)
idx = item.data(Qt.UserRole)
if idx is None:
continue
order.append(idx)
if item.checkState() == Qt.Checked:
checked.add(idx)
job.audio_order, job.audio_checked = order, checked
self.model.refresh(job)
def _track_item_changed(self, _item) -> None:
self._sync_tracks_from_list()
def _track_order_changed(self) -> None:
self._sync_tracks_from_list()
def _check_all_tracks(self, checked: bool) -> None:
self.track_list.blockSignals(True)
for i in range(self.track_list.count()):
item = self.track_list.item(i)
if item.data(Qt.UserRole) is not None:
item.setCheckState(Qt.Checked if checked else Qt.Unchecked)
self.track_list.blockSignals(False)
self._sync_tracks_from_list()
def _apply_tracks_to_all(self) -> None:
ref = self._track_job
if ref is None or ref.info is None:
return
changed = 0
for job in self.model.jobs:
if job is ref or job.info is None or job.status in (Status.RUNNING, Status.QUEUED):
continue
if not ref.selected_audio:
job.audio_checked = set()
else:
job.set_audio_selection(match_audio_selection(ref.info.audio, ref.selected_audio, job.info.audio))
self.model.refresh(job)
changed += 1
self.statusBar().showMessage(f"Track selection applied to {changed} other file(s)", 5000)
# ------------------------------------------------------------- running
def _check_ready(self, opts: Options) -> bool:
if not self.tools:
QMessageBox.warning(self, APP_NAME, "FFmpeg was not found. Use Settings > Set FFmpeg Location.")
return False
if self.rb_custom_dir.isChecked():
if not opts.output_dir:
QMessageBox.warning(self, APP_NAME, "Choose an output folder, or select 'Same folder as source'.")
return False
try:
Path(opts.output_dir).mkdir(parents=True, exist_ok=True)
except OSError as e:
QMessageBox.warning(self, APP_NAME, f"Cannot use output folder:\n{e}")
return False
if opts.video_mode != "copy" and opts.resolution == CUSTOM_RESOLUTION \
and not (opts.custom_width or opts.custom_height):
QMessageBox.warning(self, APP_NAME, "Enter a custom width and/or height.")
return False
return True
def _start(self) -> None:
if self.runner.running:
return
opts = self.current_options()
if not self._check_ready(opts):
return
jobs = [j for j in self.model.jobs if j.status in RUNNABLE and j.info is not None]
if not jobs:
busy = any(j.status == Status.PROBING for j in self.model.jobs)
QMessageBox.information(self, APP_NAME, "Files are still being read - try again in a moment."
if busy else "Nothing to convert. Add some files first.")
return
no_audio = [j for j in jobs if j.info.audio and not j.selected_audio and opts.audio_mode != "strip"]
if no_audio:
names = "\n".join(j.path.name for j in no_audio[:10])
if QMessageBox.question(self, APP_NAME, f"These files have no audio track ticked and will be "
f"converted without audio:\n\n{names}\n\nContinue?") != QMessageBox.Yes:
return
self._save_settings()
self.overall.setValue(0)
self._set_running(True)
self.runner.start(jobs, opts, self.tools)
def _set_running(self, running: bool) -> None:
self.btn_start.setEnabled(not running)
self.btn_stop.setEnabled(running)
self.act_clear.setEnabled(not running)
self.settings_box.setEnabled(not running)
if not running:
self._update_enabled()
def _batch_finished(self, counts: dict) -> None:
self._set_running(False)
summary = ", ".join(f"{n} {k.lower()}" for k, n in counts.items())
self._log(f"Batch finished: {summary or 'nothing to do'}")
self.statusBar().showMessage(f"Finished: {summary}", 10000)
if counts.get(Status.FAILED.value) or counts.get(Status.CANCELLED.value):
return
self.overall.setValue(1000)
def _preview_command(self) -> None:
job = self._current_job()
if job is None or job.info is None:
self._log("Select a file that has been read successfully to preview its command.")
return
if not self.tools:
self._log("FFmpeg not found.")
return
opts = self.current_options()
out = plan_output_path(job.path, opts, set())
if out is None:
self._log(f"{job.path.name}: output already exists and would be skipped.")
return
built = build_command(self.tools.ffmpeg, job.info, opts, job.selected_audio, out)
# Drop the progress plumbing so the command can be pasted into a terminal.
args = list(built.args)
for flag in ("-progress", "-nostats", "-nostdin"):
if flag in args:
i = args.index(flag)
del args[i:i + (2 if flag == "-progress" else 1)]
self._log("")
self._log(f"--- Command preview for {job.path.name}")
for note in built.notes:
self._log(f" {note}")
self._log(format_command(args))
def _log(self, text: str) -> None:
self.log_view.appendPlainText(text)
def _about(self) -> None:
ff = f"FFmpeg {self.tools.version}\n{self.tools.ffmpeg}" if self.tools else "FFmpeg not found"
QMessageBox.about(self, APP_NAME, f"{APP_NAME} {__version__}\n\nA simple front-end for ffmpeg.\n\n{ff}")
def closeEvent(self, event):
if self.runner.running:
if QMessageBox.question(self, APP_NAME, "A conversion is running. Stop it and quit?") != QMessageBox.Yes:
event.ignore()
return
self.runner.kill_now()
self._save_settings()
self.pool.clear()
event.accept()

160
ffwrap/gui/queue_model.py Normal file
View File

@@ -0,0 +1,160 @@
"""The file queue: Job objects plus a Qt table model / progress delegate."""
from __future__ import annotations
import enum
from dataclasses import dataclass, field
from pathlib import Path
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtWidgets import QApplication, QStyle, QStyledItemDelegate, QStyleOptionProgressBar
from ..probe import MediaInfo
class Status(enum.Enum):
PROBING = "Reading..."
READY = "Ready"
INVALID = "Unreadable"
QUEUED = "Queued"
RUNNING = "Converting"
DONE = "Done"
FAILED = "Failed"
SKIPPED = "Skipped"
CANCELLED = "Cancelled"
# Jobs in these states are (re)processed when Start is pressed.
RUNNABLE = {Status.READY, Status.FAILED, Status.CANCELLED, Status.SKIPPED}
@dataclass(eq=False)
class Job:
path: Path
info: MediaInfo | None = None
status: Status = Status.PROBING
progress: float = 0.0
message: str = ""
audio_order: list[int] = field(default_factory=list)
audio_checked: set[int] = field(default_factory=set)
output: Path | None = None
@property
def selected_audio(self) -> list[int]:
return [i for i in self.audio_order if i in self.audio_checked]
def set_audio_selection(self, indices: list[int]) -> None:
"""Check ``indices`` and move them (in order) to the top of the list."""
rest = [i for i in self.audio_order if i not in indices]
self.audio_order = list(indices) + rest
self.audio_checked = set(indices)
def fmt_duration(seconds: float | None) -> str:
if not seconds:
return ""
s = int(round(seconds))
return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}"
COLUMNS = ["File", "Duration", "Video", "Audio", "Subs", "Status", "Progress"]
COL_STATUS, COL_PROGRESS = 5, 6
class QueueModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self.jobs: list[Job] = []
# -- Qt model API -------------------------------------------------------
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.jobs)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(COLUMNS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return COLUMNS[section]
return None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
job = self.jobs[index.row()]
col = index.column()
info = job.info
if role == Qt.DisplayRole:
if col == 0:
return job.path.name
if col == 1:
return fmt_duration(info.duration) if info else ""
if col == 2:
return info.video.describe() if info and info.video else ("none" if info else "")
if col == 3:
if not info:
return ""
return f"{len(job.audio_checked)} of {len(info.audio)}" if info.audio else "none"
if col == 4:
return str(len(info.subtitles)) if info else ""
if col == COL_STATUS:
return job.status.value + (f": {job.message}" if job.message else "")
if col == COL_PROGRESS:
return None if job.status in (Status.PROBING, Status.INVALID) else job.progress
elif role == Qt.ToolTipRole:
if col == 0:
return str(job.path)
if col == COL_STATUS:
tip = job.status.value + (f"\n{job.message}" if job.message else "")
if job.output:
tip += f"\nOutput: {job.output}"
return tip
elif role == Qt.UserRole:
return job
return None
# -- helpers ------------------------------------------------------------
def add(self, jobs: list[Job]) -> None:
if not jobs:
return
start = len(self.jobs)
self.beginInsertRows(QModelIndex(), start, start + len(jobs) - 1)
self.jobs.extend(jobs)
self.endInsertRows()
def remove_rows(self, rows: list[int]) -> None:
for row in sorted(set(rows), reverse=True):
self.beginRemoveRows(QModelIndex(), row, row)
del self.jobs[row]
self.endRemoveRows()
def row_of(self, job: Job) -> int:
for i, j in enumerate(self.jobs):
if j is job:
return i
return -1
def refresh(self, job: Job) -> None:
row = self.row_of(job)
if row >= 0:
self.dataChanged.emit(self.index(row, 0), self.index(row, len(COLUMNS) - 1))
def job_at(self, row: int) -> Job | None:
return self.jobs[row] if 0 <= row < len(self.jobs) else None
class ProgressDelegate(QStyledItemDelegate):
def paint(self, painter, option, index):
value = index.data(Qt.DisplayRole)
if value is None:
super().paint(painter, option, index)
return
bar = QStyleOptionProgressBar()
bar.rect = option.rect.adjusted(2, 3, -2, -3)
bar.minimum, bar.maximum = 0, 1000
bar.progress = int(value * 1000)
bar.text = f"{value * 100:.0f}%"
bar.textVisible = True
bar.state = option.state
style = option.widget.style() if option.widget else QApplication.style()
style.drawControl(QStyle.CE_ProgressBar, bar, painter, option.widget)

219
ffwrap/gui/runner.py Normal file
View File

@@ -0,0 +1,219 @@
"""Runs ffmpeg for each queued job, one at a time, via QProcess."""
from __future__ import annotations
import os
from pathlib import Path
from PySide6.QtCore import QObject, QProcess, Signal
from ..command import Options, build_command, format_command, plan_output_path, temp_output_path
from ..tools import FFmpegTools
from .queue_model import Job, Status
def _remove_quietly(path: Path | None) -> None:
if path is None:
return
try:
path.unlink()
except OSError:
pass
class BatchRunner(QObject):
job_changed = Signal(object) # Job
log = Signal(str)
progress = Signal(float) # overall 0..1
finished = Signal(dict) # counts per status
def __init__(self, parent=None):
super().__init__(parent)
self._proc: QProcess | None = None
self._jobs: list[Job] = []
self._pos = 0
self._opts = Options()
self._tools: FFmpegTools | None = None
self._taken: set[str] = set()
self._current: Job | None = None
self._temp: Path | None = None
self._stdout_buf = b""
self._stderr_buf = b""
self._stderr_tail: list[str] = []
self._cancelled = False
@property
def running(self) -> bool:
return self._current is not None
# -- public API ---------------------------------------------------------
def start(self, jobs: list[Job], opts: Options, tools: FFmpegTools) -> None:
self._jobs, self._opts, self._tools = jobs, opts, tools
self._pos, self._taken, self._cancelled = 0, set(), False
for job in jobs:
job.status, job.progress, job.message, job.output = Status.QUEUED, 0.0, "", None
self.job_changed.emit(job)
self._next()
def cancel(self) -> None:
self._cancelled = True
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
self.log.emit("Stopping...")
self._proc.kill()
elif self._current is None:
self._finish_batch()
def kill_now(self) -> None:
"""Used on application exit: stop ffmpeg and remove the partial file."""
self._cancelled = True
if self._proc is not None and self._proc.state() != QProcess.NotRunning:
self._proc.kill()
self._proc.waitForFinished(3000)
_remove_quietly(self._temp)
# -- internals ----------------------------------------------------------
def _emit_overall(self, current_fraction: float = 0.0) -> None:
if self._jobs:
self.progress.emit(min(1.0, (self._pos + current_fraction) / len(self._jobs)))
def _next(self) -> None:
while self._pos < len(self._jobs):
if self._cancelled:
break
job = self._jobs[self._pos]
if self._launch(job):
return
self._pos += 1
self._emit_overall()
self._finish_batch()
def _launch(self, job: Job) -> bool:
"""Start ffmpeg for ``job``. Returns False if the job finished immediately."""
opts = self._opts
out = plan_output_path(job.path, opts, self._taken)
if out is None:
job.status, job.message = Status.SKIPPED, "output already exists"
self.log.emit(f"Skipping {job.path.name}: output already exists")
self.job_changed.emit(job)
return False
try:
out.parent.mkdir(parents=True, exist_ok=True)
except OSError as e:
job.status, job.message = Status.FAILED, f"cannot create output folder: {e}"
self.log.emit(f"ERROR {job.path.name}: {job.message}")
self.job_changed.emit(job)
return False
self._temp = temp_output_path(out)
built = build_command(self._tools.ffmpeg, job.info, opts, job.selected_audio, self._temp)
job.output = out
self._taken.add(os.path.normcase(os.path.abspath(out)))
self.log.emit("")
self.log.emit(f"=== [{self._pos + 1}/{len(self._jobs)}] {job.path}")
self.log.emit(f" -> {out}")
for note in built.notes:
self.log.emit(f" {note}")
self.log.emit(format_command(built.args))
self._current = job
self._stdout_buf = self._stderr_buf = b""
self._stderr_tail = []
job.status, job.progress, job.message = Status.RUNNING, 0.0, ""
self.job_changed.emit(job)
proc = QProcess(self)
proc.readyReadStandardOutput.connect(self._on_stdout)
proc.readyReadStandardError.connect(self._on_stderr)
proc.finished.connect(self._on_finished)
proc.errorOccurred.connect(self._on_error)
self._proc = proc
proc.start(built.args[0], built.args[1:])
return True
def _on_stdout(self) -> None:
self._stdout_buf += bytes(self._proc.readAllStandardOutput())
*lines, self._stdout_buf = self._stdout_buf.split(b"\n")
job = self._current
if job is None:
return
duration = job.info.duration if job.info else None
speed = None
updated = False
for raw in lines:
key, _, value = raw.decode(errors="replace").strip().partition("=")
if key == "out_time_us" and duration:
try:
job.progress = max(0.0, min(1.0, int(value) / 1e6 / duration))
updated = True
except ValueError:
pass
elif key == "speed" and value not in ("N/A", ""):
speed = value
if updated:
job.message = f"{job.progress * 100:.0f}%" + (f" @ {speed}" if speed else "")
self.job_changed.emit(job)
self._emit_overall(job.progress)
def _on_stderr(self) -> None:
self._stderr_buf += bytes(self._proc.readAllStandardError())
*lines, self._stderr_buf = self._stderr_buf.split(b"\n")
for raw in lines:
line = raw.decode(errors="replace").rstrip()
if line:
self._stderr_tail = (self._stderr_tail + [line])[-5:]
self.log.emit(f" ffmpeg: {line}")
def _on_error(self, error) -> None:
if error == QProcess.FailedToStart:
# finished() is not emitted in this case.
self._complete(ok=False, reason=f"could not start ffmpeg ({self._proc.errorString()})")
def _on_finished(self, exit_code: int, exit_status) -> None:
self._on_stderr()
ok = exit_status == QProcess.NormalExit and exit_code == 0
reason = "" if ok else (self._stderr_tail[-1] if self._stderr_tail else f"ffmpeg exited with code {exit_code}")
self._complete(ok, reason)
def _complete(self, ok: bool, reason: str) -> None:
job = self._current
if job is None:
return
self._current = None
if self._proc is not None:
self._proc.deleteLater()
self._proc = None
if self._cancelled:
job.status, job.message, job.progress = Status.CANCELLED, "", 0.0
_remove_quietly(self._temp)
self.log.emit(f"Cancelled {job.path.name}")
elif ok:
try:
os.replace(self._temp, job.output)
job.status, job.progress, job.message = Status.DONE, 1.0, ""
self.log.emit(f"Done: {job.output}")
except OSError as e:
job.status, job.message = Status.FAILED, f"could not rename output: {e}"
_remove_quietly(self._temp)
self.log.emit(f"ERROR {job.path.name}: {job.message}")
else:
job.status, job.message = Status.FAILED, reason
_remove_quietly(self._temp)
self.log.emit(f"FAILED {job.path.name}: {reason}")
self._temp = None
self.job_changed.emit(job)
self._pos += 1
self._emit_overall()
self._next()
def _finish_batch(self) -> None:
counts: dict[str, int] = {}
for job in self._jobs:
if job.status == Status.QUEUED:
job.status = Status.READY
self.job_changed.emit(job)
counts[job.status.value] = counts.get(job.status.value, 0) + 1
self._jobs = []
self.finished.emit(counts)

184
ffwrap/probe.py Normal file
View File

@@ -0,0 +1,184 @@
"""ffprobe wrapper producing simple dataclasses."""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from .formats import describe_channels
from .tools import run_quiet
class ProbeError(Exception):
pass
@dataclass
class Stream:
index: int # absolute stream index inside the file
kind: str # "video" | "audio" | "subtitle" | "data" | "attachment"
codec: str | None
profile: str | None = None
language: str | None = None
title: str | None = None
default: bool = False
forced: bool = False
attached_pic: bool = False
# video
width: int | None = None
height: int | None = None
fps: float | None = None
rotation: int = 0
pix_fmt: str | None = None
# audio
channels: int | None = None
channel_layout: str | None = None
sample_rate: int | None = None
bit_rate: int | None = None
@property
def display_size(self) -> tuple[int, int] | None:
"""Width/height after applying rotation metadata (what players show)."""
if not self.width or not self.height:
return None
if self.rotation % 180 == 90:
return self.height, self.width
return self.width, self.height
def describe(self) -> str:
if self.kind == "audio":
parts = [(self.codec or "?").upper(), describe_channels(self.channels, self.channel_layout)]
if self.sample_rate:
parts.append(f"{self.sample_rate / 1000:g} kHz")
if self.bit_rate:
parts.append(f"{self.bit_rate // 1000} kb/s")
return " ".join(parts)
if self.kind == "video":
size = self.display_size
s = (self.codec or "?").upper()
if size:
s += f" {size[0]}x{size[1]}"
if self.fps:
s += f" {self.fps:.3g}fps"
return s
return self.codec or "?"
@dataclass
class MediaInfo:
path: Path
duration: float | None
format_name: str
streams: list[Stream] = field(default_factory=list)
@property
def video(self) -> Stream | None:
"""The main video stream (cover art / thumbnails are ignored)."""
for s in self.streams:
if s.kind == "video" and not s.attached_pic:
return s
return None
@property
def audio(self) -> list[Stream]:
return [s for s in self.streams if s.kind == "audio"]
@property
def subtitles(self) -> list[Stream]:
return [s for s in self.streams if s.kind == "subtitle"]
def stream(self, index: int) -> Stream | None:
for s in self.streams:
if s.index == index:
return s
return None
def _to_float(v) -> float | None:
try:
f = float(v)
except (TypeError, ValueError):
return None
return f if f == f else None # filter NaN
def _to_int(v) -> int | None:
f = _to_float(v)
return int(f) if f is not None else None
def _rate(v: str | None) -> float | None:
if not v or v in ("0/0", "0"):
return None
if "/" in v:
num, den = v.split("/", 1)
n, d = _to_float(num), _to_float(den)
return n / d if n and d else None
return _to_float(v)
def _rotation(raw: dict) -> int:
for sd in raw.get("side_data_list") or []:
if "rotation" in sd:
return int(_to_float(sd["rotation"]) or 0) % 360
rot = (raw.get("tags") or {}).get("rotate")
return int(_to_float(rot) or 0) % 360
def parse_probe(path: Path, data: dict) -> MediaInfo:
fmt = data.get("format") or {}
streams: list[Stream] = []
for raw in data.get("streams") or []:
tags = {k.lower(): v for k, v in (raw.get("tags") or {}).items()}
disp = raw.get("disposition") or {}
lang = tags.get("language")
s = Stream(
index=int(raw.get("index", len(streams))),
kind=raw.get("codec_type") or "data",
codec=raw.get("codec_name"),
profile=raw.get("profile"),
language=lang if lang and lang != "und" else None,
title=tags.get("title"),
default=bool(disp.get("default")),
forced=bool(disp.get("forced")),
attached_pic=bool(disp.get("attached_pic")),
width=_to_int(raw.get("width")),
height=_to_int(raw.get("height")),
fps=_rate(raw.get("avg_frame_rate")) or _rate(raw.get("r_frame_rate")),
rotation=_rotation(raw),
pix_fmt=raw.get("pix_fmt"),
channels=_to_int(raw.get("channels")),
channel_layout=raw.get("channel_layout"),
sample_rate=_to_int(raw.get("sample_rate")),
bit_rate=_to_int(raw.get("bit_rate")) or _to_int(tags.get("bps")),
)
streams.append(s)
duration = _to_float(fmt.get("duration"))
if not duration:
durations = [_to_float(r.get("duration")) for r in data.get("streams") or []]
duration = max((d for d in durations if d), default=None)
return MediaInfo(path=path, duration=duration, format_name=fmt.get("format_name", ""), streams=streams)
def probe(ffprobe: str, path: Path) -> MediaInfo:
args = [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", str(path)]
try:
res = run_quiet(args, timeout=120)
except (OSError, subprocess.SubprocessError) as e:
raise ProbeError(str(e)) from e
if res.returncode != 0:
lines = res.stderr.decode(errors="replace").strip().splitlines()
msg = lines[-1] if lines else f"ffprobe exited with code {res.returncode}"
# ffprobe prefixes errors with the file name, which the queue already shows.
raise ProbeError(msg.removeprefix(f"{path}: "))
try:
data = json.loads(res.stdout.decode("utf-8", errors="replace"))
except json.JSONDecodeError as e:
raise ProbeError(f"Could not parse ffprobe output: {e}") from e
info = parse_probe(path, data)
if not info.video and not info.audio:
raise ProbeError("No video or audio streams found")
return info

102
ffwrap/tools.py Normal file
View File

@@ -0,0 +1,102 @@
"""Locating ffmpeg / ffprobe and running them without flashing console windows."""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
IS_WINDOWS = sys.platform.startswith("win")
EXE_SUFFIX = ".exe" if IS_WINDOWS else ""
# Keep ffprobe/ffmpeg helper calls from popping up a console on Windows.
_NO_WINDOW = {"creationflags": subprocess.CREATE_NO_WINDOW} if IS_WINDOWS else {}
def app_dir() -> Path:
"""Directory holding the executable (frozen) or the project root (source)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent.parent
def run_quiet(args: list[str], timeout: float | None = 60) -> subprocess.CompletedProcess:
return subprocess.run(
args,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
**_NO_WINDOW,
)
@dataclass
class FFmpegTools:
ffmpeg: str
ffprobe: str
version: str = ""
encoders: set[str] = field(default_factory=set)
@property
def ok(self) -> bool:
return bool(self.ffmpeg and self.ffprobe)
def _candidate_dirs(configured_dir: str | None) -> list[Path]:
dirs: list[Path] = []
if configured_dir:
dirs.append(Path(configured_dir))
base = app_dir()
dirs += [base, base / "ffmpeg", base / "ffmpeg" / "bin", base / "bin"]
return dirs
def _find_in(dirs: list[Path], name: str) -> str | None:
for d in dirs:
p = d / f"{name}{EXE_SUFFIX}"
if p.is_file() and (IS_WINDOWS or os.access(p, os.X_OK)):
return str(p)
return None
def locate_tools(configured_dir: str | None = None) -> FFmpegTools | None:
"""Find ffmpeg + ffprobe: configured dir, next to the app, then PATH."""
dirs = _candidate_dirs(configured_dir)
ffmpeg = _find_in(dirs, "ffmpeg")
if ffmpeg:
# Prefer the ffprobe that ships alongside the chosen ffmpeg.
ffprobe = _find_in([Path(ffmpeg).parent], "ffprobe") or _find_in(dirs, "ffprobe")
else:
ffmpeg = shutil.which("ffmpeg")
ffprobe = None
if ffmpeg and not ffprobe:
ffprobe = _find_in([Path(ffmpeg).parent], "ffprobe") or shutil.which("ffprobe")
if not ffmpeg or not ffprobe:
return None
tools = FFmpegTools(ffmpeg=ffmpeg, ffprobe=ffprobe)
try:
out = run_quiet([ffmpeg, "-hide_banner", "-version"], timeout=15).stdout.decode(errors="replace")
first = out.splitlines()[0] if out else ""
m = re.match(r"ffmpeg version (\S+)", first)
tools.version = m.group(1) if m else first
enc = run_quiet([ffmpeg, "-hide_banner", "-encoders"], timeout=15).stdout.decode(errors="replace")
tools.encoders = parse_encoders(enc)
except (OSError, subprocess.SubprocessError):
return None
return tools
def parse_encoders(text: str) -> set[str]:
names = set()
for line in text.splitlines():
# " V....D libx264 libx264 H.264 ..."
m = re.match(r"^\s*([VAS])[\w.]{5}\s+(\S+)\s", line)
if m and m.group(2) != "=":
names.add(m.group(2))
return names