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

66
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
# Builds the Windows .exe and the Linux binary. Runs on GitHub Actions as-is;
# Gitea/Forgejo Actions also read this folder but need a Windows runner registered.
name: Build
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
# ubuntu-22.04 keeps the glibc requirement at 2.35 so the binary runs on older distros.
os: [windows-latest, ubuntu-22.04]
runs-on: ${{ matrix.os }}
env:
QT_QPA_PLATFORM: offscreen
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Qt runtime libraries (bundled into the Linux binary)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 \
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-shape0 libxcb-xkb1
- name: Install Python dependencies
run: python -m pip install -r requirements-dev.txt
- name: Download ffmpeg
run: python scripts/fetch_ffmpeg.py
- name: Run tests
run: python -m pytest -q
env:
FFWRAP_FFMPEG_DIR: ffmpeg/bin
- name: Build executable
run: python build.py --ffmpeg-dir ffmpeg/bin
- name: Smoke test the built executable
shell: bash
run: python scripts/smoke_test.py dist/package/FFmpegWrapper/FFmpegWrapper$([ "$RUNNER_OS" = Windows ] && echo .exe) smoke-${{ runner.os }}.png
- uses: actions/upload-artifact@v4
with:
name: FFmpegWrapper-${{ runner.os }}
path: |
dist/FFmpegWrapper-*.zip
dist/FFmpegWrapper-*.tar.gz
smoke-${{ runner.os }}.png
- name: Attach to release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
with:
files: |
dist/FFmpegWrapper-*.zip
dist/FFmpegWrapper-*.tar.gz

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
venv/
build/
dist/
.pytest_cache/
*.part.*
ffmpeg/

108
README.md
View File

@@ -0,0 +1,108 @@
# FFmpeg Wrapper
A desktop GUI for common ffmpeg jobs: remuxing MKV files to Jellyfin-friendly MP4s, choosing which
audio tracks to keep, stripping audio, re-encoding, and scaling. Works on single files or whole batches.
It's a single executable for Windows and Linux, built with Python, Qt (PySide6) and PyInstaller.
![icon](assets/icon.png)
## Features
- **Single file or batch.** Use *Add Files*, *Add Folder* (recursive), or drag and drop. Files are
probed in the background and shown with duration, video info, and audio and subtitle counts.
- **Target container.** MP4, MKV, MOV, M4V, WebM, AVI or MPEG-TS. The encoder lists only show codecs
the chosen container can hold.
- **Audio track picker.** Every audio track in the selected file is listed with its language, codec,
channels and title. Tick the tracks you want and drag them into order. The first ticked track
becomes the default. **Apply to All Files** copies your choice to the rest of the queue,
matching tracks by language, codec and layout, so a season of episodes takes one click.
- **Strip audio.** Set Audio mode to *Strip audio*.
- **Keep streams unaltered.** *Copy* mode remuxes video and/or audio without re-encoding. *Auto*
copies whatever the container supports and re-encodes only what it can't hold (for example
DTS/TrueHD to AAC for MP4).
- **Up/downscale video.** Pick 360p up to 8K, or a custom WxH, with aspect ratio kept and Lanczos
scaling. Portrait and rotated phone videos are handled. You can also change the frame rate.
Encoders: H.264, HEVC, AV1, VP9, MPEG-4, plus NVENC, QuickSync and AMF if your ffmpeg has them.
There are CRF quality and speed preset controls.
- **Up/downmix audio.** Change channels (mono, stereo, 5.1, 7.1), sample rate, bitrate, and codec
(AAC, AC-3, E-AC-3, Opus, MP3, Vorbis, FLAC, ALAC, PCM).
- **Output folder.** Save next to the source or in a chosen folder. You can add a filename suffix
and choose what happens when the output file already exists (keep both, overwrite, or skip).
The source file is never overwritten.
- **Subtitles** are kept where possible. MP4 and MOV turn text subs into `mov_text`, and image subs
(PGS) are dropped with a note in the log. MKV keeps everything, including font attachments.
- MP4/MOV outputs get `+faststart`, and HEVC is tagged `hvc1` for Apple and TV compatibility.
- The window shows per-file and overall progress, has a **Stop** button, and keeps a full log.
**Show Command** prints the exact ffmpeg command so you can copy it.
- ffmpeg writes to `name.part.ext` and the file is renamed only on success, so a failed or
cancelled job never leaves a half-written file.
## Getting ffmpeg
The app looks for `ffmpeg` and `ffprobe` in this order:
1. The folder set in **Settings > Set FFmpeg Location...**
2. Next to the executable (or in `ffmpeg/`, `ffmpeg/bin/` or `bin/` beside it)
3. Your `PATH`
The release archives (`FFmpegWrapper-<version>-windows-x64.zip` / `-linux-x64.tar.gz`) already
include ffmpeg and ffprobe next to the executable. If you use the bare executable, install ffmpeg
(`sudo apt install ffmpeg`, `winget install ffmpeg`, ...) or point the app at it.
## Running from source
```bash
python -m venv .venv
.venv/bin/pip install -r requirements-dev.txt # Windows: .venv\Scripts\pip ...
python scripts/fetch_ffmpeg.py # optional: downloads ffmpeg into ./ffmpeg/bin
python run.py
```
Tests: `python -m pytest`. The integration tests run real conversions when ffmpeg is found, either
on `PATH` or through `FFWRAP_FFMPEG_DIR=ffmpeg/bin`.
## Building the executables
PyInstaller does not cross-compile. Build the Windows `.exe` on Windows and the Linux binary on Linux.
```bash
pip install -r requirements-dev.txt
python build.py # -> dist/FFmpegWrapper or dist/FFmpegWrapper.exe
python build.py --ffmpeg-dir ffmpeg/bin # also bundles ffmpeg + ffprobe into a .zip / .tar.gz
python scripts/smoke_test.py dist/FFmpegWrapper # optional: headless start-up check
```
`--onedir` builds a folder instead of a single file. It starts faster because nothing has to be
unpacked on launch.
**CI:** [.github/workflows/build.yml](.github/workflows/build.yml) builds, tests and smoke-tests both
platforms on every `v*` tag (or a manual run) and attaches the archives to the release. Gitea and
Forgejo Actions can run it too, but they need a Windows runner registered.
### Notes
- **Linux:** the binary needs glibc 2.35 or newer (Ubuntu 22.04+, Debian 12+, Fedora 36+,
Mint 21+). It bundles the Qt XCB libraries, but uses the system's OpenGL/EGL driver libraries.
To get a menu entry, copy `assets/icon.png` and write a `.desktop` file that points at the binary.
- **PySide6 is pinned below 6.12.** PySide6 6.12.0 mis-counts references to Python's `None`
(in `QComboBox.clear()`/`currentData()`), which crashes the app on Python 3.11. Python 3.12+ is
not affected, but keep the pin until a fixed PySide6 ships.
- **Licensing:** the bundled ffmpeg builds come from
[BtbN/FFmpeg-Builds](https://github.com/BtbN/FFmpeg-Builds) and are GPL. Keep that in mind if you
redistribute the archives.
- Settings (last options, window size, ffmpeg location) are saved per user through Qt's QSettings:
the registry on Windows, `~/.config/ffwrap/FFmpegWrapper.conf` on Linux.
## Project layout
```
ffwrap/
formats.py container/encoder tables and compatibility rules (no Qt)
probe.py ffprobe -> MediaInfo/Stream dataclasses
command.py Options -> ffmpeg arguments, output naming, audio-track matching (no Qt)
tools.py locating ffmpeg/ffprobe, encoder detection
gui/ main window, queue model, QProcess batch runner
build.py PyInstaller build + packaging
scripts/ fetch_ffmpeg.py, smoke_test.py, make_icon.py
tests/ unit tests + real-ffmpeg integration tests
```

BIN
assets/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

82
build.py Normal file
View File

@@ -0,0 +1,82 @@
"""Build a single-file GUI executable for the current OS with PyInstaller.
python build.py # -> dist/FFmpegWrapper(.exe)
python build.py --ffmpeg-dir DIR # also copy ffmpeg + ffprobe from DIR next to the exe
# and pack everything into a .zip / .tar.gz
PyInstaller cannot cross-compile: run this on Windows to get the .exe and on
Linux to get the Linux binary (or use the CI workflow, which does both).
"""
from __future__ import annotations
import argparse
import shutil
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
NAME = "FFmpegWrapper"
IS_WINDOWS = sys.platform.startswith("win")
EXE = ".exe" if IS_WINDOWS else ""
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--ffmpeg-dir", type=Path, help="folder containing ffmpeg and ffprobe to bundle")
ap.add_argument("--onedir", action="store_true", help="build a folder instead of a single file (faster start-up)")
args = ap.parse_args()
import PyInstaller.__main__
from ffwrap import __version__
sep = ";" if IS_WINDOWS else ":"
pyi_args = [
str(ROOT / "run.py"),
"--name", NAME,
"--windowed",
"--noconfirm",
"--clean",
"--onedir" if args.onedir else "--onefile",
"--icon", str(ROOT / "assets" / "icon.ico"),
"--add-data", f"{ROOT / 'assets' / 'icon.png'}{sep}assets",
"--distpath", str(ROOT / "dist"),
"--workpath", str(ROOT / "build"),
"--specpath", str(ROOT / "build"),
# Not needed and only add weight.
"--exclude-module", "tkinter",
"--exclude-module", "unittest",
"--exclude-module", "pydoc",
]
PyInstaller.__main__.run(pyi_args)
dist = ROOT / "dist"
built = dist / NAME if args.onedir else dist / f"{NAME}{EXE}"
print(f"\nBuilt {built}")
if args.ffmpeg_dir:
# Stage <exe or onedir tree> + ffmpeg + ffprobe in one folder and archive it.
stage = dist / "package" / NAME
shutil.rmtree(stage.parent, ignore_errors=True)
if args.onedir:
shutil.copytree(built, stage)
else:
stage.mkdir(parents=True)
shutil.copy2(built, stage)
for tool in ("ffmpeg", "ffprobe"):
src = args.ffmpeg_dir / f"{tool}{EXE}"
if not src.is_file():
print(f"error: {src} not found", file=sys.stderr)
return 1
shutil.copy2(src, stage)
plat = "windows-x64" if IS_WINDOWS else "linux-x64"
archive = shutil.make_archive(
str(dist / f"{NAME}-{__version__}-{plat}"), "zip" if IS_WINDOWS else "gztar",
root_dir=stage.parent, base_dir=NAME)
print(f"Packed {archive}")
return 0
if __name__ == "__main__":
sys.exit(main())

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

2
pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
testpaths = tests

3
requirements-dev.txt Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.txt
pyinstaller>=6.10
pytest>=8

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
# PySide6 6.12.0 over-releases Python's None (QComboBox.clear/currentData), which
# crashes the app on Python < 3.12. Stay below it until a fixed release ships.
PySide6-Essentials>=6.9,<6.12

8
run.py Normal file
View File

@@ -0,0 +1,8 @@
"""Launcher used for development (`python run.py`) and as the PyInstaller entry point."""
import sys
from ffwrap.app import main
if __name__ == "__main__":
sys.exit(main())

79
scripts/fetch_ffmpeg.py Normal file
View File

@@ -0,0 +1,79 @@
"""Download a static ffmpeg + ffprobe build for this OS into ./ffmpeg/bin.
Uses the GPL builds from https://github.com/BtbN/FFmpeg-Builds (newest stable
release branch). The app finds ./ffmpeg/bin automatically when run from source,
and build.py --ffmpeg-dir ffmpeg/bin bundles them with the executable.
"""
from __future__ import annotations
import json
import re
import shutil
import sys
import tarfile
import tempfile
import urllib.request
import zipfile
from pathlib import Path
DEST = Path(__file__).resolve().parent.parent / "ffmpeg" / "bin"
API = "https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest"
def pick_asset(assets: list[dict], plat: str) -> dict:
ext = ".zip" if plat == "win64" else ".tar.xz"
stable = []
for a in assets:
m = re.fullmatch(rf"ffmpeg-n(\d+)\.(\d+)-latest-{plat}-gpl-\d+\.\d+{re.escape(ext)}", a["name"])
if m:
stable.append(((int(m.group(1)), int(m.group(2))), a))
if stable:
return max(stable, key=lambda x: x[0])[1]
for a in assets:
if a["name"] == f"ffmpeg-master-latest-{plat}-gpl{ext}":
return a
raise SystemExit(f"No ffmpeg build found for {plat}")
def main() -> int:
if sys.platform.startswith("win"):
plat, exe = "win64", ".exe"
elif sys.platform.startswith("linux"):
plat, exe = "linux64", ""
else:
raise SystemExit("Only Windows and Linux are supported")
with urllib.request.urlopen(API, timeout=30) as r:
asset = pick_asset(json.load(r)["assets"], plat)
print(f"Downloading {asset['name']} ({asset['size'] // 1_000_000} MB)...")
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / asset["name"]
with urllib.request.urlopen(asset["browser_download_url"], timeout=60) as r, open(archive, "wb") as f:
shutil.copyfileobj(r, f)
DEST.mkdir(parents=True, exist_ok=True)
wanted = {f"ffmpeg{exe}", f"ffprobe{exe}"}
if archive.suffix == ".zip":
with zipfile.ZipFile(archive) as z:
for info in z.infolist():
name = Path(info.filename).name
if name in wanted and "/bin/" in info.filename:
(DEST / name).write_bytes(z.read(info))
else:
with tarfile.open(archive) as t:
for m in t.getmembers():
name = Path(m.name).name
if name in wanted and "/bin/" in m.name:
with t.extractfile(m) as src, open(DEST / name, "wb") as out:
shutil.copyfileobj(src, out)
(DEST / name).chmod(0o755)
missing = [n for n in wanted if not (DEST / n).is_file()]
if missing:
raise SystemExit(f"Archive did not contain {missing}")
print(f"Installed ffmpeg and ffprobe into {DEST}")
return 0
if __name__ == "__main__":
sys.exit(main())

69
scripts/make_icon.py Normal file
View File

@@ -0,0 +1,69 @@
"""Render assets/icon.png and a multi-size assets/icon.ico. Run once; outputs are committed."""
import os
import struct
import sys
from pathlib import Path
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QBuffer, QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QGuiApplication, QImage, QLinearGradient, QPainter, QPainterPath, QPolygonF
ASSETS = Path(__file__).resolve().parent.parent / "assets"
def render(size: int) -> QImage:
img = QImage(size, size, QImage.Format_ARGB32)
img.fill(Qt.transparent)
p = QPainter(img)
p.setRenderHint(QPainter.Antialiasing)
s = size / 256
grad = QLinearGradient(0, 0, 0, size)
grad.setColorAt(0, QColor("#2f80ed"))
grad.setColorAt(1, QColor("#1b4f9c"))
path = QPainterPath()
path.addRoundedRect(QRectF(8 * s, 8 * s, 240 * s, 240 * s), 52 * s, 52 * s)
p.fillPath(path, grad)
# Film strip holes along the top and bottom edges.
p.setBrush(QColor(255, 255, 255, 70))
p.setPen(Qt.NoPen)
for i in range(6):
x = (38 + i * 34) * s
p.drawRoundedRect(QRectF(x, 22 * s, 18 * s, 14 * s), 3 * s, 3 * s)
p.drawRoundedRect(QRectF(x, 220 * s, 18 * s, 14 * s), 3 * s, 3 * s)
# Two chevrons: "convert from -> to".
p.setBrush(QColor("white"))
for dx in (0, 62):
p.drawPolygon(QPolygonF([QPointF((70 + dx) * s, 72 * s), QPointF((136 + dx) * s, 128 * s),
QPointF((70 + dx) * s, 184 * s), QPointF((70 + dx) * s, 152 * s),
QPointF((100 + dx) * s, 128 * s), QPointF((70 + dx) * s, 104 * s)]))
p.end()
return img
def png_bytes(img: QImage) -> bytes:
buf = QBuffer()
buf.open(QBuffer.WriteOnly)
img.save(buf, "PNG")
return bytes(buf.data())
def write_ico(path: Path, sizes=(16, 24, 32, 48, 64, 128, 256)) -> None:
images = [png_bytes(render(n)) for n in sizes]
header = struct.pack("<HHH", 0, 1, len(images))
offset = 6 + 16 * len(images)
entries, blobs = b"", b""
for n, data in zip(sizes, images):
entries += struct.pack("<BBBBHHII", n % 256, n % 256, 0, 0, 1, 32, len(data), offset)
offset += len(data)
blobs += data
path.write_bytes(header + entries + blobs)
if __name__ == "__main__":
app = QGuiApplication(sys.argv)
ASSETS.mkdir(exist_ok=True)
render(256).save(str(ASSETS / "icon.png"))
write_ico(ASSETS / "icon.ico")
print("wrote", ASSETS / "icon.png", ASSETS / "icon.ico")

26
scripts/smoke_test.py Normal file
View File

@@ -0,0 +1,26 @@
"""Launch a built executable headless, let it start up, and check it found ffmpeg.
python scripts/smoke_test.py dist/package/FFmpegWrapper/FFmpegWrapper[.exe] [shot.png]
Exits non-zero if the app crashes, hangs, or reports that ffmpeg is missing.
"""
import os
import subprocess
import sys
from pathlib import Path
def main() -> None:
exe = Path(sys.argv[1]).resolve()
shot = Path(sys.argv[2] if len(sys.argv) > 2 else "smoke-test.png").resolve()
shot.unlink(missing_ok=True)
env = dict(os.environ, QT_QPA_PLATFORM="offscreen", FFWRAP_SMOKE_TEST=str(shot))
res = subprocess.run([str(exe)], env=env, timeout=180)
if res.returncode != 0 or not shot.is_file():
sys.exit(f"Smoke test failed (exit code {res.returncode}, screenshot {'ok' if shot.is_file() else 'missing'})")
print(f"Smoke test passed; screenshot saved to {shot}")
if __name__ == "__main__":
main()

239
tests/test_command.py Normal file
View File

@@ -0,0 +1,239 @@
from pathlib import Path
import pytest
from ffwrap.command import (
Options, build_command, match_audio_selection, plan_output_path, scale_filter, temp_output_path,
)
from ffwrap.probe import MediaInfo, Stream
def video(codec="h264", w=1920, h=1080, rotation=0, index=0):
return Stream(index=index, kind="video", codec=codec, width=w, height=h, fps=23.976, rotation=rotation)
def audio(index, codec="aac", lang="eng", ch=2, title=None, default=False, rate=48000):
return Stream(index=index, kind="audio", codec=codec, language=lang, channels=ch,
title=title, default=default, sample_rate=rate)
def sub(index, codec="subrip", lang="eng"):
return Stream(index=index, kind="subtitle", codec=codec, language=lang)
def media(*streams, path="/videos/movie.mkv"):
return MediaInfo(path=Path(path), duration=100.0, format_name="matroska", streams=list(streams))
def build(info, audio_idx=None, **opts):
o = Options(**opts)
idx = [s.index for s in info.audio] if audio_idx is None else audio_idx
return build_command("ffmpeg", info, o, idx, Path("/out/movie.mp4"))
def pairs(args):
"""Return the args as a set of 'flag value' strings for easy assertions."""
return {f"{a} {b}" for a, b in zip(args, args[1:])}
# ---------------------------------------------------------------- remuxing
def test_jellyfin_remux_mkv_to_mp4():
info = media(video("hevc"), audio(1, "ac3", ch=6), audio(2, "dts", ch=6), sub(3), sub(4, "hdmv_pgs_subtitle"))
b = build(info, container="mp4", video_mode="copy", audio_mode="auto", subtitles=True)
p = pairs(b.args)
assert "-c:v copy" in p
assert "-tag:v hvc1" in p # Apple-friendly HEVC tag
assert "-c:a:0 copy" in p # AC3 is fine in MP4
assert "-c:a:1 aac" in p # DTS gets converted
assert "-c:s:0 mov_text" in p # SRT -> mov_text
assert "-map 0:4" not in p # PGS dropped for MP4
assert any("dropped" in n for n in b.notes)
assert "-movflags +faststart" in p
assert b.args[-1] == str(Path("/out/movie.mp4"))
def test_mkv_keeps_everything():
info = media(video(), audio(1, "truehd", ch=8), sub(2, "hdmv_pgs_subtitle"), sub(3, "ass"),
Stream(index=4, kind="attachment", codec="ttf"))
b = build(info, container="mkv", video_mode="copy", audio_mode="auto")
p = pairs(b.args)
assert {"-c:v copy", "-c:a:0 copy", "-c:s:0 copy", "-c:s:1 copy", "-map 0:t?", "-c:t copy"} <= p
assert "-movflags +faststart" not in p
def test_copy_mode_warns_about_incompatible_codec():
info = media(video("vp9"), audio(1, "opus"))
b = build(info, container="avi", video_mode="copy", audio_mode="copy")
assert any("vp9" in n and "Warning" in n for n in b.notes)
def test_auto_video_reencodes_only_when_needed():
info = media(video("vp9"), audio(1))
assert "-c:v copy" in pairs(build(info, container="mkv", video_mode="auto").args)
b = build(info, container="mp4", video_mode="auto") # vp9 is OK in MP4 too
assert "-c:v copy" in pairs(b.args)
b = build(info, container="mov", video_mode="auto", video_encoder="libx264")
assert "-c:v libx264" in pairs(b.args)
b = build(info, container="mkv", video_mode="auto", resolution=720)
assert "-c:v libx264" in pairs(b.args)
def test_cover_art_is_not_mapped_as_video():
cover = Stream(index=0, kind="video", codec="mjpeg", width=500, height=500, attached_pic=True)
info = media(cover, video("h264", index=1), audio(2))
b = build(info, container="mp4")
assert "-map 0:1" in pairs(b.args)
assert "-map 0:0" not in pairs(b.args)
# --------------------------------------------------------------- encoding
def test_reencode_h264_with_scaling_and_fps():
info = media(video("hevc", 3840, 2160), audio(1))
b = build(info, container="mp4", video_mode="encode", video_encoder="libx264", resolution=1080,
fps="29.97", quality=21, speed=3)
p = pairs(b.args)
assert "-vf scale=-2:1080:flags=lanczos,fps=30000/1001" in p
assert {"-c:v libx264", "-crf 21", "-preset slow", "-pix_fmt yuv420p"} <= p
def test_quality_is_clamped_to_encoder_range():
info = media(video(), audio(1))
b = build(info, video_mode="encode", video_encoder="mpeg4", container="avi", quality=60)
assert "-q:v 31" in pairs(b.args)
def test_strip_audio():
info = media(video(), audio(1), audio(2))
b = build(info, audio_mode="strip")
assert "-an" in b.args
assert not any(a.startswith("-c:a") for a in b.args)
assert "-map 0:1" not in pairs(b.args)
def test_selected_audio_tracks_in_order_with_default_disposition():
info = media(video(), audio(1, lang="jpn"), audio(2, lang="eng"), audio(3, lang="eng", title="Commentary"))
b = build(info, audio_idx=[2, 1], audio_mode="copy")
maps = [b.args[i + 1] for i, a in enumerate(b.args) if a == "-map"]
assert maps == ["0:0", "0:2", "0:1"]
p = pairs(b.args)
assert {"-disposition:a:0 default", "-disposition:a:1 0"} <= p
def test_audio_downmix_resample_and_bitrate():
info = media(video(), audio(1, "dts", ch=6, rate=48000))
b = build(info, audio_mode="encode", audio_encoder="aac", audio_channels=2, audio_sample_rate=44100,
audio_bitrate=160)
assert {"-c:a:0 aac", "-ac:a:0 2", "-ar:a:0 44100", "-b:a:0 160k"} <= pairs(b.args)
def test_auto_audio_reencodes_when_channels_change():
info = media(video(), audio(1, "aac", ch=6))
b = build(info, audio_mode="auto", audio_channels=2)
assert "-c:a:0 aac" in pairs(b.args)
b = build(info, audio_mode="auto")
assert "-c:a:0 copy" in pairs(b.args)
def test_audio_encoder_limits():
info = media(video(), audio(1, "truehd", ch=8))
b = build(info, container="mkv", audio_mode="encode", audio_encoder="libmp3lame", audio_sample_rate=96000)
p = pairs(b.args)
assert "-ac:a:0 2" in p # MP3 is stereo only
assert "-ar:a:0 48000" in p # nearest supported rate
b = build(info, container="mkv", audio_mode="encode", audio_encoder="libopus")
assert "-mapping_family:a:0 1" in pairs(b.args)
b = build(info, container="mkv", audio_mode="encode", audio_encoder="flac")
assert not any(a.startswith("-b:a") for a in b.args)
# ---------------------------------------------------------------- scaling
@pytest.mark.parametrize("w,h,rot,res,expected", [
(1920, 1080, 0, 720, "scale=-2:720:flags=lanczos"),
(1080, 1920, 0, 720, "scale=720:-2:flags=lanczos"), # portrait
(1920, 1080, 90, 720, "scale=720:-2:flags=lanczos"), # rotated phone video
(1920, 1080, 0, 1080, None), # already that size
(1280, 720, 0, 2160, "scale=-2:2160:flags=lanczos"), # upscale allowed
(1920, 1080, 0, 0, None),
])
def test_scale_filter(w, h, rot, res, expected):
assert scale_filter(video(w=w, h=h, rotation=rot), Options(resolution=res)) == expected
def test_scale_no_upscale_and_custom():
v = video(w=1280, h=720)
assert scale_filter(v, Options(resolution=1080, no_upscale=True)) is None
assert scale_filter(v, Options(resolution=480, no_upscale=True)) == "scale=-2:480:flags=lanczos"
custom = scale_filter(v, Options(resolution=-1, custom_width=640, custom_height=640))
assert custom.startswith("scale=640:640:force_original_aspect_ratio=decrease")
assert scale_filter(v, Options(resolution=-1, custom_width=853)) == "scale=852:-2:flags=lanczos"
assert scale_filter(v, Options(resolution=-1)) is None
# ------------------------------------------------------------ output paths
def test_output_next_to_source(tmp_path):
src = tmp_path / "a.mkv"
assert plan_output_path(src, Options(container="mp4"), set()) == tmp_path / "a.mp4"
def test_output_never_overwrites_source(tmp_path):
src = tmp_path / "a.mp4"
src.touch()
out = plan_output_path(src, Options(container="mp4", if_exists="overwrite"), set())
assert out == tmp_path / "a_converted.mp4"
def test_output_exists_policies(tmp_path):
src = tmp_path / "a.mkv"
(tmp_path / "a.mp4").touch()
assert plan_output_path(src, Options(if_exists="skip"), set()) is None
assert plan_output_path(src, Options(if_exists="overwrite"), set()) == tmp_path / "a.mp4"
assert plan_output_path(src, Options(if_exists="rename"), set()) == tmp_path / "a (1).mp4"
def test_output_custom_dir_and_batch_collisions(tmp_path):
out_dir = tmp_path / "out"
opts = Options(output_dir=str(out_dir), suffix="_web", if_exists="overwrite")
first = plan_output_path(tmp_path / "s1" / "ep.mkv", opts, set())
assert first == out_dir / "ep_web.mp4"
taken = {__import__("os").path.normcase(str(first))}
# Same name from another folder in the same batch must not clobber the first one.
assert plan_output_path(tmp_path / "s2" / "ep.mkv", opts, taken) == out_dir / "ep_web (1).mp4"
def test_temp_output_path():
assert temp_output_path(Path("/x/movie.mp4")) == Path("/x/movie.part.mp4")
# ----------------------------------------------------------- track matching
def test_match_identical_layout_by_position():
ref = [audio(1, lang="jpn"), audio(2, lang="eng")]
tgt = [audio(3, lang="jpn"), audio(4, lang="eng")]
assert match_audio_selection(ref, [2], tgt) == [4]
def test_match_by_language_when_layout_differs():
ref = [audio(1, "ac3", "jpn", 6), audio(2, "aac", "eng", 2)]
tgt = [audio(1, "aac", "eng", 2), audio(2, "ac3", "jpn", 6), audio(3, "aac", "eng", 2, title="Commentary")]
assert match_audio_selection(ref, [2, 1], tgt) == [1, 2]
def test_match_falls_back_to_default_track():
ref = [audio(1, lang="eng")]
tgt = [audio(1, lang="fre"), audio(2, lang="ger", default=True)]
assert match_audio_selection(ref, [1], tgt) == [2]
def test_match_empty_selection():
assert match_audio_selection([audio(1)], [], [audio(1)]) == []
assert match_audio_selection([audio(1)], [1], []) == []
def test_options_roundtrip():
o = Options(container="mkv", quality=18, no_upscale=True, output_dir="/tmp/x")
assert Options.from_dict(o.to_dict()) == o
assert Options.from_dict({"quality": "bad", "unknown": 1}) == Options()

75
tests/test_integration.py Normal file
View File

@@ -0,0 +1,75 @@
"""End-to-end tests against a real ffmpeg. Skipped when ffmpeg is not available.
Set FFWRAP_FFMPEG_DIR to point at a folder containing ffmpeg/ffprobe, or put them on PATH.
"""
import os
import subprocess
from pathlib import Path
import pytest
from ffwrap.command import Options, build_command, match_audio_selection
from ffwrap.probe import probe
from ffwrap.tools import locate_tools
TOOLS = locate_tools(os.environ.get("FFWRAP_FFMPEG_DIR"))
pytestmark = pytest.mark.skipif(TOOLS is None, reason="ffmpeg/ffprobe not found")
@pytest.fixture(scope="module")
def sample(tmp_path_factory) -> Path:
"""3 s 640x360 H.264 + jpn 5.1 AC3 + eng stereo AAC + English SRT in an MKV."""
d = tmp_path_factory.mktemp("media")
srt = d / "subs.srt"
srt.write_text("1\n00:00:00,500 --> 00:00:02,000\nHello\n", encoding="utf-8")
out = d / "sample.mkv"
subprocess.run([
TOOLS.ffmpeg, "-v", "error", "-y",
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=25:duration=3",
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
"-f", "lavfi", "-i", "sine=frequency=880:duration=3",
"-i", str(srt),
"-map", "0", "-map", "1", "-map", "2", "-map", "3",
"-c:v", "libx264", "-preset", "ultrafast",
"-c:a:0", "ac3", "-ac:a:0", "6", "-c:a:1", "aac", "-ac:a:1", "2", "-c:s", "srt",
"-metadata:s:a:0", "language=jpn", "-metadata:s:a:1", "language=eng",
"-metadata:s:s:0", "language=eng",
str(out),
], check=True)
return out
def run(info, opts, audio, out):
built = build_command(TOOLS.ffmpeg, info, opts, audio, out)
res = subprocess.run(built.args, capture_output=True, text=True)
assert res.returncode == 0, res.stderr
return probe(TOOLS.ffprobe, out)
def test_remux_to_mp4_keeps_streams(sample, tmp_path):
info = probe(TOOLS.ffprobe, sample)
eng = match_audio_selection(info.audio, [info.audio[1].index], info.audio)
out = run(info, Options(container="mp4"), eng, tmp_path / "out.mp4")
assert out.video.codec == "h264"
assert [(a.codec, a.language) for a in out.audio] == [("aac", "eng")]
assert [s.codec for s in out.subtitles] == ["mov_text"]
assert abs(out.duration - info.duration) < 0.5
def test_reencode_scale_and_downmix(sample, tmp_path):
info = probe(TOOLS.ffprobe, sample)
opts = Options(container="mkv", video_mode="encode", video_encoder="libx265", resolution=240,
speed=0, audio_mode="encode", audio_encoder="libopus", audio_channels=2, subtitles=False)
out = run(info, opts, [a.index for a in info.audio], tmp_path / "out.mkv")
assert out.video.codec == "hevc" and (out.video.width, out.video.height) == (426, 240)
assert [(a.codec, a.channels) for a in out.audio] == [("opus", 2), ("opus", 2)]
assert out.subtitles == []
def test_strip_audio_webm(sample, tmp_path):
info = probe(TOOLS.ffprobe, sample)
opts = Options(container="webm", video_mode="auto", video_encoder="libvpx-vp9", speed=0, audio_mode="strip")
out = run(info, opts, [], tmp_path / "out.webm")
assert out.video.codec == "vp9" and out.audio == []
assert [s.codec for s in out.subtitles] == ["webvtt"]

56
tests/test_probe.py Normal file
View File

@@ -0,0 +1,56 @@
from pathlib import Path
from ffwrap.probe import parse_probe
from ffwrap.tools import parse_encoders
SAMPLE = {
"format": {"format_name": "matroska,webm", "duration": "1421.5"},
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "hevc", "width": 1920, "height": 1080,
"avg_frame_rate": "24000/1001", "pix_fmt": "yuv420p10le", "disposition": {"default": 1}},
{"index": 1, "codec_type": "audio", "codec_name": "eac3", "channels": 6, "channel_layout": "5.1(side)",
"sample_rate": "48000", "tags": {"language": "jpn", "title": "Surround"}, "disposition": {"default": 1}},
{"index": 2, "codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "44100",
"tags": {"LANGUAGE": "und", "BPS": "128000"}},
{"index": 3, "codec_type": "subtitle", "codec_name": "subrip", "tags": {"language": "eng"},
"disposition": {"forced": 1}},
{"index": 4, "codec_type": "video", "codec_name": "mjpeg", "width": 600, "height": 900,
"disposition": {"attached_pic": 1}},
],
}
def test_parse_probe():
info = parse_probe(Path("x.mkv"), SAMPLE)
assert info.duration == 1421.5
assert info.video.index == 0 and info.video.codec == "hevc"
assert abs(info.video.fps - 23.976) < 0.001
assert [s.index for s in info.audio] == [1, 2]
a1, a2 = info.audio
assert a1.language == "jpn" and a1.title == "Surround" and a1.default and a1.channels == 6
assert a2.language is None and a2.bit_rate == 128000 # "und" -> None, BPS tag used
assert info.subtitles[0].forced
assert "EAC3" in a1.describe() and "5.1" in a1.describe()
def test_rotation_swaps_display_size():
data = {"format": {}, "streams": [{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920,
"height": 1080, "side_data_list": [{"rotation": -90}]}]}
info = parse_probe(Path("phone.mp4"), data)
assert info.video.display_size == (1080, 1920)
def test_duration_falls_back_to_streams():
data = {"format": {}, "streams": [{"index": 0, "codec_type": "audio", "codec_name": "aac", "duration": "12.5"}]}
assert parse_probe(Path("a.m4a"), data).duration == 12.5
def test_parse_encoders():
text = """Encoders:
V..... = Video
------
V....D libx264 libx264 H.264 / AVC
A....D aac AAC (Advanced Audio Coding)
S..... mov_text 3GPP Timed Text subtitle
"""
assert parse_encoders(text) == {"libx264", "aac", "mov_text"}