OpenTranscode - Feature Fix large files failing, and other tweaks

This commit is contained in:
Jeremy Anderson 2026-09-27 04:10:04 -04:00
parent e424bb95c6
commit 1bc57e3218
42 changed files with 3851 additions and 11460 deletions

View File

@ -2,6 +2,185 @@
All notable changes to OpenTranscode. Versions follow semantic versioning.
## [4.11.0] — 2026-09-27
### Changed — the application is one file
- **``open-transcode.py`` is the program — the ``opentranscode/``
package directory is removed.** One copy, no synchronization
obligation, no drift possible. The file is fully self-contained
(every module's logic inlined in dependency order, external imports
hoisted) and carries the project metadata (version, author,
contact, license) itself. It keeps the original direct-GUI entry:
``python open-transcode.py`` opens the window and ignores CLI
arguments, exactly as the launcher always did. The inlined
``build_parser`` / ``main`` / ``launch_gui`` remain for library
access (and the test suite exercises them).
- **Test suite loads the single module**: conftest exposes
``load_program_module()`` (registered once per session in
``sys.modules`` — one module instance shared by fixtures and direct
callers). All tests that previously imported ``opentranscode.*``
now resolve symbols from the single module; string-form
monkeypatch targets follow it. ``test_package_structure`` pins the
arrangement: the package directory must NOT reappear, and a stray
``import opentranscode`` inside the program file fails the build.
- **pyproject.toml is metadata + tool config only** — no build
system, no console script, nothing to pip-install. pytest
configuration stays in pytest.ini.
- The launch-crash fix (CLI overrides via the pending store flushed
after the async env probe) is part of the single file; README and
architecture docs describe the one-file layout.
## [4.10.0] — 2026-09-26
### Fixed — GUI launch crash (AttributeError on every launch via main())
- ``launch_gui()`` wrote CLI overrides onto ``window.env.av1an_flags``
immediately — but the environment probe runs ~500ms after the event
loop starts (async by design, so the GPU live-encode test never
freezes the UI), so ``env`` was still ``None`` and every GUI launch
through ``main()`` died with ``AttributeError: 'NoneType' object
has no attribute 'av1an_flags'``. Both entry paths were affected
(``python -m opentranscode``; the standalone launcher once it routed
through ``main()`` for CLI-flag support). The original 4.8.1
standalone never hit it only because its entry point bypassed
``launch_gui`` entirely.
- Fix: overrides now land in the window's pending store
(``_set_cli_flag`` / ``_clear_cli_flag``) and ``_probe_and_init``
flushes them into the live ``av1an_flags`` the moment the probe
finishes — including a delete sentinel so ``--chunk-method auto``
still clears probe-set overrides. Falsy values (``False``, ``0``,
``""``) survive the flush. The auto-engine status line no longer
claims a GPU verdict before the probe has run. Verified by driving
the real ``launch_gui`` offscreen on both entry paths and asserting
the flags landed; hermetic semantics pinned in
``tests/test_launch_env_pending.py`` and covered by launcher parity.
### Added — title-artifact scrubbing for output filenames
- **New `title_clean.py` module.** Output names drop the source
release's stale claims before the app's own markers are appended:
`Movie.x264.1080p.WEBRip.x265-GRP.mkv` archives as
`Movie.1080p.WEBRip.GRP_archived.mkv`. A title that still says
x264/h.265/webm on an AV1/Opus/MKV (or Theora/OGV) output
misdescribes the archive.
- Scrub classes: codec tags (video, audio, container — matched on
token boundaries so `MP4Box`/`Aviator` pass through), this app's
own earlier suffixes (`_archived`, `_<w>x<h>` — re-encodes never
stack markers), and separator residue (runs collapse to their
first separator, emptied brackets drop, edges trim). A stem that
scrubs to nothing keeps its original name. Title rewrites log under
`--verbose`.
### QA pass — production readiness (multi-discipline review)
- **Standalone launcher synchronized + parity-guarded.**
``open-transcode.py`` remains the self-contained single-file
distribution (runs with only Python + PySide6 — verified by
executing it in an isolated directory with the package absent). It
is rebuilt from the current package sources, so it now carries every
4.8.2–4.10.0 change the old copy had drifted past (CRF defaults,
Theora/OGV, title scrubbing, the filter row, the QA refactors below).
New ``tests/test_launcher_parity.py`` compares the two artifacts —
profile tables, encoder-arg builders, the Theora quality map, title
scrubbing, and the UI compat rules — and fails the build if either
copy changes without the other.
- **Behavioral drift between the two copies resolved** (the old
launcher and package had diverged; the package now carries the
user-facing behavior and the launcher mirrors it):
- Force-mode validation overrides now log a user-facing WARN per
file (were verbose-only — a silent skip of the validation gate).
- The PRE-FLIGHT VALIDATION table prints whenever any file is
invalid (was verbose-only; clean runs stay quiet).
- av1an failure DIAGNOSIS blocks are user-facing again (they explain
a failed encode attempt), and the av1an→ffmpeg fallback retry
logs one user-facing line — quiet mode previously reported only
the eventual OK, hiding per-file av1an failures entirely.
- The y4m-guard assertion in the retry tests matches the
`combined_out` (stderr+stdout) check both copies now use.
- **Deduplicated the post-probe status bar** (SEI CERT MSC04-C): the
probe path and the post-rebuild path now compose their line through
one `_status_summary()` helper (previously two hand-maintained
copies), with the fallback-encoder scan as a comprehension.
- **Unified availability gating** (`_disable_unavailable_codecs`):
audio and ffmpeg-only video entries now come from one row table
assembled from the profile tables, applied by a single pass — no
per-combo branching (profiles remain the single source of truth).
- **`_parse_extensions`** drops its accumulation loop for a
comprehension; empty parts (trailing/doubled commas) now filter out
before dot-prefixing and can never contribute a bare `.`.
- **Comment voice**: restore-flavored wording ("restores", "brought
back") is gone from code and docs; comments state decisions, not
history. Runtime degradation wording ("falls back to") is retained
deliberately — it describes the graceful step-down paths.
- **Metadata**: author contact standardized to Jeremy Anderson /
dcos.net / info@dcos.net across `__init__`, pyproject, the UI footer
and the README; pyproject version un-stuck from 4.8.1 to match the
release.
- **Docs**: README re-derives from the source — codec/container lists,
version example, libtheora requirement, title-scrub section,
architecture tree, test counts, coding-standards additions
(Unix-philosophy step-down, comment voice), project contact. The
source code is the source of truth; docs describe it.
## [4.9.0] — 2026-09-26
### Added — OGV output target (Theora) with the inverted quality-scale wrapper
- **New "Theora (OGV)" video codec + "OGV (Ogg)" container.** The legacy
open-source pairing (Ogg Theora + Vorbis/Opus/FLAC) is now a first-class
output target for compatibility with old players/portals.
- **Quality-knob wrapper (`theora_quality_from_crf`)** — libtheora grades
quality with `-q:v 0..31` where HIGHER is better, the exact opposite of
the CRF knob every other codec family uses. Theora keeps the shared
knob and its 18-52 scale; the encoder args translate through a linear
inversion (knob 18 → q 31 best, 52 → q 0, default 26 → q 24). The
mapped value is echoed in the log when the knob moves so the number
libtheora actually receives is never a mystery. Scale and saturation
verified empirically against the project ffmpeg build.
- **PRESET combo maps onto libtheora's real `-speed_level 0..2`**
(Best/Medium/Fast; out-of-range values clamp instead of erroring).
- **ffmpeg-only by construction**: av1an has no Theora encoder, so the
chunk-parallel path is force-disabled for the codec (log explains
why), and the `params_fn` guard raises a clear RuntimeError if a
regression ever reaches it. No GPU family — every engine selection
resolves to CPU for Theora.
- **Availability gating**: libtheora is probed with the other encoder
libraries; a missing library logs a probe warning and greys out the
codec entry in the UI (it has no av1an fallback, so it would only
fail at encode time).
- **Container-compatibility rules** (all verified with live ffmpeg 7.x
mux attempts): Theora + MP4/WebM → INCOMPATIBLE (the muxers reject
it); Theora + MKV → WARNING (valid but OGV is canonical — a real
encode was confirmed to work); any other video codec + OGV →
INCOMPATIBLE (the Ogg muxer only takes Theora video). Opus, Vorbis
and FLAC all mux cleanly into OGG; IAMF stays INCOMPATIBLE via the
existing non-MP4 rule.
- Skip-existing detection works for the target (`ffprobe` reports
codec_name `theora`).
## [4.8.2] — 2026-09-26
### Changed — FILTER gets its own line in the UI
- **The extension filter row was split off the options row.** The
FILTER field holds a long comma-separated extension list; sharing a
single row with four checkboxes and the GPU/ENGINE combos squeezed it
into a sliver. It now sits on its own line (with the delete-source
toggle), and Force / av1an / inline-scale / GPU / ENGINE moved down
to a second options row.
### Changed — archival-quality default CRF values
- **AV1 (SVT-AV1) 32 → 26, VP9 32 → 28, x265 28 → 24.** The old
defaults produced xvid-tier output (blocky shadows, smeared grain)
on every engine path, since CPU (`-crf`), NVENC (`-cq`), QSV
(`-global_quality`) and VAAPI all take their quality number from the
same CRF knob. The knob's start position follows the AV1 profile
default (26). Expect roughly +50-60% bitrate versus 4.8.1 defaults;
turn the knob up if you want the smaller files back.
### Added — legacy source extensions in the default filter
- **.vob (DVD rips), .xvid (AVI/ASP rips) and .ogv (Ogg/Theora
archives) are now in `DEFAULT_INPUT_EXTENSIONS`** — legacy formats
this tool exists to re-encode were silently skipped before. There is
still no .ogv OUTPUT target: the Ogg muxer only takes Theora video,
and there is no Theora encoder profile (VP9/AV1/HEVC cannot be
muxed into Ogg), so old .ogv files re-encode to MKV/WebM instead.
## [4.8.1] — 2026-09-25
### Changed — GPU dropdown / ENGINE interaction

127
README.md
View File

@ -1,11 +1,17 @@
# OpenTranscode
**Open-source batch video transcoder for Linux.** Encodes folders of video
files to AV1 / VP9 / HEVC with configurable audio codecs, resolution
scaling, and source-file management. Built on ffmpeg (default) with an
files to AV1 / VP9 / HEVC / Theora with configurable audio codecs,
resolution scaling, and source-file management. Built on ffmpeg (default) with an
optional av1an chunk-parallel path for users with a working VapourSynth
setup. NVIDIA GPU (NVENC) encoding is used automatically when available.
The application is a single self-contained file — `open-transcode.py`.
Everything (codec profiles, encoder worker, environment probe, UI,
rebuild-from-git builder) is inlined in it; run it with nothing but
Python + PySide6 on the box. The source file is the source of truth;
this README describes it.
- **Default encoder**: ffmpeg + libsvtav1 (reliable across distros)
- **GPU encoder**: NVENC (hevc_nvenc / av1_nvenc) auto-detected with a
live encode test — used by the Auto engine when it actually works
@ -13,8 +19,13 @@ setup. NVIDIA GPU (NVENC) encoding is used automatically when available.
- **Self-sufficient rebuild**: the always-enabled REBUILD FROM GIT
button installs its own build dependencies (distro-aware), then builds
VapourSynth + BestSource + av1an into `~/.local` / `~/.cargo`
- **Codecs**: AV1 (SVT-AV1), VP9, x265 (HEVC) video; Opus, Vorbis, FLAC, IAMF audio
- **Containers**: MKV, WebM, MP4
- **Codecs**: AV1 (SVT-AV1), VP9, x265 (HEVC), Theora (OGV) video; Opus,
Vorbis, FLAC, IAMF audio
- **Containers**: MKV, WebM, MP4, OGV (Ogg)
- **Title scrubbing**: output names drop the source release's stale
codec/container tags (`Movie.x264.1080p.x265-GRP` → `Movie.1080p.
WEBRip.GRP_archived.mkv`) — a title that still says x264/webm on an
AV1/Opus/MKV output misdescribes the archive
- **Resolution**: Original or scaled (16:9, 21:9, 32:9 presets from 480p to 4K)
- **Skip-existing**: Probes output with ffprobe; skips files whose codec matches
- **Audio normalization**: Per-file loudness analysis with volume gain
@ -25,7 +36,8 @@ setup. NVIDIA GPU (NVENC) encoding is used automatically when available.
- Linux (POSIX)
- Python ≥ 3.12
- ffmpeg (with libsvtav1, libvpx, libx265, libopus, libvorbis, flac)
- ffmpeg (with libsvtav1, libvpx, libx265, libopus, libvorbis, flac;
libtheora only for the OGV output target)
- ffprobe
- PySide6 (for the GUI)
- Optional: NVIDIA GPU + driver for NVENC hardware encoding
@ -33,35 +45,20 @@ setup. NVIDIA GPU (NVENC) encoding is used automatically when available.
## Quick start
### Install as a package (recommended)
```bash
cd /path/to/opentranscode
pip install -e .
opentranscode # launch the GUI
python -m opentranscode --version # → opentranscode 4.6.0
python -m opentranscode --help
python open-transcode.py # launches the GUI — that's it
```
### Run the launcher script (backwards compat)
```bash
python open-transcode.py # launches the GUI
```
### Verify environment without encoding
```bash
opentranscode --dry-run # probe + smoke test, no encode
opentranscode --verify-only FILE.mkv # re-verify an existing output
```
CLI entry points (`--version`, `--dry-run`, `--verify-only`, flag
overrides) are library access on the inlined `main()` /
`build_parser()` — the script itself opens the GUI directly and
ignores arguments.
## Usage
### Encode engine (GPU vs CPU)
The ENGINE selector (UI) or `--engine` flag picks the video encoder:
The ENGINE selector (UI) picks the video encoder:
- **Auto (default)** — uses the NVENC hardware encoder for the selected
codec family when the environment probe's *live encode test* proved it
@ -90,7 +87,7 @@ a ffmpeg build can list `hevc_nvenc` while the installed NVIDIA driver is
too old for the NVENC API version it was compiled against (e.g. "Driver
does not support the required nvenc API version. Required: 13.1 Found:
13.0") — in that case the probe reports the exact driver fix and encoding
stays on the CPU. `opentranscode --dry-run` prints the GPU verdict.
stays on the CPU. The startup log prints the GPU verdict.
#### When NVENC is "present but NOT usable" (driver / ffmpeg API mismatch)
@ -128,10 +125,28 @@ skipped. Detection uses ffprobe — verifies `codec_name` for both video
and audio streams, plus resolution when scaling is requested.
```bash
opentranscode # skip-existing ON (default)
opentranscode --force-reencode # re-encode everything
# flag handling lives in the inlined build_parser()/main()
# (library access); the GUI reflects the same options
```
### Output filenames (title scrubbing)
Output names are built from the scrubbed source title plus this app's
own markers: `<clean-title>[_<w>x<h>]_archived.<ext>`. The scrub
(`opentranscode/title_clean.py`) removes:
- **Codec tags** — video (`x264`, `h.265`, `hevc`, `xvid`, `divx`,
`theora`, `av1`, …), audio (`ac3`, `dts`, `aac`, `flac`, …) and
container (`avi`, `webm`, `mkv`, …) tokens, matched on token
boundaries so names like `MP4Box` or `Aviator` pass through
- **This app's earlier suffixes** — `_archived` and `_<w>x<h>`, so
re-encoding never stacks markers
- **Separator residue** — doubled dots/dashes left by removals
A stem that scrubs to nothing (`x264.mkv`) keeps its original name.
The resolution suffix is added only when a target resolution is
selected. Verbosity (`--verbose`) logs every title rewrite.
### Verbose logging
Default log output is minimal — two lines per file (start + finish):
@ -149,9 +164,7 @@ QUEUE COMPLETE. Success: 178, Failed: 0, Skipped: 2.
For diagnostics (CMD lines, live tail of ffmpeg/av1an stderr, disk-space
warnings, heartbeats):
```bash
opentranscode --verbose
```
Verbose detail is also available via the inlined CLI parser.
### Massive-file support
@ -168,9 +181,7 @@ For 30GB+ BluRay rips:
- **Disk-space warnings** — severe warnings (free space below the source
size on the output or temp partition) are user-facing even in quiet mode
```bash
opentranscode --timeout 36000 # 10h per-file timeout
```
The per-file timeout is configurable (24h default).
### GPU capability profiles
@ -209,7 +220,6 @@ For users with a working VapourSynth + source plugin (lsmash, ffms2,
bestsource) setup who want scene-detection-based chunk-parallel encoding:
- **UI**: Check the "av1an (chunk-parallel)" checkbox
- **CLI**: `opentranscode --use-av1an`
Known chunking failure modes (all handled with per-file retries + a
diagnosis block, and safe to hit):
@ -231,7 +241,7 @@ When av1an fails per-file, the code automatically falls back to ffmpeg
for that file. When av1an fails systematically (VSScript API mismatch,
missing encoder), the queue aborts with an actionable diagnostic.
## CLI reference
## CLI reference (library access — the script opens the GUI directly)
```
opentranscode [--version] [--dry-run] [--verify-only PATH] [--force]
@ -260,27 +270,13 @@ opentranscode [--version] [--dry-run] [--verify-only PATH] [--force]
## Architecture
```
opentranscode/
├── __init__.py # Package metadata + lazy launch_gui wrapper
├── __main__.py # python -m opentranscode entry point
├── cli.py # argparse + dry-run + verify-only
├── codec_profiles.py # VideoCodecProfile (incl. NVENC gpu fields), Audio/Container/Resolution tables
├── encoder_worker.py # QThread-based per-file encode pipeline + engine (GPU/CPU) resolution
├── env_probe.py # Distro + binary + library + av1an + NVENC probe
├── ffprobe_utils.py # ffprobe_validate, ffprobe_duration, file-type ID
├── temp_manager.py # Per-worker temp directory isolation
├── cpu_topology.py # Physical core / logical thread detection
├── distro_probe.py # Distro family + package manager detection
├── keepawake.py # systemd-inhibit + optional mouse nudge
├── source_builder.py # From-git rebuild for VapourSynth/av1an ABI mismatches
├── license_registry.py # Third-party license attribution
├── ui_window.py # PySide6 main window + launch_gui
├── ui_theme.py # Retro-futuristic QSS theme
└── widgets/ # Custom Qt widgets (radio_knob, etc.)
open-transcode.py # Launcher script (mirrors package, test target)
pyproject.toml # PEP 621 build config
tests/ # 200+ tests across 16 files
open-transcode.py # THE application — single self-contained file
# (title scrubbing, codec profiles, Theora q-map,
# encoder worker, env probe, UI, rebuild builder)
pyproject.toml # Project metadata + tool config (nothing to install)
pytest.ini # Test suite configuration
tests/ # 300+ tests; conftest.py loads the single file
scripts/ # Helper: matched-NVENC ffmpeg build script
```
### Encode pipeline
@ -321,11 +317,18 @@ The codebase follows:
`signal.SIGTERM` → `SIGKILL` escalation, `os.killpg` for child cleanup
- **MISRA** (where applicable to Python) — single exit point per function
where practical, no early returns from `try` blocks without cleanup
- **Unix philosophy** — decision forks step down (guard, guard, main
path) and each module does one thing (`title_clean.py` scrubs titles,
`codec_profiles.py` holds the profile tables); preference for
table-driven dispatch over nested `if` ladders and comprehensions
over accumulation loops where side effects allow
- **Comment voice** — comments state decisions, not history; wording
like "restored" or "brought back" does not appear in code or docs
## Testing
```bash
python -m pytest tests/ -q # 149 tests, ~10s
python -m pytest tests/ -q # 315 tests, ~50s
python -m pytest tests/ -v # verbose
python -m pytest tests/ -k "skip_existing" # subset
```
@ -338,6 +341,8 @@ Test categories:
- **Stop button** — SIGTERM/SIGKILL on process group
- **Concurrent workers** — per-PID temp directory isolation
- **Skip-existing** — codec matching, ffprobe failure, resolution mismatch
- **Theora/OGV** — inverted quality-scale wrapper, container pairing rules
- **Title scrubbing** — codec-token removal, residue cleanup, passthrough
- **Massive files** — timeout flag, 1KB integrity threshold, disk-space checks
- **Package structure** — public API surface, submodule imports, CLI parser
@ -351,6 +356,8 @@ through from upstream.
## Project
- **Author**: Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net
- **Repository**: https://git.dcos.net/dcosnet/OpenTranscode
- **Issues**: https://git.dcos.net/dcosnet/OpenTranscode/issues
- **Changelog**: [CHANGELOG.md](CHANGELOG.md)
- **Changelog**: [CHANGELOG.md](CHANGELOG.md) — the source code is the
source of truth for behavior; this README and CHANGELOG describe it

File diff suppressed because it is too large Load Diff

View File

@ -1,120 +0,0 @@
"""opentranscode — open-source batch video transcoder (av1an + ffmpeg).
A PySide6 GUI application that orchestrates av1an + ffmpeg for batch video
transcoding. Distro-aware, config-driven (codec / audio / container /
resolution / license profiles), with a QThread-based encoder worker, a
from-git source builder for resolving VapourSynth / av1an ABI mismatches,
and a retro-futuristic media-console UI.
This package is the production code path. The launcher script
``open-transcode.py`` is preserved alongside it for backwards
compatibility and as the test target for the mocked test suite.
Run as a module:
python -m opentranscode # launch the GUI
python -m opentranscode --version # print version and exit
python -m opentranscode --dry-run # probe env + smoke test, no GUI
python -m opentranscode --verify-only /path/to/output.mkv
Or import in code:
import opentranscode
print(opentranscode.__version__)
"""
from __future__ import annotations
__version__ = "4.8.1"
__author__ = "Jeremy Anderson - dcos.net"
__license__ = "AGPL-3.0"
__all__ = [
"__version__",
"__author__",
"__license__",
"build_parser",
"main",
"launch_gui",
]
# Lightweight re-exports for convenience. Heavy modules (env_probe,
# encoder_worker, ui_window) are NOT imported here so that
# ``import opentranscode`` works without PySide6 being available — this
# keeps ``opentranscode.__version__`` cheap and side-effect-free for
# ``--version`` and for tooling that just wants the metadata.
from .cli import build_parser, main
def launch_gui(argv: list[str] | None = None, force: bool = False,
chunk_method: str | None = None,
max_workers: int | None = None,
threads_per_worker: int | None = None,
use_av1an: bool = False,
verbose: bool = False,
skip_existing: bool = True,
timeout: int = 86400,
inline_scale: bool = False,
engine: str = "auto",
gpu_profile: str = "auto") -> int:
"""Launch the OpenTranscode GUI.
Thin wrapper around ``opentranscode.ui_window.launch_gui``; imported
lazily so that ``import opentranscode`` does not pull in PySide6.
Args:
argv: Optional argv list for QApplication. Defaults to sys.argv.
force: Pre-check the "Force (skip validation)" checkbox — skips
ffprobe pre-validation and attempts encode even for files
ffprobe cannot read. WARNING: invalid files will waste the
full per-file timeout before failing.
chunk_method: Override av1an's chunk-method selection (v4.0.0).
When not None, the value is written to
``env.av1an_flags["chunk_method_override"]`` after the
environment probe runs, so every EncoderWorker picks it up.
Useful for forcing ``select`` to avoid the Hybrid chunk
method's failure on phone-recorded MP4s with sparse
keyframes. ``"auto"`` clears any override the probe set.
max_workers: Override the chunk-parallel worker count (v4.1.0).
When None, EncoderWorker computes from CPU topology so that
``worker_count * threads_per_worker <= logical_threads - 1``.
Stored on ``env.av1an_flags["max_workers"]`` so the
GUI-spawned worker picks it up.
threads_per_worker: Override the per-encoder thread cap (v4.1.0).
When None, computed as ``max(1, budget // worker_count)``.
Stored on ``env.av1an_flags["threads_per_worker"]`` so the
GUI-spawned worker picks it up.
use_av1an: Opt into av1an chunk-parallel encoding (v4.2.0).
Default False = ffmpeg-only (more reliable across distros).
When True, the av1an pre-flight + smoke test runs as before.
av1an was too fragile: y4m pipe breaks, SvtAv1EncApp CLI
rejects --threads, VapourSynth plugin issues, output
buffering making it look hung. ffmpeg's libsvtav1 is invoked
as a library, doesn't need VapourSynth, and produces
immediate progress output.
verbose: Enable verbose log output (v4.2.1). Default False =
quiet (per-file success/fail + final summary only). True
= full tech detail (CMD: lines, live tail of av1an/ffmpeg
stderr, DIAGNOSIS blocks, resolution map, pre-flight
validation table, 30s heartbeat).
inline_scale: Skip the CRF-16 pre-scale intermediate when a
target resolution is selected (v4.4.4). The scale/pad filter
chain is passed directly to av1an via --ffmpeg-filter-args
instead. Eliminates the 0.5-0.8x source size intermediate
that was crashing 10GB+ encodes with mysterious "ffmpeg
error (rc=234)" disk-exhaustion messages. Default False —
the intermediate path is more robust on older av1an/
VapourSynth builds. Pre-checks the "Inline scale" UI checkbox.
engine: Video encode engine (v4.6.0). "auto" (default) uses the
NVENC GPU encoder when the selected codec family has one and
the live encode test proved it works; "gpu" forces NVENC;
"cpu" forces the software encoders; "hybrid" (v4.7.0) splits
the queue between a GPU lane and a CPU lane running
concurrently. Pre-selects the ENGINE combo in the UI.
"""
from .ui_window import launch_gui as _launch
return _launch(
argv, force=force, chunk_method=chunk_method,
max_workers=max_workers, threads_per_worker=threads_per_worker,
use_av1an=use_av1an, verbose=verbose, skip_existing=skip_existing,
timeout=timeout, inline_scale=inline_scale, engine=engine,
gpu_profile=gpu_profile,
)

View File

@ -1,23 +0,0 @@
"""``python -m opentranscode`` entry point.
Delegates to :func:`opentranscode.cli.main`, then propagates the returned
exit code via :func:`sys.exit`. Defined as a ``main()`` function (not inline
code) so it can be referenced as the
``opentranscode = opentranscode.__main__:main`` console-script entry point
in ``pyproject.toml``.
"""
from __future__ import annotations
import sys
from .cli import main as cli_main
def main() -> int:
"""Module entry point — equivalent to ``opentranscode.cli.main()``."""
return cli_main()
if __name__ == "__main__":
sys.exit(main())

View File

@ -1,436 +0,0 @@
"""Command-line interface for opentranscode.
Provides three flags:
- ``--version`` — print the package version and exit (0).
- ``--dry-run`` — probe the environment, run the av1an VSScript
smoke test if av1an is available, print a report, and exit. Does
NOT launch the GUI and does NOT encode anything.
- ``--verify-only PATH`` — re-verify an existing output file's size,
resolution, and duration via ffprobe, without re-encoding.
With no flag, ``main()`` defers to ``ui_window.launch_gui()``.
Heavy imports (``env_probe``, ``ffprobe_utils``, ``ui_window``) are
deferred into the bodies of ``run_dry_run`` / ``run_verify_only`` /
the no-flag branch so that ``--version`` does not pull in PySide6.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
"""Build the CLI argument parser."""
parser = argparse.ArgumentParser(
prog="opentranscode",
description="Open-source batch video transcoder (av1an + ffmpeg)",
)
parser.add_argument(
"--version", action="store_true",
help="Print version and exit",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Probe environment, run smoke test, print report — but do "
"NOT launch GUI or encode anything",
)
parser.add_argument(
"--verify-only", metavar="PATH",
help="Re-verify an existing output file (size, resolution, "
"duration checks) without re-encoding",
)
# v5-01: --force pre-checks the "Force (skip validation)" checkbox in
# the GUI. This is a convenience flag — the checkbox can also be toggled
# manually in the UI.
parser.add_argument(
"--force", action="store_true",
help="Pre-check the 'Force (skip validation)' checkbox in the GUI. "
"Skips ffprobe pre-validation and attempts encode even for "
"files ffprobe cannot read. WARNING: invalid files will waste "
"the full per-file timeout before failing.",
)
# v4.0.0: --chunk-method overrides av1an's chunk-method selection. Useful
# for debugging the "works up until near the end, never saves chunks
# into a full file" bug (Hybrid chunk method on phone-recorded MP4s).
# When set, the value is written to env.av1an_flags["chunk_method_override"]
# before the GUI launches, so every EncoderWorker picks it up.
parser.add_argument(
"--chunk-method", metavar="METHOD",
choices=["auto", "select", "hybrid", "segment", "ffms2",
"lsmash", "bestsource", "dgdecnv"],
help="Force av1an to use a specific chunk method. 'select' is the "
"most reliable (uses VapourSynth's select() filter) but slowest. "
"'hybrid' (av1an's default when no VS plugins) fails on phone-"
"recorded MP4s with sparse keyframes. 'ffms2'/'lsmash'/"
"'bestsource' require the corresponding VapourSynth plugin. "
"'auto' lets av1an decide (default).",
)
# v4.1.0: intelligent chunking overrides. When neither flag is given,
# EncoderWorker computes (worker_count, threads_per_worker) from CPU
# topology so worker_count * threads_per_worker <= logical_threads - 1.
# This prevents the thread-oversubscription hard-lock that v4.0.0 hit
# on high-core-count machines (13 workers × 28 threads = 364 threads
# on 28 logical CPUs → kernel scheduler drowns).
parser.add_argument(
"--max-workers", type=int, metavar="N",
help="Cap chunk-parallel worker count (av1an's --workers). When "
"omitted, computed from CPU topology (budget // 4 threads per "
"worker, capped at physical_cores - 1). Set lower than the "
"auto-computed value if the box hard-locks even with the "
"thread cap, or higher if you have fast storage and want "
"more parallelism. Combine with --threads-per-worker to "
"fully override the auto math.",
)
parser.add_argument(
"--threads-per-worker", type=int, metavar="N",
help="Per-encoder thread cap (passed to SvtAv1EncApp / vpxenc / "
"x265 via --video-params --threads N). When omitted, computed "
"as max(1, budget // worker_count). Default behavior caps "
"total active threads at logical_threads - 1 (one for OS/UI). "
"Set higher if you have few large files and want each chunk "
"to use more cores; set to 1 for maximum chunk parallelism "
"on memory-bandwidth-bound workloads.",
)
# v4.2.0: --use-av1an opts INTO the av1an chunk-parallel path. The
# default is now ffmpeg-only — av1an was too fragile across distros
# (y4m pipe breaks, SvtAv1EncApp CLI quirks like rejecting --threads,
# VapourSynth plugin issues, output buffering making it look hung).
# ffmpeg's libsvtav1 is invoked as a library, accepts -threads
# correctly, doesn't need VapourSynth, and produces immediate progress
# output. av1an is still available for users who specifically want
# scene-detection-based chunk-parallel encoding.
parser.add_argument(
"--use-av1an", action="store_true",
help="Use av1an chunk-parallel encoding (opt-in). Default is "
"ffmpeg-only, which is more reliable across distros. av1an "
"requires VapourSynth + source plugins (lsmash/ffms2/"
"bestsource) for fast chunk-parallel; without them it "
"falls back to the slow 'select' chunk method. Only use "
"--use-av1an if you have a working av1an+VapourSynth setup "
"and want scene-detection-based chunk-parallel encoding.",
)
# v4.2.1: --verbose re-enables the tech-detail log output that v4.2.1
# suppressed by default. Default is quiet — just per-file success/fail
# + final summary. --verbose brings back the CMD: lines, live tail of
# av1an/ffmpeg stderr, DIAGNOSIS blocks, resolution map, pre-flight
# validation table, and the 30s heartbeat.
parser.add_argument(
"--verbose", action="store_true",
help="Verbose log output. Default is quiet — only per-file "
"success/fail + final summary. --verbose brings back the "
"CMD: lines, live tail of av1an/ffmpeg stderr (frame= 67 "
"fps= 12 ...), DIAGNOSIS blocks, resolution map, pre-flight "
"validation table, and the 30s heartbeat.",
)
# v4.3.0: --skip-existing is the default. When the output file
# already exists AND its video+audio codec matches the selected
# encoder (verified via ffprobe), the file is skipped instead of
# re-encoded. --force-reencode disables this for users who want
# to re-encode at a different CRF/preset with the same codec.
parser.add_argument(
"--skip-existing", dest="skip_existing", action="store_true",
default=True,
help="Skip files whose output already exists with a matching "
"video+audio codec (default). Probes the output with "
"ffprobe and compares codec_name against the selected "
"encoder. Skipped files are reported in the final summary "
"as 'Skipped: N' and do NOT count as success or failure.",
)
parser.add_argument(
"--force-reencode", dest="skip_existing", action="store_false",
help="Re-encode every file, even if the output already exists "
"with a matching codec. Use this when you want to change "
"CRF/preset at the same codec — the skip-existing check "
"doesn't verify encoder settings, only the codec itself.",
)
# v4.4.0: --timeout sets the per-file encode timeout (seconds).
# Default 86400s = 24h, up from v4.0.0's 7200s = 2h. A 30GB 1080p
# BluRay rip at SVT-AV1 preset 6 takes 4-10 hours; the old 2h
# timeout killed massive-file encodes partway through. The STOP
# button handles user-initiated aborts; this is just a safety net
# for truly wedged processes.
parser.add_argument(
"--timeout", type=int, metavar="SECONDS", default=86400,
help="Per-file encode timeout in seconds (default 86400 = 24h). "
"A 30GB BluRay rip at SVT-AV1 preset 6 can take 4-10 hours; "
"the old default (7200s = 2h) killed massive-file encodes. "
"The STOP button handles user-initiated aborts; this timeout "
"is just a safety net for truly wedged processes. Set to 0 "
"for no timeout (not recommended — a wedged encode would "
"hang the queue forever).",
)
# v4.4.4: --inline-scale skips the CRF-16 pre-scale intermediate when
# a target resolution is selected. Instead, the scale/pad filter chain
# is passed directly to av1an via --ffmpeg-filter-args. This eliminates
# the 0.5-0.8× source size temp file (a 20GB source produced a 60GB
# lossless intermediate under the old CRF-0 code, crashing the encode
# with disk-exhaustion errors that presented as "ffmpeg error (rc=234)").
# Default OFF — the intermediate path is more robust against av1an/
# VapourSynth filter-arg quirks on older builds. Enable when scaling
# large files (≥10GB) to avoid wasting disk and an extra encode pass.
parser.add_argument(
"--inline-scale", action="store_true",
help="Skip the CRF-16 pre-scale intermediate. When a target "
"resolution is selected, the scale/pad filter chain is "
"passed directly to av1an via --ffmpeg-filter-args instead "
"of pre-scaling to a temp file. Eliminates the 0.5-0.8x "
"source size intermediate (was the cause of mysterious "
"'ffmpeg error (rc=234)' failures on 10GB+ sources). "
"Default OFF — the intermediate path is more robust on "
"older av1an/VapourSynth builds. Enable for large files "
"with scaling to save disk + an extra encode pass.",
)
# v4.6.0: --engine selects the video encode engine. "auto" (default)
# uses the NVENC hardware encoder for the selected codec family when
# the environment probe's live encode test proved it works; "gpu"
# forces NVENC (falls back to CPU with a log line); "cpu" forces the
# software encoders. The GPU path runs via single-pass ffmpeg —
# av1an cannot drive NVENC.
parser.add_argument(
"--engine", choices=["auto", "gpu", "cpu", "hybrid"], default="auto",
help="Video encode engine (default: auto). 'auto' uses the NVENC "
"GPU encoder (hevc_nvenc / av1_nvenc) when the selected "
"codec family has one and a live encode test proved it "
"works; falls back to CPU otherwise. 'gpu' forces NVENC. "
"'cpu' forces the software encoders (SVT-AV1 / VP9 / "
"x265). 'hybrid' splits the queue between a GPU lane and a "
"CPU lane running CONCURRENTLY (balanced by file size, CPU "
"lane budget reduced by the GPU lane's reserve) — with "
"--use-av1an the CPU lane uses chunk-parallel, so NVENC + "
"chunk workers + software all run at once. Needs 2+ "
"encodable files and a functional GPU encoder; falls back "
"to CPU otherwise. GPU encodes run via single-pass ffmpeg "
"— av1an never drives NVENC.",
)
# v4.8.0: force a GPU capability profile (see gpu_profiles.py for
# the combined generation entries). "auto" matches the detected card.
from .gpu_profiles import GPU_PROFILES
parser.add_argument(
"--gpu-profile", default="auto",
choices=["auto"] + [gp.key for gp in GPU_PROFILES],
help="GPU capability profile (default: auto-detect from the "
"installed card). Combined generation entries — e.g. "
"nv-pascal covers the whole GTX 10-series + Tesla P40/P4/"
"P100; nv-ada covers RTX 40/50. Forcing a profile extends "
"the rebuild-from-git dependency tree with that GPU's "
"packages. 'cpu' = ignore hardware.",
)
return parser
def run_dry_run(
chunk_method: str | None = None,
max_workers: int | None = None,
threads_per_worker: int | None = None,
) -> int:
"""Run the dry-run: probe env + smoke test, print report, return exit code."""
# Deferred imports so --version never pulls in PySide6 or runs the
# environment probe.
from . import __version__
from .env_probe import _av1an_vsscript_smoke_test, probe_environment
print(f"opentranscode {__version__} — dry-run environment probe")
print("=" * 60)
env = probe_environment()
# v4.0.0: --chunk-method CLI override takes precedence over the
# env_probe auto-detection. "auto" means "let av1an decide" (clears
# any override the probe set).
cli_chunk_method_note = ""
if chunk_method is not None:
if chunk_method == "auto":
env.av1an_flags.pop("chunk_method_override", None)
cli_chunk_method_note = " (CLI: auto — cleared probe setting)"
else:
env.av1an_flags["chunk_method_override"] = chunk_method
cli_chunk_method_note = f" (CLI: {chunk_method})"
# v4.1.0: --max-workers / --threads-per-worker are stored on
# env.av1an_flags so EncoderWorker picks them up via __init__'s
# fallback path (no ui_window.py code changes needed).
if max_workers is not None:
env.av1an_flags["max_workers"] = max_workers
if threads_per_worker is not None:
env.av1an_flags["threads_per_worker"] = threads_per_worker
print(f"Distro: {env.distro.name} (family={env.distro.family}, "
f"v{env.distro.version_id})")
print(f"CPU: {env.cpu.model_name} — "
f"{env.cpu.physical_cores} physical / {env.cpu.logical_threads} logical")
print(f"av1an: {env.av1an_path or 'NOT FOUND'}"
+ (f" (v{env.av1an_version})" if env.av1an_version else ""))
print(f"ffmpeg: {env.ffmpeg_path or 'NOT FOUND'}"
+ (f" (v{env.ffmpeg_version})" if env.ffmpeg_version else ""))
print(f"ffprobe: {env.ffprobe_path or 'NOT FOUND'}")
print(f"VapourSynth: {env.vs_version or 'NOT FOUND'}"
+ (f" ({env.vs_script_lib})" if env.vs_script_lib else ""))
# v4.0.0: show VS source plugins + effective chunk method
vs_plugins = env.av1an_flags.get("vs_plugins", [])
if vs_plugins:
print(f"VS plugins: {', '.join(vs_plugins)}")
else:
print(f"VS plugins: (none — Hybrid chunk method will fail on "
f"phone-recorded MP4s)")
effective_cm = env.av1an_flags.get("chunk_method_override")
print(f"Chunk method: {effective_cm or 'auto (av1an decides)'}{cli_chunk_method_note}")
# v4.6.0: GPU/NVENC probe result (live encode test, not just the
# compiled-in encoder list).
gpu = getattr(env, "gpu", None)
if gpu is None:
print("GPU: (probe unavailable)")
elif gpu.has_gpu:
print(f"GPU: {gpu.name or 'NVIDIA'} — NVENC ready: "
f"{', '.join(gpu.usable_encoders)}")
elif any(gpu.encoders.values()):
present = [e for e in ("av1_nvenc", "hevc_nvenc", "h264_nvenc")
if gpu.encoders.get(e)]
print(f"GPU: {', '.join(present)} present but NOT usable — "
f"{gpu.first_failure_detail or 'smoke test failed'}")
else:
print("GPU: none (CPU encoding)")
# v4.1.0: show intelligent worker math so the user can verify the
# chunk-parallel thread budget before launching a real encode.
# We instantiate EncoderWorker without starting the QThread to read
# the computed values — __init__ doesn't touch Qt, only sets attrs.
try:
from .encoder_worker import EncoderWorker
from .codec_profiles import VIDEO_CODECS, AUDIO_PROFILES, CONTAINER_PROFILES, RESOLUTION_PRESETS
from pathlib import Path
# Use a stub in_dir/out_dir — run() is never called, only the
# _compute_intelligent_worker_count method is invoked.
probe_worker = EncoderWorker(
in_dir=Path("/tmp"),
out_dir=Path("/tmp"),
video_codec=VIDEO_CODECS[0],
audio_profile=AUDIO_PROFILES[0],
container=CONTAINER_PROFILES[0],
crf=30,
preset_label="Medium (6)",
delete_source=False,
env=env,
extensions={".mkv"},
resolution=RESOLUTION_PRESETS[0],
max_workers=max_workers,
threads_per_worker=threads_per_worker,
)
wc, tpw = probe_worker._compute_intelligent_worker_count()
active = wc * tpw
reserved = max(0, env.cpu.logical_threads - active)
overrides = []
if max_workers is not None:
overrides.append(f"--max-workers={max_workers}")
if threads_per_worker is not None:
overrides.append(f"--threads-per-worker={threads_per_worker}")
override_note = f" (overrides: {', '.join(overrides)})" if overrides else " (auto)"
print(f"Workers: {wc} workers × {tpw} threads = {active} active"
f" — {reserved} reserved for OS/UI{override_note}")
except Exception as e:
# Don't fail the dry-run if the worker probe hits an edge case.
print(f"Workers: (could not compute: {e})")
print("ffmpeg libs: " + ", ".join(
f"{k}={'yes' if v else 'no'}" for k, v in sorted(env.ffmpeg_libs.items())
))
if env.errors:
print("\nERRORS:")
for e in env.errors:
print(f" - {e}")
if env.warnings:
print("\nWARNINGS:")
for w in env.warnings:
print(f" - {w}")
# Smoke test only if av1an + ffmpeg are both present.
if env.av1an_path and env.ffmpeg_path:
print("\n--- av1an VSScript smoke test ---")
svt_name = (env.av1an_flags or {}).get("svt_name", "svt_av1")
ok, detail = _av1an_vsscript_smoke_test(
env.av1an_path, env.ffmpeg_path, env.av1an_flags, svt_name,
)
print(f" result: {'OK' if ok else 'FAIL'}")
print(f" detail: {detail}")
if not ok:
print("\nDry-run complete — smoke test FAILED.")
return 1
else:
print("\nSmoke test skipped (av1an or ffmpeg not found).")
print("\nDry-run complete.")
return 0 if not env.errors else 1
def run_verify_only(path: str) -> int:
"""Re-verify an existing output file via ffprobe (no re-encode)."""
import os
from .ffprobe_utils import ffprobe_duration, ffprobe_validate
target = Path(path)
if not target.is_file():
print(f"verify-only: file not found: {target}", file=sys.stderr)
return 1
ffprobe_bin = os.environ.get("FFPROBE_BIN", "ffprobe")
info = ffprobe_validate(target, ffprobe_bin)
if info is None:
print(f"verify-only: ffprobe could not read {target}", file=sys.stderr)
return 1
size = target.stat().st_size
duration = ffprobe_duration(target, ffprobe_bin)
streams = info.get("streams", [])
vstream = next((s for s in streams if s.get("codec_type") == "video"), {})
width = vstream.get("width", "?")
height = vstream.get("height", "?")
print(f"file: {target}")
print(f"size: {size} bytes ({size / 1024 / 1024:.2f} MiB)")
print(f"duration: {duration if duration is not None else '?'} s"
if duration is not None else "duration: ?")
print(f"resolution: {width}x{height}")
print("\nverify-only: OK" if size > 0 else "\nverify-only: FAIL (empty file)")
return 0 if size > 0 else 1
def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the process exit code."""
args = build_parser().parse_args(argv)
if args.version:
from . import __version__
print(f"opentranscode {__version__}")
return 0
if args.dry_run:
return run_dry_run(
chunk_method=args.chunk_method,
max_workers=args.max_workers,
threads_per_worker=args.threads_per_worker,
)
if args.verify_only:
return run_verify_only(args.verify_only)
# No flag (or --force) — launch GUI. --force pre-checks the Force
# checkbox; the user can still toggle it in the UI.
# v4.0.0: --chunk-method sets env.av1an_flags["chunk_method_override"]
# before the GUI launches so every EncoderWorker picks it up.
# v4.1.0: --max-workers / --threads-per-worker do the same — stored
# on env.av1an_flags and picked up by EncoderWorker.__init__'s
# fallback path (no ui_window.py changes needed).
from .ui_window import launch_gui
return launch_gui(
force=args.force,
chunk_method=args.chunk_method,
max_workers=args.max_workers,
threads_per_worker=args.threads_per_worker,
use_av1an=args.use_av1an,
verbose=args.verbose,
skip_existing=args.skip_existing,
timeout=args.timeout,
inline_scale=args.inline_scale,
engine=args.engine,
gpu_profile=args.gpu_profile,
)

View File

@ -1,388 +0,0 @@
"""Codec / audio / container profile tables and helpers.
Data-driven configuration that replaces the v1 if/else codec chains.
Pure data + pure functions — no PySide6, no I/O, no internal package
dependencies. Safe to import from any context (incl. unit tests and
the CLI --version path).
"""
from collections.abc import Callable
from dataclasses import dataclass, field
# ──────────────────────────────────────────────
# CONFIG-DRIVEN PROFILES (replaces all if/else chains)
# ──────────────────────────────────────────────
@dataclass
class VideoCodecProfile:
label: str # Display name in combo box
av1an_encoder: str # Encoder name passed to --encoder
ffmpeg_encoder: str # Encoder name for pure-ffmpeg fallback (e.g. "libsvtav1")
container: str # Default container extension (mkv or webm)
crf_range: tuple[int, int] # (min, max) valid CRF values
default_crf: int
# (crf, preset) -> av1an --video-params string. Passed to SvtAv1EncApp /
# vpxenc / x265 as a CLI invocation, so ONLY CLI-accepted flags may
# appear here. Thread capping lives in ffmpeg_vargs_fn (where
# libsvtav1 is invoked as a library and accepts -threads) and in
# EncoderWorker's --workers count (av1an's chunk-parallel knob).
params_fn: Callable[[int, int], str]
ffmpeg_vargs_fn: Callable[[int, int], list[str]] # (crf, preset) -> ffmpeg -c:v args
presets: list[str] # Human-readable preset labels
preset_map: dict[str, int] # label -> internal preset value
# v4.3.0: the codec_name ffprobe returns for files encoded with this
# profile. Used by _output_already_encoded() to detect skip-existing.
# av1 → "av1", vp9 → "vp9", hevc → "hevc". Verified against ffprobe
# output for each encoder; this is the codec_name field in the video
# stream's JSON, NOT the encoder_name (which would be "libsvtav1" etc).
ffprobe_codec_name: str = ""
# v4.6.0: hardware (NVENC) counterpart for this codec family. Empty
# string = no hardware encoder exists for this family (VP9 has no
# NVENC encoder). The GPU path is ffmpeg-only (av1an cannot drive
# NVENC); EncoderWorker.resolve_gpu_encoder() only selects it when a
# functional probe proved the encoder works on this system. The
# ffprobe codec_name is IDENTICAL to the CPU encoder's (hevc_nvenc
# also produces "hevc"), so skip-existing detection works across
# GPU/CPU re-encodes of the same family.
gpu_encoder: str = ""
# (crf, preset) -> ffmpeg args for the NVENC encoder. Mirrors
# ffmpeg_vargs_fn. None when gpu_encoder is empty.
gpu_vargs_fn: Callable[[int, int], list[str]] | None = None
# v4.8.0: GPU-profile support. *gpu_family* is the codec family key
# used by GpuProfile.encoders ("av1"/"hevc"/"vp9"); *gpu_encoders_by_api*
# maps a hardware API (nvenc/vaapi/qsv) to this profile's ffmpeg
# encoder for that API. resolve_gpu_encoder() picks the entry matching
# the selected GPU profile.
gpu_family: str = ""
gpu_encoders_by_api: dict[str, str] = field(default_factory=dict)
@dataclass
class AudioProfile:
label: str
params: list[str] # Tokens passed to --audio-params (joined with space)
# v3 (OTC-012, SEI CERT STR09-C): the ffmpeg audio encoder name this
# profile depends on, e.g. "libopus", "libvorbis", "flac", "libiamf".
# Used by _check_combo_compatibility and _disable_unavailable_codecs
# to look up the encoder directly in EnvProbe.ffmpeg_libs — replacing
# the v2 substring match (`"libiamf" in ap.params`) which would
# falsely match a hypothetical `-libiamf-mode` argument.
# Empty string means "no ffmpeg encoder dependency" (rare; only used
# by passthrough profiles that don't transcode audio).
ffmpeg_encoder_name: str = ""
# v4.3.0: the codec_name ffprobe returns for files encoded with this
# profile. Used by _output_already_encoded() to detect skip-existing.
# opus → "opus", vorbis → "vorbis", flac → "flac", iamf → "iamf".
ffprobe_codec_name: str = ""
@dataclass
class ContainerProfile:
label: str
ext: str # e.g. "mkv", "webm"
def _av1_params(crf: int, preset: int) -> str:
"""SVT-AV1 encoder params for av1an's --video-params.
av1an splits the --video-params value by whitespace (``split_whitespace()``)
and passes each resulting token as a separate argument to SvtAv1EncApp.
Therefore the string must contain space-separated ``--flag value`` pairs
that SvtAv1EncApp can parse natively.
Colon-separated ``key=value:key=value`` does NOT work because there are
no whitespace boundaries for av1an to split on — the entire string reaches
SvtAv1EncApp as one opaque argument, producing:
``Maybe missing spacing between tokens``.
Thread capping is NOT injected here. SvtAv1EncApp (the standalone CLI
av1an invokes per-chunk) uses `--lp N` (logical processors), not
`--threads N`. Thread capping is handled via av1an's `--workers` flag
(chunk-parallel count) and via `-threads` in the ffmpeg fallback path
(where libsvtav1 is a library and accepts it).
"""
return f"--preset {preset} --crf {crf} --keyint 240"
def _vp9_params(crf: int, preset: int) -> str:
"""VP9 encoder params for av1an's --video-params.
av1an splits by whitespace, so we use space-separated --flag=value tokens
that vpxenc parses natively.
"""
cpu_used = max(0, 8 - preset)
return f"--end-usage=q --cq-level={crf} --cpu-used={cpu_used}"
def _x265_params(crf: int, preset: int) -> str:
"""x265 encoder params for av1an's --video-params.
av1an splits by whitespace, so we use space-separated --flag value tokens
that x265 parses natively.
"""
return f"--crf {crf} --preset {preset}"
def _svtav1_ffmpeg_args(crf: int, preset: int) -> list[str]:
"""FFmpeg args for SVT-AV1 (maps av1an preset=0..8 → svtav1 -preset 0..13)."""
# av1an preset range 0-8 maps to SVT-AV1 preset range 0-13
# Scale roughly: 8→0, 6→4, 4→7, 2→10
svt_preset = max(0, min(13, round((8 - preset) * 13 / 8)))
return ["-c:v", "libsvtav1", "-preset", str(svt_preset), "-crf", str(crf),
"-pix_fmt", "yuv420p10le", "-g", "240"]
def _vp9_ffmpeg_args(crf: int, preset: int) -> list[str]:
"""FFmpeg args for VP9 (maps av1an cpu-used 0..8 → -cpu-used 0..8)."""
cpu_used = max(0, min(8, preset))
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0",
"-cpu-used", str(cpu_used), "-pix_fmt", "yuv420p", "-g", "240",
"-row-mt", "1", "-tiles", "2x2"]
def _x265_ffmpeg_args(crf: int, preset: int) -> list[str]:
"""FFmpeg args for x265 (maps av1an preset 5..10 → x265 -preset)."""
# av1an x265 preset range 5-10 maps to x265 preset names
preset_names = {5: "slow", 7: "medium", 9: "fast", 10: "faster"}
p = preset_names.get(preset, "medium")
return ["-c:v", "libx265", "-preset", p, "-crf", str(crf),
"-pix_fmt", "yuv420p10le", "-g", "240"]
# ── v4.6.0: NVENC (hardware) vargs ──
# NVENC quality control: -rc vbr + -cq N + -b:v 0 is the constant-quality
# mode that maps most closely to the CPU encoders' CRF (cq ≈ crf for HEVC
# and AV1 within ~±3). -b:v 0 removes the default bitrate cap so -cq
# actually governs quality. Presets are p1 (fastest) .. p7 (slowest/best)
# on all current NVENC generations; the legacy "slow/medium/fast" aliases
# are deprecated.
#
# Pixel format: 8-bit yuv420p. Pascal-generation cards (GTX 10xx) run
# HEVC Main10 at roughly half throughput, and the archival targets here
# are 8-bit phone/BluRay sources — 8-bit keeps the GPU path at full
# speed. ffmpeg auto-converts 10-bit sources to yuv420p.
def _nvenc_preset(preset: int) -> str:
"""Map the CPU preset tiers (lower value = slower/better) to NVENC
p-presets. CPU preset values across profiles are 0..10 with 0/5 =
slowest quality tiers; NVENC is fast enough that even p7 outruns any
CPU encoder, so the whole range compresses to p3..p7."""
if preset <= 6:
return "p7" # "Slow" tier → best NVENC quality
if preset <= 8:
return "p5" # "Medium" tier
return "p4" # "Fast"/"Faster" tiers
def _hevc_nvenc_args(crf: int, preset: int) -> list[str]:
"""FFmpeg args for hevc_nvenc (x265/HEVC family hardware encoder)."""
return ["-c:v", "hevc_nvenc", "-preset", _nvenc_preset(preset),
"-tune", "hq", "-rc", "vbr", "-cq", str(crf), "-b:v", "0",
"-pix_fmt", "yuv420p", "-g", "240"]
def _h264_nvenc_args(crf: int, preset: int) -> list[str]:
"""FFmpeg args for h264_nvenc (hardware H.264 — compatibility target)."""
return ["-c:v", "h264_nvenc", "-preset", _nvenc_preset(preset),
"-tune", "hq", "-rc", "vbr", "-cq", str(crf), "-b:v", "0",
"-pix_fmt", "yuv420p", "-g", "240"]
def _av1_nvenc_args(crf: int, preset: int) -> list[str]:
"""FFmpeg args for av1_nvenc (AV1 family hardware encoder, RTX 40+)."""
return ["-c:v", "av1_nvenc", "-preset", _nvenc_preset(preset),
"-tune", "hq", "-rc", "vbr", "-cq", str(crf), "-b:v", "0",
"-pix_fmt", "yuv420p", "-g", "240"]
VIDEO_CODECS: list[VideoCodecProfile] = [
VideoCodecProfile(
label="AV1 (SVT-AV1)",
av1an_encoder="svt_av1",
ffmpeg_encoder="libsvtav1",
container="mkv",
crf_range=(18, 52),
default_crf=32,
params_fn=_av1_params,
ffmpeg_vargs_fn=_svtav1_ffmpeg_args,
presets=["Slow (8)", "Medium (6)", "Fast (4)", "Faster (2)"],
preset_map={"Slow (8)": 8, "Medium (6)": 6, "Fast (4)": 4, "Faster (2)": 2},
ffprobe_codec_name="av1", # v4.3.0: skip-existing detection
# v4.6.0: av1_nvenc exists only on RTX 40+ (Ada) cards; on Pascal
# (GTX 10xx) the functional probe fails and auto falls back to
# the SVT-AV1 CPU encoder.
gpu_encoder="av1_nvenc",
gpu_vargs_fn=_av1_nvenc_args,
gpu_family="av1",
gpu_encoders_by_api={"nvenc": "av1_nvenc", "qsv": "av1_qsv",
"vaapi": "av1_vaapi"},
),
VideoCodecProfile(
label="VP9",
av1an_encoder="vpx",
ffmpeg_encoder="libvpx-vp9",
container="webm",
crf_range=(18, 52),
default_crf=32,
params_fn=_vp9_params,
ffmpeg_vargs_fn=_vp9_ffmpeg_args,
presets=["Slow (0)", "Medium (2)", "Fast (4)", "Faster (6)"],
preset_map={"Slow (0)": 0, "Medium (2)": 2, "Fast (4)": 4, "Faster (6)": 6},
ffprobe_codec_name="vp9", # v4.3.0: skip-existing detection
# v4.8.0: VP9 has no NVENC encoder; VAAPI (AMD/older Intel) can
# encode it on some cards.
gpu_family="vp9",
gpu_encoders_by_api={"vaapi": "vp9_vaapi"},
),
VideoCodecProfile(
label="x265 (HEVC)",
av1an_encoder="x265",
ffmpeg_encoder="libx265",
container="mkv",
crf_range=(18, 40),
default_crf=28,
params_fn=_x265_params,
ffmpeg_vargs_fn=_x265_ffmpeg_args,
presets=["Slow (5)", "Medium (7)", "Fast (9)", "Faster (10)"],
preset_map={"Slow (5)": 5, "Medium (7)": 7, "Fast (9)": 9, "Faster (10)": 10},
ffprobe_codec_name="hevc", # v4.3.0: skip-existing detection
# v4.6.0: hevc_nvenc works on every NVENC generation since Maxwell
# GM206 (incl. the GTX 1070) — this is the family that benefits
# most from GPU mode.
gpu_encoder="hevc_nvenc",
gpu_vargs_fn=_hevc_nvenc_args,
gpu_family="hevc",
gpu_encoders_by_api={"nvenc": "hevc_nvenc", "qsv": "hevc_qsv",
"vaapi": "hevc_vaapi"},
),
]
AUDIO_PROFILES: list[AudioProfile] = [
AudioProfile(label="Opus (96k)", params=["-c:a", "libopus", "-b:a", "96k"],
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"],
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
AudioProfile(label="Opus (64k)", params=["-c:a", "libopus", "-b:a", "64k"],
ffmpeg_encoder_name="libopus", ffprobe_codec_name="opus"),
AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"],
ffmpeg_encoder_name="libvorbis", ffprobe_codec_name="vorbis"),
AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"],
ffmpeg_encoder_name="libvorbis", ffprobe_codec_name="vorbis"),
AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"],
ffmpeg_encoder_name="flac", ffprobe_codec_name="flac"),
# IAMF — AOMedia Immersive Audio Model and Formats (RFC 9454 family).
# Built on Opus internally; requires ffmpeg compiled with --enable-libiamf.
# CANNOT be muxed into MKV/WebM — must use the MP4 container (see below).
# The -strict experimental flag is harmless on ffmpeg builds where libiamf
# is already stable, and required on builds where it's still flagged
# experimental, so we always pass it for forward compatibility.
AudioProfile(
label="IAMF (128k)",
params=["-c:a", "libiamf", "-b:a", "128k", "-strict", "experimental"],
ffmpeg_encoder_name="libiamf",
ffprobe_codec_name="iamf",
),
]
CONTAINER_PROFILES: list[ContainerProfile] = [
ContainerProfile(label="MKV (Matroska)", ext="mkv"),
ContainerProfile(label="WebM", ext="webm"),
# MP4 is required for IAMF audio (MKV/WebM cannot mux the IAMF codec).
# Also useful as a more universally compatible output container.
ContainerProfile(label="MP4", ext="mp4"),
]
# ──────────────────────────────────────────────────────────────────────────────
# FFMPEG_LIB_KEY_MAP — single source of truth (OTC-007, SEI CERT MSC04-C).
#
# Maps the `ffmpeg_encoder` field of a VideoCodecProfile (e.g. "libsvtav1",
# "libvpx-vp9") to the corresponding key in EnvProbe.ffmpeg_libs (which is
# populated by _probe_ffmpeg_libs()).
#
# v2 had this map duplicated in three call sites:
# - _ffmpeg_fallback_encode (around line 2075)
# - _probe_and_init status bar (around line 4309)
# - _handle_vs_incompat fallback check (around line 4532)
# Adding a new codec required updating all three in sync — a classic
# MSC04-C violation. v3 hoists it to one module-level constant.
# ──────────────────────────────────────────────────────────────────────────────
FFMPEG_LIB_KEY_MAP: dict[str, str] = {
"libsvtav1": "libsvtav1",
"libaom-av1": "libaom",
"libvpx-vp9": "libvpx",
"libx265": "libx265",
# v4.6.0: hardware encoders. These keys are populated by
# _probe_ffmpeg_libs() alongside the software encoders, and — unlike
# the compiled-in check — EncoderWorker additionally gates the GPU
# path on env.gpu.functional (a real encode smoke test), because a
# ffmpeg build can list an NVENC encoder that the installed driver
# cannot open (NVENC API version mismatch).
"hevc_nvenc": "hevc_nvenc",
"h264_nvenc": "h264_nvenc",
"av1_nvenc": "av1_nvenc",
# v4.8.0: hardware APIs for AMD (VAAPI) and Intel (QSV) profiles.
"hevc_vaapi": "hevc_vaapi",
"h264_vaapi": "h264_vaapi",
"av1_vaapi": "av1_vaapi",
"vp9_vaapi": "vp9_vaapi",
"hevc_qsv": "hevc_qsv",
"h264_qsv": "h264_qsv",
"av1_qsv": "av1_qsv",
}
def ffmpeg_lib_key_for(ffmpeg_encoder: str) -> str:
"""Look up the ffmpeg_libs key for a given ffmpeg encoder name.
Returns the encoder name itself if no mapping is known — this preserves
forward compatibility with encoders added after this map was last
updated (the caller's .get() will then return False, which is the
safe default for an unknown encoder).
"""
return FFMPEG_LIB_KEY_MAP.get(ffmpeg_encoder, ffmpeg_encoder)
# ── Resolution presets ──
# Aspect ratios:
# Standard 16:9 -> w/h = 1.778
# Wide 21:9 -> w/h = 2.333
# Ultrawide 32:9 -> w/h = 3.556
@dataclass
class ResolutionProfile:
label: str # Display label in dropdown, e.g. "1080p Wide (2560x1080)"
category: str # Grouping key: "standard", "wide", "ultrawide", "original"
width: int | None # None for "original" (no scaling)
height: int | None # None for "original"
aspect_label: str # "16:9", "21:9", "32:9", "Source"
RESOLUTION_PRESETS: list[ResolutionProfile] = [
# ── Original (no scaling) ──
ResolutionProfile("Original (No Scaling)", "original", None, None, "Source"),
# ── Standard 16:9 ──
ResolutionProfile("480p ( 854x 480)", "standard", 854, 480, "16:9"),
ResolutionProfile("720p (1280x 720)", "standard", 1280, 720, "16:9"),
ResolutionProfile("1080p (1920x1080)", "standard", 1920, 1080, "16:9"),
ResolutionProfile("2K (2560x1440)", "standard", 2560, 1440, "16:9"),
ResolutionProfile("4K (3840x2160)", "standard", 3840, 2160, "16:9"),
# ── Wide 21:9 ──
ResolutionProfile("480p Wide ( 854x 366)", "wide", 854, 366, "21:9"),
ResolutionProfile("720p Wide (1280x 549)", "wide", 1280, 549, "21:9"),
ResolutionProfile("1080p Wide (2560x1080)", "wide", 2560, 1080, "21:9"),
ResolutionProfile("2K Wide (3440x1440)", "wide", 3440, 1440, "21:9"),
ResolutionProfile("4K Wide (5120x2160)", "wide", 5120, 2160, "21:9"),
# ── Ultrawide 32:9 ──
ResolutionProfile("480p UW (1706x 480)", "ultrawide", 1706, 480, "32:9"),
ResolutionProfile("1080p UW (3840x1080)", "ultrawide", 3840, 1080, "32:9"),
ResolutionProfile("2K UW (5120x1440)", "ultrawide", 5120, 1440, "32:9"),
ResolutionProfile("4K UW (7680x2160)", "ultrawide", 7680, 2160, "32:9"),
]
SUBTITLE_OPTIONS = [
("None", None),
("English", "eng"),
]
DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"}

View File

@ -1,123 +0,0 @@
"""CPU topology detection (physical cores, not hyperthreads).
Reads /sys/devices/system/cpu/* and falls back to ``lscpu``. Pure
stdlib; no internal package dependencies.
"""
import os
import shutil
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
# ──────────────────────────────────────────────
# CPU TOPOLOGY (physical cores, not hyperthreads)
# ──────────────────────────────────────────────
@dataclass
class CpuTopology:
physical_cores: int
logical_threads: int
threads_per_core: int
model_name: str
def _read_sysfs_cores() -> (tuple[int, int]) | None:
"""
Read /sys/devices/system/cpu/cpu*/topology/ to count unique
(physical_package_id, core_id) pairs — i.e. physical cores.
Returns (physical_cores, logical_threads) or None.
"""
cpu_base = Path("/sys/devices/system/cpu")
if not cpu_base.exists():
return None
unique_cores: set[tuple[str, str]] = set()
logical = 0
for cpu_dir in sorted(cpu_base.glob("cpu[0-9]*")):
core_id_file = cpu_dir / "topology" / "core_id"
pkg_id_file = cpu_dir / "topology" / "physical_package_id"
if core_id_file.exists() and pkg_id_file.exists():
try:
pkg = pkg_id_file.read_text().strip()
core = core_id_file.read_text().strip()
unique_cores.add((pkg, core))
logical += 1
except (OSError, ValueError):
# OSError: file vanished/permission; ValueError: UnicodeDecodeError
pass
if unique_cores and logical:
return (len(unique_cores), logical)
return None
def _read_lscpu_cores() -> (tuple[int, int]) | None:
"""Fallback: parse lscpu -p=CORE,SOCKET for unique physical cores."""
if not shutil.which("lscpu"):
return None
try:
res = subprocess.run(
["lscpu", "-p=CORE,SOCKET"],
capture_output=True, text=True, timeout=5,
)
lines = [l.strip() for l in res.stdout.strip().splitlines() if l.strip() and not l.startswith("#")]
if lines:
unique = set(lines)
return (len(unique), len(lines))
except (OSError, subprocess.SubprocessError):
pass
return None
def detect_cpu_topology() -> CpuTopology:
"""
Detect physical CPU topology. Prefers /sys filesystem, falls back
to lscpu, then estimates from os.cpu_count().
"""
logical = os.cpu_count() or 1
physical = logical
# Try /sys first (most reliable)
result = _read_sysfs_cores()
if result:
physical, logical = result
else:
# Try lscpu
result = _read_lscpu_cores()
if result:
physical, logical = result
else:
# Estimate: assume 2 threads/core if cpu_count > 2 and is even
if logical > 2 and logical % 2 == 0:
physical = logical // 2
tpc = logical // physical if physical > 0 else 1
# Try to get CPU model name
model = "Unknown CPU"
model_file = Path("/proc/cpuinfo")
if model_file.exists():
for line in model_file.read_text(errors="replace").splitlines():
if line.startswith("model name"):
model = line.split(":", 1)[1].strip()
break
else:
# Non-x86 / non-Linux: try lscpu
if shutil.which("lscpu"):
try:
res = subprocess.run(["lscpu"], capture_output=True, text=True, timeout=5)
for line in res.stdout.splitlines():
if "Model name" in line:
model = line.split(":", 1)[1].strip()
break
except (OSError, subprocess.SubprocessError):
pass
return CpuTopology(
physical_cores=physical,
logical_threads=logical,
threads_per_core=tpc,
model_name=model,
)

View File

@ -1,328 +0,0 @@
"""Linux distro detection and per-distro profile registry.
Replaces the v1 250-line if/elif chain with a tuple-of-dataclasses
table (``DISTRO_REGISTRY``). Adding a new distro is a one-row change.
Pure stdlib; no internal package dependencies.
"""
import os
import platform
import time
from dataclasses import dataclass, field
from pathlib import Path
# ──────────────────────────────────────────────
# DISTRO DETECTION & PROFILES
# ──────────────────────────────────────────────
@dataclass
class DistroProfile:
family: str # Canonical family: arch, debian, redhat, suse, nixos, unknown
name: str # Pretty name: "Arch Linux", "Fedora 40", etc.
version_id: str # e.g. "40", "15.6", "24.05"
pkg_manager: str # e.g. "pacman", "dnf", "zypper", "apt", "nix"
install_cmd_template: str # e.g. "sudo pacman -S {packages}"
binary_extra_paths: list[str] # Distro-specific dirs to search for binaries
av1an_known_encoder_names: list[str] # Names this distro's av1an build may accept
ffmpeg_pkg: str # Package name providing ffmpeg
av1an_pkg: str # Package name providing av1an
notes: str # Distro-specific quirks worth showing the user
# Runtime dependency packages (key = generic name, value = distro package name)
dep_pkgs: dict[str, str] = field(default_factory=dict)
# Binaries that av1an invokes directly (not via ffmpeg)
encoder_binaries: dict[str, list[str]] = field(default_factory=dict)
# VSScript package name — on most distros this is bundled into 'vapoursynth',
# but Debian/Ubuntu split it into a separate -script-dev package.
# If set, this takes priority over dep_pkgs["vapoursynth"] for the VS check.
vsscript_pkg: str = ""
def _read_os_release() -> dict[str, str]:
"""Parse /etc/os-release into a dict. Falls back to empty dict."""
os_release = Path("/etc/os-release")
fallback = Path("/usr/lib/os-release")
target = os_release if os_release.exists() else fallback
if not target.exists():
return {}
data = {}
for line in target.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
key, _, val = line.partition("=")
data[key.strip()] = val.strip().strip('"')
return data
# ──────────────────────────────────────────────────────────────────────────────
# DISTRO_REGISTRY — data-driven distro detection (v3, OTC-014).
#
# v1/v2 had a 250-line if/elif chain in detect_distro() with one branch per
# distro family. Each branch constructed a DistroProfile with mostly-identical
# fields — a classic SEI CERT MSC04-C violation (no single source of truth).
#
# v3 collapses the chain into a tuple-of-dicts table. Each entry has:
# ids: tuple of distro_id strings that match this family
# id_likes: tuple of ID_LIKE substrings that also match this family
# family: canonical family name
# pkg_manager: package manager binary name
# install_cmd: template with {packages} placeholder
# extra_paths: list of distro-specific binary search paths
# dep_pkgs: map of generic name -> distro package name
# notes: distro-specific quirks string
# vsscript_pkg: (optional) separate VSScript package name
#
# Adding a new distro is now a single-table-row change — no code modification.
# The encoder_binaries field is identical across all distros and lives in the
# function body (it's the same dict literal every time).
# ──────────────────────────────────────────────────────────────────────────────
# encoder_binaries is identical for every distro — define once.
_ENCODER_BINARIES: dict[str, list[str]] = {
"svt_av1": ["SvtAv1EncApp", "svt_av1"],
"vpx": ["vpxenc"],
"x265": ["x265"],
}
# Common av1an encoder names known across distros.
_AV1AN_KNOWN_ENCODERS: list[str] = ["svt_av1", "svt", "aom", "rav1e", "vpx", "x265"]
@dataclass(frozen=True)
class _DistroEntry:
"""One row in the DISTRO_REGISTRY table."""
ids: tuple[str, ...] # exact distro_id matches
id_likes: tuple[str, ...] # ID_LIKE substring matches
family: str
pkg_manager: str
install_cmd: str # template with {packages}
extra_paths: tuple[str, ...]
dep_pkgs: dict[str, str]
notes: str
vsscript_pkg: str = ""
DISTRO_REGISTRY: tuple[_DistroEntry, ...] = (
_DistroEntry(
ids=("arch", "manjaro", "endeavouros", "garuda", "cachyos"),
id_likes=("arch",),
family="arch",
pkg_manager="pacman",
install_cmd="sudo pacman -S {packages}",
extra_paths=("/usr/bin", "/usr/local/bin", "~/.local/bin", "~/.cargo/bin"),
dep_pkgs={
"vapoursynth": "vapoursynth",
"svt-av1": "svt-av1",
"x265": "x265",
"vpx": "libvpx",
"opus": "libopus",
"vorbis": "libvorbis",
"flac": "flac",
},
notes=(
"Arch/Manjaro: av1an is in the AUR (yay -S av1an) or community repo. "
"SVT-AV1 encoder name is typically 'svt_av1'. "
"Cargo-installed av1an may live in ~/.cargo/bin."
),
),
_DistroEntry(
ids=("fedora",),
id_likes=("fedora",),
family="redhat",
pkg_manager="dnf",
install_cmd="sudo dnf install {packages}",
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
dep_pkgs={
"vapoursynth": "vapoursynth",
"svt-av1": "svt-av1",
"x265": "x265",
"vpx": "libvpx-tools",
"opus": "opus",
"vorbis": "libvorbis",
"flac": "flac",
},
notes=(
"Fedora: av1an may require COPR enablement first: "
"sudo dnf copr enable sergiomb/av1an (or build from source). "
"SVT-AV1 is in the main repos as 'svt-av1'. "
"Ensure RPM Fusion is enabled for full codec support."
),
),
_DistroEntry(
ids=("rhel", "centos", "rocky", "almalinux", "ol"),
id_likes=("rhel", "centos"),
family="redhat",
# RHEL-family: dnf if present, fall back to yum
pkg_manager="", # resolved at runtime in detect_distro()
install_cmd="", # resolved at runtime in detect_distro()
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
dep_pkgs={
"vapoursynth": "vapoursynth",
"svt-av1": "svt-av1",
"x265": "x265",
"vpx": "libvpx-tools",
"opus": "opus",
"vorbis": "libvorbis",
"flac": "flac",
},
notes=(
"RHEL/CentOS/Rocky/Alma: av1an is NOT in default repos. "
"Options: (1) cargo install av1an, (2) build from GitHub source, "
"(3) use pre-built binary from releases. "
"Enable EPEL + RPM Fusion for FFmpeg codec support."
),
),
_DistroEntry(
ids=("opensuse-leap", "opensuse-tumbleweed", "sles"),
id_likes=("suse",),
family="suse",
pkg_manager="zypper",
install_cmd="sudo zypper install {packages}",
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
dep_pkgs={
"vapoursynth": "vapoursynth",
"svt-av1": "svt-av1",
"x265": "x265",
"vpx": "libvpx",
"opus": "libopus",
"vorbis": "libvorbis",
"flac": "flac",
},
notes=(
"openSUSE: av1an may be available via OBS (Open Build Service). "
"Check: https://build.opensuse.org/package/show/multimedia:apps/av1an. "
"Packman repo provides FFmpeg with full codec support."
),
),
_DistroEntry(
ids=("nixos",),
id_likes=("nixos",),
family="nixos",
pkg_manager="nix",
install_cmd="nix-shell -p {packages}",
extra_paths=("/run/current-system/sw/bin", "~/.nix-profile/bin"),
dep_pkgs={
"vapoursynth": "vapoursynth",
"svt-av1": "svt-av1",
"x265": "x265",
"vpx": "libvpx",
"opus": "opus",
"vorbis": "libvorbis",
"flac": "flac",
},
notes=(
"NixOS: Use 'nix-shell -p ffmpeg av1an' or add to configuration.nix. "
"Binaries live under /run/current-system/sw/bin or ~/.nix-profile/bin. "
"av1an CLI flags may differ from other distros depending on the nixpkgs channel."
),
),
_DistroEntry(
ids=("debian", "ubuntu", "linuxmint", "pop"),
id_likes=("debian",),
family="debian",
pkg_manager="apt",
install_cmd="sudo apt install {packages}",
extra_paths=("/usr/bin", "/usr/local/bin", "~/.cargo/bin"),
dep_pkgs={
"vapoursynth": "vapoursynth",
"svt-av1": "svtav1",
"x265": "x265",
"vpx": "libvpx-tools",
"opus": "libopus-dev",
"vorbis": "libvorbis-dev",
"flac": "flac",
},
notes=(
"Debian/Ubuntu: av1an is in the repos (apt install av1an). "
"Debian repo builds may use 'svt' as encoder name instead of 'svt_av1'. "
"VSScript is in a separate package: libvapoursynth-script-dev. "
"For newer builds, consider cargo install av1an."
),
vsscript_pkg="libvapoursynth-script-dev",
),
)
def _match_distro_entry(distro_id: str, id_like: list[str]) -> _DistroEntry | None:
"""Find the first DISTRO_REGISTRY entry whose ids or id_likes match.
SEI CERT MSC04-C spirit: the matching logic is one flat loop over a
table — no nested if/elif chain. Adding a new distro is a one-line
table change in DISTRO_REGISTRY above; this function never needs
modification.
"""
for entry in DISTRO_REGISTRY:
if distro_id in entry.ids:
return entry
if any(like in id_like for like in entry.id_likes):
return entry
return None
def detect_distro() -> DistroProfile:
"""
Detect the running Linux distribution via /etc/os-release.
Returns a DistroProfile with distro-specific package manager,
install commands, binary search paths, and known quirks.
v3 (OTC-014): the per-distro data lives in DISTRO_REGISTRY above.
This function is now ~30 lines of glue instead of a 250-line
if/elif chain.
"""
info = _read_os_release()
id_like = info.get("ID_LIKE", "").lower().split()
distro_id = info.get("ID", "").lower()
pretty = info.get("PRETTY_NAME", info.get("NAME", platform.system()))
version = info.get("VERSION_ID", "?")
entry = _match_distro_entry(distro_id, id_like)
if entry is None:
# Fallback: unknown distro
return DistroProfile(
family="unknown",
name=pretty,
version_id=version,
pkg_manager="unknown",
install_cmd_template="# Unknown distro — install ffmpeg and av1an manually",
binary_extra_paths=["/usr/bin", "/usr/local/bin", "~/.cargo/bin", "~/.local/bin"],
av1an_known_encoder_names=list(_AV1AN_KNOWN_ENCODERS),
ffmpeg_pkg="ffmpeg",
av1an_pkg="av1an",
dep_pkgs={},
encoder_binaries=dict(_ENCODER_BINARIES),
notes="Unknown distro detected. Ensure ffmpeg and av1an are in PATH.",
)
# Resolve runtime-determined fields (RHEL family: dnf vs yum)
pkg_manager = entry.pkg_manager
install_cmd = entry.install_cmd
if not pkg_manager:
# RHEL/CentOS family: pick dnf if installed, else yum
has_dnf = Path("/usr/bin/dnf").exists()
pkg_manager = "dnf" if has_dnf else "yum"
install_cmd = (
"sudo dnf install {packages}" if has_dnf
else "sudo yum install {packages}"
)
return DistroProfile(
family=entry.family,
name=pretty,
version_id=version,
pkg_manager=pkg_manager,
install_cmd_template=install_cmd,
binary_extra_paths=list(entry.extra_paths),
av1an_known_encoder_names=(
# Arch family includes the additional 'svt-av1' alias
["svt_av1", "svt", "svt-av1", "aom", "rav1e", "vpx", "x265"]
if entry.family == "arch"
else list(_AV1AN_KNOWN_ENCODERS)
),
ffmpeg_pkg="ffmpeg",
av1an_pkg="av1an",
dep_pkgs=dict(entry.dep_pkgs),
encoder_binaries=dict(_ENCODER_BINARIES),
vsscript_pkg=entry.vsscript_pkg,
notes=entry.notes,
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,121 +0,0 @@
"""ffprobe-backed validation and measurement helpers.
Three free functions:
- ``ffprobe_validate`` — full stream-info JSON for a file.
- ``ffprobe_duration`` — duration in seconds (or None).
- ``_verify_output_resolution``— post-encode resolution check.
- ``_identify_file_type`` — `file -b` output for a path (v5-03).
Pure stdlib (subprocess + json + shutil); no internal package dependencies.
v5-03: added ``_identify_file_type`` for invalid-file diagnostics.
"""
import json
import shutil
import subprocess
from pathlib import Path
# ──────────────────────────────────────────────
# FFPREPBE VALIDATION
# ──────────────────────────────────────────────
def ffprobe_validate(filepath: Path, ffprobe_bin: str) -> dict[str, object] | None:
"""Returns stream info dict or None if invalid/unreadable."""
try:
res = subprocess.run(
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", str(filepath)],
capture_output=True, text=True, timeout=30,
)
if res.returncode != 0:
return None
return json.loads(res.stdout)
except (OSError, subprocess.SubprocessError, ValueError):
# ValueError covers json.JSONDecodeError
return None
def ffprobe_duration(filepath: Path, ffprobe_bin: str) -> float | None:
"""Return media duration in seconds via ffprobe, or None on failure.
Used by the EncoderWorker post-encode integrity check to compare source
and output durations. Modeled after :func:`ffprobe_validate` — every
failure path returns ``None`` so the caller can treat unverifiable
durations as "skip the check" rather than crashing the worker thread.
"""
try:
res = subprocess.run(
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
"-show_format", "-show_entries", "format=duration",
str(filepath)],
capture_output=True, text=True, timeout=10,
)
if res.returncode != 0 or not res.stdout:
return None
data = json.loads(res.stdout)
dur_str = (data.get("format") or {}).get("duration")
if dur_str is None:
return None
return float(dur_str)
except (OSError, subprocess.SubprocessError, ValueError):
# ValueError covers json.JSONDecodeError and float() parse failures
return None
def _verify_output_resolution(output_path: Path, ffprobe_bin: str, target_w: int, target_h: int) -> bool:
"""Verify that an encoded file actually has the requested output resolution.
Returns True if the output matches (or is within 2px due to force_divisible_by=2),
False otherwise.
"""
try:
res = subprocess.run(
[ffprobe_bin, "-v", "quiet", "-print_format", "json",
"-show_streams", "-select_streams", "v:0", str(output_path)],
capture_output=True, text=True, timeout=15,
)
if res.returncode != 0:
return True # can't verify, don't block
data = json.loads(res.stdout)
streams = data.get("streams", [])
if not streams:
return True
ow = int(streams[0].get("width", 0) or 0)
oh = int(streams[0].get("height", 0) or 0)
# Allow 2px tolerance (force_divisible_by=2 rounding)
if abs(ow - target_w) <= 2 and abs(oh - target_h) <= 2:
return True
return False
except (OSError, subprocess.SubprocessError, ValueError):
# ValueError covers json.JSONDecodeError and int() parse failures
return True # can't verify, don't block
def _identify_file_type(file_path: Path) -> str:
"""Run `file` on the given path and return the type string.
v5-03: Used by _validate_file to tell the user WHAT a file actually is
when ffprobe can't read it. This immediately reveals:
- "HTML document" -> failed yt-dlp download (YouTube error page saved as .mp4)
- "ASCII text" -> same as above (different yt-dlp version)
- "data" -> truncated, encrypted, or partial download
- "ISO Media, MP4 Base Media v1" -> valid MP4 that ffprobe just can't parse (rare)
Returns the first line of `file` output (minus the filename prefix),
or an empty string if `file` is not available or fails.
"""
file_bin = shutil.which("file")
if not file_bin:
return ""
try:
res = subprocess.run(
[file_bin, "-b", str(file_path)],
capture_output=True, text=True, timeout=5,
)
if res.returncode == 0:
return res.stdout.strip()
except (OSError, subprocess.SubprocessError):
pass
return ""

View File

@ -1,237 +0,0 @@
"""GPU capability profiles (v4.8.0) — combined card generations.
Cards within the same hardware-encoder generation are functionally
identical for transcoding, so the dropdown lists CAPABILITY CLASSES,
not individual SKUs: one Pascal entry covers the GTX 10-series, Tesla
P40/P4/P100 and mobile chips; one Turing entry covers RTX 20-series,
GTX 16-series, the Tesla T4 and the crypto-era CMP 30/40/50HX cards.
Oddballs are included with their real capabilities:
- CMP 90HX is GA102-based (Ampere NVENC), but CMP 170HX is GA100-based
and has NO NVENC at all (like A100/V100/H100 compute boards).
- Intel Arc (QSV) and AMD RDNA (VAAPI) cover the rest of the trending
list; RDNA 3 added AV1 encode, RDNA 1/2 and GCN can only encode
H.264/HEVC.
Pure data + pure functions: no I/O, safe to import anywhere.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# ──────────────────────────────────────────────
# GPU PROFILES
# ──────────────────────────────────────────────
@dataclass(frozen=True)
class GpuProfile:
key: str # stable id (CLI/UI)
label: str # dropdown entry
vendor: str # nvidia | amd | intel | none
api: str # nvenc | vaapi | qsv | none
# codec family → ffmpeg encoder name
encoders: dict[str, str]
# case-insensitive substrings matched against the detected GPU name
# (nvidia-smi / lspci) for auto-detection. First match wins; lists
# are ordered most-specific first.
match: tuple[str, ...] = ()
notes: str = ""
# extra args that must come BEFORE -i (hardware device init)
hw_device_args: tuple[str, ...] = ()
# filter-chain fragment required before the encoder (vaapi hwupload)
filter_tail: tuple[str, ...] = ()
# NVENC encoders by generation class. Quality control: -rc vbr -cq N
# (-b:v 0). 8-bit yuv420p everywhere — Pascal 10-bit HEVC runs at half
# speed and the archival targets here are 8-bit sources.
_NV = {"h264": "h264_nvenc", "hevc": "hevc_nvenc"}
GPU_PROFILES: list[GpuProfile] = [
GpuProfile(
key="nv-kepler-maxwell",
label="NVIDIA Kepler / Maxwell 1.0 (GTX 600/700/800M) — H.264 only",
vendor="nvidia", api="nvenc",
encoders={"h264": "h264_nvenc"},
match=("GTX 6", "GTX 7", "GT 7", "GTX 8", "GT 8", "840M", "860M", "750"),
notes="First NVENC generations: H.264 only, no HEVC.",
),
GpuProfile(
key="nv-pascal",
label="NVIDIA Pascal (GTX 10-series, TITAN Xp, Tesla P40/P4/P100) — H.264 + HEVC 8/10-bit",
vendor="nvidia", api="nvenc",
encoders=dict(_NV),
match=("GTX 10", "1070", "1080", "1060", "1050", "TITAN Xp",
"Tesla P40", "Tesla P4", "P100", "Quadro P"),
notes="Pascal NVENC: HEVC Main/Main10. 10-bit runs at ~half speed.",
),
GpuProfile(
key="nv-turing",
label="NVIDIA Turing (RTX 20-series, GTX 16-series, Tesla T4, CMP 30/40/50HX) — H.264 + HEVC + B-frames",
vendor="nvidia", api="nvenc",
encoders=dict(_NV),
match=("RTX 20", "GTX 16", "2060", "2070", "2080", "1660", "1650",
"Tesla T4", "CMP 30", "CMP 40", "CMP 50"),
notes="Turing NVENC: first gen with HEVC B-frames; big quality jump.",
),
GpuProfile(
key="nv-compute",
label="NVIDIA data-center compute (V100/A100/H100, CMP 170HX) — no NVENC (CPU path)",
vendor="nvidia", api="none",
encoders={},
match=("V100", "A100", "H100", "B200", "GB200", "CMP 170"),
notes="Compute boards ship without NVENC silicon. CMP 170HX is "
"GA100-based — the fastest mining card that cannot hardware-encode.",
),
GpuProfile(
key="nv-ampere",
label="NVIDIA Ampere (RTX 30-series, A10/A40/A2, CMP 90HX) — H.264 + HEVC (no AV1 encode)",
vendor="nvidia", api="nvenc",
encoders=dict(_NV),
match=("RTX 30", "3090", "3080", "3070", "3060", "3050",
"A10", "A40", "CMP 90"),
notes="Ampere added AV1 DECODE but not encode — AV1 stays on CPU.",
),
GpuProfile(
key="nv-ada",
label="NVIDIA Ada / Blackwell (RTX 40/50-series, L4/L40) — H.264 + HEVC + AV1 10-bit",
vendor="nvidia", api="nvenc",
encoders={"h264": "h264_nvenc", "hevc": "hevc_nvenc", "av1": "av1_nvenc"},
match=("RTX 40", "RTX 50", "4090", "4080", "4070", "4060",
"5090", "5080", "5070", "5060", "L4", "L40"),
notes="Ada introduced AV1 NVENC; Blackwell doubles AV1 throughput.",
),
GpuProfile(
key="intel-arc",
label="Intel Arc (Alchemist A-series, Battlemage B-series) — QSV: H.264 + HEVC + AV1",
vendor="intel", api="qsv",
encoders={"h264": "h264_qsv", "hevc": "hevc_qsv", "av1": "av1_qsv"},
match=("Arc A", "Arc B", "A380", "A750", "A770", "B570", "B580"),
notes="Arc media engines encode AV1 8/10-bit — best value encode card.",
),
GpuProfile(
key="intel-xe",
label="Intel Iris / UHD integrated (Gen9–Xe) — QSV: H.264 + HEVC",
vendor="intel", api="qsv",
encoders={"h264": "h264_qsv", "hevc": "hevc_qsv"},
match=("Iris", "UHD", "HD Graphics"),
notes="Integrated media engines; HEVC 8/10-bit, no AV1 encode.",
),
GpuProfile(
key="amd-rdna3",
label="AMD RDNA 3 (RX 7000-series) — VAAPI: H.264 + HEVC + AV1",
vendor="amd", api="vaapi",
encoders={"h264": "h264_vaapi", "hevc": "hevc_vaapi", "av1": "av1_vaapi"},
match=("RX 7", "7900", "7800", "7700", "7600"),
notes="RDNA 3 VCN: first AMD generation with AV1 encode.",
),
GpuProfile(
key="amd-rdna12",
label="AMD RDNA 1/2 (RX 5000/6000-series) — VAAPI: H.264 + HEVC (AV1 decode only)",
vendor="amd", api="vaapi",
encoders={"h264": "h264_vaapi", "hevc": "hevc_vaapi"},
match=("RX 5", "RX 6", "5700", "5600", "6800", "6700", "6600", "6500"),
notes="RDNA 2 has AV1 decode only — AV1 encode stays on CPU.",
),
GpuProfile(
key="amd-gcn",
label="AMD GCN 4/5 / Vega (RX 400/500, Vega 56/64) — VAAPI: H.264 + HEVC",
vendor="amd", api="vaapi",
encoders={"h264": "h264_vaapi", "hevc": "hevc_vaapi"},
match=("RX 4", "RX 5", "Vega", "580", "570", "480", "470", "64", "56"),
notes="The classic crypto-era mining cards (Polaris/Vega).",
),
GpuProfile(
key="cpu",
label="None (CPU-only encode)",
vendor="none", api="none",
encoders={},
),
]
_GPU_PROFILES_BY_KEY: dict[str, GpuProfile] = {p.key: p for p in GPU_PROFILES}
def gpu_profile_by_key(key: str | None) -> GpuProfile | None:
if not key:
return None
return _GPU_PROFILES_BY_KEY.get(key)
def match_gpu_profile(gpu_name: str) -> GpuProfile | None:
"""Best-effort auto-detection from a GPU name string (nvidia-smi or
lspci output). Case-insensitive; first matching profile wins (the
match lists are ordered most-specific first, and the compute boards
are matched before the consumer generations they share names with —
e.g. 'CMP 170HX' must not hit the Ampere 'A10' style entries)."""
if not gpu_name:
return None
name = gpu_name.lower()
for profile in GPU_PROFILES:
for frag in profile.match:
if frag.lower() in name:
return profile
return None
# ──────────────────────────────────────────────
# FFMPEG ARG HELPERS (per hardware API)
# ──────────────────────────────────────────────
def encoder_for_family(profile: GpuProfile | None, family: str) -> str | None:
"""Hardware encoder name for a codec family on this profile, or None."""
if not profile or profile.api == "none":
return None
return profile.encoders.get(family)
def resolve_vaapi_device() -> str:
"""First render node, or the classic fallback path. (Best-effort I/O —
callers that need purity pass the result into encoder_pre_args.)"""
import glob
nodes = sorted(glob.glob("/dev/dri/renderD*"))
return nodes[0] if nodes else "/dev/dri/renderD128"
def encoder_pre_args(profile: GpuProfile, vaapi_device: str | None = None) -> list[str]:
"""Args that must precede -i (hardware device initialisation)."""
if profile.api == "vaapi":
dev = vaapi_device or resolve_vaapi_device()
return ["-vaapi_device", dev]
if profile.api == "qsv":
return ["-init_hw_device", "qsv=hw"]
return []
def encoder_filter_chain(profile: GpuProfile) -> list[str]:
"""Filter args that upload software frames to the hardware surface
format (VAAPI encoders only accept hw frames; -vaapi_device makes
its device the default for hwupload)."""
if profile.api == "vaapi":
return ["-vf", "format=nv12,hwupload"]
return []
def encoder_quality_args(api: str, encoder: str, crf: int, preset: int) -> list[str]:
"""Constant-quality args for a hardware encoder. NVENC maps the CPU
preset tiers to p-presets; QSV uses very_fast/medium; VAAPI uses CQP
rate mode which has no preset knob."""
if api == "nvenc":
if preset <= 6:
p = "p7"
elif preset <= 8:
p = "p5"
else:
p = "p4"
return ["-preset", p, "-tune", "hq", "-rc", "vbr",
"-cq", str(crf), "-b:v", "0", "-pix_fmt", "yuv420p",
"-g", "240"]
if api == "qsv":
p = "veryslow" if preset <= 6 else ("medium" if preset <= 8 else "very_fast")
return ["-preset", p, "-global_quality", str(crf),
"-pix_fmt", "yuv420p", "-g", "240"]
if api == "vaapi":
return ["-rc_mode", "CQP", "-qp", str(crf), "-g", "240"]
return []

View File

@ -1,115 +0,0 @@
"""Hybrid GPU+CPU batch scheduler (v4.7.0).
Splits a transcode queue between two concurrent lanes so the NVENC
engine and the CPU encoders work at the same time instead of leaving
28 Xeon threads idle while the GPU encode runs:
- GPU lane: files encoded via the functional NVENC encoder
(hevc_nvenc / av1_nvenc) through the single-pass ffmpeg path.
- CPU lane: the remaining files through the family's software encoder
(libx265 / libsvtav1 / libvpx) or av1an chunk-parallel if the user
opted in — so "all three" (NVENC + software + chunk-parallel) run
side by side when av1an is enabled.
Pure planning logic — no Qt, no I/O beyond the caller-provided file
sizes. The UI turns a HybridPlan into two EncoderWorker instances.
Scope note: lanes split FILES, never one file across encoders. Splitting
a single file between hevc_nvenc and libx265 chunks would produce
visibly inconsistent quality between scenes, and av1an cannot drive
NVENC at all (it spawns encoder CLI binaries only).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
# NVENC HEVC on a Pascal card runs several times faster than x265
# "faster" on 28 Xeon threads. Used only for load ESTIMATION (which lane
# gets the next file) — a wrong guess just skews the split slightly.
GPU_SPEED_RATIO_DEFAULT = 8
# Threads held back from the CPU lane so the GPU lane's decode / scale /
# mux processes stay responsive. The NVENC encode itself runs on the GPU
# silicon; the CPU side of a nvenc job is light.
HYBRID_CPU_RESERVE_THREADS = 2
@dataclass
class HybridPlan:
"""Result of planning a hybrid (GPU + CPU) queue split."""
gpu_files: list[Path] = field(default_factory=list)
cpu_files: list[Path] = field(default_factory=list)
gpu_encoder: str = "" # e.g. "hevc_nvenc"
cpu_budget_threads: int = 1 # CPU lane thread budget (logical - reserve)
gpu_speed_ratio: int = GPU_SPEED_RATIO_DEFAULT
@property
def total_files(self) -> int:
return len(self.gpu_files) + len(self.cpu_files)
def plan_hybrid(
files: list[Path],
gpu_encoder: str | None,
gpu_functional: bool,
logical_threads: int,
sizes: dict[Path, int] | None = None,
gpu_speed_ratio: int = GPU_SPEED_RATIO_DEFAULT,
cpu_reserve: int = HYBRID_CPU_RESERVE_THREADS,
) -> HybridPlan | None:
"""Split *files* between the GPU and CPU lanes, or return None when a
hybrid split cannot apply.
Returns None when:
- the codec family has no GPU encoder, or the live GPU probe failed
(caller should fall back to a plain CPU queue), or
- *files* is empty.
Assignment is LPT (longest-processing-time first): files are sorted
by size descending and each goes to the lane with the lower
estimated load, where the GPU lane's per-file cost is size /
gpu_speed_ratio. Both lanes then finish at roughly the same time.
*sizes* maps files to byte sizes; missing entries fall back to the
mean of the known sizes (or 10 MB when nothing is known) so a single
unreadable file cannot skew the whole split.
"""
if not gpu_encoder or not gpu_functional or not files:
return None
sizes = sizes or {}
known = [s for s in sizes.values() if s]
avg = sum(known) // len(known) if known else 10_000_000
def size_of(f: Path) -> int:
return sizes.get(f) or avg
ratio = max(1, int(gpu_speed_ratio))
gpu_files: list[Path] = []
cpu_files: list[Path] = []
gpu_load = 0.0
cpu_load = 0.0
for f in sorted(files, key=size_of, reverse=True):
s = size_of(f)
gpu_est = gpu_load + s / ratio
cpu_est = cpu_load + s
# Tie goes to the GPU lane — it finishes the file sooner and the
# CPU lane keeps its current file longer.
if gpu_est <= cpu_est:
gpu_files.append(f)
gpu_load = gpu_est
else:
cpu_files.append(f)
cpu_load = cpu_est
return HybridPlan(
gpu_files=gpu_files,
cpu_files=cpu_files,
gpu_encoder=gpu_encoder,
cpu_budget_threads=max(1, int(logical_threads) - cpu_reserve),
gpu_speed_ratio=ratio,
)

View File

@ -1,234 +0,0 @@
"""Anti-sleep / anti-hibernate subsystem (v6-06).
Keeps the system awake during long transcodes using two complementary
approaches:
1. **systemd-inhibit** (preferred, available on all systemd Linux distros):
Runs a "fork bomb" — a no-op child process held open for the duration
of the transcode. systemd sees the inhibit handle and will NOT suspend
or hibernate the system while it's active. This is the cleanest
approach: no mouse movement, no screen-lock interference, no user
-visible side effects.
2. **Periodic mouse nudge** (fallback / belt-and-suspenders):
If ``xdotool`` is available, moves the mouse 1 pixel every 60 seconds
(jitter, not constant movement — the user can still click STOP or
close the window). This catches DEs that ignore systemd-inhibit
(rare) and prevents screen-blanking timeouts. The movement is
minimal: +1px right, then -1px left on the next tick, so the cursor
ends up where it started.
The user sees a bright-red status banner in the UI while keep-awake is
active:
⚠ KEEP-AWAKE ACTIVE — system will not sleep | ETA: ~45 min | [STOP]
The banner is updated every 5 seconds with a fresh ETA. The user can
click STOP or the window close X at any time — both tear down the
keep-awake handles cleanly.
Design decisions:
- systemd-inhibit is the PRIMARY mechanism. Mouse nudging is secondary.
- Mouse nudging is OFF by default (opt-in via constructor flag) because
it's visually intrusive. systemd-inhibit is always-on when available.
- The inhibit handle is held in a subprocess (not the main process) so
it survives even if the GUI crashes — systemd cleans it up when the
subprocess exits.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from pathlib import Path
class KeepAwake:
"""Keep the system awake during a transcode.
Usage::
ka = KeepAwake(log_fn=worker.log_msg.emit)
ka.start()
try:
# ... long encode ...
while encoding:
ka.update_eta(remaining_seconds)
time.sleep(5)
finally:
ka.stop() # releases inhibit + stops mouse nudging
The ETA is displayed in the UI banner via ``update_eta()``.
"""
def __init__(
self,
log_fn=None,
enable_mouse_nudge: bool = False,
nudge_interval: int = 60,
):
self._log_fn = log_fn or (lambda msg: None)
self._enable_mouse_nudge = enable_mouse_nudge and bool(shutil.which("xdotool"))
self._nudge_interval = nudge_interval
self._inhibit_proc: subprocess.Popen | None = None
self._nudge_count = 0
self._last_nudge = 0.0
self._start_time = 0.0
self._eta_seconds: float | None = None
self._active = False
def start(self) -> None:
"""Acquire systemd-inhibit handle. Safe to call multiple times."""
if self._active:
return
self._active = True
self._start_time = time.monotonic()
self._acquire_inhibit()
if self._enable_mouse_nudge:
self._log_fn("KEEP-AWAKE: mouse nudging enabled (xdotool, every "
f"{self._nudge_interval}s)")
else:
self._log_fn("KEEP-AWAKE: mouse nudging disabled (xdotool not found "
"or not requested)")
def stop(self) -> None:
"""Release the inhibit handle and stop nudging."""
if not self._active:
return
self._active = False
self._release_inhibit()
if self._nudge_count > 0:
self._log_fn(f"KEEP-AWAKE: stopped (mouse nudged {self._nudge_count} times)")
def update_eta(self, remaining_seconds: float | None) -> None:
"""Update the ETA shown in the banner. None = unknown."""
self._eta_seconds = remaining_seconds
def tick(self) -> str | None:
"""Called periodically (e.g. every 5s) from the UI thread.
Performs mouse nudge if interval has elapsed.
Returns the current banner text, or None if keep-awake is not active.
"""
if not self._active:
return None
now = time.monotonic()
if self._enable_mouse_nudge and (now - self._last_nudge) >= self._nudge_interval:
self._nudge_mouse()
self._last_nudge = now
return self.banner_text()
def banner_text(self) -> str:
"""Return the bright-red banner text for the UI."""
eta_str = self._format_eta(self._eta_seconds)
elapsed = time.monotonic() - self._start_time
elapsed_str = self._format_eta(elapsed)
nudge_str = f" | mouse: {self._nudge_count}" if self._nudge_count > 0 else ""
return (
f"KEEP-AWAKE ACTIVE — system will not sleep | "
f"elapsed: {elapsed_str} | ETA: {eta_str}{nudge_str}"
)
def _format_eta(self, seconds: float | None) -> str:
if seconds is None:
return "unknown"
if seconds < 0:
return "almost done"
hours = int(seconds // 3600)
mins = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"~{hours}h{mins:02d}m"
if mins > 0:
return f"~{mins}m{secs:02d}s"
return f"~{secs}s"
def _acquire_inhibit(self) -> None:
"""Fork a systemd-inhibit subprocess that holds the sleep/hibernate
inhibit handle for the duration of the transcode.
systemd-inhibit takes a command to run while inhibiting. We pass
``sleep infinity`` (the GNU coreutils builtin) as the held command —
it does nothing, runs forever, and the inhibit handle stays active
until we kill the subprocess.
"""
inhibit_bin = shutil.which("systemd-inhibit")
if not inhibit_bin:
self._log_fn("KEEP-AWAKE: systemd-inhibit not found — "
"system may sleep during transcode")
return
try:
# --what=handle-lid-switch:sleep — inhibit both lid-close and
# automatic sleep/hibernate
# --who=OpenTranscode — shown in `systemd-inhibit --list`
# --why="Batch video transcode in progress" — shown in `systemd-inhibit --list`
# --mode=block — block the action entirely (not just delay)
self._inhibit_proc = subprocess.Popen(
[
inhibit_bin,
"--what=sleep:idle",
"--who=OpenTranscode",
"--why=Batch video transcode in progress",
"--mode=block",
"sleep", "infinity",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
# Don't put the child in a new session — we want it to die
# when the parent dies (implicit via Popen + stop()).
)
self._log_fn("KEEP-AWAKE: systemd-inhibit active (sleep/idle blocked)")
except (OSError, subprocess.SubprocessError) as e:
self._log_fn(f"KEEP-AWAKE: failed to acquire systemd-inhibit: {e}")
self._inhibit_proc = None
def _release_inhibit(self) -> None:
"""Kill the systemd-inhibit subprocess to release the handle."""
if self._inhibit_proc is None:
return
try:
self._inhibit_proc.terminate()
self._inhibit_proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._inhibit_proc.kill()
self._inhibit_proc.wait(timeout=1)
except (OSError, subprocess.SubprocessError):
pass
finally:
self._inhibit_proc = None
self._log_fn("KEEP-AWAKE: systemd-inhibit released")
def _nudge_mouse(self) -> None:
"""Move the mouse 1 pixel to prevent screen-blank.
Uses xdotool. Alternates +1px right / -1px left so the cursor
ends up where it started after every pair of nudges.
"""
xdotool = shutil.which("xdotool")
if not xdotool:
return
delta = 1 if (self._nudge_count % 2 == 0) else -1
try:
subprocess.run(
[xdotool, "mousemove_relative", "--", str(delta), "0"],
capture_output=True, timeout=3,
)
self._nudge_count += 1
except (OSError, subprocess.SubprocessError):
pass # best-effort — don't crash the transcode over a nudge
@property
def is_active(self) -> bool:
return self._active
@property
def has_inhibit(self) -> bool:
return self._inhibit_proc is not None
def __enter__(self):
self.start()
return self
def __exit__(self, *args):
self.stop()

View File

@ -1,248 +0,0 @@
"""License notice registry for third-party components.
Holds the canonical ``LicenseNotice`` table + helpers that filter the
notices down to the ones active in the running environment. Pure data
+ pure functions; the ``env`` parameter is duck-typed so this module
does not import ``EnvProbe`` (avoids a circular dependency).
"""
from dataclasses import dataclass
# ──────────────────────────────────────────────
# LICENSE NOTICES — third-party components invoked by this application.
#
# Each entry is a tuple of (tool name, SPDX identifier, short attribution,
# full notice). The short form is used for the startup banner and the
# pre-transcode summary; the full form is shown in the About dialog.
#
# This application is a thin orchestration layer; it does not incorporate
# the source code of any of these tools. The license obligations of each
# tool therefore flow through to the end user independently, and this
# registry exists to make those obligations visible at runtime.
# ──────────────────────────────────────────────
@dataclass(frozen=True)
class LicenseNotice:
"""Immutable descriptor for a third-party component license.
SEI CERT MSC04-C spirit: secrets and licensing data are not duplicated
across the codebase; the canonical source is this table.
"""
name: str # e.g. "FFmpeg"
spdx: str # e.g. "LGPL-2.1-or-later"
home_url: str # canonical upstream URL
short: str # one-line attribution shown in banners
full: str # multi-line notice shown in About dialog
LICENSE_NOTICES: tuple[LicenseNotice, ...] = (
LicenseNotice(
name="FFmpeg",
spdx="LGPL-2.1-or-later (or GPL-2.0-or-later with --enable-gpl)",
home_url="https://ffmpeg.org",
short="FFmpeg (LGPL-2.1+, GPL build flags noted at runtime)",
full=(
"FFmpeg\n"
"Copyright (c) FFmpeg developers\n"
"Licensed under LGPL-2.1-or-later; the build's effective license\n"
"may upgrade to GPL-2.0-or-later when --enable-gpl or any GPL-only\n"
"library (libx264, libx265, libfdk-aac) is configured in.\n"
"Source: https://ffmpeg.org\n"
"License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
),
),
LicenseNotice(
name="av1an",
spdx="GPL-3.0-or-later",
home_url="https://github.com/master-of-zen/av1an",
short="av1an (GPL-3.0+)",
full=(
"av1an — Av1an is a frame-parallel AV1/VP9/x265 encoder\n"
"Copyright (c) master-of-zen and contributors\n"
"Licensed under GPL-3.0-or-later.\n"
"Source: https://github.com/master-of-zen/av1an\n"
"License: https://www.gnu.org/licenses/gpl-3.0.html"
),
),
LicenseNotice(
name="VapourSynth",
spdx="LGPL-2.1-or-later",
home_url="https://www.vapoursynth.com",
short="VapourSynth (LGPL-2.1+)",
full=(
"VapourSynth — a video processing framework\n"
"Copyright (c) Fredrik Mellbin and contributors\n"
"Licensed under LGPL-2.1-or-later.\n"
"Source: https://github.com/vapoursynth/vapoursynth\n"
"License: https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
),
),
LicenseNotice(
name="SVT-AV1",
spdx="BSD-3-Clause AND PMK-2-Clause",
home_url="https://gitlab.com/AOMediaCodec/SVT-AV1",
short="SVT-AV1 (BSD-3-Clause, AOMedia)",
full=(
"SVT-AV1 — Scalable Video Technology for AV1\n"
"Copyright (c) Alliance for Open Media and contributors\n"
"Licensed under BSD-3-Clause and the AOMedia Patent License.\n"
"Source: https://gitlab.com/AOMediaCodec/SVT-AV1\n"
"License: https://opensource.org/license/bsd-3-clause"
),
),
LicenseNotice(
name="libvpx",
spdx="BSD-3-Clause",
home_url="https://github.com/webmproject/libvpx",
short="libvpx / VP9 (BSD-3-Clause)",
full=(
"libvpx — VP8/VP9 codec library\n"
"Copyright (c) The WebM Project authors\n"
"Licensed under BSD-3-Clause.\n"
"Source: https://github.com/webmproject/libvpx\n"
"License: https://opensource.org/license/bsd-3-clause"
),
),
LicenseNotice(
name="x265",
spdx="GPL-2.0-or-later (commercial license available)",
home_url="https://bitbucket.org/multicoreware/x265_git",
short="x265 / HEVC (GPL-2.0+)",
full=(
"x265 — HEVC encoder\n"
"Copyright (c) MulticoreWare, Inc and contributors\n"
"Licensed under GPL-2.0-or-later; a commercial license is\n"
"available from MulticoreWare for non-GPL distribution.\n"
"Source: https://bitbucket.org/multicoreware/x265_git\n"
"License: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html"
),
),
LicenseNotice(
name="libopus",
spdx="BSD-3-Clause",
home_url="https://opus-codec.org",
short="libopus / Opus (BSD-3-Clause)",
full=(
"libopus — Opus audio codec (IETF RFC 6716)\n"
"Copyright (c) Xiph.Org Foundation, Skype Limited, Mozilla,\n"
"and contributors\n"
"Licensed under BSD-3-Clause.\n"
"Source: https://github.com/xiph/opus\n"
"License: https://opensource.org/license/bsd-3-clause"
),
),
LicenseNotice(
name="libvorbis",
spdx="BSD-3-Clause",
home_url="https://xiph.org/vorbis",
short="libvorbis / Vorbis (BSD-3-Clause)",
full=(
"libvorbis — Vorbis audio codec\n"
"Copyright (c) Xiph.Org Foundation and contributors\n"
"Licensed under BSD-3-Clause.\n"
"Source: https://github.com/xiph/vorbis\n"
"License: https://opensource.org/license/bsd-3-clause"
),
),
LicenseNotice(
name="libFLAC",
spdx="BSD-3-Clause",
home_url="https://xiph.org/flac",
short="libFLAC / FLAC (BSD-3-Clause)",
full=(
"libFLAC — Free Lossless Audio Codec\n"
"Copyright (c) Xiph.Org Foundation and contributors\n"
"Licensed under BSD-3-Clause.\n"
"Source: https://github.com/xiph/flac\n"
"License: https://opensource.org/license/bsd-3-clause"
),
),
LicenseNotice(
name="libiamf",
spdx="BSD-2-Clause",
home_url="https://github.com/AOMediaCodec/libiamf",
short="libiamf / IAMF (BSD-2-Clause, AOMedia)",
full=(
"libiamf — AOMedia Immersive Audio Model and Formats\n"
"Copyright (c) Alliance for Open Media and contributors\n"
"Licensed under BSD-2-Clause.\n"
"Source: https://github.com/AOMediaCodec/libiamf\n"
"License: https://opensource.org/license/bsd-2-clause"
),
),
LicenseNotice(
name="Qt / PySide6",
spdx="LGPL-3.0-only (commercial available from The Qt Company)",
home_url="https://www.qt.io",
short="Qt / PySide6 (LGPL-3.0)",
full=(
"Qt — application framework\n"
"Copyright (c) The Qt Company Ltd and contributors\n"
"Licensed under LGPL-3.0-only; a commercial license is available.\n"
"Source: https://www.qt.io\n"
"License: https://www.gnu.org/licenses/lgpl-3.0.html"
),
),
LicenseNotice(
name="Python",
spdx="PSF-2.0",
home_url="https://www.python.org",
short="Python (PSF License)",
full=(
"Python — programming language\n"
"Copyright (c) Python Software Foundation\n"
"Licensed under the PSF License Agreement.\n"
"Source: https://www.python.org\n"
"License: https://docs.python.org/3/license.html"
),
),
)
def active_license_notices(env) -> list[LicenseNotice]:
"""Return the subset of LICENSE_NOTICES that apply to the running
environment. Determined by which tools / libraries env reports as
present. Always includes FFmpeg, Python, and Qt (framework deps).
Data-driven dispatch: avoids a per-tool if/elif chain by looking up
each notice's presence in env attributes via a small table.
"""
presence_rules: tuple[tuple[str, bool], ...] = (
("FFmpeg", bool(getattr(env, "ffmpeg_path", None))),
("av1an", bool(getattr(env, "av1an_path", None))),
("VapourSynth", bool(getattr(env, "vs_version", None))),
("SVT-AV1", bool(getattr(env, "av1an_flags", {}).get("svt_name"))),
("libvpx", bool(getattr(env, "ffmpeg_libs", {}).get("libvpx"))),
("x265", bool(getattr(env, "ffmpeg_libs", {}).get("libx265"))),
("libopus", bool(getattr(env, "ffmpeg_libs", {}).get("libopus"))),
("libvorbis", bool(getattr(env, "ffmpeg_libs", {}).get("libvorbis"))),
("libFLAC", bool(getattr(env, "ffmpeg_libs", {}).get("flac"))),
("libiamf", bool(getattr(env, "ffmpeg_libs", {}).get("libiamf"))),
("Qt / PySide6", True), # framework, always present
("Python", True),
)
active_names = {name for name, present in presence_rules if present}
return [n for n in LICENSE_NOTICES if n.name in active_names]
def license_banner_short(notices: list[LicenseNotice]) -> str:
"""One-line summary suitable for a status bar or log header."""
return " | ".join(n.short for n in notices)
def license_banner_full(notices: list[LicenseNotice]) -> str:
"""Multi-line text block suitable for an About / Licenses dialog."""
sep = "─" * 60
blocks = [sep, " OPEN SOURCE LICENSE ATTRIBUTIONS", sep]
for n in notices:
blocks.append(n.full)
blocks.append(sep)
blocks.append(
"This application invokes these tools as external processes.\n"
"Source code of each tool is NOT bundled with this application.\n"
"For the full text of each license, follow the upstream URL cited\n"
"above. Questions about redistribution rights should be directed\n"
"to the upstream projects."
)
return "\n".join(blocks)

View File

@ -1,845 +0,0 @@
"""SourceBuildWorker (QThread) — builds VS / av1an / ffmpeg from git.
Resolves VapourSynth/av1an ABI mismatches by compiling the affected
components from source. Installs to the user's home dir (no sudo for
the install step). v3-08 made this worker stop mutating
``os.environ`` directly — it carries its own ``_build_env`` snapshot.
v4.7.1: the rebuild is ALWAYS usable, even on a bare system. It
generates its own dependency tree: missing build tools and libraries
are installed via the distro package manager (arch/debian/redhat/suse)
before anything is compiled, and the VapourSynth build is followed by
a BestSource plugin build (submodules + vapoursynth dev headers from
the freshly installed VS) so av1an gets a fast, reliable chunk method
instead of the slow "select" fallback.
"""
import os
import re
import shutil
import signal
import site
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from PySide6.QtCore import QThread, Signal
from .distro_probe import DistroProfile, detect_distro
from .gpu_profiles import gpu_profile_by_key
# ──────────────────────────────────────────────
# BUILD DEPENDENCY TREE (v4.7.1 — distro-aware)
# ──────────────────────────────────────────────
# Binaries the build needs. pkgconf/pkg-config and python/python3 are
# aliased — any one of each pair satisfies the check.
BUILD_TOOL_ALIASES: dict[str, tuple[str, ...]] = {
"git": ("git",),
"meson": ("meson",),
"ninja": ("ninja",),
"c++ compiler": ("g++", "c++", "clang++"),
"make": ("make",),
"pkg-config": ("pkg-config", "pkgconf"),
"python3": ("python3",),
"nasm": ("nasm",),
"cmake": ("cmake",),
}
# Packages providing the toolchain + the libraries the builds link
# against (zimg is VapourSynth's one hard library dependency; rust is
# only needed for the av1an build).
BUILD_DEPS_BY_FAMILY: dict[str, list[str]] = {
"arch": ["base-devel", "meson", "ninja", "cmake", "nasm", "git",
"python", "pkgconf", "zimg", "rust"],
"debian": ["build-essential", "meson", "ninja-build", "cmake", "nasm",
"git", "python3", "python3-dev", "pkg-config", "libzimg-dev",
"cargo", "rustc"],
"redhat": ["gcc", "gcc-c++", "make", "meson", "ninja-build", "cmake",
"nasm", "git", "python3", "python3-devel",
"pkgconf-pkg-config", "zimg-devel", "cargo", "rust"],
"suse": ["gcc", "gcc-c++", "make", "meson", "ninja", "cmake", "nasm",
"git", "python3", "python3-devel", "pkg-config",
"zimg-devel", "rust", "cargo"],
}
PKG_INSTALL_CMD: dict[str, list[str]] = {
"arch": ["pacman", "-S", "--needed", "--noconfirm"],
"debian": ["apt-get", "install", "-y"],
"redhat": ["dnf", "install", "-y"],
"suse": ["zypper", "--non-interactive", "install"],
}
MANUAL_DEP_NOTE = (
"No automatic package install for this distro family. Install a C++ "
"toolchain plus meson, ninja, cmake, nasm, git, python3, pkg-config, "
"zimg development headers{rust} manually, then press REBUILD again."
)
# v4.8.0: per-GPU-profile build/runtime packages, on top of the base
# toolchain. nvidia needs nv-codec-headers at ffmpeg build time (the
# distro ffmpeg already ships nvenc; a matched git build needs the
# headers); vaapi/qsv need the driver + dev stacks for their vendor.
GPU_BUILD_PACKAGES: dict[str, dict[str, list[str]]] = {
"nvenc": {
"arch": ["nv-codec-headers"],
"debian": [],
"redhat": [],
"suse": [],
},
"vaapi": {
"arch": ["libva", "libdrm", "mesa"],
"debian": ["libva-dev", "libdrm-dev", "mesa-va-drivers"],
"redhat": ["libva-devel", "libdrm-devel", "mesa-va-drivers"],
"suse": ["libva-devel", "libdrm-devel", "Mesa-libva"],
},
"qsv": {
"arch": ["libva", "intel-media-driver", "onevpl"],
"debian": ["libva-dev", "intel-media-va-driver-non-free", "libvpl-dev"],
"redhat": ["libva-devel", "intel-media-driver", "oneVPL-devel"],
"suse": ["libva-devel", "intel-media-driver", "oneVPL-devel"],
},
}
def gpu_dep_packages(api: str, distro_family: str) -> list[str]:
"""Extra packages for a GPU hardware API on this distro (empty when
the family has no packaged set — the log says so)."""
return list(GPU_BUILD_PACKAGES.get(api, {}).get(distro_family, []))
@dataclass
class DepPlan:
"""What the rebuild needs, and how to get it on this distro."""
packages: list[str] = field(default_factory=list)
install_cmd: list[str] | None = None
manual_note: str | None = None
def build_dep_plan(distro: DistroProfile, build_av1an: bool = True) -> DepPlan:
"""Pure: the package list + install command for this distro family.
Works even when the environment probe failed — it only needs the
distro family, which is detectable from /etc/os-release alone.
"""
packages = list(BUILD_DEPS_BY_FAMILY.get(distro.family, []))
if not build_av1an:
for rust_pkg in ("rust", "rustc", "cargo"):
if rust_pkg in packages:
packages.remove(rust_pkg)
install_cmd = PKG_INSTALL_CMD.get(distro.family)
manual_note = None
if not install_cmd or not packages:
manual_note = MANUAL_DEP_NOTE.format(
rust=" and Rust/cargo" if build_av1an else "")
return DepPlan(packages=packages, install_cmd=install_cmd,
manual_note=manual_note)
# ──────────────────────────────────────────────
# SOURCE BUILD WORKER — compile VS + av1an from git
# ──────────────────────────────────────────────
class SourceBuildWorker(QThread):
"""Builds VapourSynth and/or av1an from git to resolve ABI mismatches.
Runs in a background thread. Emits progress via log_msg.
When done, emits build_done(success, message).
Everything installs to the user's home directory (no sudo for install):
VapourSynth → ~/.local/lib/ (av1an finds it via LD_LIBRARY_PATH)
av1an → ~/.cargo/bin/ (already in PATH)
Only build-dependency installation (pacman -S) may need sudo.
"""
log_msg = Signal(str)
build_done = Signal(bool, str) # (success, detail)
def __init__(self, build_vs: bool = True, build_av1an: bool = True,
build_ffmpeg_iamf: bool = False, gpu_profile_key: str = ""):
super().__init__()
self.build_vs = build_vs
self.build_av1an = build_av1an
self.build_ffmpeg_iamf = build_ffmpeg_iamf
# v4.8.0: selected GPU capability profile — extends the dep tree
# with the vendor's build/runtime packages.
self.gpu_profile_key = gpu_profile_key
self._stop = False
# Private per-worker environment snapshot. Mutating os.environ is
# process-global and leaks across threads/subsequent subprocesses;
# _build_env is local to this worker and passed via env= to every
# subprocess.run call below (see _run_cmd).
self._build_env: dict[str, str] = os.environ.copy()
def _extend_env(self, var: str, value: str, prepend: bool = False):
"""Add ``value`` to ``self._build_env[var]`` (NOT ``os.environ``).
``prepend=True`` places ``value`` first so it shadows any existing
entry (e.g. ~/.local/bin must shadow /usr/bin, libiamf's
PKG_CONFIG_PATH must shadow the system pkgconfig dir); default
appends (e.g. extending PATH with ~/.cargo/bin). Caller is
responsible for any idempotency check (matches the original
per-site ``if x not in existing:`` pattern). rstrip(":") on
prepend avoids a trailing colon when ``var`` was previously unset.
"""
existing = self._build_env.get(var, "")
if prepend:
self._build_env[var] = f"{value}:{existing}".rstrip(":")
else:
self._build_env[var] = f"{existing}:{value}" if existing else value
def _run_cmd(self, cmd, cwd=None, timeout=600, label=""):
"""Run a command, log output, return (returncode, combined_output)."""
self.log_msg.emit(f" $ {' '.join(cmd[:6])}{'...' if len(cmd)>6 else ''}")
try:
r = subprocess.run(cmd, capture_output=True, text=True,
timeout=timeout, cwd=cwd, env=self._build_env)
# Log last few lines of stderr for diagnostics
if r.stderr:
for line in r.stderr.strip().splitlines()[-5:]:
self.log_msg.emit(f" {line}")
if r.returncode != 0 and r.stdout:
for line in r.stdout.strip().splitlines()[-3:]:
self.log_msg.emit(f" {line}")
return r.returncode, (r.stdout or "") + (r.stderr or "")
except subprocess.TimeoutExpired:
self.log_msg.emit(f" TIMEOUT ({timeout}s) running: {label or cmd[0]}")
return -1, f"timeout after {timeout}s"
except (OSError, subprocess.SubprocessError) as e:
self.log_msg.emit(f" ERROR: {e}")
return -1, str(e)
def _sudo_cmd(self, cmd, timeout=120, label=""):
"""Run a command with sudo (or pkexec as graphical fallback)."""
# Try pkexec first (graphical polkit prompt — works in desktop sessions)
pkexec = shutil.which("pkexec")
if pkexec:
return self._run_cmd([pkexec] + cmd, timeout=timeout, label=label or cmd[0])
# Fall back to sudo (needs a terminal; may fail silently)
return self._run_cmd(["sudo"] + cmd, timeout=timeout, label=label or cmd[0])
def run(self):
try:
# ── v4.7.1: distro-aware dependency tree ──
# The rebuild must work on a bare system: detect missing
# tools/libraries and install them via the distro package
# manager (one privilege prompt via pkexec/sudo) BEFORE
# compiling anything.
self.log_msg.emit("")
self.log_msg.emit("=== Generating dependency tree ===")
self._distro = detect_distro()
self.log_msg.emit(
f" Distro: {self._distro.name} (family={self._distro.family})"
)
plan = build_dep_plan(self._distro, build_av1an=self.build_av1an)
# v4.8.0: GPU-profile packages on top of the base toolchain.
gpu_profile = gpu_profile_by_key(self.gpu_profile_key)
if gpu_profile is not None and gpu_profile.api != "none":
gpu_pkgs = gpu_dep_packages(gpu_profile.api, self._distro.family)
if gpu_pkgs:
self.log_msg.emit(
f" GPU profile {gpu_profile.key} ({gpu_profile.api}): "
f"+{len(gpu_pkgs)} package(s)"
)
plan.packages.extend(p for p in gpu_pkgs
if p not in plan.packages)
missing = self._missing_build_tools()
zimg_ok = self._pkgconfig_exists("zimg")
if zimg_ok:
self.log_msg.emit(" OK: zimg (VapourSynth dependency)")
else:
missing.append("zimg (library, via pkg-config)")
if self.build_av1an and not shutil.which("cargo"):
missing.append("cargo (rust)")
if missing:
self.log_msg.emit(f" Missing: {', '.join(missing)}")
if plan.install_cmd:
self.log_msg.emit(
f" Installing {len(plan.packages)} package(s) via "
f"{plan.install_cmd[0]} (privilege prompt possible)..."
)
rc, _ = self._sudo_cmd(
plan.install_cmd + plan.packages,
timeout=900, label=f"{plan.install_cmd[0]} build-deps",
)
if rc != 0:
self.log_msg.emit(
" (install reported an error — continuing; "
"some packages may already be present)"
)
else:
self.log_msg.emit(f" {plan.manual_note}")
else:
self.log_msg.emit(" All build dependencies already installed.")
# Re-verify the critical tools after install.
still_missing = self._missing_build_tools()
if still_missing:
self.log_msg.emit(
f" FATAL: still missing after install: {', '.join(still_missing)}. "
f"Install them manually and press REBUILD again."
)
self.build_done.emit(False, f"missing build tools: {still_missing}")
return
# Ensure cargo is in PATH after potential install.
# NOTE: /root/.cargo/bin was dropped (OTC-015/v3-08) — root's
# cargo dir is not readable by a non-root user. ~/.cargo/bin
# covers the user's rustup install; /usr/bin is already in the
# default PATH and is appended here only to match the original
# mutation's intent (cargo from pacman lives there).
self._extend_env("PATH", "/usr/bin")
self._extend_env("PATH", str(Path.home() / ".cargo" / "bin"))
if not shutil.which("cargo") and self.build_av1an:
self.log_msg.emit(" FATAL: cargo not found after deps install. Aborting.")
self.build_done.emit(False, "Rust/cargo not available")
return
# ── Optional: ffmpeg build deps (libopus, libvorbis dev pkgs) ──
if self.build_ffmpeg_iamf:
self._install_ffmpeg_build_deps()
# ── Build & install VapourSynth to ~/.local (NO sudo needed) ──
if self.build_vs:
self._build_vapoursynth()
# v4.7.1: BestSource right after VS, compiled against the
# fresh VS headers — gives av1an a fast chunk method.
self._build_bestsource()
# ── Build av1an to ~/.cargo/bin (NO sudo needed) ──
if self.build_av1an:
self._build_av1an()
# ── Build libiamf + ffmpeg with --enable-libiamf to ~/.local ──
if self.build_ffmpeg_iamf:
self._build_libiamf()
self._build_ffmpeg_with_iamf()
# ── Ensure the runtime env can find the fresh VS stack ──
# The git VapourSynth installs self-contained into the user
# site-packages (module + libs + plugins). av1an dlopens
# libvapoursynth-script from there, so both LD_LIBRARY_PATH
# and PYTHONPATH must include it.
local_lib = str(Path.home() / ".local" / "lib")
existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
if local_lib not in existing_ld:
self._extend_env("LD_LIBRARY_PATH", local_lib, prepend=True)
user_site = self._vs_user_site()
if user_site and (user_site / "vapoursynth" / "libvsscript.so").exists():
vs_dir = str(user_site / "vapoursynth")
if vs_dir not in self._build_env.get("LD_LIBRARY_PATH", ""):
self._extend_env("LD_LIBRARY_PATH", vs_dir, prepend=True)
if str(user_site) not in self._build_env.get("PYTHONPATH", ""):
self._extend_env("PYTHONPATH", str(user_site), prepend=True)
self.log_msg.emit(
f" Runtime env: LD_LIBRARY_PATH/PYTHONPATH include {vs_dir}"
)
self.log_msg.emit("")
self.log_msg.emit("=== Source build complete ===")
self.build_done.emit(True, "Build and install completed (local ~/.local/).")
except Exception as e:
# SEI CERT ERR01-C: justified — this method orchestrates a long
# multi-step build (git clone, meson, ninja, cargo install) whose
# helper methods signal failure by `raise Exception(msg)` (15
# sites). Catching Exception here converts any of those into a
# user-facing build_done(False, ...) signal instead of crashing
# the QThread. Narrowing would require refactoring all `raise
# Exception(...)` call sites — out of scope for ERR01-C pass.
self.log_msg.emit(f"BUILD FAILED: {e}")
self.build_done.emit(False, str(e))
def _missing_build_tools(self) -> list[str]:
"""Binaries from BUILD_TOOL_ALIASES that are not on PATH."""
missing = []
for label, candidates in BUILD_TOOL_ALIASES.items():
if not any(shutil.which(c) for c in candidates):
missing.append(label)
return missing
def _pkgconfig_exists(self, name: str) -> bool:
rc, _ = self._run_cmd(
["pkg-config", "--exists", name],
timeout=10, label=f"pkg-config {name}",
)
return rc == 0
def _vs_user_site(self) -> Path | None:
"""The user site-packages dir of the system python3 — where the
VapourSynth git install places its self-contained stack (module,
libs, headers, plugins/)."""
rc, out = self._run_cmd(
["python3", "-m", "site", "--user-site"],
timeout=15, label="python3 -m site --user-site",
)
if rc == 0 and out.strip():
return Path(out.strip().splitlines()[-1])
return None
def _build_bestsource(self):
"""Clone and build the BestSource VapourSynth plugin from git.
BestSource gives av1an a fast, frame-accurate chunk source — the
difference between 'select' (quadratic decoding, minutes per
file) and normal chunk-parallel speed. Compiled against the
vapoursynth headers of the JUST-INSTALLED git VS (via
PYTHONPATH/PKG_CONFIG_PATH), so the plugin ABI always matches
the VS that av1an will load. Requires the repo's libp2p
submodule (initialized here).
"""
self.log_msg.emit("")
self.log_msg.emit("=== Building BestSource plugin from git ===")
self.log_msg.emit(" Source: https://github.com/vapoursynth/bestsource")
build_dir = Path("/tmp/bestsource-git-build")
if build_dir.exists():
shutil.rmtree(build_dir, ignore_errors=True)
rc, out = self._run_cmd(
["git", "clone", "--depth", "1",
"https://github.com/vapoursynth/bestsource.git",
str(build_dir)],
timeout=120, label="git clone bestsource",
)
if rc != 0:
raise Exception(f"git clone bestsource failed: {out[-300:]}")
# libp2p is a required submodule (R9+ builds source from it).
rc, out = self._run_cmd(
["git", "submodule", "update", "--init", "--depth", "1"],
cwd=str(build_dir), timeout=120, label="git submodule update",
)
if rc != 0:
raise Exception(f"bestsource submodule init failed: {out[-300:]}")
# Point meson/pkg-config at the freshly built VS stack.
user_site = self._vs_user_site()
if user_site and (user_site / "vapoursynth").is_dir():
self._extend_env("PYTHONPATH", str(user_site), prepend=True)
self._extend_env("PKG_CONFIG_PATH",
str(user_site / "vapoursynth" / "pkgconfig"),
prepend=True)
self._extend_env("LD_LIBRARY_PATH",
str(user_site / "vapoursynth"), prepend=True)
else:
self.log_msg.emit(
" NOTE: git VapourSynth install not found in user "
"site-packages — building against system vapoursynth."
)
self.log_msg.emit(" Configuring with meson (--prefix=~/.local)...")
rc, out = self._run_cmd(
["meson", "setup", "build",
f"--prefix={Path.home() / '.local'}", "--libdir=lib"],
cwd=str(build_dir), timeout=180, label="meson setup bestsource",
)
if rc != 0:
raise Exception(f"bestsource meson setup failed: {out[-500:]}")
self.log_msg.emit(" Compiling BestSource (a minute or two)...")
rc, out = self._run_cmd(
["ninja", "-C", "build", "-j", str(max(1, os.cpu_count() or 2))],
cwd=str(build_dir), timeout=600, label="ninja bestsource",
)
if rc != 0:
raise Exception(f"bestsource build failed: {out[-500:]}")
rc, out = self._run_cmd(
["ninja", "-C", "build", "install"],
cwd=str(build_dir), timeout=120, label="ninja install bestsource",
)
if rc != 0:
raise Exception(f"bestsource install failed: {out[-500:]}")
plugin = None
if user_site:
candidate = user_site / "vapoursynth" / "plugins" / "libbestsource.so"
if candidate.exists():
plugin = candidate
if plugin:
self.log_msg.emit(f" BestSource plugin installed: {plugin}")
self.log_msg.emit(
" av1an will now auto-select the fast 'bestsource' chunk "
"method (restart the app so the probe sees it)."
)
else:
self.log_msg.emit(
" WARNING: libbestsource.so not found at the expected "
"user-site path — check the meson install log above."
)
shutil.rmtree(build_dir, ignore_errors=True)
def _build_vapoursynth(self):
"""Clone, build, and install VapourSynth to ~/.local/ (no sudo needed)."""
self.log_msg.emit("")
self.log_msg.emit("=== Building VapourSynth from git ===")
self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)")
build_dir = Path("/tmp/vapoursynth-git-build")
local_prefix = str(Path.home() / ".local")
if build_dir.exists():
self.log_msg.emit(f" Cleaning old build directory...")
shutil.rmtree(build_dir, ignore_errors=True)
# Clone (shallow — faster)
rc, out = self._run_cmd(
["git", "clone", "--depth", "1",
"https://github.com/vapoursynth/vapoursynth.git",
str(build_dir)],
timeout=120, label="git clone vapoursynth",
)
if rc != 0:
raise Exception(f"git clone VapourSynth failed: {out[-300:]}")
# Meson setup — install to ~/.local so it doesn't touch system dirs
self.log_msg.emit(" Configuring with meson (--prefix=~/.local)...")
rc, out = self._run_cmd(
["meson", "setup", "build",
f"--prefix={local_prefix}", "--libdir=lib"],
cwd=str(build_dir), timeout=120, label="meson setup",
)
if rc != 0:
raise Exception(f"meson setup failed: {out[-500:]}")
# Build
self.log_msg.emit(" Compiling VapourSynth (this may take a few minutes)...")
rc, out = self._run_cmd(
["ninja", "-C", "build", "-j", str(max(1, os.cpu_count() or 2))],
cwd=str(build_dir), timeout=900, label="ninja build",
)
if rc != 0:
raise Exception(f"ninja build failed: {out[-500:]}")
# Install to ~/.local/ — NO sudo needed (user owns this directory)
self.log_msg.emit(" Installing VapourSynth to ~/.local/ ...")
rc, out = self._run_cmd(
["ninja", "-C", "build", "install"],
cwd=str(build_dir), timeout=120, label="ninja install",
)
if rc != 0:
raise Exception(f"ninja install failed: {out[-500:]}")
self.log_msg.emit(f" VapourSynth installed to {local_prefix}/ (libs in {local_prefix}/lib/)")
# Cleanup build directory
shutil.rmtree(build_dir, ignore_errors=True)
def _build_av1an(self):
"""Clone and build av1an from git. Installs to ~/.cargo/bin/ (no sudo needed)."""
self.log_msg.emit("")
self.log_msg.emit("=== Building av1an from git ===")
self.log_msg.emit(" Install target: ~/.cargo/bin/ (no system-wide changes)")
# Ensure cargo is in PATH
cargo_bin = shutil.which("cargo")
if not cargo_bin:
# Common locations
for p in [Path.home() / ".cargo" / "bin" / "cargo", "/usr/bin/cargo"]:
if p.exists():
self._extend_env("PATH", str(p.parent))
cargo_bin = str(p)
break
if not cargo_bin:
raise Exception("cargo not found — cannot build av1an")
self.log_msg.emit(f" Using cargo at: {cargo_bin}")
self.log_msg.emit(" Compiling av1an (this may take 10-30 minutes)...")
rc, out = self._run_cmd(
["cargo", "install", "av1an",
"--git", "https://github.com/master-of-zen/av1an",
"--force", "--root", str(Path.home() / ".cargo")],
timeout=3600, label="cargo install av1an",
)
if rc != 0:
raise Exception(f"cargo install av1an failed: {out[-500:]}")
new_av1an = Path.home() / ".cargo" / "bin" / "av1an"
if new_av1an.exists():
self.log_msg.emit(f" av1an installed: {new_av1an}")
else:
self.log_msg.emit(" WARNING: av1an binary not found at expected path after build.")
def _install_ffmpeg_build_deps(self):
"""Install ffmpeg build deps (libopus, libvorbis dev packages).
Uses pkg-config to detect missing libraries, then installs the
corresponding Arch/pacman packages. On other distros the user
must install these manually; the log will name them.
"""
self.log_msg.emit("")
self.log_msg.emit("=== Checking ffmpeg build dependencies ===")
# (pkg-config name, Arch package name, Debian package name)
pkg_checks = [
("opus", "opus", "libopus-dev"),
("vorbis", "libvorbis", "libvorbis-dev"),
("ogg", "libogg", "libogg-dev"),
]
missing_arch = []
missing_debian = []
for pc_name, arch_pkg, debian_pkg in pkg_checks:
rc, _ = self._run_cmd(
["pkg-config", "--exists", pc_name],
timeout=10, label=f"pkg-config {pc_name}",
)
if rc != 0:
missing_arch.append(arch_pkg)
missing_debian.append(debian_pkg)
self.log_msg.emit(f" Missing: {arch_pkg} (pkg-config {pc_name})")
else:
self.log_msg.emit(f" OK: {pc_name}")
if not missing_arch:
self.log_msg.emit(" All ffmpeg build deps satisfied.")
return
# Try pacman (Arch) first since the rest of this app assumes Arch
if shutil.which("pacman"):
self.log_msg.emit(f" Installing via pacman: {', '.join(missing_arch)}")
rc, _ = self._sudo_cmd(
["pacman", "-S", "--needed", "--noconfirm"] + missing_arch,
timeout=300, label="pacman ffmpeg-deps",
)
if rc != 0:
self.log_msg.emit(" WARNING: pacman install failed — configure may fail.")
elif shutil.which("apt-get"):
self.log_msg.emit(f" Installing via apt: {', '.join(missing_debian)}")
rc, _ = self._sudo_cmd(
["apt-get", "install", "-y"] + missing_debian,
timeout=300, label="apt ffmpeg-deps",
)
if rc != 0:
self.log_msg.emit(" WARNING: apt install failed — configure may fail.")
else:
self.log_msg.emit(
f" No supported package manager found. Install manually: "
f"{', '.join(missing_arch)} (Arch) or {', '.join(missing_debian)} (Debian)."
)
def _build_libiamf(self):
"""Clone, build, and install libiamf to ~/.local/ (no sudo needed).
libiamf is the AOMedia Immersive Audio Model and Formats reference
library. ffmpeg links against it via --enable-libiamf.
"""
self.log_msg.emit("")
self.log_msg.emit("=== Building libiamf from git ===")
self.log_msg.emit(" Source: https://github.com/AOMediaCodec/libiamf")
self.log_msg.emit(" Install target: ~/.local/ (no system-wide changes)")
build_dir = Path("/tmp/libiamf-git-build")
local_prefix = Path.home() / ".local"
if build_dir.exists():
shutil.rmtree(build_dir, ignore_errors=True)
# Clone (shallow)
self.log_msg.emit(" Cloning libiamf source (shallow)...")
rc, out = self._run_cmd(
["git", "clone", "--depth", "1",
"https://github.com/AOMediaCodec/libiamf.git",
str(build_dir)],
timeout=120, label="git clone libiamf",
)
if rc != 0:
raise Exception(f"git clone libiamf failed: {out[-300:]}")
# CMake configure
cmake_build = build_dir / "build"
cmake_build.mkdir(exist_ok=True)
self.log_msg.emit(f" Configuring with cmake (--prefix={local_prefix})...")
rc, out = self._run_cmd(
["cmake", "-S", str(build_dir), "-B", str(cmake_build),
f"-DCMAKE_INSTALL_PREFIX={local_prefix}",
"-DCMAKE_BUILD_TYPE=Release",
"-DBUILD_SHARED_LIBS=ON"],
timeout=120, label="cmake configure libiamf",
)
if rc != 0:
raise Exception(f"cmake configure libiamf failed:\n{out[-500:]}")
# Build
self.log_msg.emit(" Compiling libiamf...")
rc, out = self._run_cmd(
["cmake", "--build", str(cmake_build), "-j",
str(max(1, os.cpu_count() or 2))],
timeout=600, label="cmake build libiamf",
)
if rc != 0:
raise Exception(f"cmake build libiamf failed:\n{out[-500:]}")
# Install
self.log_msg.emit(f" Installing libiamf to {local_prefix}/ ...")
rc, out = self._run_cmd(
["cmake", "--install", str(cmake_build)],
timeout=120, label="cmake install libiamf",
)
if rc != 0:
raise Exception(f"cmake install libiamf failed:\n{out[-500:]}")
# Make libiamf discoverable: PKG_CONFIG_PATH and LD_LIBRARY_PATH
pc_dir = local_prefix / "lib" / "pkgconfig"
if pc_dir.exists():
existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "")
if str(pc_dir) not in existing_pkgs:
self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)
self.log_msg.emit(f" Added {pc_dir} to PKG_CONFIG_PATH")
lib_dir = local_prefix / "lib"
existing_ld = self._build_env.get("LD_LIBRARY_PATH", "")
if str(lib_dir) not in existing_ld:
self._extend_env("LD_LIBRARY_PATH", str(lib_dir), prepend=True)
self.log_msg.emit(f" libiamf installed to {local_prefix}/")
# Cleanup
shutil.rmtree(build_dir, ignore_errors=True)
def _build_ffmpeg_with_iamf(self):
"""Rebuild ffmpeg from source with libiamf (and IAMF's Opus dep).
Strategy: detect the current ffmpeg's --enable-* configure flags,
reuse them, and append --enable-libiamf. This preserves all
existing functionality (libsvtav1, libvpx, libx265, etc.) while
adding IAMF support.
Installs to ~/.local/bin/ffmpeg so it shadows the system ffmpeg
without overwriting it. The user must restart the app for the
new ffmpeg to take effect (probe_environment re-runs on launch).
"""
self.log_msg.emit("")
self.log_msg.emit("=== Building ffmpeg from git with IAMF ===")
self.log_msg.emit(" Install target: ~/.local/bin/ (shadows system ffmpeg)")
# 1. Detect current ffmpeg configure flags
ffmpeg_bin = shutil.which("ffmpeg") or "/usr/bin/ffmpeg"
self.log_msg.emit(f" Probing current ffmpeg config: {ffmpeg_bin}")
rc, out = self._run_cmd(
[ffmpeg_bin, "-buildconf"],
timeout=30, label="ffmpeg -buildconf",
)
if rc != 0:
raise Exception(f"ffmpeg -buildconf failed:\n{out[-300:]}")
# Parse --enable-* flags from output (one per line, sometimes with leading whitespace)
enables = re.findall(r"--enable-[a-z0-9_-]+", out)
# Dedupe while preserving order
seen = set()
enable_flags = []
for e in enables:
if e not in seen:
seen.add(e)
enable_flags.append(e)
# Make sure libiamf and libopus are in the list (core requirements)
if "--enable-libiamf" not in enable_flags:
enable_flags.append("--enable-libiamf")
if "--enable-libopus" not in enable_flags:
enable_flags.append("--enable-libopus")
self.log_msg.emit(f" Configure flags ({len(enable_flags)}):")
for f in enable_flags:
self.log_msg.emit(f" {f}")
# 2. Clone ffmpeg source
build_dir = Path("/tmp/ffmpeg-git-build")
if build_dir.exists():
shutil.rmtree(build_dir, ignore_errors=True)
self.log_msg.emit(" Cloning ffmpeg source (shallow)...")
rc, out = self._run_cmd(
["git", "clone", "--depth", "1",
"https://git.ffmpeg.org/ffmpeg.git",
str(build_dir)],
timeout=300, label="git clone ffmpeg",
)
if rc != 0:
# Fall back to GitHub mirror
self.log_msg.emit(" Primary mirror failed, trying github mirror...")
rc, out = self._run_cmd(
["git", "clone", "--depth", "1",
"https://github.com/FFmpeg/FFmpeg.git",
str(build_dir)],
timeout=300, label="git clone ffmpeg (github)",
)
if rc != 0:
raise Exception(f"git clone ffmpeg failed:\n{out[-300:]}")
local_prefix = Path.home() / ".local"
# Make sure pkg-config finds the freshly-built libiamf
pc_dir = local_prefix / "lib" / "pkgconfig"
existing_pkgs = self._build_env.get("PKG_CONFIG_PATH", "")
if str(pc_dir) not in existing_pkgs:
self._extend_env("PKG_CONFIG_PATH", str(pc_dir), prepend=True)
# 3. Configure
self.log_msg.emit(" Running ./configure (this may take a minute)...")
configure_cmd = [
"./configure",
f"--prefix={local_prefix}",
"--enable-shared",
"--enable-pic",
"--enable-version3",
] + enable_flags
rc, out = self._run_cmd(
configure_cmd,
cwd=str(build_dir), timeout=300, label="ffmpeg configure",
)
if rc != 0:
# Show the actual error — usually a missing -dev package
raise Exception(
"ffmpeg configure failed. This usually means a dev library\n"
"is missing. Install the corresponding -dev package and retry.\n"
f"Output:\n{out[-800:]}"
)
# 4. Build
self.log_msg.emit(" Compiling ffmpeg (this may take 10-20 minutes)...")
rc, out = self._run_cmd(
["make", "-j", str(max(1, os.cpu_count() or 2))],
cwd=str(build_dir), timeout=2400, label="make ffmpeg",
)
if rc != 0:
raise Exception(f"ffmpeg make failed:\n{out[-500:]}")
# 5. Install to ~/.local
self.log_msg.emit(f" Installing ffmpeg to {local_prefix}/ ...")
rc, out = self._run_cmd(
["make", "install"],
cwd=str(build_dir), timeout=300, label="make install ffmpeg",
)
if rc != 0:
raise Exception(f"make install ffmpeg failed:\n{out[-500:]}")
# 6. Ensure ~/.local/bin is in PATH so new ffmpeg shadows system one
local_bin = local_prefix / "bin"
existing_path = self._build_env.get("PATH", "")
if str(local_bin) not in existing_path:
self._extend_env("PATH", str(local_bin), prepend=True)
self.log_msg.emit(f" Prepended {local_bin} to PATH (shadows system ffmpeg)")
new_ffmpeg = local_bin / "ffmpeg"
if new_ffmpeg.exists():
self.log_msg.emit(f" ffmpeg installed: {new_ffmpeg}")
self.log_msg.emit(
" IMPORTANT: Restart the app for the new ffmpeg (with libiamf)\n"
" to be detected and used. The IAMF audio entry will then\n"
" be selectable (not greyed out)."
)
else:
self.log_msg.emit(" WARNING: ffmpeg binary not found at expected path after build.")
# Cleanup build dir (keep source for re-runs? No — disk is cheap, time isn't, but
# a clean clone is more reliable than a stale tree.)
shutil.rmtree(build_dir, ignore_errors=True)
def stop(self):
self._stop = True

View File

@ -1,118 +0,0 @@
"""Temp directory management for intermediate encode files.
Owns the shared app cache dir, per-worker temp subdirs (v3-09 race
fix), private-dir mkdir (v3-09 umask defeat), and the per-source-path
hash naming helper. Pure stdlib; no internal package dependencies.
"""
import hashlib
import os
import tempfile
from pathlib import Path
# ──────────────────────────────────────────────
# TEMP DIRECTORY MANAGEMENT
# ──────────────────────────────────────────────
_APP_CACHE_DIR: Path | None = None
def _get_app_temp_dir() -> Path:
"""Return the shared temp directory for all intermediate files.
Priority:
1. ``~/.cache/OpenTranscode/tmp/`` (XDG-compliant, persistent across reboots)
2. ``/tmp/OpenTranscode/`` (fallback if home cache is unwritable)
The directory is created on first call. All temp intermediates
(pre-scaled MKVs, av1an work dirs) go here so the user's video
folders stay clean.
v3 (OTC-013, SEI CERT FIO09-C): the directory is created with
``mode=0o700`` so that other users on the system cannot create
symlinks inside it (which the cleanup sweep would then follow and
delete arbitrary files). The mode is verified after creation in
case the directory already existed with looser permissions.
"""
global _APP_CACHE_DIR
if _APP_CACHE_DIR is not None:
return _APP_CACHE_DIR
# Try XDG cache dir first
xdg_cache = os.environ.get("XDG_CACHE_HOME", "")
if xdg_cache:
candidate = Path(xdg_cache) / "OpenTranscode" / "tmp"
else:
candidate = Path.home() / ".cache" / "OpenTranscode" / "tmp"
if _mkdir_private(candidate):
_APP_CACHE_DIR = candidate
return _APP_CACHE_DIR
# Fallback: /tmp/OpenTranscode
fallback = Path("/tmp/OpenTranscode")
if _mkdir_private(fallback):
_APP_CACHE_DIR = fallback
return _APP_CACHE_DIR
# Last resort: system temp
_APP_CACHE_DIR = Path(tempfile.gettempdir()) / "OpenTranscode"
_mkdir_private(_APP_CACHE_DIR)
return _APP_CACHE_DIR
def _mkdir_private(path: Path) -> bool:
"""Create *path* (and parents) with mode 0o700.
Returns True on success, False on OSError/PermissionError.
SEI CERT FIO09-C: if the directory already existed with looser
permissions (e.g. created by a previous version of this app, or by
another user before us), we attempt to tighten the mode with
os.chmod(). The chmod may fail silently if we don't own the dir —
that's an accepted risk, logged but not fatal.
"""
try:
path.mkdir(parents=True, exist_ok=True, mode=0o700)
# mkdir(mode=) is masked by umask; explicitly chmod to be sure
os.chmod(path, 0o700)
return True
except (OSError, PermissionError):
return False
def _worker_temp_dir(worker_pid: int, lane: str = "") -> Path:
"""Return a per-worker temp subdir named by PID.
v3: each EncoderWorker gets its own subdir under the shared app temp
dir, so the final cleanup sweep can safely nuke only this worker's
intermediates without affecting a concurrent worker. The subdir is
also created with mode=0o700 (FIO09-C).
v4.7.0: *lane* suffixes the dir ("gpu"/"cpu") for the hybrid
scheduler's concurrent lanes — both run in the SAME process, so the
PID alone no longer separates them, and a lane finishing early must
not sweep the other lane's intermediates out from under it.
"""
base = _get_app_temp_dir()
name = f"worker-{worker_pid}" + (f"-{lane}" if lane else "")
sub = base / name
_mkdir_private(sub)
return sub
def _temp_path_for(file_path: Path, suffix: str = ".scaled_tmp.mkv",
worker_dir: Path | None = None) -> Path:
"""Build a unique temp path for *file_path* inside the app temp dir.
Uses a short hash of the original absolute path to avoid collisions
when files in different subdirs share the same stem.
v3: if *worker_dir* is provided (per-worker subdir), the temp file
lands there instead of the shared parent. This isolates concurrent
workers' intermediates from each other.
"""
tmp_dir = worker_dir if worker_dir is not None else _get_app_temp_dir()
# Hash the absolute source path for uniqueness
path_hash = hashlib.sha256(str(file_path.resolve()).encode()).hexdigest()[:12]
return tmp_dir / f"{file_path.stem}.{path_hash}{suffix}"

View File

@ -1,319 +0,0 @@
"""MMD3 retro-futuristic media console Qt stylesheet (QSS string).
Brushed aluminum panels, amber/green LED displays, beveled metallic
group boxes, modernized with rounded corners, subtle glow, and
glassmorphism hints. Pure string constant — no imports at all.
"""
# ──────────────────────────────────────────────
# MAIN WINDOW (merged UI from all 3)
# ──────────────────────────────────────────────
# ──────────────────────────────────────────────
# RETRO-FUTURISTIC MEDIA CONSOLE THEME
# ──────────────────────────────────────────────
# Brushed aluminum, amber/green LED displays,
# beveled metallic panels, VU meters, spectrum bars.
# Modernized with: rounded corners, subtle glow, glassmorphism hints,
# information-dense DAW-style layout.
MMD3_QSS = """
/* ── Global ── */
QMainWindow, QWidget#central {
background-color: #1a1a1e;
}
/* ── Group Boxes — brushed aluminum panels ── */
QGroupBox {
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
font-size: 10px;
font-weight: bold;
color: #8a8a8a;
border: 1px solid #3a3a40;
border-radius: 8px;
margin-top: 14px;
padding: 14px 10px 10px 10px;
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #2c2c32, stop:0.5 #27272c, stop:1 #222228);
}
QGroupBox::title {
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 2px 10px;
color: #666;
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #2c2c32, stop:1 #222228);
border-radius: 4px;
}
/* ── Labels ── */
QLabel {
color: #999;
font-size: 10px;
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
}
/* ── Line Edits — recessed aluminum wells ── */
QLineEdit {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #18181c, stop:1 #141418);
border: 1px solid #333;
border-radius: 4px;
padding: 5px 8px;
color: #d4aa50; /* amber LED */
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
font-size: 11px;
selection-background-color: #d4aa50;
selection-color: #000;
}
QLineEdit:focus {
border-color: #d4aa50;
}
/* ── Combo Boxes ── */
QComboBox {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #1e1e24, stop:1 #1a1a20);
border: 1px solid #3a3a40;
border-radius: 4px;
padding: 4px 8px;
color: #c8c8c8;
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
font-size: 11px;
min-height: 24px;
}
QComboBox:hover {
border-color: #555;
}
QComboBox:focus {
border-color: #d4aa50;
}
QComboBox::drop-down {
border: none;
width: 22px;
}
QComboBox::down-arrow {
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 6px solid #888;
margin-right: 6px;
}
QComboBox QAbstractItemView {
background: #1e1e24;
border: 1px solid #3a3a40;
border-radius: 4px;
color: #c8c8c8;
selection-background-color: #3a3a48;
selection-color: #d4aa50;
padding: 4px;
}
QComboBox item {
min-height: 22px;
padding: 2px 8px;
}
/* ── Buttons — beveled metallic (MMD3 transport style) ── */
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #404048, stop:0.15 #38383f,
stop:0.85 #2e2e35, stop:1 #28282e);
border: 1px solid #4a4a52;
border-bottom-color: #1a1a1e;
border-radius: 5px;
padding: 6px 16px;
color: #d0d0d0;
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
font-size: 11px;
font-weight: bold;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #4a4a54, stop:0.15 #424248,
stop:0.85 #363640, stop:1 #303038);
border-color: #5a5a64;
color: #fff;
}
QPushButton:pressed {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #28282e, stop:1 #3a3a42);
border-bottom-color: #4a4a52;
border-top-color: #1a1a1e;
}
QPushButton:disabled {
background: #222228;
border-color: #2a2a30;
color: #555;
}
/* Primary action button — amber glow */
QPushButton#btnRun {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #3a3428, stop:0.15 #332e22,
stop:0.85 #2a261c, stop:1 #221e16);
border: 1px solid #5a4a30;
border-bottom-color: #1a1608;
color: #d4aa50;
font-size: 13px;
letter-spacing: 2px;
}
QPushButton#btnRun:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #4a4030, stop:0.15 #423828,
stop:0.85 #3a3020, stop:1 #322a1a);
border-color: #d4aa50;
color: #f0d080;
}
QPushButton#btnRun:disabled {
background: #22201a;
border-color: #2a2820;
color: #5a4a30;
}
/* Stop button — red danger */
QPushButton#btnStop {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #3a2222, stop:0.15 #321c1c,
stop:0.85 #2a1616, stop:1 #221010);
border: 1px solid #5a3030;
border-bottom-color: #1a0808;
color: #e05050;
font-size: 13px;
letter-spacing: 2px;
}
QPushButton#btnStop:hover {
border-color: #e05050;
color: #ff7070;
}
QPushButton#btnStop:disabled {
background: #221a1a;
border-color: #2a2020;
color: #5a3030;
}
/* Rebuild-from-git button — muted teal */
QPushButton#btnRebuild {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #1e2e2e, stop:0.15 #1a2a2a,
stop:0.85 #162424, stop:1 #121e1e);
border: 1px solid #2a5050;
border-bottom-color: #0e1818;
color: #50b0b0;
font-size: 10px;
letter-spacing: 1px;
}
QPushButton#btnRebuild:hover {
border-color: #50b0b0;
color: #70d0d0;
}
QPushButton#btnRebuild:disabled {
background: #1a1e1e;
border-color: #222828;
color: #304040;
}
/* Browse buttons — small, subdued */
QPushButton#btnBrowse {
font-size: 9px;
padding: 4px 10px;
letter-spacing: 1px;
}
/* ── Check Boxes ── */
QCheckBox {
color: #999;
font-size: 10px;
spacing: 8px;
font-family: 'Segoe UI', 'Ubuntu', sans-serif;
}
QCheckBox::indicator {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1px solid #444;
background: #1a1a1e;
}
QCheckBox::indicator:checked {
background: #d4aa50;
border-color: #b8903a;
}
QCheckBox#dangerCheck {
color: #c05050;
font-weight: bold;
}
QCheckBox#dangerCheck::indicator:checked {
background: #c04040;
border-color: #a03030;
}
/* ── Text Edit (log) — LED terminal display ── */
QTextEdit#logBox {
background: #0a0a0c;
border: 2px solid #1e1e24;
border-radius: 6px;
color: #40d060; /* green phosphor LED */
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
font-size: 11px;
padding: 8px;
}
/* ── Status Bar — LED readout strip ── */
QStatusBar {
background: #0e0e12;
border-top: 1px solid #2a2a30;
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
font-size: 10px;
color: #d4aa50;
padding: 2px 8px;
}
QStatusBar QLabel {
color: #d4aa50;
font-family: 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', monospace;
font-size: 10px;
}
/* ── Tooltips ── */
QToolTip {
background: #2a2a30;
color: #c8c8c8;
border: 1px solid #444;
border-radius: 4px;
padding: 6px;
font-size: 10px;
}
/* ── Scrollbars — thin, dark ── */
QScrollBar:vertical {
background: #141418;
width: 10px;
border-radius: 5px;
margin: 0;
}
QScrollBar::handle:vertical {
background: #3a3a42;
border-radius: 5px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background: #4a4a54;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0;
}
QScrollBar:horizontal {
background: #141418;
height: 10px;
border-radius: 5px;
}
QScrollBar::handle:horizontal {
background: #3a3a42;
border-radius: 5px;
min-width: 30px;
}
QScrollBar::handle:horizontal:hover {
background: #4a4a54;
}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
width: 0;
}
"""

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
"""Widget subpackage for opentranscode UI components.
"""
from .radio_knob import RadioKnob
__all__ = ["RadioKnob"]

View File

@ -1,282 +0,0 @@
"""RadioKnob widget — retro radio-style rotary knob.
A self-contained PySide6 widget (arc range, tick marks, glowing
indicator dot). Has no internal package dependencies — only PySide6
and ``math`` from the stdlib — so it can be imported standalone.
"""
import math
from PySide6.QtCore import Qt, Signal, QPointF, QRectF
from PySide6.QtGui import (
QFont, QColor, QPainter, QPen, QBrush,
QRadialGradient, QFontMetrics,
)
from PySide6.QtWidgets import QWidget
# ──────────────────────────────────────────────
# RADIO KNOB WIDGET (oldschool rotary control)
# ──────────────────────────────────────────────
class RadioKnob(QWidget):
"""
A retro radio-style rotary knob widget.
Supports arc range, tick marks, and a glowing indicator dot.
Rotation: 7 o'clock (min) to 5 o'clock (max) = 300 degrees.
"""
valueChanged = Signal(float)
def __init__(
self,
parent=None,
min_val: float = 0.0,
max_val: float = 100.0,
default_val: float = 50.0,
label: str = "",
unit: str = "",
color: tuple = (42, 130, 218),
num_ticks: int = 17,
tick_labels: list[str] | None = None,
snap_ticks: bool = False,
compact: bool = False,
):
super().__init__(parent)
self.min_val = min_val
self.max_val = max_val
self._value = default_val
self.label = label
self.unit = unit
self.color = QColor(*color)
self.num_ticks = num_ticks
self.tick_labels = tick_labels
self.snap_ticks = snap_ticks
self._dragging = False
self.compact = compact
# Arc geometry: 300-degree sweep, centered at 12 o'clock
self._arc_start = 210.0 # degrees (7 o'clock)
self._arc_span = -300.0 # negative = clockwise
# Scaling factor for compact mode (~70% of full size)
s = 0.70 if compact else 1.0
self._s = s
self.setFixedSize(int(180 * s), int(210 * s))
self.setCursor(Qt.CursorShape.PointingHandCursor)
# --- Public API ---
def value(self) -> float:
return self._value
def setValue(self, v: float):
v = max(self.min_val, min(self.max_val, v))
if self.snap_ticks:
v = self._snap(v)
if v != self._value:
self._value = v
self.update()
self.valueChanged.emit(v)
def intValue(self) -> int:
return int(round(self._value))
def _snap(self, v: float) -> float:
"""Snap to nearest tick."""
step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
return round((v - self.min_val) / step) * step + self.min_val
def _val_to_angle(self, v: float) -> float:
"""Map value to angle in degrees (matching the conical gradient)."""
ratio = (v - self.min_val) / (self.max_val - self.min_val) if self.max_val != self.min_val else 0
return self._arc_start + ratio * self._arc_span # goes from 210 -> -90
def _angle_to_val(self, angle_deg: float) -> float:
"""Map angle back to value."""
# Normalize angle relative to arc start
ratio = (angle_deg - self._arc_start) / self._arc_span
ratio = max(0.0, min(1.0, ratio))
v = self.min_val + ratio * (self.max_val - self.min_val)
if self.snap_ticks:
v = self._snap(v)
return v
# --- Painting ---
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
s = self._s # scale factor (0.7 for compact, 1.0 for full)
cx = w / 2
cy = h / 2 - 4 * s
outer_r = 70 * s
knob_r = 40 * s
arc_w = max(1, int(8 * s))
tick_w = max(1, 1.5 * s)
bezel_pad = 6 * s
# --- Outer bezel ring ---
bezel_grad = QRadialGradient(cx, cy, outer_r + bezel_pad)
bezel_grad.setColorAt(0.85, QColor(48, 48, 52))
bezel_grad.setColorAt(1.0, QColor(26, 26, 30))
p.setBrush(QBrush(bezel_grad))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QPointF(cx, cy), outer_r + bezel_pad, outer_r + bezel_pad)
# --- Inactive arc (dark track) ---
p.setPen(QPen(QColor(50, 50, 56), arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
int(self._arc_start * 16), int(self._arc_span * 16))
# --- Active arc (colored fill up to current value) ---
val_angle = self._val_to_angle(self._value)
active_span = val_angle - self._arc_start
if abs(active_span) > 0.5:
arc_color = QColor(self.color)
p.setPen(QPen(arc_color, arc_w, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
p.drawArc(QRectF(cx - outer_r, cy - outer_r, outer_r * 2, outer_r * 2),
int(self._arc_start * 16), int(active_span * 16))
# --- Tick marks ---
for i in range(self.num_ticks):
t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
tick_rad = tick_angle * math.pi / 180.0
ox = cx + (outer_r + 12 * s) * (-1) * math.sin(tick_rad)
oy = cy + (outer_r + 12 * s) * (-1) * (-math.cos(tick_rad))
ix_ = cx + (outer_r + 3 * s) * (-1) * math.sin(tick_rad)
iy_ = cy + (outer_r + 3 * s) * (-1) * (-math.cos(tick_rad))
p.setPen(QPen(QColor(130, 130, 130), tick_w))
p.drawLine(QPointF(ix_, iy_), QPointF(ox, oy))
# Tick labels (if provided)
if self.tick_labels:
p.setFont(QFont("Sans", max(5, int(7 * s))))
p.setPen(QColor(160, 160, 160))
step = max(1, self.num_ticks // len(self.tick_labels))
label_idx = 0
for i in range(0, self.num_ticks, step):
if label_idx >= len(self.tick_labels):
break
t = i / (self.num_ticks - 1) if self.num_ticks > 1 else 0
tick_angle = self._val_to_angle(self.min_val + t * (self.max_val - self.min_val))
tick_rad = tick_angle * math.pi / 180.0
lx = cx + (outer_r + 24 * s) * (-1) * math.sin(tick_rad)
ly = cy + (outer_r + 24 * s) * (-1) * (-math.cos(tick_rad))
txt = self.tick_labels[label_idx]
fm = QFontMetrics(p.font())
tw = fm.horizontalAdvance(txt)
p.drawText(QPointF(lx - tw / 2, ly + 2 * s), txt)
label_idx += 1
# --- Knob body (dark brushed aluminum) ---
knob_grad = QRadialGradient(cx - 6 * s, cy - 6 * s, knob_r * 1.3)
knob_grad.setColorAt(0.0, QColor(72, 72, 78))
knob_grad.setColorAt(0.5, QColor(50, 50, 55))
knob_grad.setColorAt(1.0, QColor(34, 34, 38))
p.setBrush(QBrush(knob_grad))
p.setPen(QPen(QColor(26, 26, 30), max(1, 1.5 * s)))
p.drawEllipse(QPointF(cx, cy), knob_r, knob_r)
# --- Inner shadow ring ---
inner_shadow = QRadialGradient(cx, cy, knob_r - 2)
inner_shadow.setColorAt(0.85, QColor(0, 0, 0, 0))
inner_shadow.setColorAt(1.0, QColor(0, 0, 0, 60))
p.setBrush(QBrush(inner_shadow))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QPointF(cx, cy), knob_r - 1, knob_r - 1)
# --- Indicator line (pointer) ---
ptr_angle = self._val_to_angle(self._value)
ptr_rad = ptr_angle * 3.14159265 / 180.0
ptr_len = knob_r - 8 * s
px = cx + ptr_len * (-1) * math.sin(ptr_rad)
py = cy + ptr_len * (-1) * (-math.cos(ptr_rad))
p.setPen(QPen(QColor(255, 255, 255, 220), max(1, 2.5 * s),
Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
p.drawLine(QPointF(cx, cy), QPointF(px, py))
# --- Center cap dot ---
cap_r = max(2, 5 * s)
cap_grad = QRadialGradient(cx, cy, cap_r)
cap_grad.setColorAt(0.0, QColor(60, 60, 65))
cap_grad.setColorAt(1.0, QColor(30, 30, 34))
p.setBrush(QBrush(cap_grad))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QPointF(cx, cy), cap_r, cap_r)
# --- Glow dot at arc tip ---
glow_r = max(3, 10 * s)
glow_x = cx + outer_r * (-1) * math.sin(ptr_rad)
glow_y = cy + outer_r * (-1) * (-math.cos(ptr_rad))
glow = QRadialGradient(glow_x, glow_y, glow_r * 1.2)
glow.setColorAt(0.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 200))
glow.setColorAt(1.0, QColor(self.color.red(), self.color.green(), self.color.blue(), 0))
p.setBrush(QBrush(glow))
p.setPen(Qt.PenStyle.NoPen)
p.drawEllipse(QPointF(glow_x, glow_y), glow_r, glow_r)
p.end()
# --- Label + value text below knob ---
p2 = QPainter(self)
p2.setRenderHint(QPainter.RenderHint.Antialiasing)
# Value line (e.g. "32.0 CRF")
val_font_sz = max(6, int(13 * s))
p2.setFont(QFont("Consolas", val_font_sz, QFont.Weight.Bold))
val_color = QColor(self.color.red(), self.color.green(), self.color.blue())
p2.setPen(val_color)
val_text = f"{self._value:.0f} {self.unit}" if self.unit else f"{self._value:.0f}"
p2.drawText(QRectF(0, h - 38 * s, w, 20 * s), Qt.AlignmentFlag.AlignCenter, val_text)
# Label line (e.g. "Quality")
lbl_font_sz = max(5, int(9 * s))
p2.setFont(QFont("Consolas", lbl_font_sz, QFont.Weight.Bold))
p2.setPen(QColor(160, 160, 160))
p2.drawText(QRectF(0, h - 18 * s, w, 16 * s), Qt.AlignmentFlag.AlignCenter, self.label)
p2.end()
# --- Input handling ---
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = True
self._update_from_mouse(event.position())
def mouseMoveEvent(self, event):
if self._dragging:
self._update_from_mouse(event.position())
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._dragging = False
def wheelEvent(self, event):
delta = event.angleDelta().y()
step = (self.max_val - self.min_val) / max(1, self.num_ticks - 1)
if delta > 0:
self.setValue(self._value + step)
elif delta < 0:
self.setValue(self._value - step)
def _update_from_mouse(self, pos: QPointF):
cx = self.width() / 2
cy = self.height() / 2 - 4 * self._s
dx = pos.x() - cx
dy = pos.y() - cy
angle = math.degrees(math.atan2(dx, -dy)) # 0=north, CW positive
if angle < 0:
angle += 360
# Clamp to arc range: 210..510 (which is 210..360 and 0..150)
# Our arc: 210 degrees to -90 (=270) degrees clockwise
if angle < 210 and angle > 150:
# Dead zone at bottom (between 150 and 210)
# Push to nearest end
angle = 210 if abs(angle - 210) < abs(angle - 510) else 510
if angle > 360:
angle -= 360 # normalize back to 0..360
self.setValue(self._angle_to_val(angle))

View File

@ -1,71 +1,40 @@
# pyproject.toml — OpenTranscode v4.5
# pyproject.toml — OpenTranscode
#
# v4.5: master release consolidating the v4.4.4 large-file fix
# (CRF-0 → CRF-16 pre-scale intermediate; new --inline-scale flag and
# UI checkbox for skipping the intermediate entirely) with all prior
# v4.4.x stability work. Targets the "every large file fails" symptom
# that was caused by lossless intermediates exhausting the temp
# partition and presenting as cryptic "ffmpeg error (rc=234)" messages.
#
# Publish with:
# python -m build
# twine upload dist/*
#
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
# Project metadata + tool configuration. The application is a single
# self-contained file (open-transcode.py) and is not packaged for
# distribution — run it directly; there is nothing to pip-install.
[project]
name = "opentranscode"
version = "4.8.1"
description = "Open-source batch video transcoder wrapping av1an + ffmpeg with a PySide6 GUI"
version = "4.11.0"
description = "Open-source batch video transcoder (ffmpeg/av1an) with a PySide6 GUI — single-file application"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "AGPL-3.0-or-later" }
authors = [
{ name = "Jeremy Anderson", email = "dcos@dcos.net" },
{ name = "Jeremy Anderson", email = "info@dcos.net" },
]
maintainers = [
{ name = "Jeremy Anderson", email = "dcos@dcos.net" },
{ name = "Jeremy Anderson", email = "info@dcos.net" },
]
keywords = [
"av1an",
"ffmpeg",
"av1an",
"av1",
"vp9",
"x265",
"hevc",
"theora",
"video-transcoding",
"video-encoder",
"batch-encoder",
"pyside6",
"linux",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: X11 Applications :: Qt",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3 :: Only",
"Topic :: Multimedia :: Video :: Conversion",
"Topic :: Multimedia :: Video :: Non-Linear Editor",
"Typing :: Typed",
]
dependencies = [
"PySide6>=6.6.0",
]
[project.optional-dependencies]
# Dev / test extras — install with: pip install -e ".[dev]"
dev = [
"pytest>=8.0",
"pytest-cov>=4.0",
"build>=1.0",
"twine>=4.0",
]
[project.urls]
@ -73,51 +42,10 @@ Homepage = "https://git.dcos.net/dcosnet/OpenTranscode"
Repository = "https://git.dcos.net/dcosnet/OpenTranscode"
Documentation = "https://git.dcos.net/dcosnet/OpenTranscode/blob/main/README.md"
"Bug Tracker" = "https://git.dcos.net/dcosnet/OpenTranscode/issues"
"QA Report" = "https://git.dcos.net/dcosnet/OpenTranscode/blob/main/OpenTranscode_QA_Report.pdf"
[project.scripts]
# Console entry point — `opentranscode` command after `pip install opentranscode`
opentranscode = "opentranscode.__main__:main"
# ─────────────────────────────────────────────────────────────────────────────
# Setuptools-specific config
# ─────────────────────────────────────────────────────────────────────────────
[tool.setuptools]
# We're a pure-Python package — no extension modules.
zip-safe = false
[tool.setuptools.packages.find]
# Auto-discover packages under opentranscode/ and widgets/
where = ["."]
include = ["opentranscode*"]
exclude = ["tests*"]
[tool.setuptools.package-data]
# Include the QSS theme + non-Python assets
opentranscode = ["*.qss", "*.txt"]
# ─────────────────────────────────────────────────────────────────────────────
# Tool config
# ─────────────────────────────────────────────────────────────────────────────
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-ra --strict-markers"
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"e2e: marks tests as end-to-end (require real ffmpeg/av1an)",
]
[tool.coverage.run]
source = ["opentranscode"]
omit = [
"*/tests/*",
"*/__main__.py",
]
source = ["open-transcode.py"]
omit = ["*/tests/*"]
[tool.coverage.report]
exclude_lines = [

View File

@ -3,13 +3,13 @@ Shared pytest fixtures and PySide6 stubs for the OpenTranscode test suite.
Why this file exists
--------------------
The ``open-transcode.py`` launcher script is a PySide6 GUI that imports
``PySide6.QtWidgets`` / ``QtCore`` / ``QtGui`` at module load time. The
non-UI unit tests in this suite (smoke test, encoder pipeline, audio
loudnorm, subtitle mux, stop-button, concurrent workers) only need the
*non-Qt* logic (dataclasses, free functions, and the non-Qt methods of
``EncoderWorker``). They should run on any CI worker — even one without a
real PySide6 install.
``open-transcode.py`` is the single-file application (a PySide6 GUI)
that imports ``PySide6.QtWidgets`` / ``QtCore`` / ``QtGui`` at module
load time. The non-UI unit tests in this suite (smoke test, encoder
pipeline, audio loudnorm, subtitle mux, stop-button, concurrent
workers) only need the *non-Qt* logic (dataclasses, free functions,
and the non-Qt methods of ``EncoderWorker``). They should run on any
CI worker — even one without a real PySide6 install.
To make that possible, this conftest installs *stub* PySide6 modules in
``sys.modules`` BEFORE the open-transcode module is loaded, but only when a real PySide6
@ -160,26 +160,39 @@ if not _REAL_PYSIDE6_AVAILABLE:
OPENTRANSCODE_SCRIPT_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"
_PROGRAM_MODULE = "open_transcode"
@pytest.fixture(scope="session")
def opentranscode_module():
"""Load ``open-transcode.py`` as a Python module.
The filename contains a dash (illegal in Python identifiers), so we
use ``importlib.util.spec_from_file_location``. The module is loaded
once per test session (scope="session") and cached here.
def load_program_module():
"""Load ``open-transcode.py`` (the single program module) exactly once.
The filename contains a dash (illegal in Python identifiers), so the
module is exec'd via ``importlib.util.spec_from_file_location`` and
registered in ``sys.modules`` under ``open_transcode``. The session
fixture and any direct ``load_program_module()`` caller therefore
share ONE module instance — classes, tables and sentinels compare
by identity across every test file.
"""
if _PROGRAM_MODULE in sys.modules:
return sys.modules[_PROGRAM_MODULE]
if not OPENTRANSCODE_SCRIPT_PATH.is_file():
pytest.skip(f"open-transcode.py not found at {OPENTRANSCODE_SCRIPT_PATH}")
spec = importlib.util.spec_from_file_location(
"open_transcode", str(OPENTRANSCODE_SCRIPT_PATH)
_PROGRAM_MODULE, str(OPENTRANSCODE_SCRIPT_PATH)
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[_PROGRAM_MODULE] = mod
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="session")
def opentranscode_module():
"""The single program module (see ``load_program_module``)."""
return load_program_module()
# ─────────────────────────────────────────────────────────────────────────────
# Shared fixtures
# ─────────────────────────────────────────────────────────────────────────────

View File

@ -27,11 +27,13 @@ from unittest.mock import MagicMock
import pytest
from conftest import capture_signal
from conftest import load_program_module
OPENTRANSCODE_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"
def _load_opentranscode_module():
if not OPENTRANSCODE_PATH.exists():
pytest.skip(f"open-transcode.py not found at {OPENTRANSCODE_PATH}")
@ -168,7 +170,9 @@ class TestY4mBreakPatternInErrorTable:
src = Path(OPENTRANSCODE_PATH).read_text()
# Find the v6-03 SUMMARY block check and verify it's guarded by
# the y4m break exclusion.
assert '"Failed to read y4m frame delimiter" not in stderr_full' in src, \
# The guard scans combined_out (stderr + stdout) — broader than
# stderr alone, since FRAME MISMATCH dumps land in stdout.
assert '"Failed to read y4m frame delimiter" not in combined_out' in src, \
"v6-03 SUMMARY block diagnostic must be guarded by y4m break exclusion"
def test_vs_plugin_probe_in_source(self, opentranscode_module):
@ -500,7 +504,7 @@ class TestVSPluginProbe:
# legitimately report it and break the hermetic expectation.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
import opentranscode.env_probe as _ep
_ep = load_program_module()
monkeypatch.setattr(_ep.site, "getusersitepackages",
lambda: str(tmp_path / "site-packages"))
monkeypatch.setattr(_ep.site, "getsitepackages", lambda: [])
@ -552,7 +556,7 @@ class TestCLIChunkMethodFlag:
if parser is None:
# The launcher script doesn't have build_parser; test the package's
# cli module instead
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
parser = build_parser()
# Valid values
@ -565,9 +569,9 @@ class TestCLIChunkMethodFlag:
def test_chunk_method_rejects_invalid_value(self, opentranscode_module):
"""Invalid --chunk-method values should be rejected by argparse."""
try:
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
except ImportError:
pytest.skip("opentranscode.cli not importable (PySide6 missing)")
pytest.skip("open_transcode.cli not importable (PySide6 missing)")
parser = build_parser()
with pytest.raises(SystemExit):

View File

@ -32,8 +32,9 @@ def test_workers_get_distinct_temp_dirs(opentranscode_module, mock_env, tmp_path
so each worker's cleanup sweep is isolated from concurrent workers.
"""
# Redirect the shared app temp dir to tmp_path so the real
# ~/.cache/OpenTranscode/ is NOT touched by this test.
opentranscode_module._APP_CACHE_DIR = tmp_path
# ~/.cache/OpenTranscode/ is NOT touched by this test. The loaded
# launcher module owns its own _APP_CACHE_DIR global.
monkeypatch.setattr(opentranscode_module, "_APP_CACHE_DIR", tmp_path)
# Two distinct fake PIDs for the two workers. (In production, workers
# run in separate OS processes via the distro's av1an binary, which

View File

@ -20,6 +20,7 @@ from conftest import capture_signal
OPENTRANSCODE_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"
def _load_opentranscode_module():
if not OPENTRANSCODE_PATH.exists():
pytest.skip(f"open-transcode.py not found at {OPENTRANSCODE_PATH}")

View File

@ -25,6 +25,7 @@ from conftest import capture_signal
OPENTRANSCODE_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"
def _load_opentranscode_module():
if not OPENTRANSCODE_PATH.exists():
pytest.skip(f"open-transcode.py not found at {OPENTRANSCODE_PATH}")

View File

@ -19,14 +19,18 @@ import pytest
from conftest import capture_signal
from opentranscode.codec_profiles import (
FFMPEG_LIB_KEY_MAP,
VIDEO_CODECS,
_av1_nvenc_args,
_hevc_nvenc_args,
)
from opentranscode.encoder_worker import EncoderWorker, resolve_gpu_encoder
from opentranscode.env_probe import GpuInfo, _NVENC_ENCODER_NAMES, _probe_gpu
from conftest import load_program_module
_m = load_program_module()
FFMPEG_LIB_KEY_MAP = _m.FFMPEG_LIB_KEY_MAP
VIDEO_CODECS = _m.VIDEO_CODECS
_av1_nvenc_args = _m._av1_nvenc_args
_hevc_nvenc_args = _m._hevc_nvenc_args
EncoderWorker = _m.EncoderWorker
resolve_gpu_encoder = _m.resolve_gpu_encoder
GpuInfo = _m.GpuInfo
_NVENC_ENCODER_NAMES = _m._NVENC_ENCODER_NAMES
_probe_gpu = _m._probe_gpu
# ─────────────────────────────────────────────────────────────────────────────
@ -88,7 +92,7 @@ class TestProbeGpu:
def test_no_nvenc_in_build(self, monkeypatch):
monkeypatch.setattr(
"opentranscode.env_probe.subprocess.run",
"open_transcode.subprocess.run",
_fake_run_factory("V..... libsvtav1 SVT-AV1 encoder\n", smoke_ok=True),
)
info = _probe_gpu("/usr/bin/ffmpeg")
@ -100,7 +104,7 @@ class TestProbeGpu:
out = (" V....D hevc_nvenc NVIDIA NVENC hevc encoder\n"
" V....D h264_nvenc NVIDIA NVENC H.264 encoder\n")
monkeypatch.setattr(
"opentranscode.env_probe.subprocess.run",
"open_transcode.subprocess.run",
_fake_run_factory(out, smoke_ok=False),
)
info = _probe_gpu("/usr/bin/ffmpeg")
@ -112,11 +116,11 @@ class TestProbeGpu:
def test_functional_nvenc(self, monkeypatch):
out = " V....D hevc_nvenc NVIDIA NVENC hevc encoder\n"
monkeypatch.setattr(
"opentranscode.env_probe.subprocess.run",
"open_transcode.subprocess.run",
_fake_run_factory(out, smoke_ok=True),
)
monkeypatch.setattr(
"opentranscode.env_probe.shutil.which", lambda name: None
"open_transcode.shutil.which", lambda name: None
)
info = _probe_gpu("/usr/bin/ffmpeg")
assert info.has_gpu is True
@ -133,8 +137,8 @@ class TestProbeGpu:
res.stderr = ""
return res
monkeypatch.setattr("opentranscode.env_probe.subprocess.run", fake_run)
monkeypatch.setattr("opentranscode.env_probe.shutil.which", lambda n: None)
monkeypatch.setattr("open_transcode.subprocess.run", fake_run)
monkeypatch.setattr("open_transcode.shutil.which", lambda n: None)
_probe_gpu("/usr/bin/ffmpeg")
# Stage 1 (-encoders) + Stage 2 (lavfi source + -c:v hevc_nvenc).
smoke = [c for c in seen_cmds if "lavfi" in c]
@ -209,7 +213,7 @@ class TestResolveGpuEncoder:
# ── v4.8.0: profile-driven resolution ──
def test_profile_selects_vaapi_encoder(self):
from opentranscode.gpu_profiles import gpu_profile_by_key
gpu_profile_by_key = load_program_module().gpu_profile_by_key
env = _FakeEnv({"hevc_vaapi": True, "h264_vaapi": True})
env.av1an_flags = {"gpu_profile": "amd-rdna3"}
codec = _codec_by_label("x265")
@ -237,7 +241,7 @@ class TestResolveGpuEncoder:
assert resolve_gpu_encoder("auto", codec, env) == (None, None)
def test_matched_profile_used_when_no_selection(self):
from opentranscode.gpu_profiles import gpu_profile_by_key
gpu_profile_by_key = load_program_module().gpu_profile_by_key
env = _FakeEnv({"hevc_qsv": True})
env.av1an_flags = {}
env.gpu.profile_key = "intel-arc"
@ -293,14 +297,14 @@ class TestCodecProfileGpuFields:
class TestCliEngineFlag:
def test_engine_choices(self):
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
p = build_parser()
assert p.parse_args(["--engine", "gpu"]).engine == "gpu"
assert p.parse_args(["--engine", "cpu"]).engine == "cpu"
assert p.parse_args([]).engine == "auto"
def test_engine_rejects_unknown(self):
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
p = build_parser()
with pytest.raises(SystemExit):
p.parse_args(["--engine", "tape"])
@ -312,9 +316,9 @@ class TestCliEngineFlag:
class TestEncoderWorkerEngine:
def _make_worker(self, mock_env, engine=None, use_ffmpeg_fallback=True):
from opentranscode.codec_profiles import (
AUDIO_PROFILES, CONTAINER_PROFILES, RESOLUTION_PRESETS,
)
m = load_program_module()
AUDIO_PROFILES, CONTAINER_PROFILES, RESOLUTION_PRESETS = (
m.AUDIO_PROFILES, m.CONTAINER_PROFILES, m.RESOLUTION_PRESETS)
return EncoderWorker(
in_dir=Path("/tmp"),
out_dir=Path("/tmp"),

View File

@ -9,16 +9,17 @@ import os
import pytest
from opentranscode.codec_profiles import VIDEO_CODECS
from opentranscode.encoder_worker import resolve_gpu_encoder
from opentranscode.gpu_profiles import (
GPU_PROFILES,
encoder_filter_chain,
encoder_quality_args,
encoder_pre_args,
gpu_profile_by_key,
match_gpu_profile,
)
from conftest import load_program_module
_m = load_program_module()
VIDEO_CODECS = _m.VIDEO_CODECS
resolve_gpu_encoder = _m.resolve_gpu_encoder
GPU_PROFILES = _m.GPU_PROFILES
encoder_filter_chain = _m.encoder_filter_chain
encoder_quality_args = _m.encoder_quality_args
encoder_pre_args = _m.encoder_pre_args
gpu_profile_by_key = _m.gpu_profile_by_key
match_gpu_profile = _m.match_gpu_profile
class TestProfileData:
@ -57,7 +58,7 @@ class TestProfileData:
assert "av1" not in gpu_profile_by_key("amd-rdna12").encoders
def test_vp9_only_via_vaapi(self):
from opentranscode.codec_profiles import VIDEO_CODECS
VIDEO_CODECS = load_program_module().VIDEO_CODECS
vp9 = next(c for c in VIDEO_CODECS if "VP9" in c.label)
assert vp9.gpu_encoders_by_api == {"vaapi": "vp9_vaapi"}
@ -133,17 +134,17 @@ class TestProfileResolution:
class TestRebuildGpuDeps:
def test_nvenc_needs_codec_headers(self):
from opentranscode.source_builder import gpu_dep_packages
gpu_dep_packages = load_program_module().gpu_dep_packages
pkgs = gpu_dep_packages("nvenc", "arch")
assert "nv-codec-headers" in pkgs
def test_vaapi_qsv_driver_packages(self):
from opentranscode.source_builder import gpu_dep_packages
gpu_dep_packages = load_program_module().gpu_dep_packages
assert any("libva" in p for p in gpu_dep_packages("vaapi", "arch"))
assert any("intel-media-driver" in p for p in gpu_dep_packages("qsv", "arch"))
def test_unpackaged_family_returns_empty(self):
from opentranscode.source_builder import gpu_dep_packages
gpu_dep_packages = load_program_module().gpu_dep_packages
assert gpu_dep_packages("nvenc", "nixos") == []
@ -164,7 +165,7 @@ class TestGpuComboEngineInteraction:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from opentranscode.ui_window import OpenCodecMaster
OpenCodecMaster = load_program_module().OpenCodecMaster
app = QApplication.instance() or QApplication([])
window = OpenCodecMaster()
@ -189,14 +190,3 @@ class TestLauncherParity:
def test_launcher_has_gpu_profiles(self, opentranscode_module):
assert hasattr(opentranscode_module, "GPU_PROFILES")
assert hasattr(opentranscode_module, "match_gpu_profile")
def test_launcher_match_same_as_package(self, opentranscode_module):
from opentranscode.gpu_profiles import match_gpu_profile as pkg_match
assert opentranscode_module.match_gpu_profile("RTX 4090").key \
== pkg_match("RTX 4090").key
def test_launcher_worker_gpu_param(self, opentranscode_module):
import inspect
sig = inspect.signature(
opentranscode_module.SourceBuildWorker.__init__)
assert "gpu_profile_key" in sig.parameters

View File

@ -18,18 +18,18 @@ import pytest
from conftest import capture_signal
from opentranscode.codec_profiles import (
AUDIO_PROFILES,
CONTAINER_PROFILES,
RESOLUTION_PRESETS,
VIDEO_CODECS,
)
from opentranscode.encoder_worker import EncoderWorker, scan_input_files
from opentranscode.hybrid_scheduler import (
HYBRID_CPU_RESERVE_THREADS,
HybridPlan,
plan_hybrid,
)
from conftest import load_program_module
_m = load_program_module()
AUDIO_PROFILES = _m.AUDIO_PROFILES
CONTAINER_PROFILES = _m.CONTAINER_PROFILES
RESOLUTION_PRESETS = _m.RESOLUTION_PRESETS
VIDEO_CODECS = _m.VIDEO_CODECS
EncoderWorker = _m.EncoderWorker
scan_input_files = _m.scan_input_files
HYBRID_CPU_RESERVE_THREADS = _m.HYBRID_CPU_RESERVE_THREADS
HybridPlan = _m.HybridPlan
plan_hybrid = _m.plan_hybrid
def _hevc():
@ -246,11 +246,11 @@ class TestScanInputFiles:
class TestCliHybrid:
def test_engine_hybrid_accepted(self):
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
assert build_parser().parse_args(["--engine", "hybrid"]).engine == "hybrid"
def test_engine_default_still_auto(self):
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
assert build_parser().parse_args([]).engine == "auto"
@ -263,16 +263,3 @@ class TestLauncherHybridParity:
sig = inspect.signature(opentranscode_module.EncoderWorker.__init__)
for p in ("file_subset", "lane", "ffmpeg_threads"):
assert p in sig.parameters, p
def test_launcher_plan_matches_package_behavior(self, opentranscode_module, tmp_path):
files = _files(tmp_path, ["a.mp4", "b.mp4", "c.mp4", "d.mp4"])
plan = opentranscode_module.plan_hybrid(
files, "hevc_nvenc", True, 28,
sizes={f: i * 10_000_000 for i, f in enumerate(files)},
)
pkg_plan = plan_hybrid(
files, "hevc_nvenc", True, 28,
sizes={f: i * 10_000_000 for i, f in enumerate(files)},
)
assert [Path(x) for x in plan.gpu_files] == pkg_plan.gpu_files
assert [Path(x) for x in plan.cpu_files] == pkg_plan.cpu_files

View File

@ -22,20 +22,21 @@ from pathlib import Path
from unittest.mock import MagicMock
import pytest
from conftest import load_program_module
# ── CLI flag ─────────────────────────────────────────────────────────────────
def test_cli_inline_scale_default_off():
"""Without --inline-scale, the flag defaults to False."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args([])
assert args.inline_scale is False
def test_cli_inline_scale_flag():
"""--inline-scale sets the flag to True."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args(["--inline-scale"])
assert args.inline_scale is True
@ -43,7 +44,7 @@ def test_cli_inline_scale_flag():
def test_launch_gui_signature_accepts_inline_scale():
"""launch_gui() accepts the inline_scale kwarg (v4.4.4)."""
import inspect
from opentranscode import launch_gui
launch_gui = load_program_module().launch_gui
sig = inspect.signature(launch_gui)
assert "inline_scale" in sig.parameters
# Default must be False — the intermediate path is the safe default.

View File

@ -21,13 +21,14 @@ import pytest
from conftest import capture_signal
from opentranscode.codec_profiles import (
AUDIO_PROFILES,
CONTAINER_PROFILES,
RESOLUTION_PRESETS,
VIDEO_CODECS,
)
from opentranscode.encoder_worker import EncoderWorker
from conftest import load_program_module
_m = load_program_module()
AUDIO_PROFILES = _m.AUDIO_PROFILES
CONTAINER_PROFILES = _m.CONTAINER_PROFILES
RESOLUTION_PRESETS = _m.RESOLUTION_PRESETS
VIDEO_CODECS = _m.VIDEO_CODECS
EncoderWorker = _m.EncoderWorker
def _worker(mock_env, use_ffmpeg_fallback, codec=None):
@ -182,7 +183,7 @@ class TestDiskSpaceWarnings:
def test_severe_output_warning_in_quiet_mode(self, mock_env, tmp_path, monkeypatch):
w, logs, src = self._setup(mock_env, tmp_path, verbose=False)
monkeypatch.setattr(
"opentranscode.encoder_worker.shutil.disk_usage",
"open_transcode.shutil.disk_usage",
lambda _p: MagicMock(free=512 * 1024 ** 2), # 0.5 GB free < 2 GB
)
w._check_disk_space(src, tmp_path / "out.mkv", needs_scale=False)
@ -191,7 +192,7 @@ class TestDiskSpaceWarnings:
def test_no_warning_when_plenty_free_quiet(self, mock_env, tmp_path, monkeypatch):
w, logs, src = self._setup(mock_env, tmp_path, verbose=False)
monkeypatch.setattr(
"opentranscode.encoder_worker.shutil.disk_usage",
"open_transcode.shutil.disk_usage",
lambda _p: MagicMock(free=100 * 1024 ** 3),
)
w._check_disk_space(src, tmp_path / "out.mkv", needs_scale=False)
@ -210,7 +211,7 @@ class TestDiskSpaceWarnings:
free = 100 * 1024 ** 3 if calls["n"] == 1 else 512 * 1024 ** 2
return MagicMock(free=free)
monkeypatch.setattr("opentranscode.encoder_worker.shutil.disk_usage",
monkeypatch.setattr("open_transcode.shutil.disk_usage",
fake_disk_usage)
w._check_disk_space(src, tmp_path / "out.mkv", needs_scale=True)
assert any("low disk space on temp" in l for l in logs), logs
@ -236,7 +237,7 @@ class TestLoudnormAudioOnly:
return res
# _analyze_audio_loudness uses subprocess.run directly
import opentranscode.encoder_worker as ew
ew = load_program_module()
monkeypatch = pytest.MonkeyPatch()
try:
monkeypatch.setattr(ew.subprocess, "run", fake_run)

View File

@ -0,0 +1,94 @@
"""Launch-time CLI override tests — v4.10.0 regression guard.
The GUI entry (``launch_gui``) runs BEFORE the async environment probe
(500ms QTimer), so ``window.env`` is ``None`` at launch time. Writing
CLI overrides straight onto ``env.av1an_flags`` crashed with
``AttributeError: 'NoneType' object has no attribute 'av1an_flags'``
on every GUI launch through ``main()`` — both entries
(``python open-transcode.py`` and ``python -m opentranscode``).
The fix: overrides land in the window's pending store
(``_set_cli_flag`` / ``_clear_cli_flag``) and ``_probe_and_init``
flushes them into the live ``av1an_flags`` the moment the probe
finishes. These tests pin the pending/flush semantics hermetically (no
QApplication, no probe): the window is built via ``__new__`` and the
env is a stub.
"""
from __future__ import annotations
class _StubEnv:
def __init__(self, flags: dict | None = None):
self.av1an_flags = dict(flags or {})
def _bare_master(module):
master = module.OpenCodecMaster.__new__(module.OpenCodecMaster)
master._pending_av1an_flags = {}
return master
class TestPendingFlagStore:
def test_launch_before_probe_does_not_crash(self, opentranscode_module):
# The original failure mode: writing a flag with env unset.
# The pending store accepts it with env=None in sight.
master = _bare_master(opentranscode_module)
master.env = None
master._set_cli_flag("engine", "cpu") # raised before the fix
assert master._pending_av1an_flags["engine"] == "cpu"
def test_flush_applies_pending_into_live_flags(self, opentranscode_module):
master = _bare_master(opentranscode_module)
master.env = _StubEnv({"concat_method": "ffmpeg"})
master._set_cli_flag("engine", "cpu")
master._set_cli_flag("max_workers", 3)
master._set_cli_flag("use_av1an", False)
master._flush_cli_flags()
assert master.env.av1an_flags["engine"] == "cpu"
assert master.env.av1an_flags["max_workers"] == 3
assert master.env.av1an_flags["use_av1an"] is False
def test_falsy_values_survive_the_flush(self, opentranscode_module):
# The delete sentinel is an identity check — False, 0 and ""
# are legitimate flag values and must never be swallowed.
master = _bare_master(opentranscode_module)
master.env = _StubEnv()
master._set_cli_flag("use_av1an", False)
master._set_cli_flag("max_workers", 0)
master._set_cli_flag("some_str", "")
master._flush_cli_flags()
flags = master.env.av1an_flags
assert flags["use_av1an"] is False
assert flags["max_workers"] == 0
assert flags["some_str"] == ""
def test_clear_removes_probe_set_flag(self, opentranscode_module):
# --chunk-method auto must delete the probe's chunk_method_override
# (set when no VapourSynth source plugins are installed).
master = _bare_master(opentranscode_module)
master.env = _StubEnv({"chunk_method_override": "select",
"concat_method": "ffmpeg"})
master._clear_cli_flag("chunk_method_override")
master._flush_cli_flags()
assert "chunk_method_override" not in master.env.av1an_flags
assert master.env.av1an_flags["concat_method"] == "ffmpeg"
def test_clear_is_a_noop_when_key_absent(self, opentranscode_module):
master = _bare_master(opentranscode_module)
master.env = _StubEnv()
master._clear_cli_flag("chunk_method_override")
master._flush_cli_flags()
assert master.env.av1an_flags == {}
def test_last_write_wins_and_flush_drains(self, opentranscode_module):
master = _bare_master(opentranscode_module)
master.env = _StubEnv()
master._set_cli_flag("engine", "cpu")
master._set_cli_flag("engine", "hybrid")
master._flush_cli_flags()
assert master.env.av1an_flags["engine"] == "hybrid"
assert master._pending_av1an_flags == {}
# A second flush with no new pending writes changes nothing.
master._flush_cli_flags()
assert master.env.av1an_flags["engine"] == "hybrid"

View File

@ -16,20 +16,21 @@ from pathlib import Path
from unittest.mock import MagicMock
from conftest import make_minimal_worker
from conftest import load_program_module
# ── --timeout CLI flag ──────────────────────────────────────────────────────
def test_cli_timeout_default_24h():
"""Without --timeout, default is 86400s = 24h (up from v4.0.0's 7200s = 2h)."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args([])
assert args.timeout == 86400
def test_cli_timeout_override():
"""--timeout 3600 sets the per-file timeout to 1 hour."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args(["--timeout", "3600"])
assert args.timeout == 3600
@ -37,7 +38,7 @@ def test_cli_timeout_override():
def test_launch_gui_signature_accepts_timeout():
"""launch_gui() accepts the timeout kwarg (v4.4.0)."""
import inspect
from opentranscode import launch_gui
launch_gui = load_program_module().launch_gui
sig = inspect.signature(launch_gui)
assert "timeout" in sig.parameters
# Default must be 86400 (24h).

View File

@ -1,78 +1,42 @@
"""Single-file structure tests — verify open-transcode.py is the program.
The application ships as ONE self-contained module: ``open-transcode.py``
(everything inlined; runs with only Python + PySide6). These tests pin
that arrangement:
- the module loads cleanly via the conftest program loader,
- its metadata (version / author / contact / license) is intact,
- the expected public symbol surface is present (the tables, the
worker, the UI class, the probe helpers, the CLI parser),
- the file stays self-contained — a stray ``import opentranscode``
would reintroduce a second copy of the program and fails here.
Runs WITHOUT PySide6 installed (the conftest installs stubs first).
"""
Package-structure tests — verify the opentranscode/ package imports cleanly
and exposes the expected public API.
import re
These tests run WITHOUT PySide6 installed (the conftest.py installs stubs).
They verify that the v4 package split (QA item v4-03) preserved all the
public symbols that were in the v3 launcher script.
"""
import importlib
import sys
from pathlib import Path
from conftest import OPENTRANSCODE_SCRIPT_PATH, load_program_module
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Public API surface — every symbol here MUST be importable from the package
# ─────────────────────────────────────────────────────────────────────────────
EXPECTED_TOP_LEVEL_EXPORTS = {
"__version__",
"__author__",
"__license__",
"build_parser",
"main",
"launch_gui",
}
EXPECTED_SUBMODULES = {
"opentranscode.cli",
"opentranscode.codec_profiles",
"opentranscode.license_registry",
"opentranscode.cpu_topology",
"opentranscode.distro_probe",
"opentranscode.env_probe",
"opentranscode.ffprobe_utils",
"opentranscode.temp_manager",
"opentranscode.encoder_worker",
"opentranscode.source_builder",
"opentranscode.ui_theme",
"opentranscode.ui_window",
"opentranscode.widgets",
"opentranscode.widgets.radio_knob",
}
EXPECTED_CODEC_PROFILES_EXPORTS = {
"VideoCodecProfile",
"AudioProfile",
"ContainerProfile",
"ResolutionProfile",
"VIDEO_CODECS",
"AUDIO_PROFILES",
"CONTAINER_PROFILES",
"RESOLUTION_PRESETS",
"SUBTITLE_OPTIONS",
"DEFAULT_INPUT_EXTENSIONS",
"FFMPEG_LIB_KEY_MAP",
"ffmpeg_lib_key_for",
}
EXPECTED_ENV_PROBE_EXPORTS = {
"EnvProbe",
"probe_environment",
"_av1an_vsscript_smoke_test",
"_detect_av1an_svt_encoder",
}
EXPECTED_ENCODER_WORKER_EXPORTS = {
"EncoderWorker",
}
EXPECTED_DISTRO_PROBE_EXPORTS = {
"DistroProfile",
"DISTRO_REGISTRY",
"detect_distro",
# Every symbol the app's own tests and tooling reach for — one flat set
# now that the program is a single module.
EXPECTED_PROGRAM_SYMBOLS = {
# profile tables + helpers
"AUDIO_PROFILES", "CONTAINER_PROFILES", "DEFAULT_INPUT_EXTENSIONS",
"FFMPEG_LIB_KEY_MAP", "RESOLUTION_PRESETS", "SUBTITLE_OPTIONS",
"VIDEO_CODECS", "TITLE_ARTIFACT_TOKENS", "THEORA_Q_RANGE",
"AudioProfile", "ContainerProfile", "ResolutionProfile",
"VideoCodecProfile", "clean_title", "ffmpeg_lib_key_for",
"theora_quality_from_crf",
# encode pipeline
"EncoderWorker", "scan_input_files", "resolve_gpu_encoder",
"plan_hybrid", "HybridPlan", "KeepAwake",
# environment
"EnvProbe", "probe_environment", "detect_distro", "DistroProfile",
"DISTRO_REGISTRY", "CpuTopology", "GPU_PROFILES", "match_gpu_profile",
"_probe_vs_source_plugins", "_identify_file_type",
# UI + CLI
"OpenCodecMaster", "launch_gui", "SourceBuildWorker",
"RadioKnob", "build_parser", "main",
}
@ -81,174 +45,88 @@ EXPECTED_DISTRO_PROBE_EXPORTS = {
# ─────────────────────────────────────────────────────────────────────────────
class TestPackageMetadata:
class TestProgramMetadata:
def test_version_is_pep440_compliant(self):
import opentranscode
v = opentranscode.__version__
# PEP 440: X.Y.Z or X.Y.Z.devN or X.Y.ZrcN etc.
v = load_program_module().__version__
assert isinstance(v, str)
parts = v.split(".")
assert len(parts) >= 3, f"Version '{v}' should have at least major.minor.patch"
assert all(parts[0].isdigit() and parts[1].isdigit() and parts[2].split("rc")[0].split("dev")[0].isdigit() or
parts[2] == "0" for part in parts[:3]), \
assert parts[0].isdigit() and parts[1].isdigit(), \
f"Version '{v}' should be PEP 440 numeric"
def test_author_is_set(self):
import opentranscode
assert opentranscode.__author__
assert isinstance(opentranscode.__author__, str)
def test_author_and_contact_are_set(self):
m = load_program_module()
assert m.__author__ == "Jeremy Anderson"
assert m.__email__ == "info@dcos.net"
assert "dcos.net" in m.__website__
def test_license_is_agpl(self):
import opentranscode
assert "AGPL" in opentranscode.__license__
def test_all_is_defined(self):
import opentranscode
assert hasattr(opentranscode, "__all__")
assert isinstance(opentranscode.__all__, list)
assert "AGPL" in load_program_module().__license__
class TestSubmodulesImportable:
"""Every submodule in the package must import cleanly."""
class TestSingleFileArrangement:
def test_program_file_exists_at_repo_root(self):
assert OPENTRANSCODE_SCRIPT_PATH.is_file()
@pytest.mark.parametrize("modname", sorted(EXPECTED_SUBMODULES))
def test_submodule_imports(self, modname):
mod = importlib.import_module(modname)
assert mod is not None
# The module's __name__ should match what we asked for
assert mod.__name__ == modname
def test_no_second_copy_of_the_program(self):
"""The opentranscode/ package directory must not exist — the
single file is the whole program. Two copies cannot drift if
there is only one."""
repo_root = OPENTRANSCODE_SCRIPT_PATH.parent
assert not (repo_root / "opentranscode").exists(), \
"opentranscode/ package directory exists alongside the " \
"single-file program — pick one copy"
def test_program_file_is_self_contained(self):
"""No import of a package copy from inside the program file."""
src = OPENTRANSCODE_SCRIPT_PATH.read_text()
stray = re.findall(r"^\s*(?:from|import)\s+opentranscode.*$", src, re.M)
assert stray == [], f"program imports its old package copy: {stray}"
class TestPublicAPI:
"""Verify the expected public symbols are present in each module."""
def test_codec_profiles_exports(self):
from opentranscode import codec_profiles
for name in EXPECTED_CODEC_PROFILES_EXPORTS:
assert hasattr(codec_profiles, name), \
f"codec_profiles.{name} missing"
def test_env_probe_exports(self):
from opentranscode import env_probe
for name in EXPECTED_ENV_PROBE_EXPORTS:
assert hasattr(env_probe, name), \
f"env_probe.{name} missing"
def test_encoder_worker_exports(self):
from opentranscode import encoder_worker
for name in EXPECTED_ENCODER_WORKER_EXPORTS:
assert hasattr(encoder_worker, name)
def test_distro_probe_exports(self):
from opentranscode import distro_probe
for name in EXPECTED_DISTRO_PROBE_EXPORTS:
assert hasattr(distro_probe, name)
class TestPublicSurface:
def test_expected_symbols_present(self):
m = load_program_module()
missing = sorted(n for n in EXPECTED_PROGRAM_SYMBOLS
if not hasattr(m, n))
assert missing == [], f"missing program symbols: {missing}"
def test_video_codecs_table_populated(self):
from opentranscode.codec_profiles import VIDEO_CODECS
assert len(VIDEO_CODECS) >= 3, "Should have at least 3 video codecs (AV1, VP9, x265)"
VIDEO_CODECS = load_program_module().VIDEO_CODECS
assert len(VIDEO_CODECS) >= 4, \
"Should have at least 4 video codecs (AV1, VP9, x265, Theora)"
labels = [c.label for c in VIDEO_CODECS]
assert any("AV1" in l for l in labels)
assert any("VP9" in l for l in labels)
assert any("x265" in l or "HEVC" in l for l in labels)
assert any("Theora" in l for l in labels)
def test_audio_profiles_have_ffmpeg_encoder_name(self):
"""v3-02 (OTC-012): every AudioProfile must have ffmpeg_encoder_name set."""
from opentranscode.codec_profiles import AUDIO_PROFILES
AUDIO_PROFILES = load_program_module().AUDIO_PROFILES
for ap in AUDIO_PROFILES:
assert ap.ffmpeg_encoder_name, \
f"AudioProfile '{ap.label}' has empty ffmpeg_encoder_name (OTC-012 violation)"
f"AudioProfile '{ap.label}' has empty ffmpeg_encoder_name"
def test_distro_registry_has_six_entries(self):
"""v3-04: DISTRO_REGISTRY should have 6 entries (arch, fedora, rhel, suse, nixos, debian)."""
from opentranscode.distro_probe import DISTRO_REGISTRY
DISTRO_REGISTRY = load_program_module().DISTRO_REGISTRY
families = {e.family for e in DISTRO_REGISTRY}
assert "arch" in families
assert "debian" in families
assert "redhat" in families
assert "suse" in families
assert "nixos" in families
assert {"arch", "debian", "redhat", "suse", "nixos"} <= families
assert len(DISTRO_REGISTRY) >= 6
class TestCLIParser:
"""Verify the CLI argument parser works."""
def test_build_parser_parses_empty_argv(self):
args = load_program_module().build_parser().parse_args([])
assert args.engine == "auto"
assert args.verbose is False
def test_build_parser_returns_argparse(self):
import argparse
from opentranscode.cli import build_parser
p = build_parser()
assert isinstance(p, argparse.ArgumentParser)
def test_version_flag(self):
from opentranscode.cli import build_parser
p = build_parser()
args = p.parse_args(["--version"])
assert args.version is True
def test_dry_run_flag(self):
from opentranscode.cli import build_parser
p = build_parser()
args = p.parse_args(["--dry-run"])
assert args.dry_run is True
def test_verify_only_flag(self):
from opentranscode.cli import build_parser
p = build_parser()
args = p.parse_args(["--verify-only", "/tmp/test.mp4"])
assert args.verify_only == "/tmp/test.mp4"
def test_no_flags_returns_none(self):
from opentranscode.cli import build_parser
p = build_parser()
args = p.parse_args([])
assert args.version is False
assert args.dry_run is False
assert args.verify_only is None
class TestFFmpegLibKeyMap:
"""v3-01 (OTC-007): verify the single source of truth for ffmpeg lib key mapping."""
def test_map_has_all_codecs(self):
from opentranscode.codec_profiles import FFMPEG_LIB_KEY_MAP
assert "libsvtav1" in FFMPEG_LIB_KEY_MAP
assert "libaom-av1" in FFMPEG_LIB_KEY_MAP
assert "libvpx-vp9" in FFMPEG_LIB_KEY_MAP
assert "libx265" in FFMPEG_LIB_KEY_MAP
def test_helper_returns_correct_keys(self):
from opentranscode.codec_profiles import ffmpeg_lib_key_for
assert ffmpeg_lib_key_for("libsvtav1") == "libsvtav1"
assert ffmpeg_lib_key_for("libaom-av1") == "libaom"
assert ffmpeg_lib_key_for("libvpx-vp9") == "libvpx"
assert ffmpeg_lib_key_for("libx265") == "libx265"
def test_helper_returns_input_for_unknown(self):
"""Forward-compat: unknown encoders fall back to themselves."""
from opentranscode.codec_profiles import ffmpeg_lib_key_for
assert ffmpeg_lib_key_for("libfuturecodec") == "libfuturecodec"
class TestEntryPoints:
"""Verify the entry points declared in pyproject.toml are reachable."""
def test_main_callable_from_package(self):
from opentranscode import main
assert callable(main)
def test_launch_gui_callable(self):
from opentranscode import launch_gui
assert callable(launch_gui)
def test_main_module_runs(self, capsys):
"""`python -m opentranscode --version` should print version and exit 0."""
import subprocess
import sys
result = subprocess.run(
[sys.executable, "-m", "opentranscode", "--version"],
capture_output=True, text=True, timeout=10,
)
assert result.returncode == 0
import opentranscode
assert opentranscode.__version__ in result.stdout
def test_build_parser_accepts_all_flags(self):
args = load_program_module().build_parser().parse_args([
"--verbose", "--use-av1an", "--engine", "hybrid",
"--timeout", "3600", "--force-reencode",
])
assert args.verbose is True
assert args.use_av1an is True
assert args.engine == "hybrid"
assert args.timeout == 3600
assert args.skip_existing is False

View File

@ -14,6 +14,7 @@ from pathlib import Path
from unittest.mock import MagicMock
from conftest import make_minimal_worker
from conftest import load_program_module
def _ffprobe_result(payload: dict) -> subprocess.CompletedProcess:
@ -164,21 +165,21 @@ def test_skip_existing_checks_resolution_when_scaling_requested(opentranscode_mo
def test_cli_skip_existing_default_true():
"""Without --force-reencode, skip_existing defaults to True."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args([])
assert args.skip_existing is True
def test_cli_force_reencode_sets_false():
"""--force-reencode sets skip_existing to False."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args(["--force-reencode"])
assert args.skip_existing is False
def test_cli_skip_existing_explicit():
"""--skip-existing explicitly sets skip_existing to True."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args(["--skip-existing"])
assert args.skip_existing is True
@ -186,7 +187,7 @@ def test_cli_skip_existing_explicit():
def test_launch_gui_signature_accepts_skip_existing():
"""launch_gui() accepts the skip_existing kwarg (v4.3.0)."""
import inspect
from opentranscode import launch_gui
launch_gui = load_program_module().launch_gui
sig = inspect.signature(launch_gui)
assert "skip_existing" in sig.parameters
# Default must be True (skip by default).

View File

@ -20,8 +20,13 @@ from unittest.mock import MagicMock
import pytest
from opentranscode.distro_probe import DistroProfile, detect_distro
from opentranscode.source_builder import BUILD_DEPS_BY_FAMILY, build_dep_plan
from conftest import load_program_module
_m = load_program_module()
DistroProfile = _m.DistroProfile
detect_distro = _m.detect_distro
BUILD_DEPS_BY_FAMILY = _m.BUILD_DEPS_BY_FAMILY
build_dep_plan = _m.build_dep_plan
def _profile(family):
@ -77,7 +82,7 @@ class TestBuildDepPlan:
class TestPluginProbeSeesGitStack:
def test_probe_discovers_user_site_plugins(self, tmp_path, monkeypatch):
import opentranscode.env_probe as ep
ep = load_program_module()
plugins = tmp_path / "site-packages" / "vapoursynth" / "plugins"
plugins.mkdir(parents=True)
@ -89,7 +94,7 @@ class TestPluginProbeSeesGitStack:
assert "bestsource" in ep._probe_vs_source_plugins()
def test_av1an_env_carries_fresh_stack(self, tmp_path, monkeypatch):
import opentranscode.env_probe as ep
ep = load_program_module()
vs_dir = tmp_path / "site-packages" / "vapoursynth"
vs_dir.mkdir(parents=True)
@ -104,7 +109,7 @@ class TestPluginProbeSeesGitStack:
assert str(tmp_path / "site-packages") in env["PYTHONPATH"]
def test_av1an_env_without_git_stack_unchanged(self, tmp_path, monkeypatch):
import opentranscode.env_probe as ep
ep = load_program_module()
monkeypatch.setattr(ep.site, "getusersitepackages",
lambda: str(tmp_path / "empty"))
@ -128,7 +133,7 @@ class TestRebuildButtonAlwaysEnabled:
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtWidgets import QApplication
from opentranscode.ui_window import OpenCodecMaster
OpenCodecMaster = load_program_module().OpenCodecMaster
app = QApplication.instance() or QApplication([])
window = OpenCodecMaster()
@ -153,10 +158,3 @@ class TestLauncherParity:
def test_launcher_worker_builds_bestsource(self, opentranscode_module):
assert hasattr(opentranscode_module.SourceBuildWorker,
"_build_bestsource")
def test_launcher_plan_matches_package(self):
from opentranscode.source_builder import build_dep_plan as pkg_plan
plan_l = build_dep_plan(_profile("arch"))
plan_p = pkg_plan(_profile("arch"))
assert plan_l.packages == plan_p.packages
assert plan_l.install_cmd == plan_p.install_cmd

276
tests/test_theora_ogv.py Normal file
View File

@ -0,0 +1,276 @@
"""Theora/OGV target tests — v4.9.0.
Covers the three pieces of the OGV scenario:
1. The quality-scale wrapper (``theora_quality_from_crf``) — libtheora
grades quality with ``-q:v 0..31`` where HIGHER is better, the exact
opposite of the shared CRF knob. The wrapper must invert linearly,
clamp its input, and stay inside ``THEORA_Q_RANGE``.
2. The ``Theora (OGV)`` VideoCodecProfile + ``OGV (Ogg)`` container —
ffmpeg-only flag, no GPU family, ffprobe codec name for
skip-existing, and encoder args that use ``-q:v`` (never ``-crf``).
3. The UI compatibility rules — Theora is INCOMPATIBLE with MP4/WebM,
valid-but-unusual in MKV (WARNING), the OGV container is
INCOMPATIBLE with every other video codec, and IAMF audio stays
INCOMPATIBLE in OGV via the existing non-MP4 rule.
Empirical grounding (ffmpeg 7.x, libtheora): bitrate rises monotonically
with -q:v and saturates from ~31; the mp4 muxer rejects Theora; the webm
muxer only accepts VP8/VP9/AV1; the ogg muxer only accepts Theora video;
Opus, Vorbis and FLAC all mux fine into Ogg.
No PySide6 required: profile/wrapper tests import ``codec_profiles``
directly (pure data + pure functions); the compat-rule tests reuse the
``__new__``-mock pattern from test_container_compat.py, which works with
the conftest PySide6 stubs.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from conftest import load_program_module
_m = load_program_module()
CONTAINER_PROFILES = _m.CONTAINER_PROFILES
FFMPEG_LIB_KEY_MAP = _m.FFMPEG_LIB_KEY_MAP
THEORA_Q_RANGE = _m.THEORA_Q_RANGE
VIDEO_CODECS = _m.VIDEO_CODECS
theora_quality_from_crf = _m.theora_quality_from_crf
# ─────────────────────────────────────────────────────────────────────────────
# Lookups — the Theora profile and OGV container, found by VALUE so the
# tests don't depend on list positions (the combos index into the same
# lists, but the tables are free to grow).
# ─────────────────────────────────────────────────────────────────────────────
def _theora_profile():
matches = [vc for vc in VIDEO_CODECS if vc.ffmpeg_encoder == "libtheora"]
assert matches, "VIDEO_CODECS must contain a Theora profile"
return matches[0]
def _container_exts():
return {cp.ext for cp in CONTAINER_PROFILES}
# ─────────────────────────────────────────────────────────────────────────────
# 1. The quality-scale wrapper
# ─────────────────────────────────────────────────────────────────────────────
class TestTheoraQualityWrapper:
def test_best_knob_position_maps_to_best_q(self):
# Knob 18 = the knob's best → q 31 = libtheora's best (inverted!)
assert theora_quality_from_crf(18) == THEORA_Q_RANGE[1] == 31
def test_worst_knob_position_maps_to_worst_q(self):
assert theora_quality_from_crf(52) == THEORA_Q_RANGE[0] == 0
def test_default_knob_position(self):
# The 4.8.2 default knob value (26) lands at q 24 — high-quality
# archival territory on the 0..31 scale.
assert theora_quality_from_crf(26) == 24
def test_monotonic_non_increasing_over_all_integers(self):
# Turning the knob toward WORSE quality must never RAISE q
# (per-integer steps can repeat a value: 34 knob integers map
# onto 32 q values).
values = [theora_quality_from_crf(crf) for crf in range(18, 53)]
assert all(a >= b for a, b in zip(values, values[1:])), values
def test_strictly_decreasing_per_knob_tick(self):
# The knob snaps to every-2 steps (snap_ticks over 18..52); each
# full user-visible tick must strictly drop q (~1.8 per tick).
ticks = [theora_quality_from_crf(crf) for crf in range(18, 53, 2)]
assert all(a > b for a, b in zip(ticks, ticks[1:])), ticks
def test_output_stays_inside_q_scale(self):
for crf in range(-10, 80): # far outside the knob range
q = theora_quality_from_crf(crf)
assert THEORA_Q_RANGE[0] <= q <= THEORA_Q_RANGE[1], (crf, q)
def test_custom_knob_range_is_respected(self):
# The wrapper takes the range as parameters so a future profile
# with a different crf_range can reuse it.
assert theora_quality_from_crf(10, crf_lo=10, crf_hi=20) == 31
assert theora_quality_from_crf(20, crf_lo=10, crf_hi=20) == 0
# ─────────────────────────────────────────────────────────────────────────────
# 2. Profile / container invariants
# ─────────────────────────────────────────────────────────────────────────────
class TestTheoraProfile:
def test_profile_is_ffmpeg_only(self):
vc = _theora_profile()
assert vc.ffmpeg_only is True
assert vc.av1an_encoder == ""
def test_params_fn_raises_loudly(self):
# The av1an path can never legitimately build Theora params; the
# guard turns a would-be opaque av1an CLI failure into a clear
# RuntimeError naming the fix.
with pytest.raises(RuntimeError, match="ffmpeg_only"):
_theora_profile().params_fn(26, 0)
def test_ffmpeg_args_use_qscale_not_crf(self):
args = _theora_profile().ffmpeg_vargs_fn(26, 1)
assert args[0:2] == ["-c:v", "libtheora"]
assert "-q:v" in args
q_pos = args.index("-q:v")
assert args[q_pos + 1] == "24" # 4.8.2 default → q 24
assert "-crf" not in args # libtheora has no CRF
assert "-pix_fmt" in args
assert args[args.index("-pix_fmt") + 1] == "yuv420p" # 4:2:0-only codec
def test_preset_maps_to_speed_level_and_clamps(self):
vc = _theora_profile()
for label, level in vc.preset_map.items():
args = vc.ffmpeg_vargs_fn(26, level)
assert args[args.index("-speed_level") + 1] == str(level)
# Out-of-range preset values (e.g. the EncoderWorker fallback 6)
# clamp to libtheora's maximum speed level 2 instead of erroring.
clamped = vc.ffmpeg_vargs_fn(26, 6)
assert clamped[clamped.index("-speed_level") + 1] == "2"
def test_skip_existing_codec_name(self):
assert _theora_profile().ffprobe_codec_name == "theora"
def test_no_gpu_family(self):
# No hardware Theora encoder exists on any API — resolve_gpu_
# encoder() must land on CPU for every engine selection.
assert _theora_profile().gpu_family == ""
assert _theora_profile().gpu_encoder == ""
def test_ogv_container_exists_and_is_default(self):
vc = _theora_profile()
assert vc.container == "ogv"
assert "ogv" in _container_exts()
def test_lib_key_map_covers_theora(self):
# _disable_unavailable_codecs and the ffmpeg-fallback gate both
# look the encoder up through FFMPEG_LIB_KEY_MAP — a missing
# entry would silently report the codec as unavailable forever.
assert FFMPEG_LIB_KEY_MAP["libtheora"] == "libtheora"
# ─────────────────────────────────────────────────────────────────────────────
# 3. UI compatibility rules (same __new__-mock pattern as
# test_container_compat.py — works with or without real PySide6)
# ─────────────────────────────────────────────────────────────────────────────
def _make_master_for_compat(codec_idx, audio_idx, container_idx):
OpenCodecMaster = load_program_module().OpenCodecMaster
master = OpenCodecMaster.__new__(OpenCodecMaster)
codec_combo = MagicMock()
codec_combo.currentIndex.return_value = codec_idx
audio_combo = MagicMock()
audio_combo.currentIndex.return_value = audio_idx
container_combo = MagicMock()
container_combo.currentIndex.return_value = container_idx
master.codec_combo = codec_combo
master.audio_combo = audio_combo
master.container_combo = container_combo
logged: list[str] = []
master._log = lambda msg: logged.append(msg)
return master, logged
def _codec_idx(encoder: str) -> int:
for i, vc in enumerate(VIDEO_CODECS):
if vc.ffmpeg_encoder == encoder:
return i
raise AssertionError(f"no profile with ffmpeg_encoder={encoder!r}")
def _container_idx(ext: str) -> int:
for i, cp in enumerate(CONTAINER_PROFILES):
if cp.ext == ext:
return i
raise AssertionError(f"no container with ext={ext!r}")
def _audio_idx(encoder: str) -> int:
AUDIO_PROFILES = load_program_module().AUDIO_PROFILES
for i, ap in enumerate(AUDIO_PROFILES):
if ap.ffmpeg_encoder_name == encoder:
return i
raise AssertionError(f"no audio profile {encoder!r}")
class TestTheoraCompatRules:
def test_theora_in_webm_incompatible(self):
master, _ = _make_master_for_compat(
_codec_idx("libtheora"), _audio_idx("libopus"),
_container_idx("webm"),
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("INCOMPATIBLE:") and "Theora" in w
for w in warnings), warnings
def test_theora_in_mp4_incompatible(self):
master, _ = _make_master_for_compat(
_codec_idx("libtheora"), _audio_idx("libopus"),
_container_idx("mp4"),
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("INCOMPATIBLE:") and "Theora" in w
for w in warnings), warnings
def test_theora_in_mkv_is_warning_not_block(self):
# MKV really can hold Theora (verified with a live encode), so it
# must NOT hard-block — just advise OGV as the canonical pairing.
master, _ = _make_master_for_compat(
_codec_idx("libtheora"), _audio_idx("libvorbis"),
_container_idx("mkv"),
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("WARNING:") and "Theora" in w
for w in warnings), warnings
assert not any(w.startswith("INCOMPATIBLE:") for w in warnings)
def test_theora_in_ogv_is_clean(self):
# The canonical pairing (with Ogg-native audio) fires no rule.
master, _ = _make_master_for_compat(
_codec_idx("libtheora"), _audio_idx("libvorbis"),
_container_idx("ogv"),
)
assert master._check_combo_compatibility() == []
def test_theora_flac_opus_in_ogv_are_clean(self):
# Vorbis, Opus and FLAC are all Ogg-standard audio codecs.
for audio in ("libopus", "flac"):
master, _ = _make_master_for_compat(
_codec_idx("libtheora"), _audio_idx(audio),
_container_idx("ogv"),
)
assert master._check_combo_compatibility() == [], audio
@pytest.mark.parametrize("encoder", ["libsvtav1", "libvpx-vp9", "libx265"])
def test_non_theora_in_ogv_incompatible(self, encoder):
# The Ogg muxer rejects every other video codec — the user must
# switch to Theora or a different container.
master, _ = _make_master_for_compat(
_codec_idx(encoder), _audio_idx("libopus"),
_container_idx("ogv"),
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("INCOMPATIBLE:") and "Ogg" in w
for w in warnings), (encoder, warnings)
def test_iamf_in_ogv_incompatible_via_existing_rule(self):
# IAMF requires MP4; the pre-existing non-MP4 rule covers OGV
# with no Theora-specific additions.
master, _ = _make_master_for_compat(
_codec_idx("libtheora"), _audio_idx("libiamf"),
_container_idx("ogv"),
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("INCOMPATIBLE:") and "IAMF" in w
for w in warnings), warnings

86
tests/test_title_clean.py Normal file
View File

@ -0,0 +1,86 @@
"""Title-artifact scrubbing tests — v4.10.0.
``clean_title`` removes the SOURCE encode's claims from a filename stem
so outputs are not misdescribed: after transcoding to (typically)
AV1/Opus/MKV — or Theora/OGV, or any other profile — stale tags like
`x264`, `h.265`, `webm`, `xvid` or `dts` are wrong, as are this app's
own `_archived` / `_<w>x<h>` suffixes when a file is re-encoded.
All cases mirror real release-name shapes. No PySide6 needed — the
module is pure.
"""
from __future__ import annotations
import pytest
from conftest import load_program_module
_m = load_program_module()
TITLE_ARTIFACT_TOKENS = _m.TITLE_ARTIFACT_TOKENS
clean_title = _m.clean_title
# (input stem, expected output) — table-driven, one row per behavior.
SCRUB_CASES = [
# Multi-tag scene release: codecs die, content tags survive
("Movie.x264.1080p.WEBRip.x265-GRP", "Movie.1080p.WEBRip.GRP"),
("Show.S01E05.720p.hdtv.x264-RLSGRP", "Show.S01E05.720p.hdtv.RLSGRP"),
# Mixed video+audio tags
("Film.h264.aac.5.1", "Film.5.1"),
("xvid_classic.dts", "classic"),
("Thing.S02E04.hevc.vorbis.webm", "Thing.S02E04"),
# Bracketed tags and the residue they leave
("Old.Movie[DivX].2001", "Old.Movie.2001"),
# Container-only artifacts
("clip.webm", "clip"),
("svt-av1.test.theora.flac", "test"),
# Dash-joined residue collapses to a single separator
("Movie.x265-GRP", "Movie.GRP"),
# This app's own suffixes never stack across re-encodes
("vid_1920x1080_archived", "vid"),
("already_archived", "already"),
("clip_1280x720", "clip"),
# Case-insensitive matching
("MOVIE.X264", "MOVIE"),
("Movie.H.265.He-AAC", "Movie"),
]
# Stems that must pass through byte-for-byte.
PASSTHROUGH_CASES = [
# Tokens inside larger words are NOT codec claims
"MP4Box.and.Aviator.H264file",
"Totally Clean Name",
"Show.S01E12.1080p.BluRay",
"documentary_2026",
]
@pytest.mark.parametrize("stem,expected", SCRUB_CASES)
def test_scrub_cases(stem, expected):
assert clean_title(stem) == expected
@pytest.mark.parametrize("stem", PASSTHROUGH_CASES)
def test_passthrough_cases(stem):
assert clean_title(stem) == stem
def test_scrub_to_empty_falls_back_to_original():
# A file literally named after a codec still needs a valid output
# name — the original stem wins over an empty scrub.
assert clean_title("x264") == "x264"
@pytest.mark.parametrize(
"tag", ["svt-av1", "he-aac", "dtshd", "mpeg2", "mpeg4", "eac3",
"m2ts", "vc-1", "h.264", "h.265", "svtav1", "truehd"]
)
def test_overlapping_tags_scrub_completely(tag):
# Longest-first alternation: a tag that contains a shorter token
# (`svt-av1` ⊃ `av1`, `he-aac` ⊃ `aac`, `dtshd` ⊃ `dts`) must be
# consumed whole, leaving no partial residue behind.
assert clean_title(f"clip.{tag}.name") == "clip.name"
def test_token_table_has_no_duplicates():
assert len(TITLE_ARTIFACT_TOKENS) == len(set(TITLE_ARTIFACT_TOKENS))

View File

@ -15,18 +15,19 @@ The cases:
from __future__ import annotations
import pytest
from conftest import load_program_module
def test_cli_flag_use_av1an_default_false():
"""Without --use-av1an, args.use_av1an is False (default)."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args([])
assert args.use_av1an is False
def test_cli_flag_use_av1an_opt_in():
"""--use-av1an sets args.use_av1an to True."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args(["--use-av1an"])
assert args.use_av1an is True
@ -34,7 +35,7 @@ def test_cli_flag_use_av1an_opt_in():
def test_launch_gui_signature_accepts_use_av1an():
"""launch_gui() accepts the use_av1an kwarg (v4.2.0)."""
import inspect
from opentranscode import launch_gui
launch_gui = load_program_module().launch_gui
sig = inspect.signature(launch_gui)
assert "use_av1an" in sig.parameters
# Default must be False (ffmpeg-first).
@ -45,7 +46,7 @@ def test_dry_run_does_not_crash_with_use_av1an_flag():
"""--dry-run --use-av1an parses cleanly (we don't actually run the
dry-run here because it requires a real env probe; just verify the
CLI parser accepts the combination)."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args(["--dry-run", "--use-av1an"])
assert args.dry_run is True
assert args.use_av1an is True

View File

@ -7,18 +7,19 @@ tech-detail log output.
"""
from __future__ import annotations
from conftest import load_program_module
def test_cli_flag_verbose_default_false():
"""Without --verbose, args.verbose is False (default = quiet)."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args([])
assert args.verbose is False
def test_cli_flag_verbose_opt_in():
"""--verbose sets args.verbose to True."""
from opentranscode.cli import build_parser
build_parser = load_program_module().build_parser
args = build_parser().parse_args(["--verbose"])
assert args.verbose is True
@ -26,7 +27,7 @@ def test_cli_flag_verbose_opt_in():
def test_launch_gui_signature_accepts_verbose():
"""launch_gui() accepts the verbose kwarg (v4.2.1)."""
import inspect
from opentranscode import launch_gui
launch_gui = load_program_module().launch_gui
sig = inspect.signature(launch_gui)
assert "verbose" in sig.parameters
# Default must be False (quiet by default).