OpenTranscode - Feature Fix large files failing, and other tweaks
This commit is contained in:
parent
e424bb95c6
commit
1bc57e3218
179
CHANGELOG.md
179
CHANGELOG.md
|
|
@ -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
127
README.md
|
|
@ -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
|
||||
|
|
|
|||
4464
open-transcode.py
4464
open-transcode.py
File diff suppressed because it is too large
Load Diff
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
@ -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
|
|
@ -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 ""
|
||||
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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}"
|
||||
|
||||
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
"""Widget subpackage for opentranscode UI components.
|
||||
"""
|
||||
|
||||
from .radio_knob import RadioKnob
|
||||
|
||||
__all__ = ["RadioKnob"]
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue