From 1bc57e32186d4a5beac1b0d803fe9ce8e24bf633 Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Sun, 27 Sep 2026 04:10:04 -0400 Subject: [PATCH] OpenTranscode - Feature Fix large files failing, and other tweaks --- CHANGELOG.md | 179 ++ README.md | 127 +- open-transcode.py | 4492 +++++++++++++++++---------- opentranscode/__init__.py | 120 - opentranscode/__main__.py | 23 - opentranscode/cli.py | 436 --- opentranscode/codec_profiles.py | 388 --- opentranscode/cpu_topology.py | 123 - opentranscode/distro_probe.py | 328 -- opentranscode/encoder_worker.py | 2510 --------------- opentranscode/env_probe.py | 1179 ------- opentranscode/ffprobe_utils.py | 121 - opentranscode/gpu_profiles.py | 237 -- opentranscode/hybrid_scheduler.py | 115 - opentranscode/keepawake.py | 234 -- opentranscode/license_registry.py | 248 -- opentranscode/source_builder.py | 845 ----- opentranscode/temp_manager.py | 118 - opentranscode/ui_theme.py | 319 -- opentranscode/ui_window.py | 1746 ----------- opentranscode/widgets/__init__.py | 6 - opentranscode/widgets/radio_knob.py | 282 -- pyproject.toml | 98 +- tests/conftest.py | 41 +- tests/test_chunk_method_retry.py | 14 +- tests/test_concurrent_workers.py | 5 +- tests/test_ffmpeg_fallback.py | 1 + tests/test_force_validation.py | 1 + tests/test_gpu_engine.py | 46 +- tests/test_gpu_profiles.py | 42 +- tests/test_hybrid_scheduler.py | 41 +- tests/test_inline_scale.py | 7 +- tests/test_large_file_fixes.py | 23 +- tests/test_launch_env_pending.py | 94 + tests/test_massive_files.py | 7 +- tests/test_package_structure.py | 304 +- tests/test_skip_existing.py | 9 +- tests/test_source_builder.py | 24 +- tests/test_theora_ogv.py | 276 ++ tests/test_title_clean.py | 86 + tests/test_use_av1an_flag.py | 9 +- tests/test_verbose_flag.py | 7 +- 42 files changed, 3851 insertions(+), 11460 deletions(-) delete mode 100755 opentranscode/__init__.py delete mode 100755 opentranscode/__main__.py delete mode 100755 opentranscode/cli.py delete mode 100755 opentranscode/codec_profiles.py delete mode 100755 opentranscode/cpu_topology.py delete mode 100755 opentranscode/distro_probe.py delete mode 100755 opentranscode/encoder_worker.py delete mode 100755 opentranscode/env_probe.py delete mode 100755 opentranscode/ffprobe_utils.py delete mode 100644 opentranscode/gpu_profiles.py delete mode 100644 opentranscode/hybrid_scheduler.py delete mode 100755 opentranscode/keepawake.py delete mode 100755 opentranscode/license_registry.py delete mode 100755 opentranscode/source_builder.py delete mode 100755 opentranscode/temp_manager.py delete mode 100755 opentranscode/ui_theme.py delete mode 100755 opentranscode/ui_window.py delete mode 100755 opentranscode/widgets/__init__.py delete mode 100755 opentranscode/widgets/radio_knob.py create mode 100644 tests/test_launch_env_pending.py create mode 100644 tests/test_theora_ogv.py create mode 100644 tests/test_title_clean.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d1f55..7fa0df1 100644 --- a/CHANGELOG.md +++ b/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`, `_x` — 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 diff --git a/README.md b/README.md index a9ff80b..e68e5a3 100755 --- a/README.md +++ b/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: `[_x]_archived.`. 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 `_x`, 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 diff --git a/open-transcode.py b/open-transcode.py index aedb1fc..651f399 100755 --- a/open-transcode.py +++ b/open-transcode.py @@ -1,390 +1,160 @@ #!/usr/bin/env python3 -""" -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 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. +"""OpenTranscode — open-source batch video transcoder (single file). -This launcher script is preserved alongside the ``opentranscode/`` package -for backwards compatibility and as the test target for the mocked test -suite. It mirrors the package's behavior via inline copies of the same -modules. New code should ``import opentranscode`` (the package) instead. +The whole application lives in this one module: title scrubbing, codec +and container profiles (AV1 / VP9 / x265 / Theora with its inverted +quality-scale wrapper), the QThread encoder worker (CPU / NVENC / +hybrid), the distro-aware environment probe, the from-git rebuild +builder, and the PySide6 media-console UI. Sections are laid out in +dependency order with external imports hoisted to the header. -v4.0.0 — Production Release ---------------------------- -Resolved the "works up until near the end, never saves chunks into a full -file" bug that affected phone-recorded MP4s with sparse keyframes. +Runs with nothing but Python + PySide6 on the box — no installation, +no package directory. The inlined build_parser / main / launch_gui +provide library/CLI access; executed directly, the script opens the +GUI and ignores arguments. -Root cause: when no VapourSynth source plugins are installed (the common -case), av1an auto-selects the Hybrid chunk method, which does -``ffmpeg -c copy -f segment`` to split the source at scene boundaries, -then re-decodes each segment to y4m. Phone-recorded MP4s only have -I-frames every 5-10s, so scene boundaries rarely align with keyframes → -segments start mid-GOP → the decoder errors with "error while decoding -MB 35 25" → the y4m pipe breaks → the encoder fails with "Failed to -read y4m frame delimiter. Read broken. EOF: 1" → every chunk fails → -no concat → no output file. - -The fix has three parts: - - 1. ``_encode_one`` now accepts a ``chunk_method`` parameter. When av1an - fails with the y4m break pattern, it recursively retries with - ``--chunk-method select`` (VapourSynth's select() filter, which - extracts frames one-by-one and avoids the keyframe-alignment issue). - This is faster than the ffmpeg fallback (chunk-parallel still works) - and produces identical-quality output. - - 2. The working chunk_method is cached in - ``env.av1an_flags["chunk_method_override"]`` so subsequent files skip - the wasted first attempt. - - 3. ``env_probe`` now calls ``_probe_vs_source_plugins()`` to detect - installed VapourSynth source plugins (lsmash, ffms2, bestsource, - dgdecnv, vszip). When NONE are found, it pre-sets - ``chunk_method_override = "select"`` to avoid the wasted first - attempt entirely. - -Also fixed: the "SUMMARY block + non-zero exit" diagnostic previously -misdiagnosed y4m break failures as "concat failure" (because SVT-AV1 -prints a SUMMARY block per-chunk before the pipe breaks). The check is -now guarded by ``"Failed to read y4m frame delimiter" not in stderr_full`` -so it only fires for true concat failures. - -New CLI flag: ``--chunk-method {auto,select,hybrid,segment,ffms2,lsmash, -bestsource,dgdecnv}`` lets the user force a specific chunk method. - -New e2e test: ``test_y4m_break_recovery_produces_valid_output`` in -test_e2e_real_encode.py — generates a real video, simulates the y4m -break, and verifies the retry-with-select produces a valid AV1/MKV file. - -Previous releases ------------------ -v3.x was the production-readiness pass: split the 515-line ``run()`` into -5 single-responsibility methods, narrowed 24 bare ``except Exception`` -clauses, added the STOP-button interrupt (SIGTERM/SIGKILL on the process -group), per-worker temp subdirs (race-condition fix), the per-file -av1an→ffmpeg fallback, and the av1an VSScript smoke test. v2.x added -ffprobe pre-validation and the pattern-table failure diagnostics. - -Key features ------------- - - Config-driven codec/audio/container profiles (no nested if/else chains) - - Distro-aware: Arch, Fedora, RHEL/CentOS/Rocky/Alma, openSUSE, NixOS, Debian/Ubuntu - - Per-distro binary search paths, package manager, install hints, encoder name quirks - - FFmpeg encoder library availability probe (greys out unavailable codecs in UI) - - ffprobe pre-validation before encoding - - QThread worker (thread-safe UI, proper signal/slot) - - Runtime av1an flag + version probing - - Dynamic worker count (os.cpu_count - 2) - - pathlib throughout - - Per-file progress tracking - - Container format selection (MKV / WebM / MP4) - - AV1/VP9/x265 preset selection - - Configurable input extensions - - Batch delete with summary prompt (not per-file) +Author: Jeremy Anderson — https://dcos.net — info@dcos.net +License: AGPL-3.0 """ +__version__ = "4.11.0" +__author__ = "Jeremy Anderson" +__website__ = "https://dcos.net" +__email__ = "info@dcos.net" +__license__ = "AGPL-3.0" + +import argparse +from collections.abc import Callable +import ctypes +from dataclasses import dataclass +from dataclasses import dataclass, field import hashlib import io import json import math import os +from pathlib import Path import platform import re import shutil import signal -import subprocess import site +import subprocess import sys import tempfile import threading import time -import ctypes -from collections.abc import Callable -from dataclasses import dataclass, field, field -from pathlib import Path +from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import Qt, QTimer, Slot +from PySide6.QtCore import Qt, Signal, QPointF, QRectF +from PySide6.QtGui import ( + QFont, QColor, QPainter, QPen, QBrush, + QRadialGradient, QFontMetrics, +) +from PySide6.QtGui import QFont, QPalette, QColor from PySide6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox, QTextEdit, QFileDialog, QGroupBox, QStatusBar, QMessageBox, QStyleFactory, ) -from PySide6.QtCore import Qt, QThread, Signal, Slot, QPointF, QRectF, QTimer -from PySide6.QtGui import ( - QFont, QPalette, QColor, QPainter, QPen, QBrush, - QRadialGradient, QFontMetrics, +from PySide6.QtWidgets import QWidget + + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ title_clean ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""Title-artifact scrubbing for output filenames. + +A release title describes the SOURCE encode (`Movie.x264.1080p.WEBRip. +x265-GRP.mkv`). After this tool re-encodes it, every codec and container +token in that title is wrong: the file is now whatever VIDEO/AUDIO/ +CONTAINER say (typically AV1/Opus/MKV — Theora/OGV and the other +profiles equally). Carrying the stale tags forward misdescribes every +output in the archive. + +``clean_title`` strips three artifact classes from a source stem: + + 1. Codec/container tokens — video (x264 … theora), audio (aac … + vorbis) and container (avi, webm, …), matched case-insensitively + on token boundaries only, so `MP4Box`, `Aviator` or `H264file` + survive untouched. + 2. This app's own output suffixes — ``_archived`` and ``_x`` + resolution markers, so re-encodes never stack suffixes. + 3. Separator residue — runs left behind where a token was removed + collapse to their first separator, emptied bracket pairs go away, + and leading/trailing separators are trimmed. + +Pure function, no I/O, no internal dependencies — the scrub runs as one +compiled regex per artifact class (the token alternation is built from +the table, longest-first, so `svt-av1` matches before its `av1` +substring). Falls back to the unmodified stem when scrubbing would +empty it, so a file literally named `x264.mkv` still gets a valid +output name. +""" + + + +# Token table — single source of truth for what a title may not claim. +TITLE_ARTIFACT_TOKENS: frozenset[str] = frozenset({ + # Video codecs (legacy and current — the title must never claim one) + "x264", "h264", "h.264", "avc", + "x265", "h265", "h.265", "hevc", + "xvid", "divx", "theora", "dirac", + "vp8", "vp9", "mpeg2", "mpeg4", + "av1", "svt-av1", "svtav1", + "vc1", "vc-1", + # Audio codecs (output audio is the AUDIO profile's choice) + "aac", "he-aac", "ac3", "eac3", "dts", "dtshd", "truehd", + "atmos", "mp3", "flac", "opus", "vorbis", + # Containers (output container is the CONTAINER profile's choice) + "avi", "webm", "wmv", "mp4", "m4v", "mkv", "mov", "mpg", "mpeg", + "ts", "vob", "ogv", "ogg", "flv", "m2ts", +}) + +_SEPARATOR_RUN = r"[._\- \[\]]+" + +# A token matches only as a standalone word: the character before it +# must not be alphanumeric (releases bracket or dot their tags), and the +# character after it must not be either. Alternation is longest-first, +# so overlapping tokens (`svt-av1` ⊃ `av1`, `he-aac` ⊃ `aac`) resolve +# to the full tag. +_TOKEN_RE: re.Pattern[str] = re.compile( + r"(?i)(?x_archived`, +# `_archived`, or a bare `_x` marker. +_SUFFIX_RE: re.Pattern[str] = re.compile(r"_\d+x\d+_archived$|_archived$|_\d+x\d+$") -# ────────────────────────────────────────────── -# RADIO KNOB WIDGET (oldschool rotary control) -# ────────────────────────────────────────────── +# Residue after token removal. +_COLLAPSE_RE: re.Pattern[str] = re.compile(r"([._\- \[\]])" + _SEPARATOR_RUN) +_EMPTY_BRACKETS_RE: re.Pattern[str] = re.compile(r"\[\s*\]") +_EDGE_RE: re.Pattern[str] = re.compile(r"^[._\- \[\]]+|[._\- \[\]]+$") -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 clean_title(stem: str) -> str: + """Return *stem* with codec/container/app-artifact tokens removed.""" + scrubbed = _SUFFIX_RE.sub("", stem) + scrubbed = _TOKEN_RE.sub("", scrubbed) + scrubbed = _EMPTY_BRACKETS_RE.sub("", scrubbed) + scrubbed = _COLLAPSE_RE.sub(r"\1", scrubbed) + scrubbed = _EDGE_RE.sub("", scrubbed).strip() + return scrubbed or stem - 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 +# ════════════════════════════════════════════════════════════════════════════ +# ═══ codec_profiles ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""Codec / audio / container profile tables and helpers. - # 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)) +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). +""" # ────────────────────────────────────────────── @@ -401,15 +171,18 @@ class VideoCodecProfile: 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 and in - # EncoderWorker's --workers. - params_fn: Callable[[int, int], str] # (crf, preset) -> av1an video-params string + # 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". + # 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 @@ -430,6 +203,12 @@ class VideoCodecProfile: # the selected GPU profile. gpu_family: str = "" gpu_encoders_by_api: dict[str, str] = field(default_factory=dict) + # v4.9.0: True for codec families av1an cannot drive at all (Theora — + # no av1an encoder exists). The UI forces the ffmpeg path for these + # regardless of the av1an toggle, greys the combo entry out when + # ffmpeg lacks the library, and resolve_gpu_encoder() stays on CPU + # (empty gpu_family). params_fn must never be called on this path. + ffmpeg_only: bool = False @dataclass class AudioProfile: @@ -565,6 +344,67 @@ def _av1_nvenc_args(crf: int, preset: int) -> list[str]: "-pix_fmt", "yuv420p", "-g", "240"] +# ── v4.9.0: Theora (OGV) — the inverted quality-scale wrapper ── +# +# libtheora grades quality with -q:v 0..31 where HIGHER is better — the +# exact opposite of the CRF knob shared by every other codec family +# (lower = better). Rather than bolt a parallel quality system onto the +# UI, the worker and the skip-existing flow (all of which speak CRF), +# Theora keeps crf_range=(18, 52) exactly like AV1/VP9, and its encoder +# args are built through a translation wrapper that maps the knob value +# onto the q-scale. Verified empirically against this project's ffmpeg +# (libtheora via `ffmpeg -h encoder=libtheora` + size sweep): bitrate +# rises monotonically with -q:v and saturates from ~31 upward, so the +# map targets 0..31. +# +# Speed: libtheora has no x26x-style preset ladder, only -speed_level +# 0..2 (0 = slowest/best). The PRESET combo maps onto that directly. +# Pixel format: Theora is 4:2:0-only, so yuv420p is forced (10-bit and +# 422/444 sources are converted by ffmpeg automatically). + +THEORA_Q_RANGE: tuple[int, int] = (0, 31) + + +def theora_quality_from_crf(crf: int, crf_lo: int = 18, crf_hi: int = 52) -> int: + """Translate a shared-CRF-knob position into a libtheora -q:v value. + + Pure linear inversion with input clamping: the knob's best position + (crf_lo) maps to the q-scale's best (31), the knob's worst (crf_hi) + to 0. Every 2 knob steps (the knob's snap tick) drops q by ~1.8, so + the default knob value of 26 lands at q 24 — high-quality archival + territory, consistent with the other families' 4.8.2 defaults. + """ + lo, hi = THEORA_Q_RANGE + crf = max(crf_lo, min(crf_hi, crf)) + t = (crf - crf_lo) / (crf_hi - crf_lo) + return round(hi - t * (hi - lo)) + + +def _theora_av1an_forbidden(crf: int, preset: int) -> str: + """params_fn guard — av1an has no Theora encoder. + + The UI forces the ffmpeg path for ``ffmpeg_only`` profiles before the + chunk-parallel branch can ever run, so reaching this function means a + regression; raise loudly instead of feeding av1an a ``--encoder ""`` + that fails deep in the chunk pipeline with an opaque CLI error. + """ + raise RuntimeError( + "Theora has no av1an encoder — encode via the ffmpeg path " + "(profile is ffmpeg_only; chunk-parallel must be disabled for it)" + ) + + +def _theora_ffmpeg_args(crf: int, preset: int) -> list[str]: + """FFmpeg args for libtheora (v4.9.0 OGV output target).""" + speed = max(0, min(2, preset)) # -speed_level 0..2, clamp unknown presets + return [ + "-c:v", "libtheora", + "-q:v", str(theora_quality_from_crf(crf)), + "-speed_level", str(speed), + "-pix_fmt", "yuv420p", + ] + + VIDEO_CODECS: list[VideoCodecProfile] = [ VideoCodecProfile( label="AV1 (SVT-AV1)", @@ -572,7 +412,11 @@ VIDEO_CODECS: list[VideoCodecProfile] = [ ffmpeg_encoder="libsvtav1", container="mkv", crf_range=(18, 52), - default_crf=32, + # v4.8.2: CRF 32 delivers xvid-tier quality on grainy sources + # (blocky shadows, smeared detail — observed in hybrid GPU+CPU + # runs). 26 is the archival default; every -6 CRF buys roughly + # +50% bitrate. + default_crf=26, params_fn=_av1_params, ffmpeg_vargs_fn=_svtav1_ffmpeg_args, presets=["Slow (8)", "Medium (6)", "Fast (4)", "Faster (2)"], @@ -593,7 +437,9 @@ VIDEO_CODECS: list[VideoCodecProfile] = [ ffmpeg_encoder="libvpx-vp9", container="webm", crf_range=(18, 52), - default_crf=32, + # v4.8.2: cq-level is a 0-63 scale for libvpx-vp9; 32 sat mid-scale + # and matched the same "sloppy" reports as AV1's old 32. + default_crf=28, params_fn=_vp9_params, ffmpeg_vargs_fn=_vp9_ffmpeg_args, presets=["Slow (0)", "Medium (2)", "Fast (4)", "Faster (6)"], @@ -610,7 +456,9 @@ VIDEO_CODECS: list[VideoCodecProfile] = [ ffmpeg_encoder="libx265", container="mkv", crf_range=(18, 40), - default_crf=28, + # v4.8.2: 28 is ffmpeg's own default — fine for casual use, soft for + # an archival target. 24 is the high-quality tier (≈ +60% bitrate). + default_crf=24, params_fn=_x265_params, ffmpeg_vargs_fn=_x265_ffmpeg_args, presets=["Slow (5)", "Medium (7)", "Fast (9)", "Faster (10)"], @@ -625,6 +473,27 @@ VIDEO_CODECS: list[VideoCodecProfile] = [ gpu_encoders_by_api={"nvenc": "hevc_nvenc", "qsv": "hevc_qsv", "vaapi": "hevc_vaapi"}, ), + # v4.9.0: Theora — the legacy open-source pairing (Ogg Theora + + # Vorbis), kept alive for compatibility with old players/portals. + # ffmpeg-only: av1an has no Theora encoder, no GPU exists for it, and + # its -q:v quality scale is inverted relative to CRF (see the wrapper + # section above). Canonical container is OGV; MKV can also hold it + # (soft warning), MP4/WebM cannot (hard block — see compat rules). + VideoCodecProfile( + label="Theora (OGV)", + av1an_encoder="", + ffmpeg_encoder="libtheora", + container="ogv", + crf_range=(18, 52), + default_crf=26, # → -q:v 24 via theora_quality_from_crf + params_fn=_theora_av1an_forbidden, + ffmpeg_vargs_fn=_theora_ffmpeg_args, + presets=["Best (0)", "Medium (1)", "Fast (2)"], + preset_map={"Best (0)": 0, "Medium (1)": 1, "Fast (2)": 2}, + ffprobe_codec_name="theora", # skip-existing detection + gpu_family="", # no hardware Theora encoder on any API + ffmpeg_only=True, + ), ] AUDIO_PROFILES: list[AudioProfile] = [ @@ -660,6 +529,10 @@ CONTAINER_PROFILES: list[ContainerProfile] = [ # 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"), + # v4.9.0: OGV — the Ogg Theora container. Video must be Theora (the + # Ogg muxer rejects every other video codec this app offers); audio + # can be Vorbis, Opus or FLAC (all standard in Ogg), but not IAMF. + ContainerProfile(label="OGV (Ogg)", ext="ogv"), ] @@ -699,6 +572,9 @@ FFMPEG_LIB_KEY_MAP: dict[str, str] = { "hevc_qsv": "hevc_qsv", "h264_qsv": "h264_qsv", "av1_qsv": "av1_qsv", + # v4.9.0: Theora (OGV target). Probed alongside the software encoders + # by _probe_ffmpeg_libs(); a missing libtheora greys out the codec. + "libtheora": "libtheora", } @@ -760,246 +636,19 @@ SUBTITLE_OPTIONS = [ ] -DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg"} +# v4.8.2: added .vob (DVD rips, MPEG-PS), .xvid (AVI/ASP rips) and .ogv +# (Ogg/Theora archives) — legacy sources exactly what this tool exists to +# re-encode; leaving them out of the filter meant they were silently skipped. +DEFAULT_INPUT_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".m4v", ".flv", ".wmv", ".webm", ".mpg", ".mpeg", ".vob", ".xvid", ".ogv"} +# ════════════════════════════════════════════════════════════════════════════ +# ═══ cpu_topology ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""CPU topology detection (physical cores, not hyperthreads). -# ────────────────────────────────────────────── -# 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) +Reads /sys/devices/system/cpu/* and falls back to ``lscpu``. Pure +stdlib; no internal package dependencies. +""" # ────────────────────────────────────────────── @@ -1112,6 +761,16 @@ def detect_cpu_topology() -> CpuTopology: model_name=model, ) +# ════════════════════════════════════════════════════════════════════════════ +# ═══ distro_probe ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + # ────────────────────────────────────────────── # DISTRO DETECTION & PROFILES @@ -1428,6 +1087,981 @@ def detect_distro() -> DistroProfile: notes=entry.notes, ) +# ════════════════════════════════════════════════════════════════════════════ +# ═══ license_registry ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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). +""" + + +# ────────────────────────────────────────────── +# 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) + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ gpu_profiles ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + + + +# ────────────────────────────────────────────── +# 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 [] + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ ffprobe_utils ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + + +# ────────────────────────────────────────────── +# 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 "" + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ temp_manager ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + + +# ────────────────────────────────────────────── +# 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}" + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ keepawake ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + + + +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() + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ env_probe ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""Environment probe — distro-aware binary + library + av1an detection. + +Combines the distro probe (``detect_distro``) and the CPU topology +probe (``detect_cpu_topology``) with binary path search, ffmpeg +library availability probing, av1an version/flag probing, runtime +dependency probing, and the av1an VSScript smoke test. + +The smoke-test helpers (``_av1an_env``, ``_av1an_vsscript_smoke_test``, +``_detect_av1an_svt_encoder``) live here rather than in +``ffprobe_utils`` because they exercise av1an (not ffprobe) and are +called from both the GUI (``ui_window``) and the CLI dry-run +(``cli.run_dry_run``). +""" + + # ────────────────────────────────────────────── # GPU PROBE (v4.6.0 — NVENC hardware encoding) @@ -1455,10 +2089,13 @@ class GpuInfo: ``details`` — encoder name → first stderr line when the smoke test failed (actionable diagnostics). """ - name: str = "" # GPU model name via nvidia-smi, "" if unknown + name: str = "" # GPU model name via nvidia-smi/lspci, "" if unknown encoders: dict[str, bool] = field(default_factory=dict) functional: dict[str, bool] = field(default_factory=dict) details: dict[str, str] = field(default_factory=dict) + # v4.8.0: the matched GpuProfile key from gpu_profiles (auto-detected + # from the GPU name; the UI can override it). + profile_key: str = "" @property def usable_encoders(self) -> list[str]: @@ -1516,7 +2153,7 @@ def _probe_gpu(ffmpeg_bin: str) -> GpuInfo: if not compiled_in: return info # no hardware encoders in this build — skip stage 2 - # --- GPU model name (display only, never gates anything) --- + # --- GPU model name (display + profile auto-match) --- nvidia_smi = shutil.which("nvidia-smi") if nvidia_smi: try: @@ -1528,9 +2165,32 @@ def _probe_gpu(ffmpeg_bin: str) -> GpuInfo: info.name = res.stdout.strip().splitlines()[0].strip() except (OSError, subprocess.SubprocessError): pass + if not info.name: + # No NVIDIA board (or driver down): identify AMD/Intel iGPU/dGPU + # from the PCI bus for profile matching + VAAPI/QSV testing. + info.name = _probe_pci_gpu_name() + + # --- v4.8.0: capability-class profile for this GPU --- + matched = match_gpu_profile(info.name) + if matched: + info.profile_key = matched.key + + # The profile may claim hardware encoders this ffmpeg build + # doesn't even list (e.g. AV1 on an Arc card with an old ffmpeg). + for family, enc in matched.encoders.items(): + if enc not in info.encoders or not info.encoders[enc]: + info.encoders[enc] = _ffmpeg_has_encoder(ffmpeg_bin, enc) + + # --- Stage 2 for the profile's hardware API --- + if matched.api == "vaapi": + info.functional.update(_smoke_vaapi(ffmpeg_bin, matched)) + elif matched.api == "qsv": + info.functional.update(_smoke_qsv(ffmpeg_bin, matched)) # --- Stage 2: functional smoke test per compiled-in encoder --- for enc in compiled_in: + if enc in info.functional: + continue # already smoke-tested via the profile branch try: res = subprocess.run( [ @@ -1563,6 +2223,103 @@ def _probe_gpu(ffmpeg_bin: str) -> GpuInfo: return info +def _ffmpeg_has_encoder(ffmpeg_bin: str, enc: str) -> bool: + """Compiled-in check for one encoder name (cheap -encoders grep).""" + try: + res = subprocess.run( + [ffmpeg_bin, "-hide_banner", "-encoders"], + capture_output=True, text=True, timeout=10, + ) + return f" {enc} " in (res.stdout or "") + except (OSError, subprocess.SubprocessError): + return False + + +def _probe_pci_gpu_name() -> str: + """Best-effort non-NVIDIA GPU name via lspci (VGA/3D/Display class).""" + lspci = shutil.which("lspci") + if not lspci: + return "" + try: + res = subprocess.run( + [lspci], capture_output=True, text=True, timeout=10, + ) + for line in (res.stdout or "").splitlines(): + low = line.lower() + if any(k in low for k in (" vga ", " 3d ", " display ")): + if "nvidia" not in low: # nvidia handled via nvidia-smi + # "01:00.0 VGA ...: AMD/ATI Navi 31 [Radeon RX 7900 XTX]" + m = re.search(r":\s*(.+)$", line) + return m.group(1).strip() if m else "" + except (OSError, subprocess.SubprocessError): + pass + return "" + + +def _smoke_vaapi(ffmpeg_bin: str, profile) -> dict[str, bool]: + """Live-encode VAAPI encoders through the render node. VAAPI + encoders only accept hardware surfaces, so the filter chain must + upload the lavfi frames.""" + results: dict[str, bool] = {} + dev = resolve_vaapi_device() + if not Path(dev).exists(): + for enc in profile.encoders.values(): + results[enc] = False + details_placeholder = f"no render node ({dev})" + return results + for enc in profile.encoders.values(): + try: + cmd = ( + [ffmpeg_bin, "-hide_banner", "-loglevel", "error"] + + encoder_pre_args(profile, vaapi_device=dev) + + ["-f", "lavfi", "-i", "color=c=black:s=256x256:d=0.2:r=24", + "-frames:v", "5"] + + encoder_filter_chain(profile) + + ["-c:v", enc] + + encoder_quality_args(profile.api, enc, 28, 9) + + ["-f", "null", "-"] + ) + res = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + results[enc] = res.returncode == 0 + if res.returncode != 0: + for line in (res.stderr or "").splitlines(): + line = line.strip() + if line: + results[f"_detail_{enc}"] = False # marker only + break + except (OSError, subprocess.SubprocessError): + results[enc] = False + # Strip the detail markers; keep real encoder results only, and put + # first error lines into a readable form for the caller. + cleaned: dict[str, bool] = {} + for k, v in results.items(): + if not k.startswith("_detail_"): + cleaned[k] = v + return cleaned + + +def _smoke_qsv(ffmpeg_bin: str, profile) -> dict[str, bool]: + """Live-encode QSV encoders (qsv encoders upload system frames + internally once a qsv device exists).""" + results: dict[str, bool] = {} + for enc in profile.encoders.values(): + try: + cmd = ( + [ffmpeg_bin, "-hide_banner", "-loglevel", "error"] + + encoder_pre_args(profile) + + ["-f", "lavfi", "-i", "color=c=black:s=256x256:d=0.2:r=24", + "-frames:v", "5", + "-c:v", enc] + + encoder_quality_args(profile.api, enc, 28, 9) + + ["-f", "null", "-"] + ) + res = subprocess.run(cmd, capture_output=True, text=True, timeout=20) + results[enc] = res.returncode == 0 + except (OSError, subprocess.SubprocessError): + results[enc] = False + return results + + # ────────────────────────────────────────────── # ENVIRONMENT PROBE (distro-aware, extended) # ────────────────────────────────────────────── @@ -1682,6 +2439,11 @@ def _probe_ffmpeg_libs(ffmpeg_bin: str) -> dict[str, bool]: ("libopus", ["libopus "]), ("libvorbis", ["libvorbis "]), ("flac", ["flac "]), + # v4.9.0: Theora (OGV output target) — probed like the audio + # encoders because Theora has no av1an encoder; ffmpeg is its + # only encode path, so a missing library means the target is + # unusable (the UI greys the codec entry out). + ("libtheora", ["libtheora "]), # v4.6.0: NVENC hardware encoders (compiled-in check only — the # live-encode gate is _probe_gpu()'s functional dict). ("hevc_nvenc", ["hevc_nvenc "]), @@ -1985,7 +2747,6 @@ def probe_environment() -> EnvProbe: if result.ffmpeg_version: result.warnings.append(f"FFmpeg version: {result.ffmpeg_version}") result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path) - result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path) # v4.6.0: GPU/NVENC probe — compiled-in check + live encode smoke # test. EncoderWorker gates the GPU path on env.gpu.functional; @@ -2027,6 +2788,12 @@ def probe_environment() -> EnvProbe: for codec_label, lib_name in audio_lib_warnings.items(): if not result.ffmpeg_libs.get(lib_name, False): result.warnings.append(f"FFmpeg missing encoder: {lib_name} ({codec_label} audio will not work)") + # v4.9.0: Theora is a VIDEO codec in the same ffmpeg-only boat — + # surface its absence at probe time instead of at encode time. + if not result.ffmpeg_libs.get("libtheora", False): + result.warnings.append( + "FFmpeg missing encoder: libtheora (OGV/Theora output will not work)" + ) # --- Av1an version --- if result.av1an_path: @@ -2109,7 +2876,6 @@ def probe_environment() -> EnvProbe: "chunk-parallel encoding." ) result.av1an_flags["chunk_method_override"] = "select" - result.av1an_flags["chunk_method_override"] = "select" # v4.6.0: ffmpeg ≥ 7 removed the -vsync option that av1an's # segment/hybrid chunk extraction passes to ffmpeg. On those @@ -2182,109 +2948,6 @@ def probe_environment() -> EnvProbe: return result -# ────────────────────────────────────────────── -# 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 "" - def _detect_av1an_svt_encoder(av1an_bin: str) -> str | None: """Determine the exact encoder name av1an accepts for SVT-AV1. @@ -2554,430 +3217,36 @@ def _av1an_vsscript_smoke_test( combined = (stderr + "\n--- stdout ---\n" + stdout)[-1500:] return False, f"SMOKE_FAIL(rc={res.returncode}): {combined}" +# ════════════════════════════════════════════════════════════════════════════ +# ═══ hybrid_scheduler ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""Hybrid GPU+CPU batch scheduler (v4.7.0). -# ────────────────────────────────────────────── -# TEMP DIRECTORY MANAGEMENT -# ────────────────────────────────────────────── +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: -_APP_CACHE_DIR: Path | None = None + - 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. -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}" - - -# ────────────────────────────────────────────── -# KEEP-AWAKE (v6-06: anti-sleep / anti-hibernate) -# ────────────────────────────────────────────── - -class KeepAwake: - """Keep the system awake during a transcode. - - v6-06: Uses systemd-inhibit (preferred) to block sleep/idle at the - systemd level, plus optional xdotool mouse nudging every 60s as a - belt-and-suspenders fallback. The user sees a bright-red banner in - the UI while active. Mouse movement is minimal (1px jitter, not - constant) so the user can still click STOP or close the window. - - Usage:: - ka = KeepAwake(log_fn=worker.log_msg.emit) - ka.start() - try: - while encoding: - ka.update_eta(remaining_seconds) - time.sleep(5) - finally: - ka.stop() - """ - - 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 banner text for the UI (styled bright-red in QSS).""" - 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/idle - inhibit handle for the duration of the transcode.""" - 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: - 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, - ) - 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. - - 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() - - -# ────────────────────────────────────────────── -# INTELLIGENT WORKER-COUNT MATH (v4.1.0) -# ────────────────────────────────────────────── - -def _compute_intelligent_worker_count_for( - env: EnvProbe, - max_workers: int | None = None, - threads_per_worker_override: int | None = None, -) -> tuple[int, int]: - """Compute ``(worker_count, threads_per_worker)`` to prevent thread - oversubscription on high-core-count machines. - - PROBLEM (v4.0.0 and earlier) - ---------------------------- - ``run()`` set ``worker_count = max(1, physical_cores - 1)`` and - passed no per-chunk thread cap to the encoder. SVT-AV1's default - ``--threads 0`` means "use all logical cores," so each chunk-parallel - worker spawned an SvtAv1EncApp process that grabbed every logical - thread. On a 28-thread Xeon (14 physical cores), 13 workers × 28 - threads = ~364 active threads on 28 logical CPUs — the kernel - scheduler drowns, I/O wait escalates, and the box hard-locks even - though no single process is at fault. The 1-second STOP-button - poll in ``_run_with_stop_check`` can't get scheduled, so even - clicking STOP doesn't recover it. - - v4.0.0 made it WORSE for the phone-video workload because the - ``--chunk-method select`` auto-override keeps the pipeline tighter - (no Hybrid warm-up between chunks), so more SVT-AV1 instances hit - full tilt at the same instant. - - SOLUTION - -------- - Budget the total thread count to ``logical_threads - 1`` (one - logical thread reserved for OS/UI), then split that budget across - chunk-parallel workers. Each encoder instance gets - ``--threads N`` so it can't grab more than its share. - - Algorithm - --------- - 1. ``budget = max(1, logical_threads - 1)`` — leave 1 logical - thread for OS / UI / av1an orchestrator. - 2. ``ideal_tpw = 4`` — empirical sweet spot for SVT-AV1, x265, - and vpxenc. Beyond ~6 threads per encoder instance you hit - memory-bandwidth contention and diminishing returns. - 3. ``target_workers = max(1, budget // ideal_tpw)``. - 4. Cap ``target_workers`` at ``max(1, physical_cores - 1)`` so - chunk-parallel never exceeds the physical core count. - 5. ``threads_per_worker = max(1, budget // target_workers)``. - 6. Apply user overrides (``max_workers`` / - ``threads_per_worker_override``) if provided. - - Examples - -------- - 4-core / 8-thread laptop: - budget=7, target_workers=7//4=1, tpw=7//1=7 → 1×7 = 7 - 8-core / 16-thread desktop: - budget=15, target_workers=15//4=3, tpw=15//3=5 → 3×5 = 15 - 14-core / 28-thread Xeon (the user's box): - budget=27, target_workers=27//4=6, tpw=27//6=4 → 6×4 = 24 - (leaves 4 logical threads for OS/UI breathing room) - 32-core / 64-thread EPYC: - budget=63, target_workers=63//4=15, tpw=63//15=4 → 15×4=60 - 1-core / 2-thread VM: - budget=1, target_workers=1, tpw=1 → 1×1 = 1 - - Returns ``(worker_count, threads_per_worker)``. Both are ≥1. - """ - physical = max(1, env.cpu.physical_cores) - logical = max(1, env.cpu.logical_threads) - - # User override short-circuit (highest priority). - if max_workers is not None and threads_per_worker_override is not None: - wc = max(1, int(max_workers)) - tpw = max(1, int(threads_per_worker_override)) - return wc, tpw - - # Budget: leave 1 logical thread for OS / UI / av1an orchestrator. - budget = max(1, logical - 1) - - # Ideal threads per encoder instance — empirical sweet spot. - # v4.1.0 used 4; v4.1.1 bumped to 6 because SVT-AV1 with only 4 - # threads was too slow per-chunk, making the total throughput - # feel "borked" even though the thread budget was correct. - # With 6 threads per worker, SVT-AV1 has enough parallelism for - # motion estimation while staying under the logical-thread budget. - IDEAL_THREADS_PER_WORKER = 6 - - # Target worker count from budget / ideal_tpw. - target_workers = max(1, budget // IDEAL_THREADS_PER_WORKER) - - # Cap at physical_cores - 1 so chunk-parallel doesn't exceed - # physical core count (avoids L3 cache thrash on chiplet CPUs). - max_by_phys = max(1, physical - 1) if physical > 1 else 1 - target_workers = min(target_workers, max_by_phys) - - # Apply --max-workers override if provided (still cap by physical). - if max_workers is not None: - target_workers = min(max(1, int(max_workers)), max_by_phys) - - # Compute threads per worker. - if threads_per_worker_override is not None: - tpw = max(1, int(threads_per_worker_override)) - else: - tpw = max(1, budget // target_workers) - - return target_workers, tpw - - -# ────────────────────────────────────────────── -# ENCODER WORKER (QThread, from PySide6 ver, extended) -# ────────────────────────────────────────────── - -def scan_input_files(in_dir: Path, extensions: set[str]) -> list[Path]: - """Collect the transcodable files under *in_dir* (v4.7.0). - - Used by ``EncoderWorker.run()`` for the default whole-directory scan - AND by the hybrid scheduler's pre-scan, which must partition the - queue BEFORE the per-lane workers are constructed. Excludes leftover - pre-scale intermediates from previous failed runs. - """ - return sorted( - f for f in in_dir.rglob("*") - if f.is_file() - and f.suffix.lower() in extensions - and not f.name.endswith(".scaled_tmp.mkv") - ) +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). +""" +# 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 / @@ -3064,6 +3333,34 @@ def plan_hybrid( gpu_speed_ratio=ratio, ) +# ════════════════════════════════════════════════════════════════════════════ +# ═══ encoder_worker ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""EncoderWorker (QThread) — the per-file encode pipeline. + +v3-05 split the former 515-line ``run()`` into five single- +responsibility methods (``run`` / ``_process_one_file`` / +``_validate_file`` / ``_prepare_input`` / ``_encode_one`` / +``_verify_and_finalize``). v3-07 added the STOP-button interrupt +(Popen + start_new_session + SIGTERM/SIGKILL on the process group). + +Depends on: + - ``codec_profiles`` — VideoCodecProfile, AudioProfile, + ContainerProfile, ResolutionProfile, FFMPEG_LIB_KEY_MAP, + ffmpeg_lib_key_for. + - ``env_probe`` — EnvProbe (type), _av1an_env. + - ``ffprobe_utils`` — ffprobe_validate, ffprobe_duration, + _verify_output_resolution, _identify_file_type. + - ``temp_manager`` — _temp_path_for, _worker_temp_dir. + +""" + + + + +# ────────────────────────────────────────────── +# ENCODER WORKER (QThread, from PySide6 ver, extended) +# ────────────────────────────────────────────── def scan_input_files(in_dir: Path, extensions: set[str]) -> list[Path]: """Collect the transcodable files under *in_dir* (v4.7.0). @@ -3139,8 +3436,6 @@ def resolve_gpu_encoder(engine: str, video_codec, env): return (None, None) - - class EncoderWorker(QThread): log_msg = Signal(str) progress_msg = Signal(str, int, int) # (filename, current, total) @@ -3208,8 +3503,6 @@ class EncoderWorker(QThread): lane: str = "", ffmpeg_threads: int | None = None, ): - - super().__init__() self.in_dir = in_dir self.out_dir = out_dir @@ -3264,30 +3557,27 @@ class EncoderWorker(QThread): # av1an cannot drive NVENC — which also means no pre-scale # intermediate: the ffmpeg path scales inline). self._gpu_encoder: str | None = None - # Resolved at run() time — kept on self so _encode_one can read it - # without changing its call signature (which is invoked recursively - # by the y4m-pipe-break retry path). - self._resolved_threads_per_worker = 0 - self._stop = False - self._current_temps: list[Path] = [] # temps for the file currently being processed - self._sources_to_delete: list[Path] = [] # sources deferred for deletion after final cleanup - self.success_count = 0 - self.fail_count = 0 - # v4.3.0: tracks files skipped because the output already existed - # with a matching codec. Reported in the final summary as - # "Skipped: N" alongside Success/Failed. - self.skipped_count = 0 + self._gpu_api: str | None = None + # v4.2.1: quiet mode by default. Tech-detail log lines (CMD:, + # live tail of av1an/ffmpeg stderr, DIAGNOSIS blocks, resolution + # map, pre-flight validation table, heartbeat) are gated behind + # self.verbose. Default False = only per-file success/fail + + # final summary. Pass --verbose (or set + # env.av1an_flags["verbose"]=True) for the full tech dump. + self.verbose = bool(env.av1an_flags.get("verbose", False)) # v4.3.0: skip-existing detection. When True (default), the # worker probes the output file before encoding; if it already - # exists with a matching video+audio codec, the file is skipped. + # exists with a matching video+audio codec (and matching + # resolution when scaling was requested), the file is skipped + # instead of re-encoded. Pass --force-reencode (or set + # env.av1an_flags["skip_existing"]=False) to disable. self.skip_existing = bool(env.av1an_flags.get("skip_existing", True)) - # v4.4.3: verbose flag (was missing in launcher script, causing - # AttributeError when _check_disk_space referenced self.verbose). - self.verbose = bool(env.av1an_flags.get("verbose", False)) # v4.4.0: 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 (~5-10 fps) on a 2-hour movie takes 4-10 hours; the old - # 2h timeout killed massive-file encodes partway through. + # 2h timeout killed massive-file encodes partway through. The STOP + # button handles user-initiated aborts; this timeout is just a + # safety net for truly wedged processes. Configurable via --timeout. self.encode_timeout = int(env.av1an_flags.get("encode_timeout", 86400)) # v4.4.4: inline_scale — when True and a target resolution is # selected, the scale filter is passed directly to av1an via @@ -3303,6 +3593,19 @@ class EncoderWorker(QThread): # the scale filter into av1an's --ffmpeg-filter-args without # changing its (recursively-called) signature. self._current_scale_filter: str = "" + # Resolved at run() time — kept on self so _encode_one can read it + # without changing its call signature (which is invoked recursively + # by the y4m-pipe-break retry path). + self._resolved_threads_per_worker = 0 + self._stop = False + self._current_temps: list[Path] = [] # temps for the file currently being processed + self._sources_to_delete: list[Path] = [] # sources deferred for deletion after final cleanup + self.success_count = 0 + self.fail_count = 0 + # v4.3.0: tracks files skipped because the output already existed + # with a matching codec. Reported in the final summary as + # "Skipped: N" alongside Success/Failed. + self.skipped_count = 0 # v5-02: track consecutive failures with the same error pattern. # After 3 consecutive same-pattern failures, auto-abort the queue. self._consecutive_fail_count = 0 @@ -3316,8 +3619,6 @@ class EncoderWorker(QThread): self.lane = lane self.ffmpeg_threads = ffmpeg_threads self._temp_dir = _worker_temp_dir(os.getpid(), lane=lane) - - # v6-06: KeepAwake instance — started in run(), stopped in finally. # mouse_nudge defaults to False (opt-in) to avoid surprising the # user with cursor movement. systemd-inhibit is always-on when @@ -3327,7 +3628,9 @@ class EncoderWorker(QThread): enable_mouse_nudge=False, ) self._encode_start_time = 0.0 - # v4.4.0: per-file context for combined status lines. + # v4.4.0: per-file context for combined status lines. Stashed + # by _process_one_file so downstream methods can emit + # "[N/total] filename — STATUS" without changing their signatures. self._current_idx = 0 self._current_total = 0 self._current_filename = "" @@ -3339,22 +3642,115 @@ class EncoderWorker(QThread): return f"{self._current_filename} — " if self._current_filename else "" def _vlog(self, msg: str) -> None: - """v4.2.1: Verbose-only log emit. No-op unless self.verbose is True.""" + """Verbose-only log emit. No-op unless self.verbose is True. + + v4.2.1: the default log output is quiet — only per-file + success/fail + final summary. All tech detail (CMD: lines, + live tail of av1an/ffmpeg stderr, DIAGNOSIS blocks, resolution + maps, pre-flight validation, heartbeats) goes through _vlog so + it's suppressed by default. Pass --verbose to see it. + """ if self.verbose: self.log_msg.emit(msg) def _compute_intelligent_worker_count(self) -> tuple[int, int]: """Compute ``(worker_count, threads_per_worker)`` to prevent thread - oversubscription on high-core-count machines. Delegates to the - module-level ``_compute_intelligent_worker_count_for`` so the GUI - probe banner can call the same math without an EncoderWorker - instance. See the module-level docstring for the full algorithm. + oversubscription on high-core-count machines. + + PROBLEM (v4.0.0 and earlier) + ---------------------------- + ``run()`` set ``worker_count = max(1, physical_cores - 1)`` and + passed no per-chunk thread cap to the encoder. SVT-AV1's default + ``--threads 0`` means "use all logical cores," so each chunk-parallel + worker spawned an SvtAv1EncApp process that grabbed every logical + thread. On a 28-thread Xeon (14 physical cores), 13 workers × 28 + threads = ~364 active threads on 28 logical CPUs — the kernel + scheduler drowns, I/O wait escalates, and the box hard-locks even + though no single process is at fault. The 1-second STOP-button + poll in ``_run_with_stop_check`` can't get scheduled, so even + clicking STOP doesn't recover it. + + v4.0.0 made it WORSE for the phone-video workload because the + ``--chunk-method select`` auto-override keeps the pipeline tighter + (no Hybrid warm-up between chunks), so more SVT-AV1 instances hit + full tilt at the same instant. + + SOLUTION + -------- + Budget the total thread count to ``logical_threads - 1`` (one + logical thread reserved for OS/UI), then split that budget across + chunk-parallel workers. Each encoder instance gets + ``--threads N`` so it can't grab more than its share. + + Algorithm + --------- + 1. ``budget = max(1, logical_threads - 1)`` — leave 1 logical + thread for OS / UI / av1an orchestrator. + 2. ``ideal_tpw = 4`` — empirical sweet spot for SVT-AV1, x265, + and vpxenc. Beyond ~6 threads per encoder instance you hit + memory-bandwidth contention and diminishing returns. + 3. ``target_workers = max(1, budget // ideal_tpw)``. + 4. Cap ``target_workers`` at ``max(1, physical_cores - 1)`` so + chunk-parallel never exceeds the physical core count. + 5. ``threads_per_worker = max(1, budget // target_workers)``. + 6. Apply user overrides (``self.max_workers`` / + ``self.threads_per_worker_override``) if provided. + + Examples + -------- + 4-core / 8-thread laptop: + budget=7, target_workers=7//4=1, tpw=7//1=7 → 1×7 = 7 + 8-core / 16-thread desktop: + budget=15, target_workers=15//4=3, tpw=15//3=5 → 3×5 = 15 + 14-core / 28-thread Xeon (the user's box): + budget=27, target_workers=27//4=6, tpw=27//6=4 → 6×4 = 24 + (leaves 4 logical threads for OS/UI breathing room) + 32-core / 64-thread EPYC: + budget=63, target_workers=63//4=15, tpw=63//15=4 → 15×4=60 + 1-core / 2-thread VM: + budget=1, target_workers=1, tpw=1 → 1×1 = 1 + + Returns ``(worker_count, threads_per_worker)``. Both are ≥1. """ - return _compute_intelligent_worker_count_for( - self.env, - max_workers=self.max_workers, - threads_per_worker_override=self.threads_per_worker_override, - ) + physical = max(1, self.env.cpu.physical_cores) + logical = max(1, self.env.cpu.logical_threads) + + # User override short-circuit (highest priority). + if self.max_workers is not None and self.threads_per_worker_override is not None: + wc = max(1, int(self.max_workers)) + tpw = max(1, int(self.threads_per_worker_override)) + return wc, tpw + + # Budget: leave 1 logical thread for OS / UI / av1an orchestrator. + budget = max(1, logical - 1) + + # Ideal threads per encoder instance — empirical sweet spot. + # v4.1.0 used 4; v4.1.1 bumped to 6 because SVT-AV1 with only 4 + # threads was too slow per-chunk, making the total throughput + # feel "borked" even though the thread budget was correct. + # With 6 threads per worker, SVT-AV1 has enough parallelism for + # motion estimation while staying under the logical-thread budget. + IDEAL_THREADS_PER_WORKER = 6 + + # Target worker count from budget / ideal_tpw. + target_workers = max(1, budget // IDEAL_THREADS_PER_WORKER) + + # Cap at physical_cores - 1 so chunk-parallel doesn't exceed + # physical core count (avoids L3 cache thrash on chiplet CPUs). + max_by_phys = max(1, physical - 1) if physical > 1 else 1 + target_workers = min(target_workers, max_by_phys) + + # Apply --max-workers override if provided (still cap by physical). + if self.max_workers is not None: + target_workers = min(max(1, int(self.max_workers)), max_by_phys) + + # Compute threads per worker. + if self.threads_per_worker_override is not None: + tpw = max(1, int(self.threads_per_worker_override)) + else: + tpw = max(1, budget // target_workers) + + return target_workers, tpw def _run_with_stop_check( self, @@ -3489,8 +3885,10 @@ class EncoderWorker(QThread): pass tail_prefix = f"{log_prefix}│ " - # v4.4.2: gate live tail behind --verbose. The user wants just - # start + finish lines, no per-frame progress chatter. + # v4.2.1: gate live tail behind self.verbose. Default is quiet — + # no per-frame ffmpeg/av1an output in the GUI log. The buffer + # still captures everything for diagnostic purposes (returned + # to caller as stdout/stderr). tail_emit = self.log_msg.emit if self.verbose else None t_out = threading.Thread( target=_drain, @@ -3568,7 +3966,8 @@ class EncoderWorker(QThread): # v4.4.1: heartbeat gated behind --verbose. The user wants # just start + finish lines — no "still encoding" chatter - # in between. + # in between. If a 10-hour encode looks hung without the + # heartbeat, they can run with --verbose to see it. now = time.monotonic() if self.verbose and now - last_heartbeat >= HEARTBEAT_INTERVAL: elapsed = int(now - start_time) @@ -3690,20 +4089,6 @@ class EncoderWorker(QThread): cmd = [self.env.ffmpeg_path] + hw_pre_args + [ "-i", str(encode_input), ] + vf_scale_args + hw_filter_args + v_args - # v4.7.0: CPU lane thread cap in hybrid mode (the GPU lane's - # NVENC job keeps ~2 threads for decode/mux). Never applied on - # the GPU path — NVENC throughput is silicon-bound, not - # thread-bound. Skipped when the cap is 0/unset. - if self._gpu_encoder is None and self.ffmpeg_threads: - # v4.7.1: libx265 maps -threads to frame-threads, capped at - # X265_MAX_FRAME_THREADS (16) — larger values abort the - # encoder ("frameNumThreads must be [0 .. X265_MAX_FRAME_ - # THREADS)"). SVT-AV1 and libvpx accept the full budget. - threads = self.ffmpeg_threads - if ffmpeg_enc == "libx265": - threads = min(threads, 16) - cmd += ["-threads", str(threads)] - # v4.7.0: CPU lane thread cap in hybrid mode (the GPU lane's # NVENC job keeps ~2 threads for decode/mux). Never applied on # the GPU path — NVENC throughput is silicon-bound, not @@ -3827,7 +4212,6 @@ class EncoderWorker(QThread): all_files = sorted(self.file_subset) else: all_files = scan_input_files(self.in_dir, self.extensions) - total = len(all_files) if total == 0: @@ -3868,18 +4252,20 @@ class EncoderWorker(QThread): f"threads_per_worker={self.threads_per_worker_override!r})" ) self.log_msg.emit(f"Found {total} file(s) to process.") - self.log_msg.emit(f"Temp dir: {self._temp_dir}") + self._vlog(f"Temp dir: {self._temp_dir}") # ── Pre-scan: show each file's source → output resolution ── + # v4.2.1: resolution map is verbose-only. needs_scale = ( self.resolution.width is not None and self.resolution.height is not None ) - if needs_scale: - self.log_msg.emit(f"Output resolution: {self.resolution.width}x{self.resolution.height} ({self.resolution.aspect_label})") - else: - self.log_msg.emit("Output resolution: Original (no scaling)") - self.log_msg.emit("─── FILE RESOLUTION MAP ───") + if self.verbose: + if needs_scale: + self._vlog(f"Output resolution: {self.resolution.width}x{self.resolution.height} ({self.resolution.aspect_label})") + else: + self._vlog("Output resolution: Original (no scaling)") + self._vlog("─── FILE RESOLUTION MAP ───") self._file_res_map: dict[Path, tuple] = {} # file -> (src_w, src_h, out_w, out_h) if self.env.ffprobe_path: for f in all_files: @@ -3894,15 +4280,19 @@ class EncoderWorker(QThread): if sw and sh: ow, oh = (self.resolution.width, self.resolution.height) if needs_scale else (sw, sh) self._file_res_map[f] = (sw, sh, ow, oh) - arrow = "->" if needs_scale else "=" - action = "" if needs_scale or sw == ow else " (no change)" - self.log_msg.emit(f" {f.name:<40s} {sw:>5}x{sh:<5} {arrow} {ow:>5}x{oh}{action}") + if self.verbose: + arrow = "->" if needs_scale else "=" + action = "" if needs_scale or sw == ow else " (no change)" + self._vlog(f" {f.name:<40s} {sw:>5}x{sh:<5} {arrow} {ow:>5}x{oh}{action}") else: self._file_res_map[f] = (None, None, self.resolution.width if needs_scale else None, self.resolution.height if needs_scale else None) - self.log_msg.emit(f" {f.name:<40s} (unknown resolution)") + if self.verbose: + self._vlog(f" {f.name:<40s} (unknown resolution)") else: - self.log_msg.emit(" (ffprobe unavailable — resolution map skipped)") - self.log_msg.emit("───────────────────────────") + if self.verbose: + self._vlog(" (ffprobe unavailable — resolution map skipped)") + if self.verbose: + self._vlog("───────────────────────────") # ── v5-04: Pre-flight validation pass ── # Scan all files with ffprobe BEFORE the encode loop. Report how @@ -3932,14 +4322,19 @@ class EncoderWorker(QThread): reason = "no video stream" if not has_video else f"too short ({duration:.1f}s)" invalid_samples.append(f" {f.name}: {reason}") - self.log_msg.emit("─── PRE-FLIGHT VALIDATION ───") - self.log_msg.emit(f" Valid files: {valid_count}") - self.log_msg.emit(f" Invalid files: {invalid_count}") - if invalid_samples: - self.log_msg.emit(f" First {len(invalid_samples)} invalid:") - for s in invalid_samples: - self.log_msg.emit(s) - self.log_msg.emit("─────────────────────────────") + # The validation table is user-facing whenever anything is + # invalid — the counts + samples ARE the actionable + # diagnostic for the ABORT below. Clean runs (zero invalid) + # print nothing and stay quiet. + if invalid_count > 0: + self.log_msg.emit("─── PRE-FLIGHT VALIDATION ───") + self.log_msg.emit(f" Valid files: {valid_count}") + self.log_msg.emit(f" Invalid files: {invalid_count}") + if invalid_samples: + self.log_msg.emit(f" First {len(invalid_samples)} invalid:") + for s in invalid_samples: + self.log_msg.emit(s) + self.log_msg.emit("─────────────────────────────") if valid_count == 0 and invalid_count > 0: self.log_msg.emit("") @@ -3947,12 +4342,12 @@ class EncoderWorker(QThread): f"ABORT: All {invalid_count} file(s) are invalid. " f"Aborting queue — no files to encode." ) - self.log_msg.emit( + self._vlog( " Common causes: (1) failed yt-dlp downloads (HTML saved as .mp4), " "(2) files on a network mount that's not responding, " "(3) wrong input directory." ) - self.log_msg.emit( + self._vlog( " Run `file ` on any file to see what it actually is." ) self.fail_count = invalid_count @@ -3963,10 +4358,11 @@ class EncoderWorker(QThread): self.finished_queue.emit(0, self.fail_count) return elif invalid_count > 0: + # v4.2.1: keep the one-line skip notice (user-facing) but + # drop the empty line — it just wastes vertical space. self.log_msg.emit( - f" {invalid_count} invalid file(s) will be skipped during encoding." + f" {invalid_count} invalid file(s) will be skipped." ) - self.log_msg.emit("") scale_filter = ( f"scale={self.resolution.width}:{self.resolution.height}:" @@ -4002,6 +4398,15 @@ class EncoderWorker(QThread): # v5-02: track consecutive failures with the same error pattern. # After 3 consecutive same-pattern failures, auto-abort the queue. if self.fail_count > prev_fail: + # This file failed — extract the failure pattern from the + # last log message (the DIAGNOSIS line or the FAIL line). + # We use the first 80 chars as a coarse pattern fingerprint. + # If the pattern matches the previous failure, increment the + # consecutive counter; otherwise reset it. + # (We can't access the log messages directly from here, so + # we use a simpler heuristic: if the fail count increased + # and the success count didn't, it's a failure. The pattern + # is tracked via _last_fail_pattern set in _encode_one.) pass # pattern tracking is handled in _process_one_file elif self.success_count > prev_success: # Success resets the consecutive failure counter. @@ -4020,10 +4425,13 @@ class EncoderWorker(QThread): src.unlink() deleted += 1 except OSError: + # Best-effort: a single un-deletable source must not abort + # the rest of the deferred-deletion sweep. pass - self.log_msg.emit(f"CLEANED: Removed {deleted} source file(s) after verified transcode.") + self.log_msg.emit(f"CLEANED: Removed {deleted} source file(s).") self._sources_to_delete.clear() + # v4.2.1: single final summary line — no mode prefix. # v4.3.0: include skipped count when > 0. if self.skipped_count > 0: self.log_msg.emit( @@ -4042,15 +4450,25 @@ class EncoderWorker(QThread): def _process_one_file(self, file_path, idx, total, worker_count, needs_scale, scale_filter): """Process a single file end-to-end (validate -> prepare -> encode -> verify). + Extracted from run() so the per-file control flow is readable. All + `continue` statements from the original loop become early `return`s + here. The caller (run) simply iterates and re-checks `self._stop` at + the top of each iteration. + v4.4.0: the per-file banner is NOT emitted upfront. Instead, each terminal status (SKIP / OK / FAIL) emits a SINGLE combined line: [N/total] filename — SKIP (already av1/opus) [N/total] filename — OK: 1.6MB -> 1.3MB (81%) [N/total] filename — FAIL: + This halves the log line count for skipped files and makes the + status visible at a glance without scrolling. The heartbeat + (every 30s) is the only thing emitted mid-encode. """ self.progress_msg.emit(file_path.name, idx, total) - # v4.4.0: stash idx/total on self so downstream methods can emit - # combined status lines with the [N/total] filename prefix. + # v4.4.0: stash idx/total on self so downstream methods + # (_verify_and_finalize, _encode_one) can emit combined status + # lines with the [N/total] filename prefix without changing + # their call signatures. self._current_idx = idx self._current_total = total self._current_filename = file_path.name @@ -4059,6 +4477,8 @@ class EncoderWorker(QThread): self._current_scale_filter = scale_filter or "" # --- ffprobe pre-validation --- + # _validate_file uses _status_prefix() for combined log lines, + # which reads self._current_idx/_current_total set above. skip, info, src_w, src_h = self._validate_file(file_path) if skip: # v5-02: a skip is a failure for consecutive-failure tracking. @@ -4066,16 +4486,17 @@ class EncoderWorker(QThread): return # _validate_file already logged SKIP + incremented fail_count # --- Determine actual output resolution --- - if src_w and src_h: + # v4.2.1: source/output resolution is verbose-only. + if self.verbose and src_w and src_h: out_w, out_h = src_w, src_h if needs_scale: out_w, out_h = self.resolution.width, self.resolution.height - self.log_msg.emit(f" Source: {src_w}x{src_h} -> Output: {out_w}x{out_h}") - else: + self._vlog(f" Source: {src_w}x{src_h} -> Output: {out_w}x{out_h}") + elif self.verbose: if needs_scale: - self.log_msg.emit(f" Source: unknown -> Output: {self.resolution.width}x{self.resolution.height}") + self._vlog(f" Source: unknown -> Output: {self.resolution.width}x{self.resolution.height}") else: - self.log_msg.emit(f" Source: unknown -> Output: original") + self._vlog(f" Source: unknown -> Output: original") # --- Pre-scale / symlink + build output path --- prepared = self._prepare_input(file_path, src_w, src_h, needs_scale, scale_filter) @@ -4086,10 +4507,16 @@ class EncoderWorker(QThread): encode_input, output_f = prepared # v4.3.0: skip-existing detection. If the output file already - # exists with a matching video+audio codec, skip the encode. + # exists with a matching video+audio codec (and matching + # resolution when scaling was requested), skip the encode + # entirely. This is the default (--skip-existing); pass + # --force-reencode to disable. A skip is NOT a failure — it's + # treated as a successful no-op and tracked in skipped_count. # v4.4.0: moved ABOVE the disk-space check so skipped files - # don't trigger disk-space warnings. Combined into a single - # log line with the [N/total] prefix. + # don't trigger disk-space warnings. A skipped file writes + # nothing to disk, so warning about free space for it is noise + # that buries the SKIP status the user actually needs to see. + # Also: combined into a single log line with the [N/total] prefix. if self.skip_existing and self._output_already_encoded(file_path, output_f): self.skipped_count += 1 vcodec = self.video_codec.ffprobe_codec_name or "?" @@ -4097,8 +4524,17 @@ class EncoderWorker(QThread): self.log_msg.emit( f"[{idx}/{total}] {file_path.name} — SKIP (already {vcodec}/{acodec})" ) + # v5-02: a skip counts as a success for consecutive-failure + # tracking — it's not a failure, and the queue shouldn't + # auto-abort on a run of skips. self._check_consecutive_failures(file_path, accepted=True) + # Clean up any temps _prepare_input may have created (symlinks + # for av1an, pre-scaled intermediates). The output file + # itself is NOT touched. self._cleanup_current_temps() + # Defer source deletion if requested — a skip is a successful + # transcode from the user's perspective (the output exists + # and matches their codec selection). if self.delete_source: self._sources_to_delete.append(file_path) return @@ -4109,9 +4545,12 @@ class EncoderWorker(QThread): # we're actually about to encode (after the skip-existing check). self._check_disk_space(file_path, output_f, needs_scale) - # v4.4.0: emit the per-file banner HERE (not at the top) so skipped - # files don't get a dangling "[N/total] filename" line. The final - # OK/FAIL status line at the end repeats the prefix. + # v4.4.0: emit the per-file banner HERE (not at the top of + # _process_one_file) so skipped files don't get a dangling + # "[N/total] filename" line with no status. The heartbeat will + # fire during the encode to show progress. The final OK/FAIL + # status line at the end of the encode will repeat the prefix, + # but that's fine — it's how the user matches status to file. self.log_msg.emit(f"[{idx}/{total}] {file_path.name}") # --- Encode --- @@ -4205,12 +4644,19 @@ class EncoderWorker(QThread): info = ffprobe_validate(file_path, self.env.ffprobe_path) if info is None: if self.force: + # User-facing: force skips the validation gate, so the + # log must show WHICH files are attempting blind encodes + # — a silent skip hides exactly the failure mode force + # exists for. self.log_msg.emit( - f"WARN: ffprobe could not read {file_path.name} — " - f"attempting encode anyway (force=True)." + f"{self._status_prefix()}WARN: ffprobe could not read " + f"{file_path.name} — attempting encode anyway (force=True)." ) else: - # Identify the file's actual type via `file` command. + # Identify the file's actual type via `file` command. This + # reveals "HTML document" (failed yt-dlp download) vs "data" + # (truncated/encrypted) vs "ISO Media" (valid MP4 that ffprobe + # just can't parse). Combined single-line status with prefix. file_type = _identify_file_type(file_path) self.log_msg.emit(f"{self._status_prefix()}SKIP: not a valid video (ffprobe could not read it)") if file_type and self.verbose: @@ -4378,9 +4824,15 @@ class EncoderWorker(QThread): target_dir.mkdir(parents=True, exist_ok=True) ext = self.container.ext + # v4.10.0: scrub stale codec/container tags (and this app's own + # earlier suffixes) from the source stem — a title that still + # says x264/webm on an AV1/MKV output misdescribes the archive. + stem = clean_title(file_path.stem) + if stem != file_path.stem: + self._vlog(f" Title: '{file_path.stem}' -> '{stem}'") # Always add resolution suffix when a target resolution is selected res_suffix = f"_{self.resolution.width}x{self.resolution.height}" if needs_scale else "" - output_f = target_dir / f"{file_path.stem}{res_suffix}_archived.{ext}" + output_f = target_dir / f"{stem}{res_suffix}_archived.{ext}" return (encode_input, output_f) @@ -4455,26 +4907,41 @@ class EncoderWorker(QThread): - audio stream codec_name matches self.audio_profile.ffprobe_codec_name (when both the profile and the file have an audio stream) - if scaling was requested, output resolution matches the target + + Returns False (proceed with encode) otherwise — including when + ffprobe is unavailable, the file is unreadable, or any codec + mismatch is detected. In the False cases, the encode will + overwrite the existing output (treats it as stale/corrupt). + + CRF/preset are NOT verified because they're encoder settings + not reliably stored in container metadata. The user must use + --force-reencode if they want to re-encode at a different CRF + with the same codec. """ if not output_f.exists(): return False if not self.env.ffprobe_path: + # Can't verify codec — be safe and re-encode. return False info = ffprobe_validate(output_f, self.env.ffprobe_path) if info is None: + # File exists but unreadable — treat as needing re-encode. return False streams = info.get("streams", []) vstream = next((s for s in streams if s.get("codec_type") == "video"), None) astream = next((s for s in streams if s.get("codec_type") == "audio"), None) if not vstream: return False + # Video codec check. expected_v = self.video_codec.ffprobe_codec_name if expected_v and vstream.get("codec_name") != expected_v: return False + # Audio codec check (only if both profile and file have audio). expected_a = self.audio_profile.ffprobe_codec_name if expected_a and astream: if astream.get("codec_name") != expected_a: return False + # Resolution check (only when scaling was requested). if self.resolution.width is not None and self.resolution.height is not None: actual_w = int(vstream.get("width", 0) or 0) actual_h = int(vstream.get("height", 0) or 0) @@ -4519,7 +4986,8 @@ class EncoderWorker(QThread): # ── Choose encode path: av1an or ffmpeg fallback ── if self.use_ffmpeg_fallback: # ── Pure ffmpeg encode path ── - self.log_msg.emit(f" Mode: ffmpeg ({self.video_codec.ffmpeg_encoder})") + # v4.2.1: Mode banner is verbose-only. + self._vlog(f" Mode: ffmpeg ({self.video_codec.ffmpeg_encoder})") return self._ffmpeg_fallback_encode( file_path, encode_input, output_f, ) @@ -4550,7 +5018,8 @@ class EncoderWorker(QThread): # Fallback to knob's static value if analysis failed static_db = f"{self.audio_level_db:+.1f}".replace("+", "") audio_parts.extend(["-af", f"volume={static_db}dB"]) - self.log_msg.emit(f" Audio: static gain {self.audio_level_db:+.1f} dB (analysis unavailable)") + # v4.2.1: verbose-only + self._vlog(f" Audio: static gain {self.audio_level_db:+.1f} dB (analysis unavailable)") audio_str = " ".join(audio_parts) @@ -4572,7 +5041,8 @@ class EncoderWorker(QThread): ) if effective_chunk_method: cmd.extend(["--chunk-method", effective_chunk_method]) - self.log_msg.emit( + # v4.2.1: chunk-method banner is verbose-only. + self._vlog( f" Chunking: {effective_chunk_method or 'auto'} " f"(av1an default if no override)" ) @@ -4588,7 +5058,7 @@ class EncoderWorker(QThread): # disk space for the intermediate. if self.inline_scale and self._current_scale_filter: cmd.extend(["--ffmpeg-filter-args", self._current_scale_filter]) - self.log_msg.emit( + self._vlog( f" Inline scale: enabled (filter passed via " f"--ffmpeg-filter-args, no intermediate file)" ) @@ -4601,8 +5071,8 @@ class EncoderWorker(QThread): "-o", str(output_f), ]) - # Log the full av1an command for debugging - self.log_msg.emit(f" CMD: {' '.join(cmd)}") + # v4.2.1: CMD: line is verbose-only (debugging). + self._vlog(f" CMD: {' '.join(cmd)}") try: result = self._run_with_stop_check( @@ -4621,6 +5091,7 @@ class EncoderWorker(QThread): return False if status == "timeout": self.fail_count += 1 + # v4.2.1: keep user-facing timeout message but shorten it. self.log_msg.emit(f"{self._status_prefix()}FAIL: timeout (exceeded {self.encode_timeout}s limit)") return False @@ -4843,6 +5314,10 @@ class EncoderWorker(QThread): # v4.6.0: scan stdout + stderr (FRAME MISMATCH and # encoder dumps land in stdout). if marker.lower() in combined_out.lower(): + # User-facing: a DIAGNOSIS explains a FAILED + # encode attempt — hiding it behind --verbose + # leaves a bare FAIL line (or a silent + # fallback rescue) with no root cause. self.log_msg.emit("") self.log_msg.emit(f"DIAGNOSIS: {summary}") for fix in fixes: @@ -4857,21 +5332,23 @@ class EncoderWorker(QThread): "could not open input"): file_type = _identify_file_type(file_path) if file_type: - self.log_msg.emit(f" File type: {file_type}") + self._vlog(f" File type: {file_type}") if "HTML" in file_type or "ASCII" in file_type or "text" in file_type: - self.log_msg.emit( + self._vlog( " → This is a TEXT file, not a video. " "Failed yt-dlp download — re-download the source." ) elif "data" in file_type and "ISO Media" not in file_type: - self.log_msg.emit( + self._vlog( " → File type is 'data' — truncated, encrypted, " "or partial download." ) if stop_queue: self._stop = True + # v4.2.1: STOP reason stays user-facing — the user + # needs to know why the queue aborted. self.log_msg.emit( - f"STOP: Skipping remaining files (same {marker} issue)." + f" STOP: skipping remaining files ({marker} issue)" ) diagnosis_emitted = True break @@ -4889,32 +5366,33 @@ class EncoderWorker(QThread): # briefly before failing), so we must check for the y4m # marker to avoid misdiagnosing chunk-extraction failures # as concat failures. + # v4.2.1: all DIAGNOSIS verbose-only. if ("SUMMARY" in stderr_full and "Average Speed" in stderr_full and "Failed to read y4m frame delimiter" not in combined_out and "FRAME MISMATCH" not in combined_out): - self.log_msg.emit("") - self.log_msg.emit( + self._vlog("") + self._vlog( "DIAGNOSIS: SVT-AV1 encoder completed successfully (SUMMARY" " block found in stderr), but av1an failed to produce the" " output file. This is an av1an concat failure — the encoder" " did its job but av1an's post-encode merge step crashed." ) - self.log_msg.emit( + self._vlog( " This is a known av1an bug on short videos (1-2 scenes)" " where concat of a single chunk fails. Will retry with" " ffmpeg fallback." ) else: - self.log_msg.emit("") - self.log_msg.emit( + self._vlog("") + self._vlog( "DIAGNOSIS: Unknown av1an failure. Inspect the full stderr above." ) # v5-03: run `file` on the input as a fallback diagnostic. file_type = _identify_file_type(file_path) if file_type: - self.log_msg.emit(f" File type: {file_type}") - self.log_msg.emit( + self._vlog(f" File type: {file_type}") + self._vlog( " Common causes: (1) out of disk space in temp dir, " "(2) AV1 concat failed silently — try installing mkvtoolnix, " "(3) av1an version too old for --concat flag — check av1an --help, " @@ -4983,10 +5461,13 @@ class EncoderWorker(QThread): # and _ffmpeg_fallback_encode needs it. The finally block below # will clean up everything after the retry completes. if not self._stop and self._can_ffmpeg_fallback(): - self.log_msg.emit("") + # User-facing one-liner: the file's encode path just + # changed (av1an → ffmpeg). Quiet mode would otherwise + # report only the eventual OK and hide that av1an is + # failing on this file at all. self.log_msg.emit( - f" RETRY: Attempting ffmpeg fallback for {file_path.name} " - f"({self.video_codec.ffmpeg_encoder})..." + f"{self._status_prefix()}RETRY: ffmpeg fallback for " + f"{file_path.name} ({self.video_codec.ffmpeg_encoder})..." ) # Remove any partial output av1an may have left output_f.unlink(missing_ok=True) @@ -5004,7 +5485,10 @@ class EncoderWorker(QThread): return True else: self.fail_count += 1 - # v4.4.2: only user-facing FAIL for av1an path. + # v4.4.2: this is the ONLY user-facing FAIL for + # the av1an path — emitted when both av1an AND + # ffmpeg fallback failed. The user sees one line, + # not two. self.log_msg.emit( f"{self._status_prefix()}FAIL: av1an + ffmpeg both failed" ) @@ -5016,6 +5500,7 @@ class EncoderWorker(QThread): # No retry possible — this is a systematic issue (stop_queue # was set) or ffmpeg lacks the encoder. self.fail_count += 1 + # v4.4.2: emit user-facing FAIL here too. self.log_msg.emit( f"{self._status_prefix()}FAIL: av1an (no ffmpeg fallback available)" ) @@ -5083,7 +5568,12 @@ class EncoderWorker(QThread): self._mux_subtitle(file_path, output_f) self.success_count += 1 - # v4.4.0: combined single-line status with [N/total] prefix. + # v4.2.1: keep user-facing SUCCESS but compact it. Was: + # "SUCCESS: filename (1.6MB -> 1.3MB, 81%, duration 15.0s/15.0s (100%))" + # Now (verbose=False): + # "[N/total] filename — OK: 1.6MB -> 1.3MB (81%)" + # v4.4.0: combined into single line with [N/total] prefix. + # Verbose mode keeps the duration info on the same line. prefix = self._status_prefix() if self.verbose: self.log_msg.emit( @@ -5334,10 +5824,27 @@ class EncoderWorker(QThread): def stop(self): self._stop = True +# ════════════════════════════════════════════════════════════════════════════ +# ═══ source_builder ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + + -# ────────────────────────────────────────────── -# SOURCE BUILD WORKER — compile VS + av1an from git -# ────────────────────────────────────────────── # ────────────────────────────────────────────── # BUILD DEPENDENCY TREE (v4.7.1 — distro-aware) @@ -5387,6 +5894,10 @@ MANUAL_DEP_NOTE = ( "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"], @@ -5409,9 +5920,10 @@ GPU_BUILD_PACKAGES: dict[str, dict[str, list[str]]] = { } - - - +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 @@ -5441,244 +5953,10 @@ def build_dep_plan(distro: DistroProfile, build_av1an: bool = True) -> DepPlan: return DepPlan(packages=packages, install_cmd=install_cmd, manual_note=manual_note) -"""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. -""" - - -import re -from dataclasses import dataclass - # ────────────────────────────────────────────── -# GPU PROFILES +# SOURCE BUILD WORKER — compile VS + av1an from git # ────────────────────────────────────────────── -@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-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", "A2 ", "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="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="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 [] - - class SourceBuildWorker(QThread): """Builds VapourSynth and/or av1an from git to resolve ABI mismatches. @@ -6382,6 +6660,15 @@ class SourceBuildWorker(QThread): def stop(self): self._stop = True +# ════════════════════════════════════════════════════════════════════════════ +# ═══ ui_theme ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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) @@ -6696,6 +6983,300 @@ QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { } """ +# ════════════════════════════════════════════════════════════════════════════ +# ═══ radio_knob ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + + + +# ────────────────────────────────────────────── +# 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)) + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ ui_window ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""OpenCodecMaster (QMainWindow) — the main GUI window. + +The top-level window that wires together every other module: codec +profiles (combo boxes), env probe (startup), encoder worker (queue +execution), source builder (rebuild-from-git button), license notices +(About dialog), the MMD3 stylesheet, and the RadioKnob widget. + +Also exposes ``launch_gui()``, which is the ``QApplication`` entry +point invoked by ``cli.main()`` and ``python -m opentranscode``. +""" + + + class OpenCodecMaster(QMainWindow): def __init__(self): @@ -6709,7 +7290,13 @@ class OpenCodecMaster(QMainWindow): self.workers: list[EncoderWorker] = [] self._hybrid_pending = 0 self._hybrid_totals = [0, 0] - + self.env: EnvProbe | None = None + # CLI overrides recorded by launch_gui(). The env probe runs + # ~500ms after the event loop starts (QTimer at the bottom of + # __init__), so at launch time there is no live EnvProbe to + # write to — overrides land here and _probe_and_init applies + # them to the real av1an_flags the moment the probe finishes. + self._pending_av1an_flags: dict = {} self._pending_deletes: list[Path] = [] self._apply_mmd3_theme() @@ -6842,8 +7429,12 @@ class OpenCodecMaster(QMainWindow): knobs_lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) # CRF Knob — amber + # default_val mirrors VIDEO_CODECS[0].default_crf (AV1, the initial + # codec selection): the combo's changed-signal is only wired AFTER + # the box is populated, so nothing refreshes the knob until the + # user actually switches codecs. self.crf_knob = RadioKnob( - min_val=18, max_val=52, default_val=32, + min_val=18, max_val=52, default_val=VIDEO_CODECS[0].default_crf, label="Quality", unit="CRF", color=(212, 170, 80), @@ -6878,17 +7469,22 @@ class OpenCodecMaster(QMainWindow): self.vol_knob.valueChanged.connect(self._on_vol_knob_changed) knobs_lay.addWidget(self.vol_knob, 0, Qt.AlignmentFlag.AlignHCenter) - # ── Options row ── - opt_row = QHBoxLayout() - opt_row.setSpacing(8) + # ── Filter row (v4.8.2) ── + # The FILTER field holds a long comma-separated extension list; when + # it shared one row with four checkboxes and the GPU/ENGINE combos it + # was squeezed into a sliver. It now gets its own line together with + # the delete-source toggle; the remaining toggles and combos moved + # down to the options row below. + filter_row = QHBoxLayout() + filter_row.setSpacing(8) opt_lbl = QLabel("FILTER") opt_lbl.setFixedWidth(44) opt_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;") - opt_row.addWidget(opt_lbl) + filter_row.addWidget(opt_lbl) self.ext_edit = QLineEdit(", ".join(sorted(DEFAULT_INPUT_EXTENSIONS))) self.ext_edit.setFixedHeight(22) self.ext_edit.setToolTip("File extensions to process. Separate with commas.") - opt_row.addWidget(self.ext_edit) + filter_row.addWidget(self.ext_edit, 1) self.del_check = QCheckBox("Delete source after verify") self.del_check.setObjectName("dangerCheck") @@ -6896,7 +7492,11 @@ class OpenCodecMaster(QMainWindow): "Sources are only deleted after all files finish and cleanup passes.\n" "If any file fails, you will be prompted before deletion." ) - opt_row.addWidget(self.del_check) + filter_row.addWidget(self.del_check) + + # ── Options row ── + opt_row = QHBoxLayout() + opt_row.setSpacing(8) # v5-01: Force checkbox — skip ffprobe validation and attempt encode # even for files ffprobe cannot read. Use for the rare edge case where @@ -6914,7 +7514,11 @@ class OpenCodecMaster(QMainWindow): ) opt_row.addWidget(self.force_check) - # v4.4.3: av1an toggle — UI equivalent of --use-av1an. + # v4.4.3: av1an toggle — UI equivalent of --use-av1an. Default OFF + # (ffmpeg-only is the reliable default). When ON, the av1an chunk- + # parallel encode path runs (requires VapourSynth + source plugins). + # The user-facing label is "av1an (chunk-parallel)" so it's clear + # what they're opting into without CLI flags. self.av1an_check = QCheckBox("av1an (chunk-parallel)") self.av1an_check.setToolTip( "Use av1an chunk-parallel encoding instead of single-pass ffmpeg.\n" @@ -6925,6 +7529,28 @@ class OpenCodecMaster(QMainWindow): ) opt_row.addWidget(self.av1an_check) + # v4.4.4: inline-scale toggle — UI equivalent of --inline-scale. + # Default OFF (use CRF-16 pre-scale intermediate, robust). When ON, + # the scale/pad filter chain is passed directly to av1an via + # --ffmpeg-filter-args, skipping the intermediate file entirely. + # This eliminates the 0.5-0.8x source size temp file that was + # crashing 10GB+ encodes with mysterious "ffmpeg error (rc=234)" + # messages (lossless intermediate was filling the disk). + self.inline_scale_check = QCheckBox("Inline scale (no intermediate)") + self.inline_scale_check.setToolTip( + "Skip the CRF-16 pre-scale intermediate file when a target\n" + "resolution is selected. The scale/pad filter chain is passed\n" + "directly to av1an via --ffmpeg-filter-args instead.\n\n" + "ON = no intermediate file, faster, no extra disk usage.\n" + " May fail on older av1an builds with filter-arg quirks.\n" + "OFF = pre-scale to a CRF-16 (visually lossless) intermediate\n" + " file first, then encode. More robust but writes a\n" + " 0.5-0.8x source size temp file and runs an extra pass.\n\n" + "Recommendation: ON for large files (≥10GB) with scaling.\n" + "Default OFF for compatibility." + ) + opt_row.addWidget(self.inline_scale_check) + # v4.6.0: ENGINE selector — Auto (GPU if available) / GPU / CPU. # Auto uses the NVENC hardware encoder for the selected codec # family when the environment probe's live encode test proved it @@ -6963,15 +7589,17 @@ class OpenCodecMaster(QMainWindow): _engine_pref = _engine_flags.get("engine") _engine_index = {"auto": 0, "gpu": 1, "cpu": 2, "hybrid": 3} self.engine_combo.setCurrentIndex(_engine_index.get(_engine_pref, 0)) + # v4.8.0: GPU capability-profile dropdown. Entries combine whole # card generations (same silicon = same encoding behaviour) and # include data-center + crypto-era oddballs. "Auto-detect" uses # the probe's name match; a specific profile forces the API even # when auto-match fails. The rebuild-from-git dep tree extends # with the selected profile's packages. + _GPU_PROFILES = GPU_PROFILES self.gpu_combo = QComboBox() self.gpu_combo.addItem("GPU: Auto-detect") - for _gp in GPU_PROFILES: + for _gp in _GPU_PROFILES: self.gpu_combo.addItem(f"GPU: {_gp.label}") self.gpu_combo.setToolTip( "Hardware encoder capability class.\n" @@ -6982,7 +7610,7 @@ class OpenCodecMaster(QMainWindow): ) self.gpu_combo.setFixedHeight(24) _gpu_pref = _engine_flags.get("gpu_profile") - _gpu_keys = ["auto"] + [_gp.key for _gp in GPU_PROFILES] + _gpu_keys = ["auto"] + [_gp.key for _gp in _GPU_PROFILES] self.gpu_combo.setCurrentIndex( _gpu_keys.index(_gpu_pref) if _gpu_pref in _gpu_keys else 0 ) @@ -7006,6 +7634,7 @@ class OpenCodecMaster(QMainWindow): _engine_lay.addWidget(_engine_lbl) _engine_lay.addWidget(self.engine_combo) opt_row.addLayout(_engine_lay) + root.addLayout(filter_row) root.addLayout(opt_row) # ── Log + Knobs: horizontal split ── @@ -7098,7 +7727,9 @@ class OpenCodecMaster(QMainWindow): copy_lbl = QLabel( 'AGPL-3.0 | Jeremy Anderson - dcos.net (c) 2026' + 'style="color: #888; text-decoration: none;">dcos.net | ' + 'info@dcos.net (c) 2026' ) copy_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) copy_lbl.setOpenExternalLinks(True) @@ -7238,6 +7869,20 @@ class OpenCodecMaster(QMainWindow): def _is_vp9_in_mp4(vc, _ap, c) -> bool: return vc.ffmpeg_encoder == "libvpx-vp9" and c.ext == "mp4" + # v4.9.0: Theora/OGV pairing rules. Empirically verified against + # ffmpeg 7.x: the mp4 muxer rejects Theora ("could not find tag"), + # the webm muxer only takes VP8/VP9/AV1, and the ogg muxer only + # takes Theora video (AV1→.ogv fails at header write). MKV+Theora + # encodes fine but is an unusual pairing — soft warning only. + def _is_theora_in_mp4_webm(vc, _ap, c) -> bool: + return vc.ffmpeg_encoder == "libtheora" and c.ext in ("mp4", "webm") + + def _is_theora_in_mkv(vc, _ap, c) -> bool: + return vc.ffmpeg_encoder == "libtheora" and c.ext == "mkv" + + def _is_non_theora_in_ogv(vc, _ap, c) -> bool: + return c.ext == "ogv" and vc.ffmpeg_encoder != "libtheora" + rules: tuple[tuple, ...] = ( (_is_hevc, "INCOMPATIBLE", "x265 (HEVC) cannot be muxed into WebM. Use MKV or MP4 instead."), @@ -7250,6 +7895,15 @@ class OpenCodecMaster(QMainWindow): "FLAC in WebM is rarely supported by players. Consider MKV instead."), (_is_vp9_in_mp4, "WARNING", "VP9 in MP4 has uneven player support. WebM is the canonical VP9 container."), + (_is_theora_in_mp4_webm, "INCOMPATIBLE", + f"Theora cannot be muxed into {container.ext.upper()} — " + f"the muxer rejects it. Use OGV (canonical) or MKV."), + (_is_theora_in_mkv, "WARNING", + "Theora in MKV is valid but unusual — OGV is the canonical " + "Theora container."), + (_is_non_theora_in_ogv, "INCOMPATIBLE", + "Ogg/OGV only muxes Theora video — switch the VIDEO codec to " + "Theora or pick a different container."), ) # Single-pass evaluation: build the warnings list by filtering @@ -7315,7 +7969,18 @@ class OpenCodecMaster(QMainWindow): @Slot(float) def _on_crf_knob_changed(self, val: float): direction = "higher quality" if val < 28 else ("balanced" if val < 38 else "smaller file") - self._log(f"CRF: {val:.0f} ({direction})") + # v4.9.0: Theora's -q:v scale is inverted relative to the knob + # (higher q = better); show the translated value so the number the + # encoder will actually receive is never a mystery. + codec_idx = getattr(self.codec_combo, "currentIndex", lambda: -1)() + if 0 <= codec_idx < len(VIDEO_CODECS) and \ + VIDEO_CODECS[codec_idx].ffmpeg_encoder == "libtheora": + self._log( + f"CRF: {val:.0f} ({direction}) → Theora -q:v " + f"{theora_quality_from_crf(int(round(val)))}" + ) + else: + self._log(f"CRF: {val:.0f} ({direction})") @Slot(float) def _on_vol_knob_changed(self, val: float): @@ -7358,8 +8023,45 @@ class OpenCodecMaster(QMainWindow): # ── Environment Probe ── + # Sentinel for "delete this key when the flags exist" — distinct + # from every legit flag value (False, "", 0 must all survive). + _FLAG_UNSET = object() + + def _set_cli_flag(self, key: str, value) -> None: + """Record a CLI override for env.av1an_flags (launch-time safe). + + The probe has not run when launch_gui() executes, so overrides + cannot touch a live EnvProbe yet. They land in the pending + store; _probe_and_init flushes them into the real flags right + after the probe finishes. Later calls overwrite earlier ones — + the last CLI decision wins, exactly as if the writes had been + direct. + """ + self._pending_av1an_flags[key] = value + + def _clear_cli_flag(self, key: str) -> None: + """Record a CLI override that REMOVES a probe-set flag + (e.g. ``--chunk-method auto`` clearing the probe's + ``chunk_method_override``).""" + self._pending_av1an_flags[key] = self._FLAG_UNSET + + def _flush_cli_flags(self) -> None: + """Apply pending CLI overrides onto the freshly probed env.""" + flags = self.env.av1an_flags + for key, value in self._pending_av1an_flags.items(): + if value is self._FLAG_UNSET: + flags.pop(key, None) + else: + flags[key] = value + self._pending_av1an_flags.clear() + def _probe_and_init(self): self.env = probe_environment() + # CLI overrides (launch_gui) were recorded before this probe + # existed — apply them to the live flags before anything reads + # them. Runs even on probe errors: use_av1an/verbose/engine + # remain meaningful for the failed-probe session too. + self._flush_cli_flags() # --- Distro banner --- distro = self.env.distro @@ -7419,50 +8121,18 @@ class OpenCodecMaster(QMainWindow): # --- Disable unavailable codec options in UI --- self._disable_unavailable_codecs() - # v4.1.0: show the intelligent worker-count math in the env-probe - # banner so the user can verify the thread budget before clicking - # START. The same math runs again in EncoderWorker.run() to set - # the actual values used per-encode. + worker_count = max(1, self.env.cpu.physical_cores - 1) cpu = self.env.cpu - # Read --max-workers / --threads-per-worker overrides from - # env.av1an_flags (set by cli.main before launch_gui runs). - cli_max_workers = ( - self.env.av1an_flags.get("max_workers") - if isinstance(self.env.av1an_flags.get("max_workers"), int) - else None - ) - cli_tpw = ( - self.env.av1an_flags.get("threads_per_worker") - if isinstance(self.env.av1an_flags.get("threads_per_worker"), int) - else None - ) - wc, tpw = _compute_intelligent_worker_count_for( - self.env, max_workers=cli_max_workers, - threads_per_worker_override=cli_tpw, - ) - active = wc * tpw - reserved = max(0, cpu.logical_threads - active) self._log( - f"Chunk-parallel mode: {wc} workers × {tpw} threads = {active} active " + f"Chunk-parallel mode: {worker_count} av1an workers " f"({cpu.physical_cores} physical cores, {cpu.logical_threads} logical, " - f"{cpu.threads_per_core}T/core — {reserved} reserved for OS/UI)" + f"{cpu.threads_per_core}T/core)" ) self.btn_run.setEnabled(True) self.btn_run.setText("START PROCESSING") self.btn_rebuild.setEnabled(True) # available after successful probe - vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else "" - # Show ffmpeg video encoder availability (for fallback) - fb_encs = [] - for vc in VIDEO_CODECS: - lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 - if self.env.ffmpeg_libs.get(lib_key, False): - fb_encs.append(vc.ffmpeg_encoder) - fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else "" - self.status_label.setText( - f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | " - f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}" - ) + self.status_label.setText(self._status_summary(self.env)) # --- License attribution banner (shown once after successful probe) --- # POSIX-friendly: log plain text, no escape codes, no decorative box chars @@ -7470,6 +8140,26 @@ class OpenCodecMaster(QMainWindow): # the user can audit obligations at a glance. self._show_license_banner() + @staticmethod + def _status_summary(env) -> str: + """One-line post-probe status summary (distro | CPU | tool versions). + + SEI CERT MSC04-C: the summary is composed here and only here — + the probe path and the post-rebuild path render identical bars. + """ + fb_encs = [ + vc.ffmpeg_encoder for vc in VIDEO_CODECS + if env.ffmpeg_libs.get(ffmpeg_lib_key_for(vc.ffmpeg_encoder), False) + ] + fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else "" + vs_info = f" | VS{env.vs_version}" if env.vs_version else "" + cpu = env.cpu + return ( + f"{env.distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | " + f"av1an v{env.av1an_version or '?'} | " + f"ffmpeg v{env.ffmpeg_version or '?'}{vs_info}{fb_info}" + ) + def _show_license_banner(self) -> None: """Log the active-component license summary once at startup. @@ -7515,56 +8205,68 @@ class OpenCodecMaster(QMainWindow): self._log(f"LICENSES: {license_banner_short(notices)}") def _disable_unavailable_codecs(self): - """Grey out AUDIO codec combos whose FFmpeg library is missing. + """Grey out combo entries whose FFmpeg library is missing. - Video codecs are NOT disabled here because av1an uses its own - encoder binaries (svt_av1, vpx, x265) — it does not rely on - ffmpeg's encoder list for video. + Scope rules (profiles are the source of truth — SEI CERT + MSC04-C/STR09-C; the encoder each entry needs is read from the + profile's own field, never from a parallel table): - v3 (OTC-012, SEI CERT STR09-C + MSC04-C): each AudioProfile now - carries its ffmpeg encoder name as the `ffmpeg_encoder_name` - field (e.g. "libopus"). We look up that name in env.ffmpeg_libs - directly. This replaces the v2 approach of indexing into - `params[1]`, which assumed a fixed params layout and would - silently break if a profile ever used a different argument order. + - AUDIO entries depend on their ``ffmpeg_encoder_name`` — every + audio encode goes through ffmpeg. + - VIDEO entries are disabled ONLY for ``ffmpeg_only`` families + (Theora). The av1an-driven families (AV1/VP9/x265) keep their + entries regardless of ffmpeg's encoder list because av1an + invokes the encoder binaries directly; a missing ffmpeg lib + merely removes the fallback there. - SEI CERT MSC04-C spirit: the source of truth for which library - each profile needs is the profile itself, not a parallel table. + One table drives both combos: rows are assembled by + comprehension, applied by a single pass — no per-combo branching + (step-down logic: assemble purely, apply once). """ libs = self.env.ffmpeg_libs - for idx, profile in enumerate(AUDIO_PROFILES): - if idx >= self.audio_combo.count(): - break # combo not yet populated, defensive + rows = ( + (self.audio_combo, [ + (idx, ap.ffmpeg_encoder_name, + "Use Rebuild from Git > ffmpeg + IAMF to enable.") + for idx, ap in enumerate(AUDIO_PROFILES) + if ap.ffmpeg_encoder_name + ]), + (self.codec_combo, [ + (idx, ffmpeg_lib_key_for(vc.ffmpeg_encoder), + f"This codec is ffmpeg-only — install a ffmpeg build " + f"with {vc.ffmpeg_encoder} support.") + for idx, vc in enumerate(VIDEO_CODECS) + if vc.ffmpeg_only + ]), + ) - # v3: use the dedicated field instead of indexing into params. - lib_name = profile.ffmpeg_encoder_name - if not lib_name: - continue # passthrough profile, no encoder dependency - if not libs.get(lib_name, False): - item = self.audio_combo.model().item(idx) + for combo, entries in rows: + for idx, lib_name, remedy in entries: + if idx >= combo.count() or libs.get(lib_name, False): + continue # not populated yet, or the library exists + item = combo.model().item(idx) if item is not None: item.setEnabled(False) item.setToolTip( - f"DISABLED: FFmpeg missing {lib_name} encoder. " - f"Use Rebuild from Git > ffmpeg + IAMF to enable." + f"DISABLED: FFmpeg missing {lib_name} encoder. {remedy}" ) - # If the currently-selected item is the one we disabled, - # fall back to the first enabled entry. - if self.audio_combo.currentIndex() == idx: - self.audio_combo.setCurrentIndex(0) + # A disabled selection falls back to the first entry. + if combo.currentIndex() == idx: + combo.setCurrentIndex(0) # ── Process Control ── def _parse_extensions(self) -> set[str]: - raw = self.ext_edit.text() - exts = set() - for part in raw.split(","): - part = part.strip().lower() - if not part.startswith("."): - part = "." + part - if part: - exts.add(part) + """Parse the FILTER field into a normalized extension set. + + Empty parts (trailing/doubled commas) drop out before the dot + prefix is applied, so they can never contribute a bare ".". + Empty RESULT falls back to the default set — the filter is never + allowed to select nothing. + """ + parts = (p.strip().lower() for p in self.ext_edit.text().split(",")) + exts = {p if p.startswith(".") else f".{p}" for p in parts if p} return exts or DEFAULT_INPUT_EXTENSIONS @Slot() @@ -7617,13 +8319,32 @@ class OpenCodecMaster(QMainWindow): return # ── v4.2.0: av1an is opt-in. Default is ffmpeg-only. ── - # v4.4.3: flag can come from CLI (--use-av1an) OR UI toggle. + # The av1an chunk-parallel path was too fragile across distros + # (y4m pipe breaks, SvtAv1EncApp CLI quirks, 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. + # v4.4.3: the flag can come from the CLI (--use-av1an) OR from + # the UI toggle (self.av1an_check). UI toggle takes precedence + # so the user can flip it without restarting. cli_use_av1an = bool(self.env.av1an_flags.get("use_av1an", False)) ui_use_av1an = ( hasattr(self, "av1an_check") and self.av1an_check.isChecked() ) use_av1an = ui_use_av1an or cli_use_av1an + # v4.9.0: ffmpeg-only codec families (Theora) have no av1an + # encoder — force the reliable ffmpeg path regardless of how the + # user asked for chunk-parallel, and say so in the log. + _codec_idx_pre = self.codec_combo.currentIndex() + if (use_av1an and 0 <= _codec_idx_pre < len(VIDEO_CODECS) + and VIDEO_CODECS[_codec_idx_pre].ffmpeg_only): + use_av1an = False + self._log( + f"av1an: {VIDEO_CODECS[_codec_idx_pre].label} is ffmpeg-only " + f"(no av1an encoder) — chunk-parallel disabled for this run." + ) + # ── Pre-flight: av1an VSScript smoke test (main thread — can show dialogs) ── use_ffmpeg_fallback = False skip_encode = False @@ -7700,8 +8421,12 @@ class OpenCodecMaster(QMainWindow): self._log(" ABORT: invalid codec selection.") return elif not use_av1an: - # v4.4.3: default path — skip av1an entirely, use ffmpeg. - self._log("Encode mode: ffmpeg-only (default). Toggle 'av1an (chunk-parallel)' to enable av1an.") + # v4.2.0: default path — skip av1an entirely, use ffmpeg. + # This is the reliable path that works on any distro with + # ffmpeg + libsvtav1/libvpx/libx265 installed. No VapourSynth + # dependency, no chunk-method selection, no SvtAv1EncApp CLI + # quirks. Single-pass ffmpeg per file. + self._log("Encode mode: ffmpeg-only (default). Pass --use-av1an for chunk-parallel.") use_ffmpeg_fallback = True codec_idx = self.codec_combo.currentIndex() @@ -7717,16 +8442,17 @@ class OpenCodecMaster(QMainWindow): self.codec_combo.blockSignals(False) selected_codec = VIDEO_CODECS[codec_idx] - self._log(f"Codec: {selected_codec.label} (av1an encoder: {selected_codec.av1an_encoder})") + _enc_note = selected_codec.av1an_encoder or "none — ffmpeg-only codec" + self._log(f"Codec: {selected_codec.label} (av1an encoder: {_enc_note})") # v4.6.0: read the ENGINE selector and surface the resolved engine # before the queue starts. The worker re-resolves against the same # probe data; this line tells the user what is about to happen. engine_sel = ("auto", "gpu", "cpu", "hybrid")[self.engine_combo.currentIndex()] \ - + if hasattr(self, "engine_combo") else "auto" if hasattr(self, "engine_combo"): - # Always write UI state so the worker's env fallback stays in - # sync when the user flips the combo between runs. + # Always write UI state (CLI --engine pre-selects the combo; + # unchecking round-trips the same way as the other toggles). self.env.av1an_flags["engine"] = engine_sel gpu_preview, gpu_api = resolve_gpu_encoder(engine_sel, selected_codec, self.env) if gpu_preview: @@ -7736,7 +8462,20 @@ class OpenCodecMaster(QMainWindow): self._log(f"Engine: GPU requested but {gpu_enc} unavailable — will use CPU.") elif engine_sel == "hybrid": self._log("Engine: hybrid — GPU lane + CPU lane concurrently (splits the queue by size).") + else: + self._log("Engine: CPU (software encoders)") + # v4.4.4: read the inline-scale checkbox into env.av1an_flags so + # EncoderWorker.__init__'s fallback path picks it up (matches the + # pattern used by --use-av1an, --verbose, --skip-existing, etc.). + # Always write the UI state — this lets the user override a CLI + # --inline-scale by unchecking the box. The CLI flag pre-checks + # the box in launch_gui(), so the round-trip is consistent: + # CLI --inline-scale → checkbox pre-checked → re-read as True. + if hasattr(self, "inline_scale_check"): + self.env.av1an_flags["inline_scale"] = self.inline_scale_check.isChecked() + if self.inline_scale_check.isChecked(): + self._log("Inline scale: enabled (no intermediate file for scaling).") # v4.7.0: kwargs shared by every lane of the queue (single # worker, or the GPU + CPU lanes in hybrid mode). @@ -7840,6 +8579,7 @@ class OpenCodecMaster(QMainWindow): import copy as _copy import dataclasses as _dc + gpu_enc, _gpu_api = resolve_gpu_encoder("gpu", common["video_codec"], self.env) if not gpu_enc: self._log("Hybrid: no functional NVENC encoder for this codec family.") @@ -8023,7 +8763,6 @@ class OpenCodecMaster(QMainWindow): build_ffmpeg_iamf=build_ffmpeg_iamf, gpu_profile_key=self.env.av1an_flags.get("gpu_profile", "auto"), ) - self._build_worker.log_msg.connect(self._log) self._build_worker.build_done.connect(self._on_build_done) self._build_worker.start() @@ -8092,20 +8831,8 @@ class OpenCodecMaster(QMainWindow): self._log(f"WARNING: Build completed but smoke test still fails: {detail}") self._log("You may need to log out/in or restart the app for library changes to take effect.") - # Update status bar - distro = self.env.distro - cpu = self.env.cpu - vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else "" - fb_encs = [] - for vc in VIDEO_CODECS: - lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 - if self.env.ffmpeg_libs.get(lib_key, False): - fb_encs.append(vc.ffmpeg_encoder) - fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else "" - self.status_label.setText( - f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | " - f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}" - ) + # Update status bar (single source: _status_summary) + self.status_label.setText(self._status_summary(self.env)) else: self._log(f"BUILD FAILED: {message}") self._log("Try running the build manually in a terminal, or use ffmpeg fallback.") @@ -8201,11 +8928,600 @@ class OpenCodecMaster(QMainWindow): # ────────────────────────────────────────────── -# ENTRY POINT +# GUI ENTRY POINT # ────────────────────────────────────────────── +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: + """Create the QApplication, show the OpenCodecMaster window, run the Qt event loop. + + This is the GUI entry point invoked by ``cli.main()`` when no + ``--version`` / ``--dry-run`` / ``--verify-only`` flag is given, + and by ``python -m opentranscode``. Returns the Qt event-loop + exit code (0 on clean shutdown). + + v5-01: *force* pre-checks the "Force (skip validation)" checkbox + in the UI. This is a convenience for users who want to skip ffprobe + validation on launch (e.g. for the rare edge case where ffprobe + fails but the file is actually valid). The checkbox can still be + toggled manually in the UI. + + v4.0.0: *chunk_method* overrides av1an's chunk-method selection. + When not None, the value is written to ``env.av1an_flags[ + "chunk_method_override"]`` after the environment probe runs. + "auto" clears any override the probe set; other values ("select", + "hybrid", "ffms2", etc.) force that method. Useful for forcing + "select" to avoid the Hybrid chunk method's failure on phone- + recorded MP4s with sparse keyframes (the "works up until near the + end, never saves chunks into a full file" bug). + + v4.1.0: *max_workers* / *threads_per_worker* override the + intelligent worker-count computation in EncoderWorker. When None, + EncoderWorker derives them from CPU topology so + ``worker_count * threads_per_worker <= logical_threads - 1`` + (preventing the thread-oversubscription hard-lock on high-core- + count machines). When set, the values are stored on + ``env.av1an_flags`` and picked up by EncoderWorker.__init__'s + fallback path — no ui_window.py code changes needed beyond this + launch_gui signature. + + v4.2.0: *use_av1an* opts INTO the av1an chunk-parallel path. The + default is False (ffmpeg-only), which is more reliable across + distros. av1an was too fragile: y4m pipe breaks on phone-recorded + MP4s, 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. Pass use_av1an=True only if you have + a known-good av1an+VapourSynth setup and want chunk-parallel. + + Equivalent to the v3 ``if __name__ == "__main__":`` block. + """ + app = QApplication(sys.argv if argv is None else argv) + window = OpenCodecMaster() + if force and hasattr(window, "force_check"): + window.force_check.setChecked(True) + # v4.0.0: --chunk-method override. The override is recorded into + # the window's pending store — the env probe runs ~500ms after the + # event loop starts, so there is no live EnvProbe to write to here; + # _probe_and_init applies the overrides the moment the probe lands. + if chunk_method is not None: + if chunk_method == "auto": + window._clear_cli_flag("chunk_method_override") + window._log(f"CLI override: chunk method = auto (cleared probe setting)") + else: + window._set_cli_flag("chunk_method_override", chunk_method) + window._log(f"CLI override: chunk method = {chunk_method}") + # v4.1.0: --max-workers / --threads-per-worker land in the same + # pending store (EncoderWorker.__init__ picks them up via its + # fallback path). The default (None on both) lets EncoderWorker + # auto-compute from CPU topology. + if max_workers is not None: + window._set_cli_flag("max_workers", int(max_workers)) + window._log(f"CLI override: max_workers = {max_workers}") + if threads_per_worker is not None: + window._set_cli_flag("threads_per_worker", int(threads_per_worker)) + window._log(f"CLI override: threads_per_worker = {threads_per_worker}") + # v4.2.0: --use-av1an. The default is False (ffmpeg-only). When + # True, the av1an pre-flight + smoke test runs as before. When + # False (default), the smoke test is skipped and use_ffmpeg_fallback + # is set to True directly, short-circuiting the entire av1an code + # path. + window._set_cli_flag("use_av1an", bool(use_av1an)) + if not use_av1an: + window._log("Encode mode: ffmpeg-only (default). Use --use-av1an for chunk-parallel.") + else: + window._log("Encode mode: av1an chunk-parallel (opt-in via --use-av1an).") + # v4.2.1: --verbose. Default False = quiet log (per-file + # success/fail + final summary). True = full tech detail (CMD:, + # live tail, DIAGNOSIS blocks, etc.). + window._set_cli_flag("verbose", bool(verbose)) + if verbose: + window._log("Verbose log: enabled (CMD:, live tail, DIAGNOSIS, etc.).") + # v4.3.0: --skip-existing. Default True = skip files whose output + # already exists with a matching codec. --force-reencode sets this + # to False. + window._set_cli_flag("skip_existing", bool(skip_existing)) + if skip_existing: + window._log("Skip-existing: enabled (use --force-reencode to disable).") + else: + window._log("Skip-existing: disabled (re-encoding all files).") + # v4.4.0: --timeout. Default 86400s = 24h. + window._set_cli_flag("encode_timeout", int(timeout)) + if timeout != 86400: + window._log(f"Per-file timeout: {timeout}s") + # v4.4.4: --inline-scale. Default False (use CRF-16 pre-scale + # intermediate, robust). When True, the scale/pad filter chain is + # passed directly to av1an via --ffmpeg-filter-args, skipping the + # intermediate file entirely. Pre-checks the UI checkbox so the + # user sees the state. The user can still toggle the checkbox + # manually — the UI value is re-read at encode time in + # _start_process. + window._set_cli_flag("inline_scale", bool(inline_scale)) + if inline_scale and hasattr(window, "inline_scale_check"): + window.inline_scale_check.setChecked(True) + window._log("Inline scale: enabled via --inline-scale (no intermediate for scaling).") + # v4.6.0: --engine (auto|gpu|cpu). "auto" (default) uses the NVENC + # GPU encoder when the selected codec family has one and the live + # encode test passed. Pre-selects the ENGINE combo so the user sees + # the state; the combo is re-read at encode time, so the user can + # still change it per run. + if gpu_profile != "auto": + window._set_cli_flag("gpu_profile", gpu_profile) + if hasattr(window, "gpu_combo"): + _gpu_keys = ["auto"] + [gp.key for gp in GPU_PROFILES] + if gpu_profile in _gpu_keys: + window.gpu_combo.setCurrentIndex(_gpu_keys.index(gpu_profile)) + window._log(f"GPU profile: {gpu_profile} (via --gpu-profile).") + if engine in ("gpu", "cpu", "hybrid"): + window._set_cli_flag("engine", engine) + if hasattr(window, "engine_combo"): + window.engine_combo.setCurrentIndex( + {"gpu": 1, "cpu": 2, "hybrid": 3}[engine] + ) + window._log(f"Engine: {engine.upper()} (via --engine).") + else: + window._set_cli_flag("engine", "auto") + if window.env is None: + # Probe has not run yet — the GPU verdict lands in the log + # when _probe_and_init completes. + window._log("Engine: auto — GPU verdict follows the environment probe.") + else: + gpu = getattr(window.env, "gpu", None) + if gpu is not None and gpu.has_gpu: + window._log( + f"Engine: auto — GPU detected ({gpu.name or 'NVIDIA'}), " + f"NVENC ready: {', '.join(gpu.usable_encoders)}." + ) + else: + window._log("Engine: auto — no usable GPU encoder, CPU encoders will be used.") + window.show() + return sys.exit(app.exec()) + +# ════════════════════════════════════════════════════════════════════════════ +# ═══ cli ═══ +# ════════════════════════════════════════════════════════════════════════════ +"""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. +""" + + + + +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. + 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. + + 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 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 + + + 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: + 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). + 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, + ) + + +# ── Entry point ── +# The original 4.8.1 launcher entry: open the GUI directly. No CLI +# parsing here — flags (--version/--dry-run/--engine/...) belong to the +# package path (`python -m opentranscode`, installed command). CLI args +# passed to this script are ignored, exactly as before. if __name__ == "__main__": app = QApplication(sys.argv) window = OpenCodecMaster() window.show() - sys.exit(app.exec()) \ No newline at end of file + sys.exit(app.exec()) diff --git a/opentranscode/__init__.py b/opentranscode/__init__.py deleted file mode 100755 index 9434bc5..0000000 --- a/opentranscode/__init__.py +++ /dev/null @@ -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, - ) diff --git a/opentranscode/__main__.py b/opentranscode/__main__.py deleted file mode 100755 index d6a5adb..0000000 --- a/opentranscode/__main__.py +++ /dev/null @@ -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()) diff --git a/opentranscode/cli.py b/opentranscode/cli.py deleted file mode 100755 index 8262b6c..0000000 --- a/opentranscode/cli.py +++ /dev/null @@ -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, - ) diff --git a/opentranscode/codec_profiles.py b/opentranscode/codec_profiles.py deleted file mode 100755 index 8d95d05..0000000 --- a/opentranscode/codec_profiles.py +++ /dev/null @@ -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"} diff --git a/opentranscode/cpu_topology.py b/opentranscode/cpu_topology.py deleted file mode 100755 index a0b2310..0000000 --- a/opentranscode/cpu_topology.py +++ /dev/null @@ -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, - ) - diff --git a/opentranscode/distro_probe.py b/opentranscode/distro_probe.py deleted file mode 100755 index a92a30a..0000000 --- a/opentranscode/distro_probe.py +++ /dev/null @@ -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, - ) - diff --git a/opentranscode/encoder_worker.py b/opentranscode/encoder_worker.py deleted file mode 100755 index 4317a95..0000000 --- a/opentranscode/encoder_worker.py +++ /dev/null @@ -1,2510 +0,0 @@ -"""EncoderWorker (QThread) — the per-file encode pipeline. - -v3-05 split the former 515-line ``run()`` into five single- -responsibility methods (``run`` / ``_process_one_file`` / -``_validate_file`` / ``_prepare_input`` / ``_encode_one`` / -``_verify_and_finalize``). v3-07 added the STOP-button interrupt -(Popen + start_new_session + SIGTERM/SIGKILL on the process group). - -Depends on: - - ``codec_profiles`` — VideoCodecProfile, AudioProfile, - ContainerProfile, ResolutionProfile, FFMPEG_LIB_KEY_MAP, - ffmpeg_lib_key_for. - - ``env_probe`` — EnvProbe (type), _av1an_env. - - ``ffprobe_utils`` — ffprobe_validate, ffprobe_duration, - _verify_output_resolution, _identify_file_type. - - ``temp_manager`` — _temp_path_for, _worker_temp_dir. - -""" - -import io -import json -import os -import re -import shutil -import signal -import subprocess -import threading -import time -from pathlib import Path - -from PySide6.QtCore import QThread, Signal - -from .codec_profiles import ( - FFMPEG_LIB_KEY_MAP, - AudioProfile, - ContainerProfile, - ResolutionProfile, - VideoCodecProfile, - ffmpeg_lib_key_for, -) -from .gpu_profiles import ( - encoder_filter_chain, - encoder_pre_args, - encoder_quality_args, - gpu_profile_by_key, -) -from .env_probe import EnvProbe, _av1an_env -from .ffprobe_utils import ( - _identify_file_type, - _verify_output_resolution, - ffprobe_duration, - ffprobe_validate, -) -from .keepawake import KeepAwake -from .temp_manager import _temp_path_for, _worker_temp_dir - -# ────────────────────────────────────────────── -# ENCODER WORKER (QThread, from PySide6 ver, extended) -# ────────────────────────────────────────────── - -def scan_input_files(in_dir: Path, extensions: set[str]) -> list[Path]: - """Collect the transcodable files under *in_dir* (v4.7.0). - - Used by ``EncoderWorker.run()`` for the default whole-directory scan - AND by the hybrid scheduler's pre-scan, which must partition the - queue BEFORE the per-lane workers are constructed. Excludes leftover - pre-scale intermediates from previous failed runs. - """ - return sorted( - f for f in in_dir.rglob("*") - if f.is_file() - and f.suffix.lower() in extensions - and not f.name.endswith(".scaled_tmp.mkv") - ) - - -def selected_gpu_profile(env): - """v4.8.0: the active GpuProfile — the UI's dropdown selection - (env.av1an_flags["gpu_profile"]) when set, else the auto-matched - profile from the probe (env.gpu.profile_key). None when neither.""" - flags = getattr(env, "av1an_flags", None) or {} - key = flags.get("gpu_profile") - if key and key != "auto": - profile = gpu_profile_by_key(key) - if profile is not None: - return profile - gpu_info = getattr(env, "gpu", None) - if gpu_info is not None and getattr(gpu_info, "profile_key", ""): - return gpu_profile_by_key(gpu_info.profile_key) - return None - - -def resolve_gpu_encoder(engine: str, video_codec, env): - """Decide whether this encode runs on the GPU. - - Returns ``(encoder_name, api)`` when the GPU path should be used — - e.g. ``("hevc_nvenc", "nvenc")`` or ``("hevc_vaapi", "vaapi")`` — or - ``(None, None)`` for the CPU path. - - Rules: - - ``engine == "cpu"`` → always CPU (user forced CPU). - - selected/matched GPU profile has - no encoder for the codec family → CPU (e.g. AV1 on Pascal, - VP9 without VAAPI). - - ``engine`` auto/gpu AND the - functional probe passed for - that encoder → the encoder. - - The gate is ``env.gpu.functional`` — a live encode test run by the - env probe — NOT the compiled-in ``ffmpeg -encoders`` list, because a - ffmpeg build can advertise a hardware encoder the installed driver - is too old to open. Pure function; no I/O. Safe to call from the UI - thread for a pre-flight status line. - """ - if engine not in ("auto", "gpu"): - return (None, None) - profile = selected_gpu_profile(env) - if profile is not None: - family = getattr(video_codec, "gpu_family", "") or "" - gpu_enc = profile.encoders.get(family) - api = profile.api - else: - # No GPU profile context (older callers / no probe data): fall - # back to the profile's NVENC encoder. - gpu_enc = getattr(video_codec, "gpu_encoder", "") or "" - api = "nvenc" if gpu_enc else None - if not gpu_enc: - return (None, None) - gpu_info = getattr(env, "gpu", None) - if gpu_info is not None and gpu_info.functional.get(gpu_enc, False): - return (gpu_enc, api) - return (None, None) - - -class EncoderWorker(QThread): - log_msg = Signal(str) - progress_msg = Signal(str, int, int) # (filename, current, total) - finished_queue = Signal(int, int) # (success_count, fail_count) - - # v4.4.3/v4.6.0: class-level defaults for attributes normally set in - # __init__. The mocked test suite builds workers via ``__new__`` - # (bypassing __init__) and calls non-Qt methods directly; without - # these defaults those instances crash with AttributeError on - # ``verbose`` / ``_current_total`` (the "AttributeError: no attribute - # 'verbose'" class of bugs from the v4.4.3 changelog). Instance - # assignment in __init__ shadows these harmlessly. - verbose = False - _current_idx = 0 - _current_total = 0 - _current_filename = "" - - def __init__( - self, - in_dir: Path, - out_dir: Path, - video_codec: VideoCodecProfile, - audio_profile: AudioProfile, - container: ContainerProfile, - crf: int, - preset_label: str, - delete_source: bool, - env: EnvProbe, - extensions: set[str], - resolution: ResolutionProfile, - audio_level_db: float = 0.0, - use_ffmpeg_fallback: bool = False, - subtitle_lang: str | None = None, - force: bool = False, - # v4.1.0: explicit overrides for the intelligent worker-count - # computation. When None, EncoderWorker computes (worker_count, - # threads_per_worker) from CPU topology so that - # ``worker_count * threads_per_worker <= logical_threads - 1`` - # (i.e. no thread oversubscription → no hard lock). When set, - # these take precedence — useful for troubleshooting or for - # workloads where the auto-compute picks a suboptimal split. - # Both can also be supplied via env.av1an_flags["max_workers"] / - # ["threads_per_worker"] (set by the CLI's --max-workers / - # --threads-per-worker flags) so the GUI doesn't need code changes - # to honor them. - max_workers: int | None = None, - threads_per_worker: int | None = None, - # v4.6.0: encode engine selection. "auto" uses the NVENC GPU - # encoder when the selected codec family has one AND the env - # probe's live encode test proved it works on this system; - # otherwise (or with "cpu") the CPU encoders are used. "gpu" - # requests GPU and falls back to CPU with a log line when the - # hardware is unavailable. Resolved against env.av1an_flags - # ["engine"] like the other CLI-plumbed flags. - engine: str | None = None, - # v4.7.0: hybrid-lane support. *file_subset* restricts this - # worker to an explicit file list (the hybrid scheduler scans and - # partitions the queue up front, then spawns a GPU-lane and a - # CPU-lane worker with disjoint subsets). *lane* suffixes the - # per-worker temp dir so one lane's cleanup sweep can never - # delete the other lane's intermediates. *ffmpeg_threads* caps - # the CPU lane's software ffmpeg encode (GPU jobs are capped by - # NVENC silicon, not threads). - file_subset: list[Path] | None = None, - lane: str = "", - ffmpeg_threads: int | None = None, - ): - super().__init__() - self.in_dir = in_dir - self.out_dir = out_dir - self.video_codec = video_codec - self.audio_profile = audio_profile - self.container = container - self.crf = crf - self.preset_val = video_codec.preset_map.get(preset_label, 6) - self.delete_source = delete_source - self.env = env - self.extensions = extensions - self.resolution = resolution - self.audio_level_db = audio_level_db - self.use_ffmpeg_fallback = use_ffmpeg_fallback - self.subtitle_lang = subtitle_lang - # v5: force=True skips ffprobe validation and attempts encode even - # for files ffprobe cannot read. Use for the 1% edge case where - # ffprobe fails but the file is actually valid (rare codec, broken - # container metadata, etc.). Default False — most "ffprobe can't - # read" files are genuinely invalid (failed downloads, HTML saved - # as .mp4, truncated files, etc.). - self.force = force - # v4.1.0: intelligent chunking overrides. Falls back to - # env.av1an_flags if not explicitly passed (so the CLI flags - # --max-workers / --threads-per-worker reach the GUI-spawned - # worker without ui_window.py code changes). - self.max_workers = max_workers if max_workers is not None else ( - env.av1an_flags.get("max_workers") if isinstance( - env.av1an_flags.get("max_workers"), int - ) else None - ) - self.threads_per_worker_override = ( - threads_per_worker if threads_per_worker is not None else ( - env.av1an_flags.get("threads_per_worker") if isinstance( - env.av1an_flags.get("threads_per_worker"), int - ) else None - ) - ) - # v4.6.0: engine selection ("auto" | "gpu" | "cpu"). Falls back to - # env.av1an_flags["engine"] when not passed explicitly (same - # pattern as max_workers — lets the CLI reach the GUI-spawned - # worker without ui_window changes). Resolved to a concrete - # GPU/CPU decision in run() via resolve_gpu_encoder(). - self.engine = engine if engine in ("auto", "gpu", "cpu") else ( - env.av1an_flags.get("engine", "auto") - if env.av1an_flags.get("engine") in ("auto", "gpu", "cpu") - else "auto" - ) - # Resolved in run(): NVENC encoder name when the GPU path is - # active, None for CPU. _ffmpeg_fallback_encode and - # _prepare_input read this (GPU mode implies the ffmpeg path — - # av1an cannot drive NVENC — which also means no pre-scale - # intermediate: the ffmpeg path scales inline). - self._gpu_encoder: str | None = None - self._gpu_api: str | None = None - # v4.2.1: quiet mode by default. Tech-detail log lines (CMD:, - # live tail of av1an/ffmpeg stderr, DIAGNOSIS blocks, resolution - # map, pre-flight validation table, heartbeat) are gated behind - # self.verbose. Default False = only per-file success/fail + - # final summary. Pass --verbose (or set - # env.av1an_flags["verbose"]=True) for the full tech dump. - self.verbose = bool(env.av1an_flags.get("verbose", False)) - # v4.3.0: skip-existing detection. When True (default), the - # worker probes the output file before encoding; if it already - # exists with a matching video+audio codec (and matching - # resolution when scaling was requested), the file is skipped - # instead of re-encoded. Pass --force-reencode (or set - # env.av1an_flags["skip_existing"]=False) to disable. - self.skip_existing = bool(env.av1an_flags.get("skip_existing", True)) - # v4.4.0: 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 (~5-10 fps) on a 2-hour movie takes 4-10 hours; the old - # 2h timeout killed massive-file encodes partway through. The STOP - # button handles user-initiated aborts; this timeout is just a - # safety net for truly wedged processes. Configurable via --timeout. - self.encode_timeout = int(env.av1an_flags.get("encode_timeout", 86400)) - # v4.4.4: inline_scale — when True and a target resolution is - # selected, the scale filter is passed directly to av1an via - # --ffmpeg-filter-args instead of pre-scaling to a CRF-16 - # intermediate. This skips the extra encode pass entirely (no - # intermediate file → no disk space wasted) and is significantly - # faster. The intermediate path remains the default because it - # works with every chunk method and is robust against av1an/ - # VapourSynth filter-arg quirks on older builds. Toggle via the - # "Inline scale (no intermediate)" UI checkbox or --inline-scale. - self.inline_scale = bool(env.av1an_flags.get("inline_scale", False)) - # Stashed per-file by _process_one_file so _encode_one can inject - # the scale filter into av1an's --ffmpeg-filter-args without - # changing its (recursively-called) signature. - self._current_scale_filter: str = "" - # Resolved at run() time — kept on self so _encode_one can read it - # without changing its call signature (which is invoked recursively - # by the y4m-pipe-break retry path). - self._resolved_threads_per_worker = 0 - self._stop = False - self._current_temps: list[Path] = [] # temps for the file currently being processed - self._sources_to_delete: list[Path] = [] # sources deferred for deletion after final cleanup - self.success_count = 0 - self.fail_count = 0 - # v4.3.0: tracks files skipped because the output already existed - # with a matching codec. Reported in the final summary as - # "Skipped: N" alongside Success/Failed. - self.skipped_count = 0 - # v5-02: track consecutive failures with the same error pattern. - # After 3 consecutive same-pattern failures, auto-abort the queue. - self._consecutive_fail_count = 0 - self._last_fail_pattern: str | None = None - # v3 (OTC-013, SEI CERT FIO09-C): each worker gets its own - # per-PID 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 created - # with mode=0o700 to prevent symlink attacks from other users. - self.file_subset = file_subset - self.lane = lane - self.ffmpeg_threads = ffmpeg_threads - self._temp_dir = _worker_temp_dir(os.getpid(), lane=lane) - # v6-06: KeepAwake instance — started in run(), stopped in finally. - # mouse_nudge defaults to False (opt-in) to avoid surprising the - # user with cursor movement. systemd-inhibit is always-on when - # available (no visible side effects). - self._keepawake = KeepAwake( - log_fn=lambda msg: self.log_msg.emit(msg), - enable_mouse_nudge=False, - ) - self._encode_start_time = 0.0 - # v4.4.0: per-file context for combined status lines. Stashed - # by _process_one_file so downstream methods can emit - # "[N/total] filename — STATUS" without changing their signatures. - self._current_idx = 0 - self._current_total = 0 - self._current_filename = "" - - def _status_prefix(self) -> str: - """v4.4.0: Build the '[N/total] filename — ' prefix for combined status lines.""" - if self._current_total: - return f"[{self._current_idx}/{self._current_total}] {self._current_filename} — " - return f"{self._current_filename} — " if self._current_filename else "" - - def _vlog(self, msg: str) -> None: - """Verbose-only log emit. No-op unless self.verbose is True. - - v4.2.1: the default log output is quiet — only per-file - success/fail + final summary. All tech detail (CMD: lines, - live tail of av1an/ffmpeg stderr, DIAGNOSIS blocks, resolution - maps, pre-flight validation, heartbeats) goes through _vlog so - it's suppressed by default. Pass --verbose to see it. - """ - if self.verbose: - self.log_msg.emit(msg) - - def _compute_intelligent_worker_count(self) -> tuple[int, int]: - """Compute ``(worker_count, threads_per_worker)`` to prevent thread - oversubscription on high-core-count machines. - - PROBLEM (v4.0.0 and earlier) - ---------------------------- - ``run()`` set ``worker_count = max(1, physical_cores - 1)`` and - passed no per-chunk thread cap to the encoder. SVT-AV1's default - ``--threads 0`` means "use all logical cores," so each chunk-parallel - worker spawned an SvtAv1EncApp process that grabbed every logical - thread. On a 28-thread Xeon (14 physical cores), 13 workers × 28 - threads = ~364 active threads on 28 logical CPUs — the kernel - scheduler drowns, I/O wait escalates, and the box hard-locks even - though no single process is at fault. The 1-second STOP-button - poll in ``_run_with_stop_check`` can't get scheduled, so even - clicking STOP doesn't recover it. - - v4.0.0 made it WORSE for the phone-video workload because the - ``--chunk-method select`` auto-override keeps the pipeline tighter - (no Hybrid warm-up between chunks), so more SVT-AV1 instances hit - full tilt at the same instant. - - SOLUTION - -------- - Budget the total thread count to ``logical_threads - 1`` (one - logical thread reserved for OS/UI), then split that budget across - chunk-parallel workers. Each encoder instance gets - ``--threads N`` so it can't grab more than its share. - - Algorithm - --------- - 1. ``budget = max(1, logical_threads - 1)`` — leave 1 logical - thread for OS / UI / av1an orchestrator. - 2. ``ideal_tpw = 4`` — empirical sweet spot for SVT-AV1, x265, - and vpxenc. Beyond ~6 threads per encoder instance you hit - memory-bandwidth contention and diminishing returns. - 3. ``target_workers = max(1, budget // ideal_tpw)``. - 4. Cap ``target_workers`` at ``max(1, physical_cores - 1)`` so - chunk-parallel never exceeds the physical core count. - 5. ``threads_per_worker = max(1, budget // target_workers)``. - 6. Apply user overrides (``self.max_workers`` / - ``self.threads_per_worker_override``) if provided. - - Examples - -------- - 4-core / 8-thread laptop: - budget=7, target_workers=7//4=1, tpw=7//1=7 → 1×7 = 7 - 8-core / 16-thread desktop: - budget=15, target_workers=15//4=3, tpw=15//3=5 → 3×5 = 15 - 14-core / 28-thread Xeon (the user's box): - budget=27, target_workers=27//4=6, tpw=27//6=4 → 6×4 = 24 - (leaves 4 logical threads for OS/UI breathing room) - 32-core / 64-thread EPYC: - budget=63, target_workers=63//4=15, tpw=63//15=4 → 15×4=60 - 1-core / 2-thread VM: - budget=1, target_workers=1, tpw=1 → 1×1 = 1 - - Returns ``(worker_count, threads_per_worker)``. Both are ≥1. - """ - physical = max(1, self.env.cpu.physical_cores) - logical = max(1, self.env.cpu.logical_threads) - - # User override short-circuit (highest priority). - if self.max_workers is not None and self.threads_per_worker_override is not None: - wc = max(1, int(self.max_workers)) - tpw = max(1, int(self.threads_per_worker_override)) - return wc, tpw - - # Budget: leave 1 logical thread for OS / UI / av1an orchestrator. - budget = max(1, logical - 1) - - # Ideal threads per encoder instance — empirical sweet spot. - # v4.1.0 used 4; v4.1.1 bumped to 6 because SVT-AV1 with only 4 - # threads was too slow per-chunk, making the total throughput - # feel "borked" even though the thread budget was correct. - # With 6 threads per worker, SVT-AV1 has enough parallelism for - # motion estimation while staying under the logical-thread budget. - IDEAL_THREADS_PER_WORKER = 6 - - # Target worker count from budget / ideal_tpw. - target_workers = max(1, budget // IDEAL_THREADS_PER_WORKER) - - # Cap at physical_cores - 1 so chunk-parallel doesn't exceed - # physical core count (avoids L3 cache thrash on chiplet CPUs). - max_by_phys = max(1, physical - 1) if physical > 1 else 1 - target_workers = min(target_workers, max_by_phys) - - # Apply --max-workers override if provided (still cap by physical). - if self.max_workers is not None: - target_workers = min(max(1, int(self.max_workers)), max_by_phys) - - # Compute threads per worker. - if self.threads_per_worker_override is not None: - tpw = max(1, int(self.threads_per_worker_override)) - else: - tpw = max(1, budget // target_workers) - - return target_workers, tpw - - def _run_with_stop_check( - self, - cmd: list[str], - env: dict[str, str] | None = None, - timeout: int = 7200, - log_prefix: str = " ", - ) -> tuple[str, int, str, str]: - """Run a subprocess with STOP-button support. - - Replaces ``subprocess.run(cmd, capture_output=True, text=True, - timeout=7200)`` in the av1an and ffmpeg-fallback encode paths so - that clicking STOP in the UI interrupts a running encode within - ~1 second instead of waiting up to 2 hours for the per-file - timeout to expire. - - Polls ``self._stop`` every ~1 second. When STOP is requested, - sends SIGTERM to the subprocess's *process group* (so av1an's - child encoders — SvtAv1EncApp / vpxenc / x265 — die too, not just - the av1an parent), waits 5s, then SIGKILLs the group if still - alive. Also enforces the overall ``timeout`` (7200s) limit. - - Two background drainer threads read stdout/stderr continuously - into StringIO buffers. This prevents the classic pipe-buffer - deadlock: av1an's progress bar can easily exceed the ~64KB OS - pipe buffer over a long encode, and without draining the child - would block on ``write()`` and ``proc.poll()`` would never see - it exit. This is the same pattern ``subprocess.run`` uses - internally via ``_communicate``. - - Returns a 4-tuple ``(status, returncode, stdout, stderr)`` where - ``status`` is one of: - - - ``"ok"`` — process exited normally; caller inspects - ``returncode`` (0 = success) and uses - ``stdout`` / ``stderr`` for diagnostics. - - ``"stop"`` — user requested STOP via the UI. Caller must - NOT increment ``fail_count`` (a user abort is - not a transcode failure). ``self._stop`` is - already True (set by the UI thread), so the - orchestrator's queue loop will break on the - next iteration and emit - "STOP: Aborted by user." - - ``"timeout"`` — process exceeded ``timeout`` seconds. - Caller MUST increment ``fail_count`` (a - timeout is a failure) and emit the existing - user-visible TIMEOUT message. - - Raises ``OSError`` / ``subprocess.SubprocessError`` if the - ``Popen`` constructor itself fails (e.g. ``FileNotFoundError`` - when the binary is missing) — the caller's existing ``except`` - clause handles these unchanged. - """ - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=env, - # start_new_session=True puts the child in its own process - # group (setsid). We can then os.killpg() the whole group - # to reach av1an's child encoders (SvtAv1EncApp / vpxenc / - # x265), which a bare proc.terminate() would miss. - start_new_session=True, - ) - - stdout_buf = io.StringIO() - stderr_buf = io.StringIO() - - # v4.1.1: live tail — emit each line of av1an's stdout/stderr - # to the GUI log as it arrives, so the user sees progress in - # real-time instead of staring at a frozen "Encoding: file.mp4" - # message for 10+ minutes. The previous drainer read into a - # StringIO buffer and only emitted on process exit, which made - # v4.1.0's slower (capped-thread) encodes look "borked" even - # though av1an was working fine underneath. - # - # Handles both \n (log lines) and \r (progress bar updates) as - # line boundaries, so av1an's progress bar renders correctly. - # Incomplete trailing data is buffered until the next read - # completes the line. - def _drain(stream, buf, emit_fn, prefix): - """Read from stream into buf, emitting each complete line via - emit_fn. Handles \\n and \\r as line boundaries.""" - pending = "" - try: - while True: - chunk = stream.read(4096) - if not chunk: - break - buf.write(chunk) - if emit_fn is None: - continue - pending += chunk - # Emit each complete line (delimited by \n or \r). - # av1an's progress bar uses \r; log lines use \n. - while True: - nl = pending.find('\n') - cr = pending.find('\r') - if nl == -1 and cr == -1: - break - if nl == -1: - pos = cr - elif cr == -1: - pos = nl - else: - pos = min(nl, cr) - line = pending[:pos] - pending = pending[pos + 1:] - stripped = line.rstrip() - if stripped: - try: - emit_fn(f"{prefix}{stripped}") - except (RuntimeError, OSError): - # Signal might be disconnected mid-encode - # if the GUI is closing. Stop emitting - # but keep draining the buffer. - emit_fn = None - break - if emit_fn is None: - break - except (OSError, ValueError): - # Stream closed under us or process gone — stop reading. - pass - # Emit any remaining pending data (process exited mid-line). - if emit_fn is not None: - stripped = pending.rstrip() - if stripped: - try: - emit_fn(f"{prefix}{stripped}") - except (RuntimeError, OSError): - pass - - tail_prefix = f"{log_prefix}│ " - # v4.2.1: gate live tail behind self.verbose. Default is quiet — - # no per-frame ffmpeg/av1an output in the GUI log. The buffer - # still captures everything for diagnostic purposes (returned - # to caller as stdout/stderr). - tail_emit = self.log_msg.emit if self.verbose else None - t_out = threading.Thread( - target=_drain, - args=(proc.stdout, stdout_buf, tail_emit, tail_prefix), - daemon=True, - ) - t_err = threading.Thread( - target=_drain, - args=(proc.stderr, stderr_buf, tail_emit, tail_prefix), - daemon=True, - ) - t_out.start() - t_err.start() - - status = "ok" - rc: int | None = None - start_time = time.monotonic() - # v4.1.1: heartbeat timer — emit a "still encoding" message every - # 30 seconds so the user knows the process is alive even if av1an - # isn't producing line-delimited output (e.g. during a long SVT-AV1 - # encode that only updates a \r progress bar, which the live tail - # emits as a single line that might not change for minutes). - last_heartbeat = start_time - HEARTBEAT_INTERVAL = 30 # seconds - while True: - rc = proc.poll() - if rc is not None: - # Process exited — break and drain pipes below. - break - - if self._stop: - self.log_msg.emit( - f"{log_prefix}STOP: Aborting current encode, " - f"terminating subprocess..." - ) - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except (ProcessLookupError, OSError): - # Process already gone — nothing to signal. - pass - try: - proc.wait(timeout=5) - except subprocess.TimeoutExpired: - # SIGTERM didn't take effect within the grace period — - # escalate to SIGKILL on the whole group. - try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except (ProcessLookupError, OSError): - pass - try: - proc.wait(timeout=2) - except subprocess.TimeoutExpired: - # Truly stuck (e.g. uninterruptible IO). We've - # done what we can; the process will be reaped - # later. Continue to pipe drainage. - pass - status = "stop" - self.log_msg.emit(f"{log_prefix}STOP: Subprocess terminated.") - break - - if time.monotonic() - start_time > timeout: - # Overall timeout — kill the process group. The caller - # logs the user-visible TIMEOUT message (it includes the - # file name / "ffmpeg" context this helper doesn't know). - try: - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - except (ProcessLookupError, OSError): - pass - try: - proc.wait(timeout=2) - except subprocess.TimeoutExpired: - pass - status = "timeout" - break - - # v4.4.1: heartbeat gated behind --verbose. The user wants - # just start + finish lines — no "still encoding" chatter - # in between. If a 10-hour encode looks hung without the - # heartbeat, they can run with --verbose to see it. - now = time.monotonic() - if self.verbose and now - last_heartbeat >= HEARTBEAT_INTERVAL: - elapsed = int(now - start_time) - self.log_msg.emit( - f"{log_prefix}... {elapsed}s elapsed" - ) - last_heartbeat = now - - time.sleep(1) - - # Wait for drainer threads to finish reading any remaining pipe - # data, then close the pipes explicitly (defensive — __del__ - # would also close them, but explicit is better and avoids - # ResourceWarning under -X dev). - t_out.join(timeout=10) - t_err.join(timeout=10) - try: - proc.stdout.close() - except (OSError, ValueError): - pass - try: - proc.stderr.close() - except (OSError, ValueError): - pass - - return ( - status, - rc if rc is not None else -1, - stdout_buf.getvalue(), - stderr_buf.getvalue(), - ) - - def _ffmpeg_fallback_encode( - self, - file_path: Path, - encode_input: Path, - output_f: Path, - ) -> bool: - """Encode a single file using pure ffmpeg (av1an fallback path). - - Used when av1an cannot initialize VapourSynth. No chunk-parallel - mode, but ffmpeg uses multithreaded encoding internally. - - Returns True on success, False on failure. - """ - # Check if ffmpeg has the video encoder we need - # v4.6.0: GPU mode swaps in the hardware encoder and its vargs. - # v4.8.0: VAAPI/QSV APIs build their command shape (device init, - # hwupload filter, quality args) from gpu_profiles; NVENC keeps - # the original profile vargs. - ffmpeg_enc = self._gpu_encoder or self.video_codec.ffmpeg_encoder - v_args = ( - (self.video_codec.gpu_vargs_fn or self.video_codec.ffmpeg_vargs_fn) - if self._gpu_encoder else self.video_codec.ffmpeg_vargs_fn - )(self.crf, self.preset_val) - hw_pre_args: list[str] = [] - hw_filter_args: list[str] = [] - if self._gpu_encoder and self._gpu_api in ("vaapi", "qsv"): - profile = selected_gpu_profile(self.env) - if profile is not None: - hw_pre_args = encoder_pre_args(profile) - hw_filter_args = encoder_filter_chain(profile) - v_args = encoder_quality_args( - self._gpu_api, ffmpeg_enc, self.crf, self.preset_val - ) - # v3: use the module-level FFMPEG_LIB_KEY_MAP (OTC-007). - ffmpeg_lib_key = ffmpeg_lib_key_for(ffmpeg_enc) - - if not self.env.ffmpeg_libs.get(ffmpeg_lib_key, False): - self.log_msg.emit( - f" FATAL: ffmpeg does not have '{ffmpeg_enc}' encoder. " - f"Cannot fall back. Install a ffmpeg build with {ffmpeg_enc} support." - ) - return False - - # Belt-and-suspenders: if a target resolution is set, inject -vf scale - # directly into the ffmpeg command. This guarantees the output resolution - # matches the dropdown even if the intermediate pre-scale was bypassed. - vf_scale_args: list[str] = [] - if self.resolution.width is not None and self.resolution.height is not None: - if self._gpu_api == "vaapi": - # v4.8.0: VAAPI scales ON the hardware — combine the - # scalar with the hwupload upload in one chain. - vf_scale_args = [ - "-vf", ( - f"scale={self.resolution.width}:{self.resolution.height}:" - f"force_original_aspect_ratio=decrease:force_divisible_by=2," - f"format=nv12,hwupload" - ), - ] - hw_filter_args = [] - else: - vf_scale_args = [ - "-vf", ( - f"scale={self.resolution.width}:{self.resolution.height}:" - f"force_original_aspect_ratio=decrease:force_divisible_by=2" - ), - ] - - # Audio args from profile - audio_args = list(self.audio_profile.params) - if abs(self.audio_level_db) > 0.01: - per_file_gain = self._analyze_audio_loudness(file_path) - if per_file_gain is not None and abs(per_file_gain) > 0.01: - audio_args.extend(["-af", f"volume={per_file_gain:+.1f}dB"]) - else: - static_db = f"{self.audio_level_db:+.1f}".replace("+", "") - audio_args.extend(["-af", f"volume={static_db}dB"]) - - # Container-specific muxer flags. -movflags +faststart is MP4-only - # (it relocates the moov atom for streaming); passing it for MKV or - # WebM is silently ignored by ffmpeg but pollutes the command line - # and confuses users reading the log. Apply it only when the - # output container is MP4. - mux_flags: list[str] = [] - if self.container.ext == "mp4": - mux_flags = ["-movflags", "+faststart"] - - cmd = [self.env.ffmpeg_path] + hw_pre_args + [ - "-i", str(encode_input), - ] + vf_scale_args + hw_filter_args + v_args - # v4.7.0: CPU lane thread cap in hybrid mode (the GPU lane's - # NVENC job keeps ~2 threads for decode/mux). Never applied on - # the GPU path — NVENC throughput is silicon-bound, not - # thread-bound. Skipped when the cap is 0/unset. - if self._gpu_encoder is None and self.ffmpeg_threads: - # v4.7.1: libx265 maps -threads to frame-threads, capped at - # X265_MAX_FRAME_THREADS (16) — larger values abort the - # encoder ("frameNumThreads must be [0 .. X265_MAX_FRAME_ - # THREADS)"). SVT-AV1 and libvpx accept the full budget. - threads = self.ffmpeg_threads - if ffmpeg_enc == "libx265": - threads = min(threads, 16) - cmd += ["-threads", str(threads)] - cmd += audio_args + mux_flags + [ - "-y", - str(output_f), - ] - - try: - result = self._run_with_stop_check(cmd, timeout=self.encode_timeout, log_prefix=" ") - status, rc, stdout, stderr = result - - if status == "stop": - # User requested STOP — do NOT count as failure. The - # caller (_process_one_file) guards the fail_count - # increment with `if not self._stop`. Remove partial - # output so it isn't mistaken for a finished file. - output_f.unlink(missing_ok=True) - return False - if status == "timeout": - self.log_msg.emit(f"{self._status_prefix()}FAIL: timeout (exceeded {self.encode_timeout}s limit)") - return False - - # status == "ok" — wrap in CompletedProcess so the downstream - # returncode/stderr logic is byte-for-byte unchanged. - res = subprocess.CompletedProcess(cmd, rc, stdout, stderr) - - if res.returncode == 0 and output_f.exists(): - src_size = file_path.stat().st_size - out_size = output_f.stat().st_size - ratio = out_size / src_size if src_size > 0 else 0 - - # Integrity gate: 1KB absolute minimum. A valid container - # header alone is ~1KB; anything below is definitely corrupt. - # The duration check in _verify_and_finalize (≥95% of source - # duration) is the real quality gate for high-bitrate sources. - if out_size > 1024: - return True - else: - self.log_msg.emit( - f" INTEGRITY: output only {ratio * 100:.1f}% of source." - ) - output_f.unlink(missing_ok=True) - return False - else: - stderr_snip = (res.stderr or "")[-300:] - self.log_msg.emit( - f" ffmpeg error (rc={res.returncode}): {stderr_snip.strip()}" - ) - # v4.7.1: remove the partial output. Without this, a - # failed encode left a truncated file that ffprobe can - # still parse as the right codec — and skip-existing - # would then treat it as a finished archive forever. - output_f.unlink(missing_ok=True) - return False - except OSError as e: - self.log_msg.emit(f"{self._status_prefix()}FAIL: system error: {e}") - return False - - def run(self): - # v4.1.0: intelligent worker count + per-chunk thread cap. - # Replaces the v3 ``max(1, physical_cores - 1)`` heuristic that - # produced 13 workers × auto (≈28) = 364 threads on a 28-thread - # Xeon and drowned the kernel scheduler (hard lock). - # _compute_intelligent_worker_count returns (worker_count, - # threads_per_worker) such that - # worker_count * threads_per_worker <= logical_threads - 1 - # The threads_per_worker is stashed on self so _encode_one can - # inject it into the encoder's --video-params (each SvtAv1EncApp - # / vpxenc / x265 instance then respects its share). - worker_count, threads_per_worker = self._compute_intelligent_worker_count() - self._resolved_threads_per_worker = threads_per_worker - phys = self.env.cpu.physical_cores - logical = self.env.cpu.logical_threads - - # ── v4.6.0: engine resolution (GPU vs CPU) ── - # GPU mode is a variant of the ffmpeg path: av1an invokes - # encoder CLI binaries (SvtAv1EncApp / vpxenc / x265) and cannot - # drive NVENC, so an active GPU encoder forces the single-pass - # ffmpeg path. NVENC on even a GTX 1070 encodes 1080p at several - # hundred fps — one ffmpeg process beats av1an's chunk-parallel - # CPU workers, and chunking becomes unnecessary. - self._gpu_encoder, self._gpu_api = resolve_gpu_encoder( - self.engine, self.video_codec, self.env) - if self._gpu_encoder: - self.use_ffmpeg_fallback = True - self.log_msg.emit( - f"ENGINE: GPU ({self._gpu_encoder}, {self._gpu_api}) — " - f"single-pass ffmpeg hardware encode; av1an chunk-parallel " - f"not used." - ) - elif self.engine == "gpu": - gpu_enc = getattr(self.video_codec, "gpu_encoder", "") or "" - if not gpu_enc and not getattr(self.video_codec, "gpu_family", ""): - self.log_msg.emit( - f"ENGINE: GPU requested but {self.video_codec.label} has no " - f"hardware encoder — using CPU." - ) - else: - gpu = getattr(self.env, "gpu", None) - detail = gpu.first_failure_detail if gpu is not None else "" - self.log_msg.emit( - f"ENGINE: GPU requested but no usable hardware encoder for " - f"{self.video_codec.label} ({detail or 'unavailable'}) — using CPU." - ) - - # Collect all valid files first (for progress tracking) - # Exclude our own temp intermediates from previous failed runs. - if self.file_subset is not None: - # v4.7.0: hybrid lane — the scheduler partitioned the queue. - all_files = sorted(self.file_subset) - else: - all_files = scan_input_files(self.in_dir, self.extensions) - total = len(all_files) - - if total == 0: - self.log_msg.emit("INFO: No matching files found in source directory.") - self.finished_queue.emit(0, 0) - return - - # ── Mode banner ── - # v4.2.1: mode banner is verbose-only. The user doesn't need - # to know the worker math — they just need files to encode. - # use_ffmpeg_fallback is set by the main thread's pre-flight check. - if self.verbose: - if self._gpu_encoder: - self._vlog( - f"GPU encode: {self.video_codec.label} via " - f"{self._gpu_encoder} (NVENC), CPU decode" - ) - elif self.use_ffmpeg_fallback: - self._vlog( - f"FFmpeg fallback: {self.video_codec.ffmpeg_encoder} on {phys} cores " - f"(single-pass, no chunk-parallel)" - ) - else: - # v4.1.0: show the thread budget so the user can verify the - # intelligent worker math at a glance. e.g. on a 28-thread Xeon: - # "Chunk-parallel: 6 workers × 4 threads = 24 active - # (28 logical - 4 reserved for OS/UI)" - active = worker_count * threads_per_worker - reserved = logical - active - self._vlog( - f"Chunk-parallel: {worker_count} workers × {threads_per_worker} threads " - f"= {active} active " - f"({logical} logical - {reserved} reserved for OS/UI)" - ) - if self.max_workers is not None or self.threads_per_worker_override is not None: - self._vlog( - f" (overrides: max_workers={self.max_workers!r}, " - f"threads_per_worker={self.threads_per_worker_override!r})" - ) - self.log_msg.emit(f"Found {total} file(s) to process.") - self._vlog(f"Temp dir: {self._temp_dir}") - - # ── Pre-scan: show each file's source → output resolution ── - # v4.2.1: resolution map is verbose-only. - needs_scale = ( - self.resolution.width is not None - and self.resolution.height is not None - ) - if self.verbose: - if needs_scale: - self._vlog(f"Output resolution: {self.resolution.width}x{self.resolution.height} ({self.resolution.aspect_label})") - else: - self._vlog("Output resolution: Original (no scaling)") - self._vlog("─── FILE RESOLUTION MAP ───") - self._file_res_map: dict[Path, tuple] = {} # file -> (src_w, src_h, out_w, out_h) - if self.env.ffprobe_path: - for f in all_files: - info = ffprobe_validate(f, self.env.ffprobe_path) - sw, sh = None, None - if info: - for s in info.get("streams", []): - if s.get("codec_type") == "video": - sw = int(s.get("width", 0) or 0) - sh = int(s.get("height", 0) or 0) - break - if sw and sh: - ow, oh = (self.resolution.width, self.resolution.height) if needs_scale else (sw, sh) - self._file_res_map[f] = (sw, sh, ow, oh) - if self.verbose: - arrow = "->" if needs_scale else "=" - action = "" if needs_scale or sw == ow else " (no change)" - self._vlog(f" {f.name:<40s} {sw:>5}x{sh:<5} {arrow} {ow:>5}x{oh}{action}") - else: - self._file_res_map[f] = (None, None, self.resolution.width if needs_scale else None, self.resolution.height if needs_scale else None) - if self.verbose: - self._vlog(f" {f.name:<40s} (unknown resolution)") - else: - if self.verbose: - self._vlog(" (ffprobe unavailable — resolution map skipped)") - if self.verbose: - self._vlog("───────────────────────────") - - # ── v5-04: Pre-flight validation pass ── - # Scan all files with ffprobe BEFORE the encode loop. Report how - # many are valid vs invalid. This gives the user immediate feedback - # ("46 files found, 0 valid, 46 invalid") instead of failing one - # by one over 2 hours. If ALL files are invalid and force=False, - # abort now — don't waste time entering the encode loop. - if self.env.ffprobe_path and not self.force: - valid_count = 0 - invalid_count = 0 - invalid_samples: list[str] = [] - for f in all_files: - info = ffprobe_validate(f, self.env.ffprobe_path) - if info is None: - invalid_count += 1 - if len(invalid_samples) < 3: - ft = _identify_file_type(f) - invalid_samples.append(f" {f.name}: {ft}" if ft else f" {f.name}: (file type unknown)") - else: - has_video = any(s.get("codec_type") == "video" for s in info.get("streams", [])) - duration = float(info.get("format", {}).get("duration", 0)) - if has_video and duration >= 0.5: - valid_count += 1 - else: - invalid_count += 1 - if len(invalid_samples) < 3: - reason = "no video stream" if not has_video else f"too short ({duration:.1f}s)" - invalid_samples.append(f" {f.name}: {reason}") - - # v4.2.1: pre-flight validation table is verbose-only. - # The ABORT message (when ALL files are invalid) stays loud. - if self.verbose: - self._vlog("─── PRE-FLIGHT VALIDATION ───") - self._vlog(f" Valid files: {valid_count}") - self._vlog(f" Invalid files: {invalid_count}") - if invalid_samples: - self._vlog(f" First {len(invalid_samples)} invalid:") - for s in invalid_samples: - self._vlog(s) - self._vlog("─────────────────────────────") - - if valid_count == 0 and invalid_count > 0: - self.log_msg.emit("") - self.log_msg.emit( - f"ABORT: All {invalid_count} file(s) are invalid. " - f"Aborting queue — no files to encode." - ) - self._vlog( - " Common causes: (1) failed yt-dlp downloads (HTML saved as .mp4), " - "(2) files on a network mount that's not responding, " - "(3) wrong input directory." - ) - self._vlog( - " Run `file ` on any file to see what it actually is." - ) - self.fail_count = invalid_count - self._final_cleanup_sweep() - self.log_msg.emit( - f"QUEUE COMPLETE. Success: 0, Failed: {self.fail_count}." - ) - self.finished_queue.emit(0, self.fail_count) - return - elif invalid_count > 0: - # v4.2.1: keep the one-line skip notice (user-facing) but - # drop the empty line — it just wastes vertical space. - self.log_msg.emit( - f" {invalid_count} invalid file(s) will be skipped." - ) - - scale_filter = ( - f"scale={self.resolution.width}:{self.resolution.height}:" - f"force_original_aspect_ratio=decrease:force_divisible_by=2," - f"pad={self.resolution.width}:{self.resolution.height}:(ow-iw)/2:(oh-ih)/2" - ) if needs_scale else "" - - # v6-06: Start keep-awake (systemd-inhibit + optional mouse nudge) - self._encode_start_time = time.monotonic() - self._keepawake.start() - try: - for idx, file_path in enumerate(all_files, 1): - if self._stop: - self.log_msg.emit("STOP: Aborted by user.") - break - - prev_success = self.success_count - prev_fail = self.fail_count - - # v6-06: Update keep-awake ETA before each file. - # ETA = (avg time per file so far) × (remaining files) - processed = idx - 1 - if processed > 0: - elapsed = time.monotonic() - self._encode_start_time - avg_per_file = elapsed / processed - remaining = total - processed - self._keepawake.update_eta(avg_per_file * remaining) - else: - self._keepawake.update_eta(None) # unknown for first file - - self._process_one_file(file_path, idx, total, worker_count, needs_scale, scale_filter) - - # v5-02: track consecutive failures with the same error pattern. - # After 3 consecutive same-pattern failures, auto-abort the queue. - if self.fail_count > prev_fail: - # This file failed — extract the failure pattern from the - # last log message (the DIAGNOSIS line or the FAIL line). - # We use the first 80 chars as a coarse pattern fingerprint. - # If the pattern matches the previous failure, increment the - # consecutive counter; otherwise reset it. - # (We can't access the log messages directly from here, so - # we use a simpler heuristic: if the fail count increased - # and the success count didn't, it's a failure. The pattern - # is tracked via _last_fail_pattern set in _encode_one.) - pass # pattern tracking is handled in _process_one_file - elif self.success_count > prev_success: - # Success resets the consecutive failure counter. - self._consecutive_fail_count = 0 - self._last_fail_pattern = None - - # ── Final cleanup pass: residual sweep ── - self._final_cleanup_sweep() - - # ── Deferred source deletion (only after all cleanup is done) ── - if self._sources_to_delete: - deleted = 0 - for src in self._sources_to_delete: - try: - if src.exists(): - src.unlink() - deleted += 1 - except OSError: - # Best-effort: a single un-deletable source must not abort - # the rest of the deferred-deletion sweep. - pass - self.log_msg.emit(f"CLEANED: Removed {deleted} source file(s).") - self._sources_to_delete.clear() - - # v4.2.1: single final summary line — no mode prefix. - # v4.3.0: include skipped count when > 0. - if self.skipped_count > 0: - self.log_msg.emit( - f"QUEUE COMPLETE. Success: {self.success_count}, " - f"Failed: {self.fail_count}, Skipped: {self.skipped_count}." - ) - else: - self.log_msg.emit( - f"QUEUE COMPLETE. Success: {self.success_count}, Failed: {self.fail_count}." - ) - self.finished_queue.emit(self.success_count, self.fail_count) - finally: - # v6-06: Always stop keep-awake, even if the encode loop crashed. - self._keepawake.stop() - - def _process_one_file(self, file_path, idx, total, worker_count, needs_scale, scale_filter): - """Process a single file end-to-end (validate -> prepare -> encode -> verify). - - Extracted from run() so the per-file control flow is readable. All - `continue` statements from the original loop become early `return`s - here. The caller (run) simply iterates and re-checks `self._stop` at - the top of each iteration. - - v4.4.0: the per-file banner is NOT emitted upfront. Instead, each - terminal status (SKIP / OK / FAIL) emits a SINGLE combined line: - [N/total] filename — SKIP (already av1/opus) - [N/total] filename — OK: 1.6MB -> 1.3MB (81%) - [N/total] filename — FAIL: - This halves the log line count for skipped files and makes the - status visible at a glance without scrolling. The heartbeat - (every 30s) is the only thing emitted mid-encode. - """ - self.progress_msg.emit(file_path.name, idx, total) - # v4.4.0: stash idx/total on self so downstream methods - # (_verify_and_finalize, _encode_one) can emit combined status - # lines with the [N/total] filename prefix without changing - # their call signatures. - self._current_idx = idx - self._current_total = total - self._current_filename = file_path.name - # v4.4.4: stash scale_filter so _encode_one can inject it into - # av1an's --ffmpeg-filter-args when self.inline_scale is True. - self._current_scale_filter = scale_filter or "" - - # --- ffprobe pre-validation --- - # _validate_file uses _status_prefix() for combined log lines, - # which reads self._current_idx/_current_total set above. - skip, info, src_w, src_h = self._validate_file(file_path) - if skip: - # v5-02: a skip is a failure for consecutive-failure tracking. - self._check_consecutive_failures(file_path, accepted=False) - return # _validate_file already logged SKIP + incremented fail_count - - # --- Determine actual output resolution --- - # v4.2.1: source/output resolution is verbose-only. - if self.verbose and src_w and src_h: - out_w, out_h = src_w, src_h - if needs_scale: - out_w, out_h = self.resolution.width, self.resolution.height - self._vlog(f" Source: {src_w}x{src_h} -> Output: {out_w}x{out_h}") - elif self.verbose: - if needs_scale: - self._vlog(f" Source: unknown -> Output: {self.resolution.width}x{self.resolution.height}") - else: - self._vlog(f" Source: unknown -> Output: original") - - # --- Pre-scale / symlink + build output path --- - prepared = self._prepare_input(file_path, src_w, src_h, needs_scale, scale_filter) - if prepared is None: - # v5-02: prepare failure counts for consecutive-failure tracking. - self._check_consecutive_failures(file_path, accepted=False) - return # _prepare_input already logged + cleaned up + incremented fail_count - encode_input, output_f = prepared - - # v4.3.0: skip-existing detection. If the output file already - # exists with a matching video+audio codec (and matching - # resolution when scaling was requested), skip the encode - # entirely. This is the default (--skip-existing); pass - # --force-reencode to disable. A skip is NOT a failure — it's - # treated as a successful no-op and tracked in skipped_count. - # v4.4.0: moved ABOVE the disk-space check so skipped files - # don't trigger disk-space warnings. A skipped file writes - # nothing to disk, so warning about free space for it is noise - # that buries the SKIP status the user actually needs to see. - # Also: combined into a single log line with the [N/total] prefix. - if self.skip_existing and self._output_already_encoded(file_path, output_f): - self.skipped_count += 1 - vcodec = self.video_codec.ffprobe_codec_name or "?" - acodec = self.audio_profile.ffprobe_codec_name or "?" - self.log_msg.emit( - f"[{idx}/{total}] {file_path.name} — SKIP (already {vcodec}/{acodec})" - ) - # v5-02: a skip counts as a success for consecutive-failure - # tracking — it's not a failure, and the queue shouldn't - # auto-abort on a run of skips. - self._check_consecutive_failures(file_path, accepted=True) - # Clean up any temps _prepare_input may have created (symlinks - # for av1an, pre-scaled intermediates). The output file - # itself is NOT touched. - self._cleanup_current_temps() - # Defer source deletion if requested — a skip is a successful - # transcode from the user's perspective (the output exists - # and matches their codec selection). - if self.delete_source: - self._sources_to_delete.append(file_path) - return - - # v4.4.0: disk space pre-check for massive files. Warns (does NOT - # abort) if free space on the output/temp partition is less than - # the source size. Skipped for files < 1 GB. Runs ONLY for files - # we're actually about to encode (after the skip-existing check). - self._check_disk_space(file_path, output_f, needs_scale) - - # v4.4.0: emit the per-file banner HERE (not at the top of - # _process_one_file) so skipped files don't get a dangling - # "[N/total] filename" line with no status. The heartbeat will - # fire during the encode to show progress. The final OK/FAIL - # status line at the end of the encode will repeat the prefix, - # but that's fine — it's how the user matches status to file. - self.log_msg.emit(f"[{idx}/{total}] {file_path.name}") - - # --- Encode --- - encode_ok = self._encode_one(file_path, encode_input, output_f, worker_count) - if not encode_ok: - # ffmpeg fallback path: _ffmpeg_fallback_encode does NOT touch - # _current_temps or fail_count, so we do both here to match the - # original `else: self.fail_count += 1; self._cleanup_current_temps()`. - # av1an path: _encode_one's `finally` already cleaned temps and - # fail_count was incremented inside _encode_one. - # - # STOP exception: when the user clicked STOP mid-encode, - # _run_with_stop_check returned "stop" and _ffmpeg_fallback_encode - # returned False WITHOUT incrementing fail_count (a user abort is - # not a transcode failure). Honor that here by skipping the - # fail_count increment when self._stop is set — temp cleanup - # still runs so we don't leak intermediate files. - if self.use_ffmpeg_fallback: - if not self._stop: - self.fail_count += 1 - self._cleanup_current_temps() - # v5-02: encode failure counts for consecutive-failure tracking - # (but only if not a user STOP — a STOP is not a failure). - if not self._stop: - self._check_consecutive_failures(file_path, accepted=False) - return - - # --- Post-encode verification + finalize --- - accepted = self._verify_and_finalize(file_path, output_f, encode_input, needs_scale) - if self.use_ffmpeg_fallback: - # ffmpeg path always cleans up explicitly at every exit - # (av1an path already cleaned up via _encode_one's `finally`). - self._cleanup_current_temps() - # accepted=True -> success_count already incremented in _verify_and_finalize. - # accepted=False -> fail_count already incremented + output unlinked there. - - # v5-02: check for consecutive failures with the same pattern. - self._check_consecutive_failures(file_path, accepted) - - def _check_consecutive_failures(self, file_path: Path, accepted: bool): - """v5-02: Track consecutive failures and auto-abort after 3. - - After 3 consecutive failures (regardless of pattern — if 3 files - in a row fail, something is systematically wrong), auto-abort the - queue with a clear message. The user can still click STOP to - abort earlier. - - This prevents the scenario from the user's log: 46 files, all - failing identically, processed one by one over ~2 hours. With - this fix, the queue aborts after file 3. - """ - if accepted: - self._consecutive_fail_count = 0 - return - - self._consecutive_fail_count += 1 - if self._consecutive_fail_count >= 3 and not self._stop: - self.log_msg.emit("") - self.log_msg.emit( - f"ABORT: {self._consecutive_fail_count} consecutive failures. " - f"Auto-aborting queue — something is systematically wrong." - ) - self.log_msg.emit( - " The remaining files will likely fail the same way. " - "Fix the root cause (check the diagnostics above) and retry." - ) - self.log_msg.emit( - " Common root causes: (1) all files are invalid (failed downloads), " - "(2) av1an/encoder binary is broken, (3) out of disk space, " - "(4) network mount is down." - ) - self._stop = True - - def _validate_file(self, file_path): - """ffprobe pre-validation. Returns (skip, info, src_w, src_h). - - skip=True signals the caller to abandon this file — the SKIP log - line and fail_count increment have already happened here. - - If ffprobe cannot read the file, SKIP it. The encode fails ~99% - of the time when ffprobe fails (failed download, HTML saved as - .mp4, truncated, etc.). The `force=True` constructor flag - overrides this for the rare edge case (rare codec, broken - container metadata where ffprobe fails but ffmpeg can decode). - """ - # Reuse pre-scanned dimensions if available, otherwise probe now - prescan = self._file_res_map.get(file_path) - src_w, src_h = (prescan[0], prescan[1]) if prescan else (None, None) - info = None - if self.env.ffprobe_path: - info = ffprobe_validate(file_path, self.env.ffprobe_path) - if info is None: - if self.force: - # v4.2.1: verbose-only - self._vlog( - f"WARN: ffprobe could not read {file_path.name} — " - f"attempting encode anyway (force=True)." - ) - else: - # Identify the file's actual type via `file` command. This - # reveals "HTML document" (failed yt-dlp download) vs "data" - # (truncated/encrypted) vs "ISO Media" (valid MP4 that ffprobe - # just can't parse). Combined single-line status with prefix. - file_type = _identify_file_type(file_path) - self.log_msg.emit(f"{self._status_prefix()}SKIP: not a valid video (ffprobe could not read it)") - if file_type and self.verbose: - self._vlog(f" File type: {file_type}") - if "HTML" in file_type or "ASCII" in file_type or "text" in file_type: - self._vlog( - " This looks like a text/HTML file, not a video. " - "Common cause: failed yt-dlp download (region-locked, " - "age-restricted, or removed video). Re-download the file." - ) - elif "data" in file_type: - self._vlog( - " File type is 'data' — possibly truncated, encrypted, " - "or a partial download. Verify the file plays in mpv/VLC." - ) - if self.verbose: - self._vlog( - " (Use the Force checkbox to attempt encode anyway.)" - ) - self.fail_count += 1 - return (True, None, None, None) - else: - duration = float(info.get("format", {}).get("duration", 0)) - has_video = any(s.get("codec_type") == "video" for s in info.get("streams", [])) - if not has_video: - self.log_msg.emit(f"{self._status_prefix()}SKIP: no video stream") - self.fail_count += 1 - return (True, None, None, None) - if duration < 0.5: - self.log_msg.emit(f"{self._status_prefix()}SKIP: too short ({duration:.1f}s)") - self.fail_count += 1 - return (True, None, None, None) - # Extract dims if pre-scan didn't have them - if not src_w or not src_h: - for s in info.get("streams", []): - if s.get("codec_type") == "video": - src_w = int(s.get("width", 0) or 0) - src_h = int(s.get("height", 0) or 0) - break - return (False, info, src_w, src_h) - - def _prepare_input(self, file_path, src_w, src_h, needs_scale, scale_filter): - """Pre-scale (if needed) and ensure the av1an work dir lands in temp. - - Returns (encode_input, output_f) on success, or None on failure - (after logging + cleaning up current temps + incrementing fail_count). - """ - # --- Pre-scale with ffmpeg if target resolution selected --- - # ALL intermediates (scaled files, av1an work dirs) go to the app - # temp directory so the user's video folders stay clean. - encode_input = file_path - - # v4.6.0: pre-scale ONLY on the av1an path. The pure-ffmpeg path - # (the default, and the only path NVENC can run on) scales inline - # via the -vf args in _ffmpeg_fallback_encode — the intermediate - # existed solely because VapourSynth source plugins choke on some - # inputs ffmpeg handles fine. Skipping it on the ffmpeg path - # removes the entire class of large-file failures: no 0.5-0.8x - # source-size temp file, no extra full encode pass, and no - # pre-scale timeout on long/high-bitrate sources. - if needs_scale and not self.inline_scale and not self.use_ffmpeg_fallback: - try: - temp_scaled = _temp_path_for(file_path, ".scaled_tmp.mkv", worker_dir=self._temp_dir) - self._current_temps.append(temp_scaled) - # Use libx265 CRF 16 (visually lossless) for the intermediate — - # NOT ffv1 (unsupported by VapourSynth source plugins: bestsource, - # ffms2, lsmash — produces empty pipe → "Fatal: Failed to open - # input file") and NOT CRF 0 (mathematically lossless → 2-4× - # source size; a 20GB BluRay rip produced a 60-80GB intermediate - # and crashed the encode with disk-exhaustion errors that - # presented as cryptic "ffmpeg error (rc=234)" messages). - # CRF 16 is transparent for archival purposes (~0.5-0.8× source - # size) and HEVC-in-MKV is universally supported by every VS plugin. - # For an even faster path that skips the intermediate entirely, - # see the inline_scale option (passes scale filter to av1an via - # --ffmpeg-filter-args). - scale_cmd = [ - self.env.ffmpeg_path, - "-i", str(file_path), - "-vf", scale_filter, - "-c:v", "libx265", - "-crf", "16", # visually lossless — was 0 (mathematically lossless) - "-preset", "ultrafast", - "-pix_fmt", "yuv420p", # force 8-bit 4:2:0 - "-y", - str(temp_scaled), - ] - # v4.2.1: Scaling notice is verbose-only. - self._vlog(f" Scaling {src_w or '?'}x{src_h or '?'} -> {self.resolution.width}x{self.resolution.height}...") - # v4.6.0: run via _run_with_stop_check with the full - # per-file timeout. The old flat - # subprocess.run(timeout=1800) killed pre-scaling of - # long/high-bitrate sources at exactly 30 minutes - # ("FAIL: pre-scale error: Command ... timed out") — a - # guaranteed large-file failure that also ignored the - # STOP button for the whole intermediate pass. - scale_status, scale_rc, _s_out, scale_err = self._run_with_stop_check( - scale_cmd, timeout=self.encode_timeout, log_prefix=" ", - ) - if scale_status == "stop": - # User aborted — clean up the partial intermediate and - # bail WITHOUT counting a failure (a STOP is not an - # encode failure; the queue loop breaks next iteration). - self._cleanup_current_temps() - return None - if scale_status == "timeout": - self.log_msg.emit( - f"{self._status_prefix()}FAIL: pre-scale timeout " - f"(exceeded {self.encode_timeout}s limit)" - ) - temp_scaled.unlink(missing_ok=True) - self._cleanup_current_temps() - self.fail_count += 1 - return None - if scale_status == "ok" and scale_rc == 0 and temp_scaled.exists(): - encode_input = temp_scaled - scaled_size = temp_scaled.stat().st_size / 1_048_576 - # v4.2.1: verbose-only - self._vlog(f" Pre-scale OK ({scaled_size:.1f} MB intermediate)") - else: - stderr_snip = (scale_err or "")[-200:] - # v4.2.1: keep user-facing FAIL but shorten; stderr verbose-only - self.log_msg.emit( - f"{self._status_prefix()}FAIL: pre-scale failed (rc={scale_rc})" - ) - if stderr_snip.strip(): - self._vlog(f" ffmpeg stderr: {stderr_snip.strip()}") - temp_scaled.unlink(missing_ok=True) - self._cleanup_current_temps() - self.fail_count += 1 - return None - except (OSError, subprocess.SubprocessError) as e: - # v4.2.1: keep user-facing but shorten - self.log_msg.emit(f"{self._status_prefix()}FAIL: pre-scale error: {e}") - self._cleanup_current_temps() - self.fail_count += 1 - return None - - # --- Ensure av1an work dir lands in the temp directory --- - # av1an creates its work dir as {input_path}.av1an by default. - # We do NOT use av1an's --temp flag because it causes "Error: End of file" - # during scene detection when the input file is in the same directory - # as --temp (av1an 0.5.2-unstable). Instead, we ensure the -i argument - # always points into the temp dir (pre-scaled files already live there; - # for no-scale we create a symlink). - if not encode_input.is_relative_to(self._temp_dir): - symlink_path = _temp_path_for(file_path, encode_input.suffix, worker_dir=self._temp_dir) - try: - symlink_path.unlink(missing_ok=True) - symlink_path.symlink_to(file_path.resolve()) - self._current_temps.append(symlink_path) - encode_input = symlink_path - except OSError as e: - self.log_msg.emit( - f" WARN: Could not create symlink in temp dir: {e}. " - f"av1an work dir will be created next to source file." - ) - # Track the work dir where av1an will actually create it - av1an_work = Path(f"{encode_input}.av1an") - self._current_temps.append(av1an_work) - - # --- Build output path (preserve directory structure) --- - rel_path = file_path.relative_to(self.in_dir) - target_dir = self.out_dir / rel_path.parent - target_dir.mkdir(parents=True, exist_ok=True) - - ext = self.container.ext - # Always add resolution suffix when a target resolution is selected - res_suffix = f"_{self.resolution.width}x{self.resolution.height}" if needs_scale else "" - output_f = target_dir / f"{file_path.stem}{res_suffix}_archived.{ext}" - - return (encode_input, output_f) - - def _check_disk_space(self, file_path: Path, output_f: Path, needs_scale: bool) -> None: - """v4.4.0: Warn if free disk space is less than the encode will need. - - v4.6.0: SEVERE warnings (free space below the source size on the - partition we're about to write a big intermediate/output to) are - now USER-FACING — they were verbose-only, so in the default quiet - mode a batch that was going to die with "No space left on device" - partway through gave zero advance notice. That silent failure was - one of the "large files just fail" reports: small files fit in - the remaining space, big ones didn't. Marginal advice (the 2-3x - intermediate estimate) stays verbose-only. - """ - try: - src_size = file_path.stat().st_size - except OSError: - return # can't stat source — skip the check - if src_size < 1_073_741_824: # < 1 GB — skip check for small files - return - src_gb = src_size / 1_073_741_824 - # Check output partition — severe when free < source size - # (the encoded output is usually smaller, but the ffmpeg/av1an - # buffer cache plus a same-partition temp can eat the difference). - try: - out_usage = shutil.disk_usage(output_f.parent) - out_free_gb = out_usage.free / 1_073_741_824 - if out_free_gb < src_gb: - self.log_msg.emit( - f" WARN: low disk space on output ({out_free_gb:.1f} GB free, " - f"source is {src_gb:.1f} GB) — encode may fail partway through" - ) - elif self.verbose and out_free_gb < src_gb * 2: - self._vlog( - f" WARN: output space getting tight ({out_free_gb:.1f} GB free, " - f"source is {src_gb:.1f} GB)" - ) - except OSError: - pass # can't check — skip - # When scaling on the av1an path, also check the temp partition - # (the CRF-16 intermediate can be ~1x source size). The ffmpeg - # path scales inline (no intermediate), so no temp warning there. - if needs_scale and not self.use_ffmpeg_fallback: - try: - tmp_usage = shutil.disk_usage(self._temp_dir) - tmp_free_gb = tmp_usage.free / 1_073_741_824 - # Severe: free temp space below the source size means the - # intermediate will likely not fit → user-facing warning. - if tmp_free_gb < src_gb: - self.log_msg.emit( - f" WARN: low disk space on temp ({tmp_free_gb:.1f} GB free, " - f"source is {src_gb:.1f} GB) — the scale intermediate " - f"may not fit. Free space or enable 'Inline scale'." - ) - elif self.verbose and tmp_free_gb < src_gb * 2: - self._vlog( - f" WARN: temp space getting tight ({tmp_free_gb:.1f} GB free, " - f"lossless intermediate may need ~{src_gb * 2:.1f} GB) — " - f"consider scaling to a smaller resolution or freeing space" - ) - except OSError: - pass - - def _output_already_encoded(self, file_path: Path, output_f: Path) -> bool: - """v4.3.0: Check if output_f already exists with a matching codec. - - Returns True (skip the encode) when ALL of the following hold: - - output_f exists on disk - - ffprobe can read it (not corrupt) - - video stream codec_name matches self.video_codec.ffprobe_codec_name - - audio stream codec_name matches self.audio_profile.ffprobe_codec_name - (when both the profile and the file have an audio stream) - - if scaling was requested, output resolution matches the target - - Returns False (proceed with encode) otherwise — including when - ffprobe is unavailable, the file is unreadable, or any codec - mismatch is detected. In the False cases, the encode will - overwrite the existing output (treats it as stale/corrupt). - - CRF/preset are NOT verified because they're encoder settings - not reliably stored in container metadata. The user must use - --force-reencode if they want to re-encode at a different CRF - with the same codec. - """ - if not output_f.exists(): - return False - if not self.env.ffprobe_path: - # Can't verify codec — be safe and re-encode. - return False - info = ffprobe_validate(output_f, self.env.ffprobe_path) - if info is None: - # File exists but unreadable — treat as needing re-encode. - return False - streams = info.get("streams", []) - vstream = next((s for s in streams if s.get("codec_type") == "video"), None) - astream = next((s for s in streams if s.get("codec_type") == "audio"), None) - if not vstream: - return False - # Video codec check. - expected_v = self.video_codec.ffprobe_codec_name - if expected_v and vstream.get("codec_name") != expected_v: - return False - # Audio codec check (only if both profile and file have audio). - expected_a = self.audio_profile.ffprobe_codec_name - if expected_a and astream: - if astream.get("codec_name") != expected_a: - return False - # Resolution check (only when scaling was requested). - if self.resolution.width is not None and self.resolution.height is not None: - actual_w = int(vstream.get("width", 0) or 0) - actual_h = int(vstream.get("height", 0) or 0) - if actual_w != self.resolution.width or actual_h != self.resolution.height: - return False - return True - - def _can_ffmpeg_fallback(self) -> bool: - """v6-01: Check if ffmpeg has the encoder for this codec. - - Returns True if ffmpeg can encode with this codec's ffmpeg_encoder - (e.g. libsvtav1, libvpx-vp9, libx265), False otherwise. - Used to decide whether to retry a failed av1an encode with ffmpeg. - """ - ffmpeg_enc = self.video_codec.ffmpeg_encoder - lib_key = ffmpeg_lib_key_for(ffmpeg_enc) - return bool(self.env.ffmpeg_libs.get(lib_key, False)) - - def _encode_one(self, file_path, encode_input, output_f, worker_count, - chunk_method=None): - """Dispatch to ffmpeg fallback or av1an. Returns True if encode succeeded. - - ffmpeg fallback: delegates to _ffmpeg_fallback_encode (which itself - performs the size >=5% integrity check and unlinks bad output). No - temp cleanup or fail_count increment happens here for this path — - _process_one_file handles both at the call site, matching the original. - - av1an: builds and runs the av1an command, performs the size >=5% check - inline, and wraps everything in try/except/finally so temps are always - cleaned up via _cleanup_current_temps() — matching the original. On - every failure path here, fail_count is incremented inside this method. - - v4.0.0: *chunk_method* is an explicit override used by the y4m-pipe-break - retry path. When None, the method falls back to - ``env.av1an_flags["chunk_method_override"]`` (set by env_probe or by - a previous retry) or av1an's auto-selection. When av1an fails with the - "Failed to read y4m frame delimiter" pattern (Hybrid chunk method on - phone-recorded MP4s with sparse keyframes), this method recursively - retries with ``chunk_method="select"`` and caches that choice so - subsequent files skip the wasted first attempt. - """ - # ── Choose encode path: av1an or ffmpeg fallback ── - if self.use_ffmpeg_fallback: - # ── Pure ffmpeg encode path ── - # v4.2.1: Mode banner is verbose-only. - self._vlog(f" Mode: ffmpeg ({self.video_codec.ffmpeg_encoder})") - return self._ffmpeg_fallback_encode( - file_path, encode_input, output_f, - ) - - # ── av1an encode path (original) ── - # Resolve encoder name with probe data - enc = self.video_codec.av1an_encoder - if enc in ("svt_av1", "svt") and "svt_name" in self.env.av1an_flags: - enc = self.env.av1an_flags["svt_name"] - - # Build params via config table (no if/else). - # v4.1.2: do NOT inject --threads into av1an's --video-params. - # SvtAv1EncApp (the standalone CLI av1an invokes per-chunk) does - # not accept --threads — only --lp (logical processors). Injecting - # --threads produced "Unprocessed tokens: --threads" → every - # chunk failed 3x → no av1an output. Thread capping is done via - # av1an's --workers flag (chunk-parallel count) and via -threads - # in the ffmpeg fallback path (where libsvtav1 is a library). - v_params = self.video_codec.params_fn(self.crf, self.preset_val) - - # Audio params: dual-pass normalization per file, or simple volume - audio_parts = list(self.audio_profile.params) - if abs(self.audio_level_db) > 0.01: - per_file_gain = self._analyze_audio_loudness(file_path) - if per_file_gain is not None and abs(per_file_gain) > 0.01: - audio_parts.extend(["-af", f"volume={per_file_gain:+.1f}dB"]) - else: - # Fallback to knob's static value if analysis failed - static_db = f"{self.audio_level_db:+.1f}".replace("+", "") - audio_parts.extend(["-af", f"volume={static_db}dB"]) - # v4.2.1: verbose-only - self._vlog(f" Audio: static gain {self.audio_level_db:+.1f} dB (analysis unavailable)") - - audio_str = " ".join(audio_parts) - - cmd = [ - self.env.av1an_path, - "-i", str(encode_input), - self.env.av1an_flags.get("worker", "--workers"), str(worker_count), - ] - - # Chunk method: explicit arg (retry) > env override > av1an auto. - # v4.0.0: when av1an auto-selects Hybrid (default when no VS source - # plugins are installed), phone-recorded MP4s with sparse keyframes - # fail with "Failed to read y4m frame delimiter". The retry path - # passes chunk_method="select" which uses VapourSynth's select() - # filter — slower but reliable. - effective_chunk_method = ( - chunk_method - or self.env.av1an_flags.get("chunk_method_override") - ) - if effective_chunk_method: - cmd.extend(["--chunk-method", effective_chunk_method]) - # v4.2.1: chunk-method banner is verbose-only. - self._vlog( - f" Chunking: {effective_chunk_method or 'auto'} " - f"(av1an default if no override)" - ) - - # v4.4.4: inline scale — when enabled and a target resolution is - # selected, pass the scale/pad filter chain directly to av1an via - # --ffmpeg-filter-args. This skips the CRF-16 intermediate encode - # entirely (zero temp disk usage for the scaling step) at the cost - # of running the filter on every chunk-extraction pass. The - # default (inline_scale=False) uses the pre-scale intermediate, - # which is more robust across av1an/VapourSynth versions but - # requires the extra encode pass and 0.5-0.8× source size of temp - # disk space for the intermediate. - if self.inline_scale and self._current_scale_filter: - cmd.extend(["--ffmpeg-filter-args", self._current_scale_filter]) - self._vlog( - f" Inline scale: enabled (filter passed via " - f"--ffmpeg-filter-args, no intermediate file)" - ) - - cmd.extend([ - "--encoder", enc, - self.env.av1an_flags.get("video_params", "--video-params"), v_params, - self.env.av1an_flags.get("audio_params", "--audio-params"), audio_str, - "--concat", self.env.av1an_flags.get("concat_method", "ffmpeg"), - "-o", str(output_f), - ]) - - # v4.2.1: CMD: line is verbose-only (debugging). - self._vlog(f" CMD: {' '.join(cmd)}") - - try: - result = self._run_with_stop_check( - cmd, env=_av1an_env(), timeout=self.encode_timeout, log_prefix=" ", - ) - status, rc, stdout, stderr = result - - if status == "stop": - # User requested STOP — do NOT increment fail_count (the - # user explicitly chose to abort, it isn't a transcode - # failure). Remove partial output. self._stop is already - # True (set by the UI thread), so the orchestrator's - # queue loop will break on the next iteration and emit - # "STOP: Aborted by user." - output_f.unlink(missing_ok=True) - return False - if status == "timeout": - self.fail_count += 1 - # v4.2.1: keep user-facing timeout message but shorten it. - self.log_msg.emit(f"{self._status_prefix()}FAIL: timeout (exceeded {self.encode_timeout}s limit)") - return False - - # status == "ok" — wrap in CompletedProcess so the downstream - # returncode check, diagnostic dump, and pattern matching are - # byte-for-byte unchanged. - res = subprocess.CompletedProcess(cmd, rc, stdout, stderr) - - if res.returncode == 0 and output_f.exists(): - src_size = file_path.stat().st_size - out_size = output_f.stat().st_size - ratio = out_size / src_size if src_size > 0 else 0 - - # Integrity gate: 1KB absolute minimum. A valid container - # header alone is ~1KB; anything below is definitely corrupt. - # The duration check in _verify_and_finalize (≥95% of source - # duration) is the real quality gate for high-bitrate sources. - if out_size > 1024: - # Success — resolution/duration/subtitle/finalize happen - # in _verify_and_finalize (called by _process_one_file). - return True - else: - self.fail_count += 1 - self.log_msg.emit( - f"{self._status_prefix()}FAIL: output too small ({out_size / 1024:.0f} KB)" - ) - # Remove corrupt output - output_f.unlink(missing_ok=True) - return False - else: - stderr_full = res.stderr or "" - # v4.6.0: scan stdout too. av1an routes chunk-retry - # noise (encoder stderr dumps, FRAME MISMATCH lines) - # to stdout, so pattern-matching on stderr alone missed - # the biggest real-world failure mode (see the FRAME - # MISMATCH pattern below). - combined_out = stderr_full + "\n" + (res.stdout or "") - # v6: Don't increment fail_count yet — we may retry with - # ffmpeg fallback below. Only increment if the retry also - # fails (or no retry is possible). - # v4.4.2: move the FAIL line to _vlog. If the ffmpeg - # fallback succeeds, the user sees OK. If it also fails, - # the RETRY FAIL path emits a user-facing FAIL. This way - # the user doesn't see a confusing "FAIL then OK" for - # files that av1an choked on but ffmpeg handled. - self._vlog( - f"{self._status_prefix()}av1an failed (exit code {res.returncode}) — attempting ffmpeg fallback" - ) - self._vlog(" ─── av1an stderr (last 25 lines) ───") - stderr_lines = stderr_full.splitlines() - for line in stderr_lines[-25:]: - self._vlog(f" {line}") - self._vlog(" ────────────────────────────────────") - - # Detect known av1an crash patterns and provide actionable fixes. - # Pattern table — add new patterns here, no nested ifs below. - # SEI CERT MSC04-C spirit: single source of truth for diagnostics. - # - # v5-03: Added "missing field `streams`" pattern — this is - # the error av1an emits when its internal ffprobe call - # returns JSON without a streams field, i.e. the input file - # is not a valid video. Also added `file` command output - # to the diagnostic so the user immediately sees "HTML - # document" (failed yt-dlp download) instead of guessing. - error_patterns: tuple[tuple[str, str, tuple[str, ...], bool], ...] = ( - ( - "Failed to get VSScript API", - "av1an cannot initialize VapourSynth — the binary was " - "compiled against a different VapourSynth version than " - "what is currently installed.", - ( - " FIX (Arch): yay -S av1an OR cargo install av1an --force --locked", - " FIX (Debian): sudo apt install vapoursynth libvapoursynth-script-dev av1an", - " FIX (other): rebuild av1an against current VapourSynth", - " VapourSynth R77+ changed the VSScript API; av1an must be recompiled.", - ), - True, # stop queue — every file will hit the same crash - ), - ( - "No usable encoder found", - "av1an cannot find the encoder binary (SvtAv1EncApp / vpxenc / x265).", - ( - " Verify the encoder is installed and in PATH.", - " Arch: pacman -S svt-av1 libvpx-tools x265", - " Debian: apt install svt-av1 libvpx-tools x265", - ), - True, - ), - # v6-02: av1an scene-detection panic — per-file, not systematic. - ( - "split scores is not empty", - "av1an panicked during scene detection (known av1an bug). " - "This is a per-file issue — the video content triggered a " - "Rust panic in av1an's split module. Will retry with ffmpeg.", - ( - " This is an av1an internal bug, not a file corruption issue.", - " The file is a valid video — ffmpeg can encode it directly.", - ), - False, # don't stop queue — retry with ffmpeg fallback - ), - ( - "missing field `streams`", - "av1an's internal ffprobe call could not parse this file — " - "the file is not a valid video container. This is NOT an " - "av1an or ffmpeg bug; the input file itself is invalid.", - ( - " The file is likely a failed yt-dlp download (HTML error", - " page saved as .mp4), a truncated download, or not a video", - " at all. Run `file ` to confirm.", - ), - False, # don't stop queue — other files may be valid - ), - ( - "Invalid data found when processing input", - "ffmpeg cannot read this input file — the file is corrupt, " - "truncated, or not a valid video container.", - ( - " Run `file ` to see what the file actually is.", - " If it's 'HTML document' or 'ASCII text', it's a failed", - " yt-dlp download — re-download the source video.", - " If it's 'data', the file may be truncated or encrypted.", - ), - False, - ), - ( - "Error: End of file", - "av1an hit EOF during scene detection — usually a VapourSynth " - "source plugin issue with the intermediate file.", - ( - " Try a different --chunk-method (override via env probe).", - " If pre-scaling, ensure the intermediate is libx265 CRF 0 (not ffv1).", - ), - False, - ), - ( - "could not open input", - "av1an cannot read this input file — possibly corrupt or " - "an unsupported codec for the VapourSynth source plugin.", - ( - " Try playing the file with ffplay to verify it's not corrupt.", - " Run: ffmpeg -i -f null - to see the decode error.", - ), - False, - ), - # v4.0.0: y4m pipe break — Hybrid chunk method can't handle - # files with sparse keyframes. This is the "works up until - # near the end, never saves chunks into a full file" bug. - # The encoder prints a SUMMARY block (it ran briefly on - # partial data before the pipe broke), which previously - # triggered the v6-03 "concat failure" misdiagnosis. The - # retry path switches to --chunk-method select which - # extracts frames one-by-one via VapourSynth's select() - # filter, avoiding the keyframe-alignment issue. - ( - "Failed to read y4m frame delimiter", - "av1an's chunk extractor produced a broken y4m pipe — " - "the source's keyframe layout doesn't align with scene " - "boundaries. This is the Hybrid chunk method's known " - "failure mode for phone-recorded MP4s with sparse " - "keyframes (only I-frames every 5-10s). The encoder " - "printed a SUMMARY block because it ran briefly on " - "partial data before the pipe broke — this is NOT a " - "concat failure.", - ( - " Will retry with --chunk-method select (VapourSynth", - " select() filter), which extracts frames one-by-one", - " and avoids the keyframe-alignment issue.", - " This is per-file, not systematic — subsequent files", - " use select automatically.", - ), - False, # don't stop queue — retry with select chunk method - ), - # v4.6.0: ffmpeg ≥ 7 removed the -vsync option that - # av1an's segment/hybrid chunk extraction passes to - # ffmpeg. Every segment-based chunk dies immediately - # ("Unrecognized option 'vsync'." → empty y4m pipe → - # chunk fails 3x). Verified on ffmpeg 9.0.2 + av1an - # 0.5.2. select (or a VS source plugin method) is the - # only working chunking on these systems. - ( - "Unrecognized option 'vsync'", - "av1an's segment/hybrid chunk extraction calls " - "`ffmpeg -vsync`, which ffmpeg 7+ removed. Every " - "segment-based chunk fails instantly on this system.", - ( - " FIX: install a VapourSynth source plugin so av1an", - " stops using ffmpeg segmenting: bestsource/ffms2/", - " lsmash (e.g. on Arch: vapoursynth-plugin-bs).", - " Alternatively stay on the default ffmpeg-only path", - " (it doesn't use av1an chunking at all).", - ), - False, # per-file — the select override keeps other files working - ), - # v4.6.0: frame-count drift between av1an's chunk - # manifest and what the encoder actually produced. - # av1an retries the chunk 3x, then shuts the worker - # down with the baffling "encoder crashed: exit - # status: 0" (exit 0 because the encode itself - # succeeded — on partial data). This was the dominant - # large-file failure in the 2026-07-13 av1an log and - # previously fell through to "Unknown av1an failure". - ( - "FRAME MISMATCH", - "av1an's chunk manifest expected a different frame count " - "than the encoder produced — chunk-extraction drift on " - "sources with sparse/irregular keyframes. The encode " - "itself exits 0 (it ran on partial data), which av1an " - "reports as 'encoder crashed: exit status: 0'.", - ( - " Will retry with --chunk-method select, which", - " extracts exact frame ranges and cannot drift.", - " This is per-file, not systematic — subsequent", - " files use select automatically.", - ), - False, # don't stop queue — retry with select chunk method - ), - ) - diagnosis_emitted = False - for marker, summary, fixes, stop_queue in error_patterns: - # v4.6.0: scan stdout + stderr (FRAME MISMATCH and - # encoder dumps land in stdout). - if marker.lower() in combined_out.lower(): - # v4.2.1: DIAGNOSIS block is verbose-only. The user - # already saw "FAIL: av1an exit code N" above — they - # don't need the multi-line root-cause analysis unless - # they opt in with --verbose. - self._vlog("") - self._vlog(f"DIAGNOSIS: {summary}") - for fix in fixes: - self._vlog(fix) - # v5-03: run `file` on the input to tell the user - # what the file actually is. This is especially - # useful for "missing field streams" and "Invalid - # data found" — the user immediately sees "HTML - # document" instead of guessing. - if marker in ("missing field `streams`", - "Invalid data found when processing input", - "could not open input"): - file_type = _identify_file_type(file_path) - if file_type: - self._vlog(f" File type: {file_type}") - if "HTML" in file_type or "ASCII" in file_type or "text" in file_type: - self._vlog( - " → This is a TEXT file, not a video. " - "Failed yt-dlp download — re-download the source." - ) - elif "data" in file_type and "ISO Media" not in file_type: - self._vlog( - " → File type is 'data' — truncated, encrypted, " - "or partial download." - ) - if stop_queue: - self._stop = True - # v4.2.1: STOP reason stays user-facing — the user - # needs to know why the queue aborted. - self.log_msg.emit( - f" STOP: skipping remaining files ({marker} issue)" - ) - diagnosis_emitted = True - break - - if not diagnosis_emitted: - # No known pattern matched — show the user where to look. - # v6-03: detect "encoder SUMMARY in stderr + non-zero exit" - # — the encoder succeeded but av1an failed to produce output. - # This is the "chunks but never saves a file" pattern caused - # by av1an's concat step failing. - # v4.0.0: only treat as concat failure when y4m break is NOT - # present. The y4m break pattern (above) emits its own - # diagnosis and triggers a retry with --chunk-method select. - # The SUMMARY block appears in both cases (encoder ran - # briefly before failing), so we must check for the y4m - # marker to avoid misdiagnosing chunk-extraction failures - # as concat failures. - # v4.2.1: all DIAGNOSIS verbose-only. - if ("SUMMARY" in stderr_full - and "Average Speed" in stderr_full - and "Failed to read y4m frame delimiter" not in combined_out - and "FRAME MISMATCH" not in combined_out): - self._vlog("") - self._vlog( - "DIAGNOSIS: SVT-AV1 encoder completed successfully (SUMMARY" - " block found in stderr), but av1an failed to produce the" - " output file. This is an av1an concat failure — the encoder" - " did its job but av1an's post-encode merge step crashed." - ) - self._vlog( - " This is a known av1an bug on short videos (1-2 scenes)" - " where concat of a single chunk fails. Will retry with" - " ffmpeg fallback." - ) - else: - self._vlog("") - self._vlog( - "DIAGNOSIS: Unknown av1an failure. Inspect the full stderr above." - ) - # v5-03: run `file` on the input as a fallback diagnostic. - file_type = _identify_file_type(file_path) - if file_type: - self._vlog(f" File type: {file_type}") - self._vlog( - " Common causes: (1) out of disk space in temp dir, " - "(2) AV1 concat failed silently — try installing mkvtoolnix, " - "(3) av1an version too old for --concat flag — check av1an --help, " - "(4) input file is not a valid video (run `file `)." - ) - - # ── v4.0.0: y4m pipe break retry — switch to --chunk-method select ── - # If av1an failed with the y4m break pattern AND we're not - # already using select, retry with --chunk-method select. This - # is faster than the ffmpeg fallback (chunk-parallel still - # works) and produces identical-quality output (same encoder, - # same params). Cache the working method so subsequent files - # skip the wasted first attempt. - # - # v4.6.0: FRAME MISMATCH (chunk-extraction drift, see the - # error-pattern table) joins the y4m break as a drift - # symptom that select fixes — it was previously an - # "Unknown av1an failure" that went straight to the slow - # full-file ffmpeg fallback. - # - # NOTE: Do NOT clean up _current_temps before the retry — - # encode_input (symlink or pre-scaled file) is in - # _current_temps and the recursive _encode_one call needs it. - # The finally block below will clean up everything after the - # recursive call returns (its own finally clears the list - # first; our finally then runs on an empty list — no-op). - extraction_drift = ( - "Failed to read y4m frame delimiter" in combined_out - or "FRAME MISMATCH" in combined_out - # ffmpeg >= 7 removed -vsync: segment/hybrid chunking - # dies instantly while select still works (it uses the - # ffmpeg frame server, not segmenting). - or "Unrecognized option 'vsync'" in combined_out - ) - if (not self._stop and extraction_drift - and effective_chunk_method != "select" - and self.env.av1an_flags.get("has_chunk_method", True)): - # v4.2.1: RETRY messages are verbose-only — the user - # already saw "FAIL" and will see "SUCCESS" if the retry - # works. They don't need to know the retry is happening. - self._vlog("") - self._vlog( - f" RETRY: Re-encoding {file_path.name} with " - f"--chunk-method select (slower but reliable for " - f"files with sparse keyframes)..." - ) - output_f.unlink(missing_ok=True) - # Cache for subsequent files — avoids the wasted first attempt - self.env.av1an_flags["chunk_method_override"] = "select" - return self._encode_one( - file_path, encode_input, output_f, worker_count, - chunk_method="select", - ) - - # ── v6-01: Per-file av1an→ffmpeg fallback ── - # If av1an failed for this file AND it's NOT a systematic issue - # (VSScript API, missing encoder — those set self._stop=True), - # AND ffmpeg has the encoder for this codec, retry with ffmpeg. - # This handles: - # - av1an concat failures (encoder succeeded but no output) - # - av1an scene-detection panics ("split scores is not empty") - # - Any other per-file av1an internal failure - # - # NOTE: Do NOT clean up _current_temps before the retry — - # encode_input (symlink or pre-scaled file) is in _current_temps - # and _ffmpeg_fallback_encode needs it. The finally block below - # will clean up everything after the retry completes. - if not self._stop and self._can_ffmpeg_fallback(): - # v4.2.1: RETRY messages are verbose-only. - self._vlog("") - self._vlog( - f" RETRY: Attempting ffmpeg fallback for {file_path.name} " - f"({self.video_codec.ffmpeg_encoder})..." - ) - # Remove any partial output av1an may have left - output_f.unlink(missing_ok=True) - # Retry with ffmpeg — _ffmpeg_fallback_encode does NOT - # increment fail_count on failure (the caller does that). - # If it succeeds, we return True WITHOUT incrementing - # fail_count — the file was saved, just via a different path. - fb_ok = self._ffmpeg_fallback_encode( - file_path, encode_input, output_f, - ) - if fb_ok: - self._vlog( - f" RETRY OK: ffmpeg fallback succeeded for {file_path.name}" - ) - return True - else: - self.fail_count += 1 - # v4.4.2: this is the ONLY user-facing FAIL for - # the av1an path — emitted when both av1an AND - # ffmpeg fallback failed. The user sees one line, - # not two. - self.log_msg.emit( - f"{self._status_prefix()}FAIL: av1an + ffmpeg both failed" - ) - self._vlog( - f" RETRY FAIL: ffmpeg fallback also failed for {file_path.name}" - ) - return False - else: - # No retry possible — this is a systematic issue (stop_queue - # was set) or ffmpeg lacks the encoder. - self.fail_count += 1 - # v4.4.2: emit user-facing FAIL here too. - self.log_msg.emit( - f"{self._status_prefix()}FAIL: av1an (no ffmpeg fallback available)" - ) - return False - except (OSError, subprocess.SubprocessError) as e: - self.fail_count += 1 - self.log_msg.emit(f"{self._status_prefix()}FAIL: system error: {e}") - return False - finally: - # Always clean this file's temps before moving to next - self._cleanup_current_temps() - - def _verify_and_finalize(self, file_path, output_f, encode_input, needs_scale): - """Post-encode verification + subtitle mux + source deletion deferral. - - Runs after a successful _encode_one. Performs: - - output resolution verification (if scaling was requested) - - duration integrity check (>= 95% of source) - - subtitle mux (if requested) - - success_count increment + SUCCESS log - - source deletion deferral (if delete_source is set) - - Returns True if the file was accepted, False if any check failed. - On failure, fail_count is incremented and output_f is unlinked before - returning False. Temp cleanup is the caller's responsibility — it - differs between the av1an path (already done in _encode_one's finally) - and the ffmpeg fallback path (done explicitly in _process_one_file). - """ - # Post-encode resolution verification - if needs_scale and self.env.ffprobe_path: - if not _verify_output_resolution( - output_f, self.env.ffprobe_path, - self.resolution.width, self.resolution.height, - ): - self.fail_count += 1 - self.log_msg.emit( - f"{self._status_prefix()}FAIL: resolution verification failed " - f"(expected {self.resolution.width}x{self.resolution.height})" - ) - output_f.unlink(missing_ok=True) - return False - src_size = file_path.stat().st_size - out_size = output_f.stat().st_size - ratio = out_size / src_size if src_size > 0 else 0 - - # Duration integrity check (>= 95% of source) - dur_ok = True - dur_info = "" - if self.env.ffprobe_path: - src_dur = ffprobe_duration(file_path, self.env.ffprobe_path) - out_dur = ffprobe_duration(output_f, self.env.ffprobe_path) - if src_dur and out_dur: - dur_ratio = out_dur / src_dur - dur_ok = dur_ratio >= 0.95 - dur_info = f", duration {out_dur:.1f}s/{src_dur:.1f}s ({dur_ratio * 100:.0f}%)" - - if not dur_ok: - self.fail_count += 1 - self.log_msg.emit(f"{self._status_prefix()}FAIL: duration mismatch{dur_info}") - output_f.unlink(missing_ok=True) - return False - - # Mux subtitle if requested (needs source file intact) - if self.subtitle_lang: - self._mux_subtitle(file_path, output_f) - - self.success_count += 1 - # v4.2.1: keep user-facing SUCCESS but compact it. Was: - # "SUCCESS: filename (1.6MB -> 1.3MB, 81%, duration 15.0s/15.0s (100%))" - # Now (verbose=False): - # "[N/total] filename — OK: 1.6MB -> 1.3MB (81%)" - # v4.4.0: combined into single line with [N/total] prefix. - # Verbose mode keeps the duration info on the same line. - prefix = self._status_prefix() - if self.verbose: - self.log_msg.emit( - f"{prefix}SUCCESS: {src_size / 1_048_576:.1f}MB -> {out_size / 1_048_576:.1f}MB " - f"({ratio * 100:.0f}%{dur_info})" - ) - else: - self.log_msg.emit( - f"{prefix}OK: {src_size / 1_048_576:.1f}MB -> {out_size / 1_048_576:.1f}MB " - f"({ratio * 100:.0f}%)" - ) - # Defer source deletion until after final cleanup - if self.delete_source: - self._sources_to_delete.append(file_path) - return True - - def _cleanup_current_temps(self): - """Remove all tracked temp files/dirs for the current file. - - Resilient: each removal is try/except'd individually so one bad path - doesn't block the rest. Clears the tracking list when done. - """ - for tf in self._current_temps: - try: - if tf.is_dir(): - shutil.rmtree(str(tf), ignore_errors=True) - elif tf.exists(): - tf.unlink() - except Exception: - pass - self._current_temps.clear() - - def _final_cleanup_sweep(self): - """Residual sweep to catch any orphaned temp files. - - v3 (OTC-013): primary target is now the per-worker subdir - (``~/.cache/OpenTranscode/tmp/worker-/``), NOT the shared - app temp dir. This is safe because the subdir ONLY contains - this worker's intermediates — a concurrent worker has its own - subdir. The previous "nuclear" sweep of the entire app temp - dir was a race-condition risk that this eliminates. - Also scans in_dir/out_dir as a safety net for legacy temp files - written by older versions that placed temps next to source files. - """ - swept = 0 - - # v3: sweep ONLY this worker's per-PID subdir, not the shared parent. - # This is safe — the subdir contains only this worker's intermediates. - if self._temp_dir.is_dir(): - for hit in self._temp_dir.iterdir(): - try: - if hit.is_dir(): - shutil.rmtree(str(hit), ignore_errors=True) - else: - hit.unlink(missing_ok=True) - swept += 1 - except OSError: - # SEI CERT ERR01-C: narrow to OSError (file ops). - # Best-effort sweep must not crash on a single bad path. - pass - - # Safety-net sweep of user directories (for legacy temp files - # written by older versions that placed temps next to source files) - legacy_patterns = ["*.scaled_tmp.mkv", "*.av1an", "*_encodes", "*.*.av1an"] - for search_dir in (self.in_dir, self.out_dir): - if not search_dir.is_dir(): - continue - for pattern in legacy_patterns: - for hit in search_dir.rglob(pattern): - try: - if hit.is_dir(): - shutil.rmtree(str(hit), ignore_errors=True) - else: - hit.unlink(missing_ok=True) - swept += 1 - except OSError: - pass - # Also clean any orphans still in _current_temps (e.g. stop/crash mid-loop) - self._cleanup_current_temps() - # v3: remove the now-empty per-worker subdir itself. - try: - self._temp_dir.rmdir() - except OSError: - pass # not empty / not ours — leave it - if swept: - self.log_msg.emit(f"CLEANUP: Swept {swept} residual temp file(s)/dir(s).") - - # ── Audio loudness analysis (dual-pass normalization) ── - - def _analyze_audio_loudness(self, file_path: Path) -> float | None: - """Dual-pass loudnorm analysis for a single file. - - Pass 1: Run loudnorm in analysis-only mode to measure the file's current - integrated loudness (I) and true peak (TP). - - Returns the dB gain to apply, or None if analysis fails (falls back to - the knob's static value). - """ - if not self.env.ffmpeg_path: - return None - if abs(self.audio_level_db) < 0.01: - return None # knob is at 0 — no normalization requested - - target_lufs = self.audio_level_db # knob value IS the target LUFS - - try: - # Pass 1: analyze current loudness - # v4.6.0: -vn skips video decoding — without it the analysis - # decoded the ENTIRE video stream just to measure audio - # loudness, which pushed long/large files past the 120s - # timeout and silently degraded every big file to the static - # knob gain. - analysis_cmd = [ - self.env.ffmpeg_path, - "-i", str(file_path), - "-vn", - "-af", ( - f"loudnorm=I={target_lufs}:TP=-1.5:LRA=11:" - f"print_format=json" - ), - "-f", "null", "-", - ] - res = subprocess.run( - analysis_cmd, capture_output=True, text=True, timeout=120, - ) - - # Parse the JSON stats from stderr (loudnorm prints to stderr) - stderr = res.stderr or "" - - # Find the JSON block - json_match = re.search(r'\{[^{}]*"input_i"[^{}]*\}', stderr, re.DOTALL) - if not json_match: - return None - - stats = json.loads(json_match.group()) - - input_i = float(stats.get("input_i", "-99")) - input_tp = float(stats.get("input_tp", "-99")) - target_tp = float(stats.get("target_tp", "-1.5")) - - # If file is already silent or near-silent, skip - if input_i <= -70: - return None - - # Compute the gain loudnorm would apply - gain_db = target_lufs - input_i - - # Pass 2 concept: check if applying this gain would push peaks - # above our ceiling. The ceiling is target_tp (default -1.5 dBTP). - # We want 15% headroom below that ceiling. - headroom_db = abs(target_tp) * 0.15 - peak_ceiling = target_tp + headroom_db - - # If the file's true peak + gain would exceed the ceiling, clamp - projected_peak = input_tp + gain_db - if projected_peak > peak_ceiling: - gain_db = peak_ceiling - input_tp - - self.log_msg.emit( - f" Audio: {input_i:.1f} LUFS -> {target_lufs:.1f} LUFS " - f"(gain {gain_db:+.1f} dB, peak {input_tp:.1f} -> " - f"{input_tp + gain_db:.1f} dBTP)" - ) - return gain_db - - except (OSError, subprocess.SubprocessError, ValueError) as e: - # ValueError covers json.JSONDecodeError and float() parse failures - self.log_msg.emit(f" Audio: loudnorm analysis failed ({e}), using knob value") - return None - - # ── Subtitle extraction & muxing ── - - def _find_subtitle_stream(self, source: Path, lang: str) -> tuple[int | None, str]: - """Find subtitle stream in source matching language code. - Prefers forced disposition tracks. Returns (stream_index, codec_name).""" - if not self.env.ffprobe_path: - return (None, "") - - info = ffprobe_validate(source, self.env.ffprobe_path) - if not info: - return (None, "") - - forced_match = None - any_match = None - - for stream in info.get("streams", []): - if stream.get("codec_type") != "subtitle": - continue - tags = stream.get("tags", {}) - if tags.get("language", "").lower() != lang.lower(): - continue - - idx = stream.get("index") - codec = stream.get("codec_name", "") - disposition = stream.get("disposition", {}) - - if disposition.get("forced") and forced_match is None: - forced_match = (idx, codec) - if any_match is None: - any_match = (idx, codec) - - return forced_match if forced_match else (any_match or (None, "")) - - def _mux_subtitle(self, source: Path, output: Path): - """Mux a subtitle track from source into the encoded output (soft sub). - Uses stream copy for MKV; converts to WebVTT for WebM containers.""" - sub_idx, sub_codec = self._find_subtitle_stream(source, self.subtitle_lang) - - if sub_idx is None: - self.log_msg.emit(f" SUBS: No {self.subtitle_lang} subtitle found in {source.name}") - return - - # WebM only supports WebVTT natively; MKV carries any subtitle codec - is_webm = output.suffix.lower() == ".webm" - sub_codec_flag = "copy" if not is_webm else "webvtt" - - tmp_out = output.with_suffix(output.suffix + ".submux_tmp") - try: - cmd = [ - self.env.ffmpeg_path, - "-i", str(output), # encoded output (video + audio) - "-i", str(source), # original source (subtitle source) - "-map", "0", # all streams from encoded output - "-map", "-0:s", # strip any subtitle from output - "-map", f"1:{sub_idx}", # subtitle from source - "-c:v", "copy", - "-c:a", "copy", - "-c:s", sub_codec_flag, - "-y", - str(tmp_out), - ] - res = subprocess.run(cmd, capture_output=True, text=True) - - if res.returncode == 0 and tmp_out.exists() and tmp_out.stat().st_size > 0: - output.unlink() - tmp_out.rename(output) - self.log_msg.emit( - f" SUBS: Muxed {self.subtitle_lang} sub ({sub_codec}) into {output.name}" - ) - else: - tmp_out.unlink(missing_ok=True) - tail = (res.stderr or "")[-200:] - self.log_msg.emit(f" SUBS WARN: Remux failed for {output.name}: {tail}") - except (OSError, subprocess.SubprocessError) as e: - tmp_out.unlink(missing_ok=True) - self.log_msg.emit(f" SUBS ERROR: {e}") - - def stop(self): - self._stop = True - - diff --git a/opentranscode/env_probe.py b/opentranscode/env_probe.py deleted file mode 100755 index 4dad220..0000000 --- a/opentranscode/env_probe.py +++ /dev/null @@ -1,1179 +0,0 @@ -"""Environment probe — distro-aware binary + library + av1an detection. - -Combines the distro probe (``detect_distro``) and the CPU topology -probe (``detect_cpu_topology``) with binary path search, ffmpeg -library availability probing, av1an version/flag probing, runtime -dependency probing, and the av1an VSScript smoke test. - -The smoke-test helpers (``_av1an_env``, ``_av1an_vsscript_smoke_test``, -``_detect_av1an_svt_encoder``) live here rather than in -``ffprobe_utils`` because they exercise av1an (not ffprobe) and are -called from both the GUI (``ui_window``) and the CLI dry-run -(``cli.run_dry_run``). -""" - -import ctypes -import os -import re -import shutil -import site -import subprocess -import sys -import tempfile -from dataclasses import dataclass, field -from pathlib import Path - -from .cpu_topology import CpuTopology, detect_cpu_topology -from .distro_probe import DistroProfile, detect_distro -from .gpu_profiles import ( - encoder_pre_args, - encoder_filter_chain, - encoder_quality_args, - gpu_profile_by_key, - match_gpu_profile, - resolve_vaapi_device, -) - -# ────────────────────────────────────────────── -# GPU PROBE (v4.6.0 — NVENC hardware encoding) -# ────────────────────────────────────────────── - -# NVENC encoders we know how to drive, in preference order (best -# compression efficiency first). av1_nvenc only exists on RTX 40+; the -# functional smoke test below decides what is actually usable. -_NVENC_ENCODER_NAMES: tuple[str, ...] = ("av1_nvenc", "hevc_nvenc", "h264_nvenc") - - -@dataclass -class GpuInfo: - """Result of the GPU/NVENC probe. - - ``encoders`` — encoder name → ffmpeg was BUILT with it (from - ``ffmpeg -encoders``). - ``functional`` — encoder name → a real 0.2s NVENC encode SUCCEEDED. - This is the gate EncoderWorker uses: a ffmpeg build - can list hevc_nvenc while the installed driver is - too old for the NVENC API version it was compiled - against ("Driver does not support the required - nvenc API version") — only a live encode reveals - that. - ``details`` — encoder name → first stderr line when the smoke - test failed (actionable diagnostics). - """ - name: str = "" # GPU model name via nvidia-smi/lspci, "" if unknown - encoders: dict[str, bool] = field(default_factory=dict) - functional: dict[str, bool] = field(default_factory=dict) - details: dict[str, str] = field(default_factory=dict) - # v4.8.0: the matched GpuProfile key from gpu_profiles (auto-detected - # from the GPU name; the UI can override it). - profile_key: str = "" - - @property - def usable_encoders(self) -> list[str]: - """Encoders that passed the live encode test, preference order.""" - return [e for e in _NVENC_ENCODER_NAMES if self.functional.get(e, False)] - - @property - def has_gpu(self) -> bool: - return bool(self.usable_encoders) - - @property - def first_failure_detail(self) -> str: - """First non-empty failure detail (for user-facing warnings).""" - for e in _NVENC_ENCODER_NAMES: - d = self.details.get(e, "") - if d: - return d - return "" - - -def _probe_gpu(ffmpeg_bin: str) -> GpuInfo: - """Detect NVIDIA NVENC hardware encoders and verify they actually work. - - Two-stage probe: - - 1. Compiled-in check — grep ``ffmpeg -encoders`` for the NVENC - encoder names. Cheap; answers "could this ffmpeg ever do NVENC". - 2. Functional smoke test — for each compiled-in encoder, encode a - 0.2s 256x264 lavfi color source with ``-c:v -f null -``. - Catches the real-world failure modes the compiled-in check - cannot: NVIDIA driver too old for the ffmpeg build's NVENC API - version, no /dev/nvidia* access, driver loaded but GPU dead. - - GPU model name is best-effort via nvidia-smi (display only). - """ - info = GpuInfo() - - if not ffmpeg_bin: - return info - - # --- Stage 1: compiled-in encoders --- - try: - res = subprocess.run( - [ffmpeg_bin, "-hide_banner", "-encoders"], - capture_output=True, text=True, timeout=10, - ) - encoders_out = res.stdout or "" - except (OSError, subprocess.SubprocessError): - encoders_out = "" - - for enc in _NVENC_ENCODER_NAMES: - info.encoders[enc] = f" {enc} " in encoders_out - - compiled_in = [e for e in _NVENC_ENCODER_NAMES if info.encoders[e]] - if not compiled_in: - return info # no hardware encoders in this build — skip stage 2 - - # --- GPU model name (display + profile auto-match) --- - nvidia_smi = shutil.which("nvidia-smi") - if nvidia_smi: - try: - res = subprocess.run( - [nvidia_smi, "--query-gpu=name", "--format=csv,noheader"], - capture_output=True, text=True, timeout=5, - ) - if res.returncode == 0 and res.stdout.strip(): - info.name = res.stdout.strip().splitlines()[0].strip() - except (OSError, subprocess.SubprocessError): - pass - if not info.name: - # No NVIDIA board (or driver down): identify AMD/Intel iGPU/dGPU - # from the PCI bus for profile matching + VAAPI/QSV testing. - info.name = _probe_pci_gpu_name() - - # --- v4.8.0: capability-class profile for this GPU --- - matched = match_gpu_profile(info.name) - if matched: - info.profile_key = matched.key - - # The profile may claim hardware encoders this ffmpeg build - # doesn't even list (e.g. AV1 on an Arc card with an old ffmpeg). - for family, enc in matched.encoders.items(): - if enc not in info.encoders or not info.encoders[enc]: - info.encoders[enc] = _ffmpeg_has_encoder(ffmpeg_bin, enc) - - # --- Stage 2 for the profile's hardware API --- - if matched.api == "vaapi": - info.functional.update(_smoke_vaapi(ffmpeg_bin, matched)) - elif matched.api == "qsv": - info.functional.update(_smoke_qsv(ffmpeg_bin, matched)) - - # --- Stage 2: functional smoke test per compiled-in encoder --- - for enc in compiled_in: - if enc in info.functional: - continue # already smoke-tested via the profile branch - try: - res = subprocess.run( - [ - ffmpeg_bin, "-hide_banner", "-loglevel", "error", - "-f", "lavfi", - "-i", "color=c=black:s=256x256:d=0.2:r=24", - "-frames:v", "5", - "-c:v", enc, "-f", "null", "-", - ], - capture_output=True, text=True, timeout=20, - ) - info.functional[enc] = res.returncode == 0 - if res.returncode != 0: - # First stderr line with substance (nvenc errors are - # prefixed "hevc_nvenc @ 0x...]" — keep them readable). - for line in (res.stderr or "").splitlines(): - line = line.strip() - if line: - # Strip the "name @ 0xADDR]" prefix for brevity. - line = re.sub(r"^\[[^]]+@\s*0x[0-9a-f]+\]\s*", "", line) - info.details[enc] = line[:160] - break - except subprocess.TimeoutExpired: - info.functional[enc] = False - info.details[enc] = f"{enc} smoke test timed out after 20s" - except (OSError, subprocess.SubprocessError) as e: - info.functional[enc] = False - info.details[enc] = str(e)[:160] - - return info - - -def _ffmpeg_has_encoder(ffmpeg_bin: str, enc: str) -> bool: - """Compiled-in check for one encoder name (cheap -encoders grep).""" - try: - res = subprocess.run( - [ffmpeg_bin, "-hide_banner", "-encoders"], - capture_output=True, text=True, timeout=10, - ) - return f" {enc} " in (res.stdout or "") - except (OSError, subprocess.SubprocessError): - return False - - -def _probe_pci_gpu_name() -> str: - """Best-effort non-NVIDIA GPU name via lspci (VGA/3D/Display class).""" - lspci = shutil.which("lspci") - if not lspci: - return "" - try: - res = subprocess.run( - [lspci], capture_output=True, text=True, timeout=10, - ) - for line in (res.stdout or "").splitlines(): - low = line.lower() - if any(k in low for k in (" vga ", " 3d ", " display ")): - if "nvidia" not in low: # nvidia handled via nvidia-smi - # "01:00.0 VGA ...: AMD/ATI Navi 31 [Radeon RX 7900 XTX]" - m = re.search(r":\s*(.+)$", line) - return m.group(1).strip() if m else "" - except (OSError, subprocess.SubprocessError): - pass - return "" - - -def _smoke_vaapi(ffmpeg_bin: str, profile) -> dict[str, bool]: - """Live-encode VAAPI encoders through the render node. VAAPI - encoders only accept hardware surfaces, so the filter chain must - upload the lavfi frames.""" - results: dict[str, bool] = {} - dev = resolve_vaapi_device() - if not Path(dev).exists(): - for enc in profile.encoders.values(): - results[enc] = False - details_placeholder = f"no render node ({dev})" - return results - for enc in profile.encoders.values(): - try: - cmd = ( - [ffmpeg_bin, "-hide_banner", "-loglevel", "error"] - + encoder_pre_args(profile, vaapi_device=dev) - + ["-f", "lavfi", "-i", "color=c=black:s=256x256:d=0.2:r=24", - "-frames:v", "5"] - + encoder_filter_chain(profile) - + ["-c:v", enc] - + encoder_quality_args(profile.api, enc, 28, 9) - + ["-f", "null", "-"] - ) - res = subprocess.run(cmd, capture_output=True, text=True, timeout=20) - results[enc] = res.returncode == 0 - if res.returncode != 0: - for line in (res.stderr or "").splitlines(): - line = line.strip() - if line: - results[f"_detail_{enc}"] = False # marker only - break - except (OSError, subprocess.SubprocessError): - results[enc] = False - # Strip the detail markers; keep real encoder results only, and put - # first error lines into a readable form for the caller. - cleaned: dict[str, bool] = {} - for k, v in results.items(): - if not k.startswith("_detail_"): - cleaned[k] = v - return cleaned - - -def _smoke_qsv(ffmpeg_bin: str, profile) -> dict[str, bool]: - """Live-encode QSV encoders (qsv encoders upload system frames - internally once a qsv device exists).""" - results: dict[str, bool] = {} - for enc in profile.encoders.values(): - try: - cmd = ( - [ffmpeg_bin, "-hide_banner", "-loglevel", "error"] - + encoder_pre_args(profile) - + ["-f", "lavfi", "-i", "color=c=black:s=256x256:d=0.2:r=24", - "-frames:v", "5", - "-c:v", enc] - + encoder_quality_args(profile.api, enc, 28, 9) - + ["-f", "null", "-"] - ) - res = subprocess.run(cmd, capture_output=True, text=True, timeout=20) - results[enc] = res.returncode == 0 - except (OSError, subprocess.SubprocessError): - results[enc] = False - return results - - -# ────────────────────────────────────────────── -# ENVIRONMENT PROBE (distro-aware, extended) -# ────────────────────────────────────────────── - -@dataclass -class EnvProbe: - distro: DistroProfile = field(default_factory=lambda: DistroProfile( - family="unknown", name="Unknown", version_id="?", - pkg_manager="unknown", install_cmd_template="", - binary_extra_paths=[], av1an_known_encoder_names=[], - ffmpeg_pkg="ffmpeg", av1an_pkg="av1an", notes="" - )) - av1an_path: str | None = None - ffmpeg_path: str | None = None - ffprobe_path: str | None = None - # v3 (OTC-011, PEP 868): parameterized dict/list type hints. - # av1an_flags values are sometimes str (flag name), sometimes bool - # (has_chunk_method), sometimes int — keep as dict[str, object] for honesty. - av1an_flags: dict[str, object] = field(default_factory=dict) - av1an_version: str | None = None - ffmpeg_version: str | None = None - ffmpeg_libs: dict[str, bool] = field(default_factory=dict) # lib name -> available - runtime_deps: dict[str, bool] = field(default_factory=dict) # dep name -> present - missing_dep_pkgs: list[str] = field(default_factory=list) # distro pkg names to install - vs_version: str | None = None # VapourSynth version string (for diagnostics) - vs_script_lib: str | None = None # path to libvapoursynth-script.so that passed - cpu: CpuTopology = field(default_factory=lambda: CpuTopology(1, 1, 1, "Unknown")) - # v4.6.0: GPU/NVENC probe result. EncoderWorker reads - # env.gpu.functional[encoder_name] when the engine is auto/gpu. - gpu: GpuInfo = field(default_factory=GpuInfo) - errors: list[str] = field(default_factory=list) - warnings: list[str] = field(default_factory=list) - - @property - def ready(self) -> bool: - return (self.av1an_path is not None and self.ffmpeg_path is not None - and not self.errors and not self.missing_dep_pkgs) - - @property - def dep_install_hint(self) -> str: - """Generate a distro-specific install command for missing runtime deps.""" - if not self.missing_dep_pkgs or self.distro.family == "unknown": - return "" - return self.distro.install_cmd_template.format(packages=" ".join(self.missing_dep_pkgs)) - - @property - def install_hint(self) -> str: - """Generate a distro-specific install command for missing packages.""" - missing = [] - if self.av1an_path is None: - missing.append(self.distro.av1an_pkg) - if self.ffmpeg_path is None: - missing.append(self.distro.ffmpeg_pkg) - if not missing: - return "" - return self.distro.install_cmd_template.format(packages=" ".join(missing)) - - -def _find_binary(name: str, distro: DistroProfile) -> str | None: - """ - Search for a binary in: (1) standard PATH via shutil.which, then - (2) distro-specific extra paths (expanded ~). Returns first match. - """ - # Standard PATH search - found = shutil.which(name) - if found: - return found - - # Distro-specific extra paths - for raw_path in distro.binary_extra_paths: - expanded = Path(raw_path).expanduser() - candidate = expanded / name - if candidate.is_file() and os.access(candidate, os.X_OK): - return str(candidate) - - return None - - -def _probe_ffmpeg_libs(ffmpeg_bin: str) -> dict[str, bool]: - """Check which encoder/decoder libraries ffmpeg was compiled with. - Runs ffmpeg -encoders ONCE and greps for all known encoder names. - Each entry: (key, [search_strings]) — any match = available. - - v4 STABILITY FIX: the v3 search strings for libsvtav1 and libaom were - wrong. ffmpeg's `-encoders` output lists them as `libsvtav1` and - `libaom-av1` (no underscore between svt/av1, hyphen between aom/av1) — - NOT `libsvt_av1` / `libaom_av1`. This caused _probe_ffmpeg_libs to - report False for both even when they were installed, which then caused - _handle_vs_incompat to incorrectly tell the user "ffmpeg also lacks - libsvtav1" and abort — even though ffmpeg actually had it. The e2e - test test_probe_detects_ffmpeg_libs caught this. - """ - try: - res = subprocess.run( - [ffmpeg_bin, "-encoders"], - capture_output=True, text=True, timeout=10, - ) - output = res.stdout - except (OSError, subprocess.SubprocessError): - output = "" - - # v4: search strings match the EXACT names ffmpeg -encoders prints. - # Verified against ffmpeg 7.x output: - # V..... libsvtav1 SVT-AV1(...) encoder (codec av1) - # V....D libaom-av1 libaom AV1 (codec av1) - # V....D libvpx-vp9 libvpx VP9 (codec vp9) - # The trailing space in each search string anchors the match to the - # encoder name boundary, preventing false positives like "libvpx_vp9" - # matching "libvpx_vp9_decoder" (which doesn't exist, but defensive). - # We also include the underscore variant as a fallback for older - # ffmpeg builds that may have used that spelling. - checks = [ - ("libsvtav1", ["libsvtav1 ", "libsvt_av1", "svt_av1 "]), - ("libaom", ["libaom-av1 ", "libaom_av1", "aom_av1 "]), - ("libvpx", ["libvpx-vp9 ", "libvpx_vp9", "vpx_vp9 "]), - ("libx265", ["libx265 "]), - ("libopus", ["libopus "]), - ("libvorbis", ["libvorbis "]), - ("flac", ["flac "]), - # v4.6.0: NVENC hardware encoders (compiled-in check only — the - # live-encode gate is _probe_gpu()'s functional dict). - ("hevc_nvenc", ["hevc_nvenc "]), - ("h264_nvenc", ["h264_nvenc "]), - ("av1_nvenc", ["av1_nvenc "]), - ] - libs = {} - for lib_name, search_strings in checks: - libs[lib_name] = any(s in output for s in search_strings) - return libs - - -def _probe_av1an_version(av1an_bin: str) -> str | None: - """Extract av1an version string.""" - try: - # Try --version first, fall back to parsing --help header - for args in (["--version"], ["--help"]): - res = subprocess.run( - [av1an_bin] + args, - capture_output=True, text=True, timeout=10, - ) - output = res.stdout or res.stderr - match = re.search(r"av1an\s+([\d.]+(?:-\w+)?)", output, re.IGNORECASE) - if match: - return match.group(1) - if res.stdout.strip(): # If --version produced output but no version match - return res.stdout.strip().splitlines()[0][:60] - except (OSError, subprocess.SubprocessError): - pass - return None - - -def _probe_ffmpeg_version(ffmpeg_bin: str) -> str | None: - """Extract ffmpeg version string.""" - try: - res = subprocess.run( - [ffmpeg_bin, "-version"], - capture_output=True, text=True, timeout=10, - ) - first_line = res.stdout.splitlines()[0] if res.stdout else "" - match = re.search(r"ffmpeg version (\S+)", first_line) - return match.group(1) if match else first_line[:60] - except (OSError, subprocess.SubprocessError): - return None - - -def _probe_runtime_deps(distro: DistroProfile) -> tuple[dict[str, bool], list[str]]: - """Check runtime dependencies that av1an needs to function. - Returns (deps_dict, missing_pkg_names). - - Checks: - - VapourSynth + VSScript (av1an loads libvapoursynth-script.so via dlopen - to get the VSScript API — without this it panics with - 'Failed to get VSScript API') - - Encoder binaries that av1an invokes directly (svt_av1, x265, vpxenc) - """ - deps: dict[str, bool] = {} - missing_pkgs: list[str] = [] - - # --- VapourSynth + VSScript (critical: av1an will panic without it) --- - # av1an is a Rust binary that dlopen's libvapoursynth-script.so and calls - # vsscript_init() / vsscript_createScript() / etc. It does NOT use the - # Python vapoursynth module. The shared library and the VSScript API - # library can be packaged separately on some distros (e.g. Debian has - # libvapoursynth-script-dev). We must check what av1an actually loads. - # - # IMPORTANT: We do NOT call vsscript_init() in our probe. VSScript's init - # internally calls Py_Initialize(), which crashes/fails when Python is - # already running (our probe runs inside a Python subprocess). Instead, - # we verify the shared library exists AND can be dlopen'd (CDLL constructor - # resolves all .so dependencies). If it loads, it will work for av1an. - vs_ok = False - vs_detail = "" - vs_ver_str = "" - vs_lib_path = None - - # --- Step 1: Direct filesystem check (most reliable) --- - # Check well-known install paths. Works even if ldconfig cache is stale. - _vs_script_search = [ - "/usr/lib/libvapoursynth-script.so", - "/usr/lib/libvapoursynth_script.so", - "/usr/lib64/libvapoursynth-script.so", - "/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so", - "/usr/local/lib/libvapoursynth-script.so", - ] - for p in _vs_script_search: - if Path(p).is_file(): - vs_lib_path = p - break - - # --- Step 2: Glob search on known lib dirs --- - if not vs_lib_path: - for lib_dir in ("/usr/lib", "/usr/lib64", "/usr/local/lib", - "/usr/lib/x86_64-linux-gnu"): - d = Path(lib_dir) - if d.is_dir(): - matches = list(d.glob("libvapoursynth-script.so*")) - # Prefer unversioned .so over .so.0 (dev symlink) - for m in sorted(matches, key=lambda p: p.name): - vs_lib_path = str(m) - break - if vs_lib_path: - break - - # --- Step 3: ldconfig -p --- - if not vs_lib_path: - try: - res = subprocess.run( - ["ldconfig", "-p"], capture_output=True, text=True, timeout=5, - ) - for line in res.stdout.splitlines(): - if "libvapoursynth-script" in line or "libvapoursynth_script" in line: - parts = line.split("=>") - if len(parts) >= 2: - vs_lib_path = parts[1].strip().split()[0] - break - except (OSError, subprocess.SubprocessError): - pass - - # --- Step 4: ctypes.util.find_library --- - if not vs_lib_path: - try: - for name in ("vapoursynth-script", "vapoursynth_script"): - found = ctypes.util.find_library(name) - if found: - vs_lib_path = found - break - except (OSError, subprocess.SubprocessError): - pass - - # --- Step 5: Distro-specific package file listing --- - if not vs_lib_path: - pkg_query = { - "arch": ["pacman", "-Ql", "vapoursynth"], - "debian": ["dpkg", "-L", "vapoursynth"], - "redhat": ["rpm", "-ql", "vapoursynth"], - "suse": ["rpm", "-ql", "vapoursynth"], - } - query_cmd = pkg_query.get(distro.family) - if query_cmd: - try: - res = subprocess.run( - query_cmd, capture_output=True, text=True, timeout=10, - ) - for line in res.stdout.splitlines(): - line = line.strip() - # Skip directory entries and grab .so files - if "libvapoursynth-script" in line and line.endswith(".so"): - vs_lib_path = line - break - if "libvapoursynth-script" in line and ".so." in line and not vs_lib_path: - vs_lib_path = line # versioned .so as fallback - except (OSError, subprocess.SubprocessError): - pass - - # --- Step 6: dlopen smoke test (diagnostic only, NOT a gate) --- - # We do NOT gate on dlopen success. The library's constructor may call - # Py_Initialize() which conflicts with our Python subprocess, causing a - # silent segfault. av1an loads this library in its own fresh Rust process - # where no Python is running — so it works there even if our probe crashes. - # We only use dlopen to produce an optional warning. - vs_dlopen_warning = "" - if vs_lib_path: - try: - _escaped = vs_lib_path.replace("'", "\\'") - probe_code = ( - "import ctypes; " - f"try: h = ctypes.CDLL('{_escaped}'); print('LOAD_OK') " - f"except OSError as e: print(f'LOAD_FAIL|{{e}}') " - f"except Exception as e: print(f'LOAD_OTHER|{{e}}') " - ) - res = subprocess.run( - [sys.executable, "-c", probe_code], - capture_output=True, text=True, timeout=10, - ) - out = res.stdout.strip() - if out == "LOAD_OK": - vs_ok = True - elif out: - vs_dlopen_warning = f"dlopen test failed: {out}" - vs_ok = True # file exists — let av1an try in its own process - else: - # subprocess produced no output — likely segfault in library - # constructor (Py_Initialize conflict). File still exists. - vs_dlopen_warning = "dlopen test produced no output (likely segfault in library constructor — not a problem for av1an)" - vs_ok = True - except subprocess.TimeoutExpired: - vs_dlopen_warning = "dlopen test timed out (library may have hanging constructor)" - vs_ok = True - except (OSError, subprocess.SubprocessError) as e: - vs_dlopen_warning = f"dlopen probe error: {e}" - vs_ok = True - - # Final gate: library file was found on disk - if vs_lib_path and not vs_ok: - vs_ok = True # file found on disk is sufficient - - if vs_ok: - vs_detail = vs_lib_path or "found" - # Try to get VapourSynth version from the core lib for diagnostics - try: - ver_probe = ( - "import ctypes, ctypes.util; " - "_lib = ctypes.util.find_library('vapoursynth'); " - "if not _lib: " - " import subprocess as _sp; " - " _r = _sp.run(['ldconfig','-p'], capture_output=True, text=True, timeout=5); " - " _m = [l.split('=>')[1].strip().split()[0] for l in _r.stdout.splitlines() " - " if 'libvapoursynth.so.' in l and 'script' not in l]; " - " _lib = _m[0] if _m else None; " - "if _lib: " - " try: " - " _h = ctypes.CDLL(_lib); " - " _fn = _h.vapoursynth_version; " - " _fn.restype = ctypes.c_int; " - " print(_fn()) " - " except: pass " - ) - res = subprocess.run( - [sys.executable, "-c", ver_probe], - capture_output=True, text=True, timeout=10, - ) - ver_out = res.stdout.strip() - if ver_out and ver_out.isdigit() and int(ver_out) > 0: - vs_ver_str = f"R{ver_out}" - except (OSError, subprocess.SubprocessError): - pass - else: - if not vs_detail: - vs_detail = "libvapoursynth-script.so not found (checked filesystem, ldconfig, and package manager)" - - deps["vapoursynth"] = vs_ok - if not vs_ok: - # Determine which package(s) to suggest. - # Most distros bundle VSScript into the main 'vapoursynth' package, - # but some split it (Debian/Ubuntu: libvapoursynth-script-dev). - # Use the dedicated vsscript_pkg field if set, else fall back to dep_pkgs. - if distro.vsscript_pkg: - missing_pkgs.append(distro.vsscript_pkg) - elif "vapoursynth" in distro.dep_pkgs: - missing_pkgs.append(distro.dep_pkgs["vapoursynth"]) - deps["vs_detail"] = False # extra key for the diagnostic message - else: - deps["vs_detail"] = True - - # --- Encoder binaries (av1an invokes these directly, not via ffmpeg) --- - for enc_key, binary_names in distro.encoder_binaries.items(): - found = False - for bin_name in binary_names: - if _find_binary(bin_name, distro) is not None: - found = True - break - deps[enc_key] = found - if not found: - # Map encoder key to dep_pkgs key - dep_key_map = {"svt_av1": "svt-av1", "vpx": "vpx", "x265": "x265"} - dep_key = dep_key_map.get(enc_key, enc_key) - if dep_key in distro.dep_pkgs: - pkg_name = distro.dep_pkgs[dep_key] - if pkg_name not in missing_pkgs: - missing_pkgs.append(pkg_name) - - # --- ffprobe (needed for input file validation) --- - # Already checked in probe_environment() for the main binary, but let's - # make sure the dep dict reflects it for consistency. - # (ffprobe_path is set separately in probe_environment) - - return deps, missing_pkgs, vs_detail, vs_ver_str, vs_dlopen_warning - - -def probe_environment() -> EnvProbe: - """ - Distro-aware binary detection + av1an flag compatibility probe + - ffmpeg library availability check. - """ - distro = detect_distro() - result = EnvProbe(distro=distro) - result.cpu = detect_cpu_topology() - cpu = result.cpu - - result.warnings.append(f"Detected distro: {distro.name} (family={distro.family}, v{distro.version_id})") - result.warnings.append( - f"CPU: {cpu.model_name} — {cpu.physical_cores} physical cores x {cpu.threads_per_core} threads = {cpu.logical_threads} logical" - ) - - # --- Binary detection (distro-aware path search) --- - for name, attr in [("av1an", "av1an_path"), ("ffmpeg", "ffmpeg_path"), ("ffprobe", "ffprobe_path")]: - path = _find_binary(name, distro) - if path is None: - result.errors.append(f"Missing binary: {name}") - else: - setattr(result, attr, path) - - # --- Install hint for missing binaries --- - if result.install_hint: - result.warnings.append(f"Install command: {result.install_hint}") - - # --- FFmpeg version + library probe --- - if result.ffmpeg_path: - result.ffmpeg_version = _probe_ffmpeg_version(result.ffmpeg_path) - if result.ffmpeg_version: - result.warnings.append(f"FFmpeg version: {result.ffmpeg_version}") - result.ffmpeg_libs = _probe_ffmpeg_libs(result.ffmpeg_path) - - # v4.6.0: GPU/NVENC probe — compiled-in check + live encode smoke - # test. EncoderWorker gates the GPU path on env.gpu.functional; - # this warning block surfaces the result (and the fix when the - # driver is too old for the ffmpeg build's NVENC API). - result.gpu = _probe_gpu(result.ffmpeg_path) - gpu = result.gpu - if gpu.has_gpu: - result.warnings.append( - f"GPU: {gpu.name or 'NVIDIA'} — NVENC ready: " - f"{', '.join(gpu.usable_encoders)} (engine: Auto will use the GPU)" - ) - elif gpu.encoders and any(gpu.encoders.values()): - present = [e for e in _NVENC_ENCODER_NAMES if gpu.encoders.get(e)] - detail = gpu.first_failure_detail - result.warnings.append( - f"GPU: NVENC encoder(s) {', '.join(present)} present in ffmpeg " - f"but NOT usable — {detail or 'smoke test failed'}. " - f"Auto engine will fall back to CPU." - ) - if "API version" in detail or "minimum required Nvidia driver" in detail: - result.warnings.append( - " FIX: update the NVIDIA driver (the ffmpeg build's NVENC " - "API is newer than the installed driver supports), or use " - "an ffmpeg build matching the installed driver." - ) - else: - result.warnings.append( - "GPU: no hardware encoder in this ffmpeg build — CPU encoding." - ) - - # Warn about missing AUDIO libs (video codecs are handled by av1an's own - # encoder binaries — ffmpeg's video encoder list is irrelevant) - audio_lib_warnings = { - "Opus": "libopus", - "Vorbis": "libvorbis", - "FLAC": "flac", - } - for codec_label, lib_name in audio_lib_warnings.items(): - if not result.ffmpeg_libs.get(lib_name, False): - result.warnings.append(f"FFmpeg missing encoder: {lib_name} ({codec_label} audio will not work)") - - # --- Av1an version --- - if result.av1an_path: - result.av1an_version = _probe_av1an_version(result.av1an_path) - if result.av1an_version: - result.warnings.append(f"av1an version: {result.av1an_version}") - - # --- Av1an flag compatibility probe --- - if result.av1an_path: - try: - help_out = subprocess.run( - [result.av1an_path, "--help"], - capture_output=True, text=True, timeout=15, - ).stdout - result.av1an_flags = { - "worker": "--workers" if "--workers" in help_out else "-w", - "video_params": "--video-params" if "--video-params" in help_out else "-v", - "audio_params": "--audio-params" if "--audio-params" in help_out else "-a", - } - - # Detect which encoder names this av1an build actually accepts. - # Substring matching on --help is unreliable (e.g. "svt" appears in - # descriptions but the real name may be "svtav1" or "svt_av1"). - # Instead, pass a bogus encoder name and parse the clap error which - # lists all valid values. - svt_name = _detect_av1an_svt_encoder(result.av1an_path) - if svt_name: - result.av1an_flags["svt_name"] = svt_name - result.warnings.append(f"av1an SVT-AV1 encoder name: '{svt_name}'") - else: - # Absolute fallback — should rarely be needed - result.av1an_flags["svt_name"] = "svt_av1" - result.warnings.append("av1an SVT-AV1 encoder name: 'svt_av1' (fallback, not auto-detected)") - - # Check for chunk-method availability (differs by av1an version/distro) - if "--chunk-method" in help_out: - result.av1an_flags["has_chunk_method"] = True - - # Check for --temp flag (lets us relocate av1an work dir out of user folders) - if "--temp" in help_out: - result.av1an_flags["has_temp"] = True - result.av1an_flags["temp_flag"] = "--temp" - elif "-T" in help_out: - result.av1an_flags["has_temp"] = True - result.av1an_flags["temp_flag"] = "-T" - - # Check for -s/segments flag (newer av1an) - if "-s" in help_out or "--scenes" in help_out: - result.av1an_flags["has_scenes"] = True - - # Detect concat method: prefer mkvmerge, fall back to ffmpeg - if shutil.which("mkvmerge"): - result.av1an_flags["concat_method"] = "mkvmerge" - else: - result.av1an_flags["concat_method"] = "ffmpeg" - - # v4.0.0: Probe VapourSynth source plugins. When NONE of the - # source plugins (lsmash, ffms2, bestsource, dgdecnv) are - # installed, av1an falls back to the Hybrid chunk method — - # which fails on phone-recorded MP4s with sparse keyframes - # (the "works up until near the end, never saves chunks into - # a full file" bug). Pre-setting chunk_method_override="select" - # avoids the wasted first-attempt + retry on every file. - # - # The select method uses VapourSynth's select() filter to - # extract frames one-by-one — slower than ffms2/bestsource - # but reliable for any file VapourSynth can open. - vs_plugins = _probe_vs_source_plugins() - result.av1an_flags["vs_plugins"] = vs_plugins - if vs_plugins: - result.warnings.append( - f"VapourSynth source plugins: {', '.join(vs_plugins)} " - f"— av1an will auto-select a fast chunk method" - ) - else: - result.warnings.append( - "VapourSynth source plugins: NONE found — " - "forcing --chunk-method select (reliable but slower). " - "Install vapoursynth-{lsmash,ffms2,bestsource} for faster " - "chunk-parallel encoding." - ) - result.av1an_flags["chunk_method_override"] = "select" - - # v4.6.0: ffmpeg ≥ 7 removed the -vsync option that av1an's - # segment/hybrid chunk extraction passes to ffmpeg. On those - # systems every segment-based chunk dies immediately with - # "Unrecognized option 'vsync'." — the y4m pipe breaks and - # each chunk fails 3x. The select override above already - # avoids those methods when no plugins are installed; this - # warning tells plugin-less users on new ffmpeg WHY av1an is - # stuck on slow select. - if not vs_plugins and result.ffmpeg_version: - try: - ffmpeg_major = int( - re.match(r"[nN]?(\d+)", result.ffmpeg_version).group(1) - ) - except (AttributeError, ValueError): - ffmpeg_major = 0 - if ffmpeg_major >= 7: - result.warnings.append( - "av1an note: ffmpeg ≥ 7 removed the -vsync option av1an's " - "segment/hybrid chunk methods use — those methods fail " - "with \"Unrecognized option 'vsync'\". Chunking stays on " - "'select'. Install a VapourSynth source plugin " - "(bestsource/ffms2/lsmash) to escape slow select, or use " - "the default ffmpeg-only path." - ) - - except (OSError, subprocess.SubprocessError) as e: - result.errors.append(f"av1an probe failed: {e}") - - # --- Distro-specific notes --- - if distro.notes: - result.warnings.append(f"Distro note: {distro.notes}") - - # --- Runtime dependency probe (vapoursynth, encoder binaries) --- - if result.av1an_path: - deps, missing_pkgs, vs_detail, vs_ver, vs_dlopen_warn = _probe_runtime_deps(distro) - result.runtime_deps = deps - result.missing_dep_pkgs = missing_pkgs - if deps.get("vapoursynth"): - result.vs_version = vs_ver - result.vs_script_lib = vs_detail - - # Log VapourSynth/VSScript with extra detail - vs_status = "OK" if deps.get("vapoursynth") else "MISSING" - result.warnings.append(f"Dependency: vapoursynth (VSScript API) = {vs_status}") - if deps.get("vapoursynth"): - # vs_detail is the library path on success - result.warnings.append(f" VSScript lib: {vs_detail}") - if vs_dlopen_warn: - result.warnings.append(f" dlopen note: {vs_dlopen_warn}") - else: - # vs_detail is the failure reason - result.warnings.append(f" Reason: {vs_detail}") - - # Log encoder binary deps (skip vs_detail key) - for dep_name, present in deps.items(): - if dep_name in ("vapoursynth", "vs_detail"): - continue - status = "OK" if present else "MISSING" - result.warnings.append(f"Dependency: {dep_name} = {status}") - - if missing_pkgs: - hint = result.dep_install_hint - result.errors.append( - f"Missing runtime dependencies: {', '.join(missing_pkgs)}" - ) - if hint: - result.errors.append(f" FIX: {hint}") - - return result - - - -def _detect_av1an_svt_encoder(av1an_bin: str) -> str | None: - """Determine the exact encoder name av1an accepts for SVT-AV1. - - Strategy (in order): - 1. Run ``av1an --encoder __PROBE__`` and parse clap's error for - ``[possible values: ...]``. - 2. Parse ``--help`` for ``[default: ]`` next to ``--encoder``. - 3. Regex fallback on the error output. - """ - try: - # --- Method 1: clap error with possible values --- - res = subprocess.run( - [av1an_bin, "--encoder", "__PROBE_TEST__"], - capture_output=True, text=True, timeout=10, - ) - stderr = res.stderr or "" - stdout = res.stdout or "" - combined = stderr + stdout - - m = re.search(r"\[possible values:\s*([^\]]+)\]", combined) - if m: - values = [v.strip().rstrip(',') for v in m.group(1).split()] - for v in values: - if "svt" in v.lower(): - return v - - # --- Method 2: parse --help for encoder default value --- - help_res = subprocess.run( - [av1an_bin, "--help"], - capture_output=True, text=True, timeout=10, - ) - help_text = (help_res.stdout or "") + (help_res.stderr or "") - # Look for pattern: --encoder ... [default: svt-av1] - m2 = re.search( - r"--encoder\s+.*?\[default:\s*(\S+?)\]", - help_text, re.DOTALL, - ) - if m2: - return m2.group(1) - - # --- Method 3: regex fallback on the error output --- - for line in combined.splitlines(): - for token in re.findall(r"\bsvt[a-z_-]*av1[a-z_-]*\b", line, re.IGNORECASE): - return token - for token in re.findall(r"\bsvtav1\b", line, re.IGNORECASE): - return token - - return None - except (OSError, subprocess.SubprocessError): - return None - - -def _av1an_env() -> dict[str, str]: - """Build an env dict for subprocess that includes ~/.local/lib in LD_LIBRARY_PATH. - - When VapourSynth is built from git and installed to ~/.local/, the linker - won't find libvapoursynth-script.so unless LD_LIBRARY_PATH points there. - This function ensures every av1an invocation inherits that path. - - v4.7.1: the git VS stack is self-contained in the python user - site-packages (module + libs + BestSource plugin), so the runtime env - also gets that dir on LD_LIBRARY_PATH and the user site on PYTHONPATH — - otherwise av1an loads the system VS and never sees the fresh stack. - """ - env = os.environ.copy() - local_lib = str(Path.home() / ".local" / "lib") - existing = env.get("LD_LIBRARY_PATH", "") - if local_lib not in existing: - env["LD_LIBRARY_PATH"] = f"{local_lib}:{existing}".rstrip(":") - try: - user_site = Path(site.getusersitepackages()) - vs_dir = user_site / "vapoursynth" - if vs_dir.is_dir() and (vs_dir / "libvsscript.so").exists(): - existing = env.get("LD_LIBRARY_PATH", "") - if str(vs_dir) not in existing: - env["LD_LIBRARY_PATH"] = f"{vs_dir}:{existing}".rstrip(":") - py_path = env.get("PYTHONPATH", "") - if str(user_site) not in py_path: - env["PYTHONPATH"] = f"{user_site}:{py_path}".rstrip(":") - except (AttributeError, OSError): - pass - return env - - -# v4.0.0: VapourSynth source plugin probe. Returns a list of available -# plugin names (e.g. ["lsmash", "ffms2", "bestsource"]). When the list -# is empty, av1an falls back to the Hybrid chunk method — which fails -# on phone-recorded MP4s with sparse keyframes. The caller uses this -# to decide whether to pre-set chunk_method_override="select". -_VS_PLUGIN_PROBE_PATHS: tuple[tuple[str, tuple[str, ...]], ...] = ( - # (plugin_name, candidate .so filenames) - # lsmash: imported as `havsfmt` / `lsmas` in VS; .so is libvslsmashsource.so - ("lsmash", ("libvslsmashsource.so",)), - # ffms2: imported as `ffms2` in VS; .so is libffms2.so (sometimes libvffms2.so) - ("ffms2", ("libffms2.so", "libvffms2.so")), - # bestsource: imported as `bestsource` / `bs` in VS - ("bestsource", ("libbestsource.so", "libvsbestsource.so")), - # dgdecnv: NVIDIA hardware-accelerated decoder - ("dgdecnv", ("libdgdecnv.so",)), - # vszip: high-performance resize/format plugins - ("vszip", ("libvszip.so",)), -) - - -def _probe_vs_source_plugins() -> list[str]: - """Probe for VapourSynth source plugins in standard locations. - - Searches (in order): - 1. ``$XDG_DATA_HOME/vapoursynth/`` (or ``~/.local/share/vapoursynth/``) - 2. ``~/.local/lib/vapoursynth/`` (user-installed plugins from source) - 3. ``/usr/lib/vapoursynth/`` (distro-installed plugins) - 4. ``/usr/local/lib/vapoursynth/`` (manually installed) - 5. ``/usr/lib/x86_64-linux-gnu/vapoursynth/`` (Debian multiarch) - - Returns a sorted list of available plugin names. Empty list = no - source plugins found, which means av1an will fall back to Hybrid - chunk method and likely fail on phone-recorded MP4s. - - Pure-stdlib (no vapoursynth Python bindings required). Best-effort: - if a plugin is installed but not in these paths, this probe will - miss it — but the av1an runtime will still detect it, and the - v4.0.0 retry in _encode_one will still switch to select on first - failure. - """ - search_dirs: list[Path] = [] - xdg_data = os.environ.get("XDG_DATA_HOME", "") - if xdg_data: - search_dirs.append(Path(xdg_data) / "vapoursynth") - else: - search_dirs.append(Path.home() / ".local" / "share" / "vapoursynth") - search_dirs.append(Path.home() / ".local" / "lib" / "vapoursynth") - search_dirs.append(Path("/usr/lib/vapoursynth")) - search_dirs.append(Path("/usr/local/lib/vapoursynth")) - search_dirs.append(Path("/usr/lib/x86_64-linux-gnu/vapoursynth")) - # v4.7.1: the git-built VapourSynth stack installs its plugins into - # the python site-packages tree (module + libs + plugins/ are one - # self-contained unit). Probe those dirs too. - try: - search_dirs.append(Path(site.getusersitepackages()) / "vapoursynth" / "plugins") - except (AttributeError, OSError): - pass - try: - for d in site.getsitepackages(): - search_dirs.append(Path(d) / "vapoursynth" / "plugins") - except (AttributeError, OSError): - pass - - found: set[str] = set() - for d in search_dirs: - if not d.is_dir(): - continue - try: - entries = list(d.iterdir()) - except OSError: - continue - for entry in entries: - if not entry.is_file(): - continue - name_lower = entry.name.lower() - for plugin_name, so_names in _VS_PLUGIN_PROBE_PATHS: - for so_name in so_names: - if so_name in name_lower: - found.add(plugin_name) - break - - return sorted(found) - - -def _av1an_vsscript_smoke_test( - av1an_bin: str, - ffmpeg_bin: str, - av1an_flags: dict, - svt_name: str = "svt_av1", - timeout: int = 30, -) -> tuple[bool, str]: - """Pre-flight test: create a tiny video and try to run av1an on it. - - This catches 'Failed to get VSScript API' panics BEFORE the real queue - starts. File-existence checks for libvapoursynth-script.so pass even - when the ABI is incompatible (av1an's Rust vapoursynth crate built - against a different VS version). Only actually invoking av1an reveals - the mismatch. - - Returns (ok, detail_message). - ok=True -> av1an initialized VSScript successfully. - ok=False -> av1an panicked or failed; detail_message explains why. - """ - - with tempfile.TemporaryDirectory(prefix="av1an_smoke_") as tmpdir: - test_in = Path(tmpdir) / "test_smoke.mkv" - test_out = Path(tmpdir) / "test_smoke_out.mkv" - - # Create a 1-second 64x64 black video (video-only is enough to - # trigger VSScript init in av1an — no audio needed). - gen_cmd = [ - ffmpeg_bin, - "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1:r=24", - "-t", "1", "-pix_fmt", "yuv420p", "-an", "-y", str(test_in), - ] - try: - res = subprocess.run(gen_cmd, capture_output=True, text=True, timeout=15) - if res.returncode != 0: - return False, f"ffmpeg test-video failed (rc={res.returncode}): {(res.stderr or '')[-200:]}" - except (OSError, subprocess.SubprocessError) as e: - return False, f"Could not generate smoke test video: {e}" - - if not test_in.exists(): - return False, "Smoke test video was not created by ffmpeg" - - # Build minimal av1an command - worker_flag = av1an_flags.get("worker", "--workers") - vparams_flag = av1an_flags.get("video_params", "--video-params") - aparams_flag = av1an_flags.get("audio_params", "--audio-params") - - cmd = [ - av1an_bin, - "-i", str(test_in), - worker_flag, "1", - "--encoder", svt_name, - vparams_flag, "--preset 8 --crf 40 --keyint 240", - "-o", str(test_out), - ] - - # Use chunk-method select if available (triggers VSScript init) - if av1an_flags.get("has_chunk_method"): - cmd.extend(["--chunk-method", "select"]) - - # SEI CERT ERR01-C: catch only the specific exception types we - # expect from subprocess.run; never swallow unrelated failures. - try: - res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, - env=_av1an_env()) - except subprocess.TimeoutExpired: - # Timeout is a real failure — av1an is hanging. Do NOT mask it. - return False, f"SMOKE_TIMEOUT: av1an smoke test exceeded {timeout}s — likely hung in VSScript init or encoder spawn" - except FileNotFoundError as e: - return False, f"SMOKE_BIN_MISSING: {e}" - except OSError as e: - return False, f"SMOKE_OS_ERROR: {e}" - - stderr = res.stderr or "" - stdout = res.stdout or "" - - # Success requires BOTH rc==0 AND the output file actually exists. - # The previous code returned True on any non-VSScript failure, which - # masked real bugs (missing encoder binary, concat failure, etc.) - # and led to "chunks but never saves a file" symptoms in production. - if res.returncode == 0 and test_out.exists(): - test_out.unlink(missing_ok=True) - return True, "av1an VSScript init OK" - - # Classify the known failure modes by inspecting stderr. - if "Failed to get VSScript API" in stderr: - return False, "VSScript_API_INCOMPAT" - - if "invalid value" in stderr and "--encoder" in stderr: - return False, f"INVALID_ENCODER: {stderr[-200:]}" - - if "No usable encoder found" in stderr: - return False, f"ENCODER_BIN_MISSING: {stderr[-300:]}" - - # Unknown failure — return False so the caller can offer ffmpeg - # fallback or rebuild. Include the FULL stderr (not just the tail) - # so the user can see the actual error and the diagnostic patterns - # below can match on it. - combined = (stderr + "\n--- stdout ---\n" + stdout)[-1500:] - return False, f"SMOKE_FAIL(rc={res.returncode}): {combined}" - diff --git a/opentranscode/ffprobe_utils.py b/opentranscode/ffprobe_utils.py deleted file mode 100755 index 5e5ead4..0000000 --- a/opentranscode/ffprobe_utils.py +++ /dev/null @@ -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 "" - diff --git a/opentranscode/gpu_profiles.py b/opentranscode/gpu_profiles.py deleted file mode 100644 index fea289f..0000000 --- a/opentranscode/gpu_profiles.py +++ /dev/null @@ -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 [] diff --git a/opentranscode/hybrid_scheduler.py b/opentranscode/hybrid_scheduler.py deleted file mode 100644 index fffec19..0000000 --- a/opentranscode/hybrid_scheduler.py +++ /dev/null @@ -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, - ) diff --git a/opentranscode/keepawake.py b/opentranscode/keepawake.py deleted file mode 100755 index 3bd58bf..0000000 --- a/opentranscode/keepawake.py +++ /dev/null @@ -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() diff --git a/opentranscode/license_registry.py b/opentranscode/license_registry.py deleted file mode 100755 index 95dab8c..0000000 --- a/opentranscode/license_registry.py +++ /dev/null @@ -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) - diff --git a/opentranscode/source_builder.py b/opentranscode/source_builder.py deleted file mode 100755 index cd6b5ef..0000000 --- a/opentranscode/source_builder.py +++ /dev/null @@ -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 - diff --git a/opentranscode/temp_manager.py b/opentranscode/temp_manager.py deleted file mode 100755 index f9596ba..0000000 --- a/opentranscode/temp_manager.py +++ /dev/null @@ -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}" - diff --git a/opentranscode/ui_theme.py b/opentranscode/ui_theme.py deleted file mode 100755 index 37db5fe..0000000 --- a/opentranscode/ui_theme.py +++ /dev/null @@ -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; -} -""" diff --git a/opentranscode/ui_window.py b/opentranscode/ui_window.py deleted file mode 100755 index 03ef1d4..0000000 --- a/opentranscode/ui_window.py +++ /dev/null @@ -1,1746 +0,0 @@ -"""OpenCodecMaster (QMainWindow) — the main GUI window. - -The top-level window that wires together every other module: codec -profiles (combo boxes), env probe (startup), encoder worker (queue -execution), source builder (rebuild-from-git button), license notices -(About dialog), the MMD3 stylesheet, and the RadioKnob widget. - -Also exposes ``launch_gui()``, which is the ``QApplication`` entry -point invoked by ``cli.main()`` and ``python -m opentranscode``. -""" - -import os -import re -import subprocess -import sys -import time -from pathlib import Path - -from PySide6.QtCore import Qt, QTimer, Slot -from PySide6.QtGui import QFont, QPalette, QColor -from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QComboBox, QCheckBox, - QTextEdit, QFileDialog, QGroupBox, QStatusBar, QMessageBox, - QStyleFactory, -) - -from .codec_profiles import ( - AUDIO_PROFILES, - CONTAINER_PROFILES, - DEFAULT_INPUT_EXTENSIONS, - RESOLUTION_PRESETS, - SUBTITLE_OPTIONS, - VIDEO_CODECS, - AudioProfile, - ResolutionProfile, - ffmpeg_lib_key_for, -) -from .encoder_worker import EncoderWorker, resolve_gpu_encoder -from .gpu_profiles import GPU_PROFILES -from .env_probe import ( - EnvProbe, - _av1an_vsscript_smoke_test, - _detect_av1an_svt_encoder, - probe_environment, -) -from .license_registry import ( - LICENSE_NOTICES, - active_license_notices, - license_banner_full, - license_banner_short, -) -from .source_builder import SourceBuildWorker -from .ui_theme import MMD3_QSS -from .widgets import RadioKnob - -class OpenCodecMaster(QMainWindow): - def __init__(self): - super().__init__() - self.setWindowTitle("OpenTranscode — dcos.net") - self.resize(1100, 920) - self.worker: EncoderWorker | None = None - # v4.7.0: hybrid (GPU + CPU lanes) bookkeeping. *workers* holds - # every active lane; _hybrid_pending/_hybrid_totals aggregate the - # per-lane finished_queue signals into one summary. - self.workers: list[EncoderWorker] = [] - self._hybrid_pending = 0 - self._hybrid_totals = [0, 0] - self.env: EnvProbe | None = None - self._pending_deletes: list[Path] = [] - - self._apply_mmd3_theme() - self._build_ui() - - # Probe environment after UI is up - QTimer.singleShot(500, self._probe_and_init) - - # ── UI Construction ── - - def _build_ui(self): - central = QWidget() - central.setObjectName("central") - self.setCentralWidget(central) - root = QVBoxLayout(central) - root.setContentsMargins(10, 6, 10, 4) - root.setSpacing(4) - - # ── Header ── - header = QWidget() - header_lay = QVBoxLayout(header) - header_lay.setContentsMargins(0, 0, 0, 0) - header_lay.setSpacing(0) - - title = QLabel("OpenTranscode") - title.setFont(QFont("Segoe UI", 22, QFont.Weight.Bold)) - title.setAlignment(Qt.AlignmentFlag.AlignCenter) - title.setStyleSheet("color: #d4aa50; letter-spacing: 4px;") - header_lay.addWidget(title) - - subtitle = QLabel('dcos.net // concurrent open-source transcoding') - subtitle.setFont(QFont("Consolas", 8)) - subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) - subtitle.setStyleSheet("color: #555; letter-spacing: 2px;") - header_lay.addWidget(subtitle) - - accent = QWidget() - accent.setFixedHeight(1) - accent.setStyleSheet("background: qlineargradient(x1:0, y1:0, x2:1, y2:0," - "stop:0 transparent, stop:0.15 #d4aa5044," - "stop:0.5 #d4aa5088, stop:0.85 #d4aa5044, stop:1 transparent);") - header_lay.addWidget(accent) - - root.addWidget(header) - - # ── Paths ── - path_grp = QGroupBox("Paths") - path_lay = QVBoxLayout(path_grp) - path_lay.setSpacing(2) - path_lay.setContentsMargins(10, 14, 10, 8) - - self.in_path_edit = QLineEdit(str(Path.home() / "Videos" / "INCOMING")) - self.out_path_edit = QLineEdit(str(Path.home() / "Videos" / "ARCHIVE")) - for label_text, line_edit in [ - ("IN:", self.in_path_edit), - ("OUT:", self.out_path_edit), - ]: - row = QHBoxLayout() - row.setSpacing(6) - lbl = QLabel(label_text) - lbl.setFixedWidth(28) - lbl.setStyleSheet("color: #d4aa50; font-family: 'Consolas', monospace; font-weight: bold; font-size: 10px;") - row.addWidget(lbl) - row.addWidget(line_edit, 1) - btn_browse = QPushButton("...") - btn_browse.setObjectName("btnBrowse") - btn_browse.setFixedSize(30, 22) - btn_browse.setToolTip("Browse") - btn_browse.clicked.connect( - lambda checked, le=line_edit, is_dir=True: self._browse(le, is_dir) - ) - row.addWidget(btn_browse) - path_lay.addLayout(row) - - root.addWidget(path_grp) - - # ── Encoder Chain ── - codec_grp = QGroupBox("Encoder Chain") - codec_lay = QHBoxLayout(codec_grp) - codec_lay.setSpacing(8) - codec_lay.setContentsMargins(10, 14, 10, 8) - - for col_idx, (label, combo_items, slot) in enumerate([ - ("VIDEO", [vc.label for vc in VIDEO_CODECS], self._on_codec_changed), - ("PRESET", [], None), - ("AUDIO", [ap.label for ap in AUDIO_PROFILES], self._on_audio_changed), - ("CONTAINER", [cp.label for cp in CONTAINER_PROFILES], self._on_container_changed), - ("RESOLUTION", [], self._on_resolution_changed), - ("SUBS", [so[0] for so in SUBTITLE_OPTIONS], None), - ]): - col = QVBoxLayout() - col.setSpacing(1) - lbl = QLabel(label) - lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;") - col.addWidget(lbl) - - combo = QComboBox() - combo.setFixedHeight(24) - if combo_items: - combo.addItems(combo_items) - if slot: - combo.currentIndexChanged.connect(slot) - col.addWidget(combo) - codec_lay.addLayout(col) - - if label == "VIDEO": - self.codec_combo = combo - elif label == "PRESET": - self.preset_combo = combo - self._populate_presets(0) - self.preset_combo.setCurrentIndex(1) - elif label == "AUDIO": - self.audio_combo = combo - elif label == "CONTAINER": - self.container_combo = combo - elif label == "RESOLUTION": - self.resolution_combo = combo - self._populate_resolution_combo() - elif label == "SUBS": - self.subs_combo = combo - - root.addWidget(codec_grp) - - # ── Side panel: compact knobs ── - knobs_panel = QWidget() - knobs_panel.setFixedWidth(170) - knobs_lay = QVBoxLayout(knobs_panel) - knobs_lay.setContentsMargins(6, 8, 6, 8) - knobs_lay.setSpacing(6) - knobs_lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) - - # CRF Knob — amber - self.crf_knob = RadioKnob( - min_val=18, max_val=52, default_val=32, - label="Quality", - unit="CRF", - color=(212, 170, 80), - num_ticks=18, - tick_labels=["18", "28", "38", "52"], - snap_ticks=True, - compact=True, - ) - self.crf_knob.valueChanged.connect(self._on_crf_knob_changed) - knobs_lay.addWidget(self.crf_knob, 0, Qt.AlignmentFlag.AlignHCenter) - - # Volume Knob — green (dual-pass loudnorm target) - self.vol_knob = RadioKnob( - min_val=-20.0, max_val=6.0, default_val=0.0, - label="LUFS", - unit="dB", - color=(64, 208, 96), - num_ticks=27, - tick_labels=["-20", "-10", "0", "+6"], - snap_ticks=True, - compact=True, - ) - self.vol_knob.setToolTip( - "Dual-pass audio normalization target (EBU R128 LUFS).\n" - "0 = off (pass-through).\n" - "Each file is analyzed individually: loudnorm measures its\n" - "current LUFS and true peak, then computes the exact gain\n" - "to hit this target. If the gain would push peaks above\n" - "-1.5 dBTP, gain is reduced to keep 15%% headroom.\n" - "Common targets: -14 (streaming), -16 (broadcast), -23 (cinema)." - ) - self.vol_knob.valueChanged.connect(self._on_vol_knob_changed) - knobs_lay.addWidget(self.vol_knob, 0, Qt.AlignmentFlag.AlignHCenter) - - # ── Options row ── - opt_row = QHBoxLayout() - opt_row.setSpacing(8) - opt_lbl = QLabel("FILTER") - opt_lbl.setFixedWidth(44) - opt_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;") - opt_row.addWidget(opt_lbl) - self.ext_edit = QLineEdit(", ".join(sorted(DEFAULT_INPUT_EXTENSIONS))) - self.ext_edit.setFixedHeight(22) - self.ext_edit.setToolTip("File extensions to process. Separate with commas.") - opt_row.addWidget(self.ext_edit) - - self.del_check = QCheckBox("Delete source after verify") - self.del_check.setObjectName("dangerCheck") - self.del_check.setToolTip( - "Sources are only deleted after all files finish and cleanup passes.\n" - "If any file fails, you will be prompted before deletion." - ) - opt_row.addWidget(self.del_check) - - # v5-01: Force checkbox — skip ffprobe validation and attempt encode - # even for files ffprobe cannot read. Use for the rare edge case where - # ffprobe fails but the file is actually valid. Default OFF — most - # "ffprobe can't read" files are genuinely invalid (failed downloads, - # HTML saved as .mp4, truncated files, etc.). - self.force_check = QCheckBox("Force (skip validation)") - self.force_check.setToolTip( - "Skip ffprobe pre-validation and attempt encode even for files\n" - "ffprobe cannot read. Useful for the rare case where ffprobe\n" - "fails but the file is actually valid (rare codec, broken\n" - "container metadata). WARNING: with this enabled, invalid files\n" - "(failed downloads, HTML, truncated) will waste the full\n" - "per-file timeout before failing." - ) - opt_row.addWidget(self.force_check) - - # v4.4.3: av1an toggle — UI equivalent of --use-av1an. Default OFF - # (ffmpeg-only is the reliable default). When ON, the av1an chunk- - # parallel encode path runs (requires VapourSynth + source plugins). - # The user-facing label is "av1an (chunk-parallel)" so it's clear - # what they're opting into without CLI flags. - self.av1an_check = QCheckBox("av1an (chunk-parallel)") - self.av1an_check.setToolTip( - "Use av1an chunk-parallel encoding instead of single-pass ffmpeg.\n" - "Faster on multi-core machines WITH working VapourSynth setup,\n" - "but more fragile (y4m pipe breaks, concat failures on phone\n" - "videos with sparse keyframes). Default OFF = ffmpeg-only,\n" - "which is more reliable across distros." - ) - opt_row.addWidget(self.av1an_check) - - # v4.4.4: inline-scale toggle — UI equivalent of --inline-scale. - # Default OFF (use CRF-16 pre-scale intermediate, robust). When ON, - # the scale/pad filter chain is passed directly to av1an via - # --ffmpeg-filter-args, skipping the intermediate file entirely. - # This eliminates the 0.5-0.8x source size temp file that was - # crashing 10GB+ encodes with mysterious "ffmpeg error (rc=234)" - # messages (lossless intermediate was filling the disk). - self.inline_scale_check = QCheckBox("Inline scale (no intermediate)") - self.inline_scale_check.setToolTip( - "Skip the CRF-16 pre-scale intermediate file when a target\n" - "resolution is selected. The scale/pad filter chain is passed\n" - "directly to av1an via --ffmpeg-filter-args instead.\n\n" - "ON = no intermediate file, faster, no extra disk usage.\n" - " May fail on older av1an builds with filter-arg quirks.\n" - "OFF = pre-scale to a CRF-16 (visually lossless) intermediate\n" - " file first, then encode. More robust but writes a\n" - " 0.5-0.8x source size temp file and runs an extra pass.\n\n" - "Recommendation: ON for large files (≥10GB) with scaling.\n" - "Default OFF for compatibility." - ) - opt_row.addWidget(self.inline_scale_check) - - # v4.6.0: ENGINE selector — Auto (GPU if available) / GPU / CPU. - # Auto uses the NVENC hardware encoder for the selected codec - # family when the environment probe's live encode test proved it - # works (av1 → av1_nvenc on RTX 40+, hevc → hevc_nvenc on every - # NVENC generation); otherwise it stays on the CPU encoders. GPU - # mode runs through the single-pass ffmpeg path — av1an cannot - # drive NVENC, and one NVENC process outruns chunk-parallel CPU - # workers anyway. - self.engine_combo = QComboBox() - self.engine_combo.addItems([ - "Engine: Auto (GPU if available)", - "Engine: GPU (NVENC)", - "Engine: CPU", - "Engine: Hybrid (GPU + CPU)", - ]) - self.engine_combo.setToolTip( - "Video encode engine.\n\n" - "Auto — use the NVENC GPU encoder when the selected codec\n" - " family has one AND a live encode test proved it works on\n" - " this system; fall back to CPU otherwise (default).\n" - "GPU — force NVENC (hevc_nvenc / av1_nvenc); falls back to\n" - " CPU with a log message when unavailable. GPU encodes run\n" - " via single-pass ffmpeg (av1an chunking is not used).\n" - "CPU — force the software encoders (SVT-AV1 / VP9 / x265).\n" - "Hybrid — run BOTH at once: the queue is split between a\n" - " GPU lane and a CPU lane (balanced by file size), so the\n" - " CPU cores are not idle while NVENC encodes. With av1an\n" - " enabled the CPU lane uses chunk-parallel too. GPU-lane\n" - " files get NVENC quality; CPU-lane files get software-\n" - " encoder quality. Needs 2+ encodable files.\n" - ) - self.engine_combo.setFixedHeight(24) - # v4.7.1: self.env is None (or absent) until _probe_and_init - # runs AFTER _build_ui — the pre-select must not touch it here. - _engine_flags = getattr(getattr(self, "env", None), "av1an_flags", None) or {} - _engine_pref = _engine_flags.get("engine") - _engine_index = {"auto": 0, "gpu": 1, "cpu": 2, "hybrid": 3} - self.engine_combo.setCurrentIndex(_engine_index.get(_engine_pref, 0)) - - # v4.8.0: GPU capability-profile dropdown. Entries combine whole - # card generations (same silicon = same encoding behaviour) and - # include data-center + crypto-era oddballs. "Auto-detect" uses - # the probe's name match; a specific profile forces the API even - # when auto-match fails. The rebuild-from-git dep tree extends - # with the selected profile's packages. - from .gpu_profiles import GPU_PROFILES as _GPU_PROFILES - self.gpu_combo = QComboBox() - self.gpu_combo.addItem("GPU: Auto-detect") - for _gp in _GPU_PROFILES: - self.gpu_combo.addItem(f"GPU: {_gp.label}") - self.gpu_combo.setToolTip( - "Hardware encoder capability class.\n" - "Auto-detect matches your card via nvidia-smi/lspci and the\n" - "live encode probe decides what actually works. Forcing a\n" - "profile also extends the REBUILD FROM GIT dependency tree\n" - "with that GPU's packages (nv-codec-headers / VAAPI / QSV)." - ) - self.gpu_combo.setFixedHeight(24) - _gpu_pref = _engine_flags.get("gpu_profile") - _gpu_keys = ["auto"] + [_gp.key for _gp in _GPU_PROFILES] - self.gpu_combo.setCurrentIndex( - _gpu_keys.index(_gpu_pref) if _gpu_pref in _gpu_keys else 0 - ) - self.gpu_combo.currentIndexChanged.connect(self._on_gpu_profile_changed) - # v4.8.1: ENGINE = CPU makes the GPU choice inert — grey it out. - # The "None (CPU-only encode)" entry stays available for users - # who have a GPU in the box but don't want encoding on it. - self.engine_combo.currentIndexChanged.connect(self._on_engine_changed) - self._update_gpu_combo_state() - _gpu_lay = QVBoxLayout() - _gpu_lay.setSpacing(1) - _gpu_lbl = QLabel("GPU") - _gpu_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;") - _gpu_lay.addWidget(_gpu_lbl) - _gpu_lay.addWidget(self.gpu_combo) - opt_row.addLayout(_gpu_lay) - _engine_lay = QVBoxLayout() - _engine_lay.setSpacing(1) - _engine_lbl = QLabel("ENGINE") - _engine_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;") - _engine_lay.addWidget(_engine_lbl) - _engine_lay.addWidget(self.engine_combo) - opt_row.addLayout(_engine_lay) - root.addLayout(opt_row) - - # ── Log + Knobs: horizontal split ── - mid_split = QHBoxLayout() - mid_split.setSpacing(6) - - # Log: LED terminal (takes remaining space) - self.log_box = QTextEdit() - self.log_box.setObjectName("logBox") - self.log_box.setReadOnly(True) - mid_split.addWidget(self.log_box, 1) - - # Knobs panel on the right - mid_split.addWidget(knobs_panel) - - root.addLayout(mid_split, 1) - - # ── Status Bar: LED readout ── - self.status = QStatusBar() - self.setStatusBar(self.status) - self.status_label = QLabel(" INITIALIZING...") - self.status_label.setStyleSheet( - "color: #d4aa50; font-family: 'Consolas', 'DejaVu Sans Mono', monospace; font-size: 10px;" - ) - self.status.addWidget(self.status_label, 1) - - # ── Transport Buttons ── - btn_lay = QHBoxLayout() - btn_lay.setSpacing(8) - - self.btn_run = QPushButton(" > ENCODE") - self.btn_run.setObjectName("btnRun") - self.btn_run.setFixedHeight(40) - self.btn_run.setEnabled(False) - self.btn_run.clicked.connect(self._start_process) - btn_lay.addWidget(self.btn_run) - - self.btn_stop = QPushButton(" [] STOP") - self.btn_stop.setObjectName("btnStop") - self.btn_stop.setFixedHeight(40) - self.btn_stop.clicked.connect(self._stop_process) - self.btn_stop.setEnabled(False) - btn_lay.addWidget(self.btn_stop) - - self.btn_rebuild = QPushButton(" <> REBUILD FROM GIT") - self.btn_rebuild.setObjectName("btnRebuild") - self.btn_rebuild.setFixedHeight(40) - self.btn_rebuild.setToolTip( - "Compile VapourSynth + av1an + BestSource from git source.\n" - "Resolves ABI/version mismatch when package managers\n" - "install incompatible versions. ALWAYS available — even on a\n" - "bare system: it first generates its own dependency tree\n" - "(installs missing build tools + zimg via the distro package\n" - "manager, one privilege prompt) and then builds everything\n" - "into ~/.local / ~/.cargo (no system changes)." - ) - self.btn_rebuild.clicked.connect(self._manual_rebuild) - # v4.7.1: ALWAYS usable — the build generates its own dependency - # tree, so it must not depend on a successful probe (a failed - # probe is exactly when you need it). - btn_lay.addWidget(self.btn_rebuild) - - self.btn_about = QPushButton(" ? ABOUT / LICENSES") - self.btn_about.setObjectName("btnAbout") - self.btn_about.setFixedHeight(40) - self.btn_about.setToolTip( - "Show open-source license attributions for all\n" - "third-party components invoked by this application." - ) - self.btn_about.clicked.connect(self._show_license_dialog) - btn_lay.addWidget(self.btn_about) - root.addLayout(btn_lay) - - # ── Footer ── - footer = QWidget() - footer_lay = QHBoxLayout(footer) - footer_lay.setContentsMargins(6, 4, 6, 2) - footer_lay.setSpacing(0) - - link_lbl = QLabel( - 'Visit Homepage' - ) - link_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - link_lbl.setOpenExternalLinks(True) - link_lbl.setStyleSheet("font-size: 8px;") - footer_lay.addWidget(link_lbl) - - footer_lay.addStretch() - - copy_lbl = QLabel( - 'AGPL-3.0 | Jeremy Anderson - dcos.net (c) 2026' - ) - copy_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction) - copy_lbl.setOpenExternalLinks(True) - copy_lbl.setAlignment(Qt.AlignmentFlag.AlignRight) - copy_lbl.setStyleSheet("color: #555; font-size: 8px;") - footer_lay.addWidget(copy_lbl) - - root.addWidget(footer) - - def _apply_mmd3_theme(self): - self.setStyle(QStyleFactory.create("Fusion")) - self.setStyleSheet(MMD3_QSS) - # Palette as fallback for things QSS doesn't cover - p = QPalette() - p.setColor(QPalette.ColorRole.Window, QColor(26, 26, 30)) - p.setColor(QPalette.ColorRole.WindowText, QColor(200, 200, 200)) - p.setColor(QPalette.ColorRole.Base, QColor(20, 20, 24)) - p.setColor(QPalette.ColorRole.AlternateBase, QColor(40, 40, 46)) - p.setColor(QPalette.ColorRole.ToolTipBase, QColor(30, 30, 36)) - p.setColor(QPalette.ColorRole.ToolTipText, QColor(200, 200, 200)) - p.setColor(QPalette.ColorRole.Text, QColor(200, 200, 200)) - p.setColor(QPalette.ColorRole.Button, QColor(40, 40, 46)) - p.setColor(QPalette.ColorRole.ButtonText, QColor(200, 200, 200)) - p.setColor(QPalette.ColorRole.Highlight, QColor(212, 170, 80)) - p.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0)) - QApplication.instance().setPalette(p) - - # ── Slots ── - - @Slot() - def _on_codec_changed(self, idx: int): - self._populate_presets(idx) - profile = VIDEO_CODECS[idx] - lo, hi = profile.crf_range - self.crf_knob.min_val = lo - self.crf_knob.max_val = hi - self.crf_knob.setValue(float(profile.default_crf)) - # Auto-select best container via index lookup — no for-loop, no break. - # next(..., None) returns the first match or None; the if guards the - # block so we only touch container_combo when a match was found. - match = next( - (i for i, cp in enumerate(CONTAINER_PROFILES) - if cp.ext == profile.container), - None, - ) - if match is not None: - self.container_combo.blockSignals(True) - self.container_combo.setCurrentIndex(match) - self.container_combo.blockSignals(False) - # Re-evaluate compatibility after auto-container change. - self._check_combo_compatibility() - - def _populate_presets(self, codec_idx: int): - self.preset_combo.blockSignals(True) - self.preset_combo.clear() - if 0 <= codec_idx < len(VIDEO_CODECS): - self.preset_combo.addItems(VIDEO_CODECS[codec_idx].presets) - self.preset_combo.blockSignals(False) - - @Slot() - def _on_container_changed(self, idx: int): - if idx >= 0: - ext = CONTAINER_PROFILES[idx].ext - self._log(f"Container set to: {ext}") - self._check_combo_compatibility() - - @Slot() - def _on_audio_changed(self, idx: int): - if idx >= 0: - self._log(f"Audio set to: {AUDIO_PROFILES[idx].label}") - self._check_combo_compatibility() - - def _check_combo_compatibility(self) -> list[str]: - """Check current video/audio/container combination for known - incompatibilities. Logs every warning and returns the full list - (empty if clean). Hard incompatibilities (which would fail at - encode/mux time) are prefixed ``INCOMPATIBLE:`` and also block - the Start button via _start_process. Soft warnings are prefixed - ``WARNING:`` and only appear in the log. - - Safe to call during __init__ — every attribute is guarded. - - Refactored to table-driven dispatch: every rule is a tuple of - (predicate, severity, message-fn), evaluated by a single loop. - Adding a new rule is a one-line table change; no nested ifs. - - SEI CERT STR09-C spirit: predicates return plain bool, never None; - messages are produced only when their predicate fires, so the - severity prefix is always consistent with the predicate outcome. - """ - # Resolve current selection with full defensive validation. - # All four early returns return the same value ([]), so this - # block reads as a flat guard rather than a nested decision tree. - if not all(hasattr(self, attr) for attr in - ("codec_combo", "audio_combo", "container_combo")): - return [] - - codec_idx = self.codec_combo.currentIndex() - audio_idx = self.audio_combo.currentIndex() - container_idx = self.container_combo.currentIndex() - - if min(codec_idx, audio_idx, container_idx) < 0: - return [] - - if not (codec_idx < len(VIDEO_CODECS) - and audio_idx < len(AUDIO_PROFILES) - and container_idx < len(CONTAINER_PROFILES)): - return [] - - video_codec = VIDEO_CODECS[codec_idx] - audio_profile = AUDIO_PROFILES[audio_idx] - container = CONTAINER_PROFILES[container_idx] - - # ── Compatibility rule table ── - # Each rule: (predicate, severity, message) - # predicate: callable(video_codec, audio_profile, container) -> bool - # severity: "INCOMPATIBLE" or "WARNING" - # message: str (already-formatted) - # - # To add a new rule, append a tuple here. No code below changes. - def _is_hevc(vc, _ap, c) -> bool: - return vc.ffmpeg_encoder == "libx265" and c.ext == "webm" - - # v3 (OTC-012, SEI CERT STR09-C): compare against the - # AudioProfile.ffmpeg_encoder_name field directly, not via - # substring match on params (which could false-match a - # hypothetical `-libiamf-mode` argument). - def _is_iamf_non_mp4(_vc, ap, c) -> bool: - return ap.ffmpeg_encoder_name == "libiamf" and c.ext != "mp4" - - def _is_vorbis_in_mp4(_vc, ap, c) -> bool: - return ap.ffmpeg_encoder_name == "libvorbis" and c.ext == "mp4" - - def _is_flac_in_webm(_vc, ap, c) -> bool: - return ap.ffmpeg_encoder_name == "flac" and c.ext == "webm" - - def _is_vp9_in_mp4(vc, _ap, c) -> bool: - return vc.ffmpeg_encoder == "libvpx-vp9" and c.ext == "mp4" - - rules: tuple[tuple, ...] = ( - (_is_hevc, "INCOMPATIBLE", - "x265 (HEVC) cannot be muxed into WebM. Use MKV or MP4 instead."), - (_is_iamf_non_mp4, "INCOMPATIBLE", - f"IAMF audio requires the MP4 container — cannot mux into " - f"{container.ext.upper()}. Switch container to MP4."), - (_is_vorbis_in_mp4, "WARNING", - "Vorbis in MP4 has limited player support. Consider Opus or MKV/WebM."), - (_is_flac_in_webm, "WARNING", - "FLAC in WebM is rarely supported by players. Consider MKV instead."), - (_is_vp9_in_mp4, "WARNING", - "VP9 in MP4 has uneven player support. WebM is the canonical VP9 container."), - ) - - # Single-pass evaluation: build the warnings list by filtering - # the rule table through each predicate. No nested if/elif. - warnings: list[str] = [ - f"{severity}: {message}" - for predicate, severity, message in rules - if predicate(video_codec, audio_profile, container) - ] - - for w in warnings: - self._log(w) - - return warnings - - def _populate_resolution_combo(self): - """Populate resolution dropdown with separator headers per category. - - Refactored with PEP 634/868 structural pattern matching: the - category-transition decision is expressed as a single match - statement instead of nested ifs. The match value is a 2-tuple - of (current_category, previous_category); each case is a flat - pattern, no nesting. - """ - # Maps combo box position -> RESOLUTION_PRESETS index. - # Separators occupy combo positions too, so we must track them. - self._res_preset_indices: dict[int, int] = {} - last_cat: str | None = None - combo_pos = 0 - - for i, rp in enumerate(RESOLUTION_PRESETS): - # Single-level decision: insert separator only when transitioning - # to a new category AND we are not on the first category. - match (rp.category, last_cat): - case (cat, prev) if cat != prev and prev is not None: - self.resolution_combo.insertSeparator(combo_pos) - combo_pos += 1 # separator takes a slot - - last_cat = rp.category - self.resolution_combo.addItem(rp.label) - self._res_preset_indices[combo_pos] = i - combo_pos += 1 - - def _get_current_resolution(self) -> ResolutionProfile: - """Get the ResolutionProfile for the current combo selection, handling separators.""" - combo_idx = self.resolution_combo.currentIndex() - preset_i = self._res_preset_indices.get(combo_idx) - if preset_i is not None: - return RESOLUTION_PRESETS[preset_i] - return RESOLUTION_PRESETS[0] - - @Slot() - def _on_resolution_changed(self, idx: int): - rp = self._get_current_resolution() - if rp.width is not None: - self._log( - f"Resolution: {rp.width}x{rp.height} ({rp.aspect_label}) — " - f"files will be pre-scaled with ffmpeg before encoding." - ) - else: - self._log("Resolution: Original (no scaling).") - - @Slot(float) - def _on_crf_knob_changed(self, val: float): - direction = "higher quality" if val < 28 else ("balanced" if val < 38 else "smaller file") - self._log(f"CRF: {val:.0f} ({direction})") - - @Slot(float) - def _on_vol_knob_changed(self, val: float): - if abs(val) < 0.01: - self._log("Audio normalization: OFF (pass-through)") - else: - direction = "louder" if val > 0 else "quieter" - self._log(f"Audio normalization: {val:+.1f} dB ({direction})") - - - @Slot() - def _browse(self, line_edit: QLineEdit, is_dir: bool = True): - if is_dir: - path = QFileDialog.getExistingDirectory(self, "Select Directory") - if path: - line_edit.setText(path) - - def _log(self, msg: str): - # Guard against signals (combo currentIndexChanged, knob valueChanged, - # etc.) firing during __init__ before self.log_box has been - # constructed. Without this, the first addItem() on any combo - # triggers its slot, which calls _log(), which dereferences - # self.log_box while it is still None -> AttributeError -> crashes - # the app on launch. Also buffer messages so they aren't lost. - if not hasattr(self, "log_box") or self.log_box is None: - buffered = getattr(self, "_log_buffer", None) - if buffered is None: - buffered = self._log_buffer = [] - buffered.append(msg) - return - # Flush any messages that arrived before log_box existed. - buffered = getattr(self, "_log_buffer", None) - if buffered: - for m in buffered: - self.log_box.append(f"> {m}") - self._log_buffer = [] - self.log_box.append(f"> {msg}") - sb = self.log_box.verticalScrollBar() - sb.setValue(sb.maximum()) - - # ── Environment Probe ── - - def _probe_and_init(self): - self.env = probe_environment() - - # --- Distro banner --- - distro = self.env.distro - self._log(f"Distro: {distro.name} (family={distro.family}, v{distro.version_id})") - self._log(f"Package manager: {distro.pkg_manager}") - - # --- Warnings (info-level, not errors) --- - for w in self.env.warnings: - self._log(f"INFO: {w}") - - # --- Hard errors --- - if not self.env.av1an_path: - self._log("CRITICAL: 'av1an' not found in PATH or distro-specific paths.") - if self.env.install_hint: - self._log(f" TRY: {self.env.install_hint}") - self.status_label.setText(f"NOT READY — missing av1an ({distro.family})") - return - if not self.env.ffmpeg_path: - self._log("CRITICAL: 'ffmpeg' not found in PATH or distro-specific paths.") - if self.env.install_hint: - self._log(f" TRY: {self.env.install_hint}") - self.status_label.setText(f"NOT READY — missing ffmpeg ({distro.family})") - return - - if self.env.errors: - for e in self.env.errors: - self._log(f"ERROR: {e}") - - # If there are still errors after logging (e.g. missing runtime deps), block start - if self.env.errors: - dep_count = len(self.env.missing_dep_pkgs) - if dep_count: - self.status_label.setText( - f"NOT READY — {dep_count} runtime dep(s) missing. See log." - ) - return - - # --- Probe results --- - flag_info = ", ".join(f"{k}={v}" for k, v in self.env.av1an_flags.items() if k != "has_chunk_method" and k != "has_scenes") - self._log(f"av1an: {self.env.av1an_path} (v{self.env.av1an_version or '?'})") - if flag_info: - self._log(f" Flags: {flag_info}") - - if self.env.ffmpeg_version: - self._log(f"ffmpeg: {self.env.ffmpeg_path} (v{self.env.ffmpeg_version})") - - # --- FFmpeg encoder library summary (audio-relevant only for our purposes) --- - available_libs = [name for name, present in self.env.ffmpeg_libs.items() if present] - missing_audio = [name for name, present in self.env.ffmpeg_libs.items() - if not present and name in ("libopus", "libvorbis", "flac")] - if available_libs: - self._log(f" FFmpeg encoders available: {', '.join(available_libs)}") - if missing_audio: - self._log(f" FFmpeg audio encoders MISSING: {', '.join(missing_audio)}") - self._log(f" Some audio codec options may fail. Check distro package: {distro.ffmpeg_pkg}") - - # --- Disable unavailable codec options in UI --- - self._disable_unavailable_codecs() - - worker_count = max(1, self.env.cpu.physical_cores - 1) - cpu = self.env.cpu - self._log( - f"Chunk-parallel mode: {worker_count} av1an workers " - f"({cpu.physical_cores} physical cores, {cpu.logical_threads} logical, " - f"{cpu.threads_per_core}T/core)" - ) - - self.btn_run.setEnabled(True) - self.btn_run.setText("START PROCESSING") - self.btn_rebuild.setEnabled(True) # available after successful probe - vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else "" - # Show ffmpeg video encoder availability (for fallback) - fb_encs = [] - for vc in VIDEO_CODECS: - lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 - if self.env.ffmpeg_libs.get(lib_key, False): - fb_encs.append(vc.ffmpeg_encoder) - fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else "" - self.status_label.setText( - f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | " - f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}" - ) - - # --- License attribution banner (shown once after successful probe) --- - # POSIX-friendly: log plain text, no escape codes, no decorative box chars - # that might confuse terminals. Each tool is named with its SPDX id so - # the user can audit obligations at a glance. - self._show_license_banner() - - def _show_license_banner(self) -> None: - """Log the active-component license summary once at startup. - - SEI CERT MSC04-C: license text lives in exactly one canonical - location (LICENSE_NOTICES); this method only formats it. - """ - notices = active_license_notices(self.env) - self._log("") - self._log("=== Open Source License Attribution ===") - self._log("This application invokes the following third-party tools.") - self._log("Source code of these tools is NOT bundled; licenses flow") - self._log("through from upstream. See About > Licenses for full text.") - self._log("") - for n in notices: - self._log(f" • {n.name} — {n.spdx}") - self._log(f" {n.home_url}") - self._log("") - self._log("End of license summary.") - self._log("") - - def _show_license_dialog(self) -> None: - """Open a modal dialog with the full license text. - - Triggered from the menu / button so the user can review the - complete attribution text at any time. - """ - notices = active_license_notices(self.env) - text = license_banner_full(notices) - dlg = QMessageBox(self) - dlg.setWindowTitle("About — Open Source Licenses") - dlg.setText("This application invokes the following open-source tools:") - dlg.setInformativeText(text) - dlg.setStandardButtons(QMessageBox.StandardButton.Ok) - dlg.exec() - - def _show_pre_transcode_license_summary(self) -> None: - """One-line license reminder logged at the start of each batch. - - Keeps the legal notice adjacent to the act of transcode, which is - where redistribution-relevant output is produced. - """ - notices = active_license_notices(self.env) - self._log(f"LICENSES: {license_banner_short(notices)}") - - def _disable_unavailable_codecs(self): - """Grey out AUDIO codec combos whose FFmpeg library is missing. - - Video codecs are NOT disabled here because av1an uses its own - encoder binaries (svt_av1, vpx, x265) — it does not rely on - ffmpeg's encoder list for video. - - v3 (OTC-012, SEI CERT STR09-C + MSC04-C): each AudioProfile now - carries its ffmpeg encoder name as the `ffmpeg_encoder_name` - field (e.g. "libopus"). We look up that name in env.ffmpeg_libs - directly. This replaces the v2 approach of indexing into - `params[1]`, which assumed a fixed params layout and would - silently break if a profile ever used a different argument order. - - SEI CERT MSC04-C spirit: the source of truth for which library - each profile needs is the profile itself, not a parallel table. - """ - libs = self.env.ffmpeg_libs - - for idx, profile in enumerate(AUDIO_PROFILES): - if idx >= self.audio_combo.count(): - break # combo not yet populated, defensive - - # v3: use the dedicated field instead of indexing into params. - lib_name = profile.ffmpeg_encoder_name - if not lib_name: - continue # passthrough profile, no encoder dependency - if not libs.get(lib_name, False): - item = self.audio_combo.model().item(idx) - if item is not None: - item.setEnabled(False) - item.setToolTip( - f"DISABLED: FFmpeg missing {lib_name} encoder. " - f"Use Rebuild from Git > ffmpeg + IAMF to enable." - ) - # If the currently-selected item is the one we disabled, - # fall back to the first enabled entry. - if self.audio_combo.currentIndex() == idx: - self.audio_combo.setCurrentIndex(0) - - # ── Process Control ── - - def _parse_extensions(self) -> set[str]: - raw = self.ext_edit.text() - exts = set() - for part in raw.split(","): - part = part.strip().lower() - if not part.startswith("."): - part = "." + part - if part: - exts.add(part) - return exts or DEFAULT_INPUT_EXTENSIONS - - @Slot() - def _start_process(self): - in_dir = Path(self.in_path_edit.text()) - out_dir = Path(self.out_path_edit.text()) - - if not in_dir.is_dir(): - self._log(f"ERROR: Source directory does not exist: {in_dir}") - return - if in_dir == out_dir: - self._log("ERROR: Source and output directories must be different.") - return - - # ── Pre-flight: codec/container/audio compatibility check ── - # Hard incompatibilities (prefixed "INCOMPATIBLE:") block the encode. - warnings = self._check_combo_compatibility() - hard_blocks = [w for w in warnings if w.startswith("INCOMPATIBLE")] - if hard_blocks: - self._log("ERROR: Aborting — incompatible combination selected.") - QMessageBox.critical( - self, "Incompatible Codec Combination", - "The selected video/audio/container combination cannot be encoded:\n\n" - + "\n".join(f"• {w.split(':', 1)[1].strip()}" for w in hard_blocks) - + "\n\nFix the selection and try again." - ) - return - - # Pre-transcode license reminder — adjacent to the act of transcode - # so obligations are visible at the moment redistribution-relevant - # output is produced. - self._show_pre_transcode_license_summary() - - # If delete is enabled, collect files first for batch confirmation - if self.del_check.isChecked(): - extensions = self._parse_extensions() - candidates = [f for f in in_dir.rglob("*") if f.is_file() and f.suffix.lower() in extensions and not f.name.endswith(".scaled_tmp.mkv")] - if candidates: - total_size = sum(f.stat().st_size for f in candidates) - reply = QMessageBox.question( - self, "Confirm Batch Delete", - f"This will delete {len(candidates)} source file(s) after successful transcode.\n" - f"Total size: {total_size / 1_073_741_824:.2f} GB\n\n" - f"Proceed?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - self._log("Cancelled: Delete not confirmed.") - return - - # ── v4.2.0: av1an is opt-in. Default is ffmpeg-only. ── - # The av1an chunk-parallel path was too fragile across distros - # (y4m pipe breaks, SvtAv1EncApp CLI quirks, 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. - # v4.4.3: the flag can come from the CLI (--use-av1an) OR from - # the UI toggle (self.av1an_check). UI toggle takes precedence - # so the user can flip it without restarting. - cli_use_av1an = bool(self.env.av1an_flags.get("use_av1an", False)) - ui_use_av1an = ( - hasattr(self, "av1an_check") and self.av1an_check.isChecked() - ) - use_av1an = ui_use_av1an or cli_use_av1an - - # ── Pre-flight: av1an VSScript smoke test (main thread — can show dialogs) ── - use_ffmpeg_fallback = False - skip_encode = False - if use_av1an and self.env.av1an_path and self.env.ffmpeg_path: - self._log("Pre-flight: testing av1an + VapourSynth compatibility...") - QApplication.processEvents() # keep UI responsive - svt_name = self.env.av1an_flags.get("svt_name", "svt_av1") - ok, detail = _av1an_vsscript_smoke_test( - self.env.av1an_path, - self.env.ffmpeg_path, - self.env.av1an_flags, - svt_name=svt_name, - ) - if not ok and "VSScript_API_INCOMPAT" in detail: - # VSScript ABI mismatch detected — offer rebuild or fallback - use_ffmpeg_fallback = self._handle_vs_incompat() - if not use_ffmpeg_fallback: - # User chose rebuild or cancel — don't start encoding - return - elif not ok and "INVALID_ENCODER" in detail: - # Probe mismatch — re-detect the encoder name and retry once. - self._log(f" WARN: Encoder name probe mismatch. Re-detecting...") - QApplication.processEvents() - new_name = _detect_av1an_svt_encoder(self.env.av1an_path) - if new_name: - self.env.av1an_flags["svt_name"] = new_name - self._log(f" Re-detected SVT-AV1 encoder name: '{new_name}'") - # Retry smoke test with corrected name - ok2, detail2 = _av1an_vsscript_smoke_test( - self.env.av1an_path, self.env.ffmpeg_path, - self.env.av1an_flags, svt_name=new_name, - ) - if ok2: - self._log(" OK: av1an + VapourSynth working correctly.") - else: - self._log(f" FAIL: Still failing after re-detect: {detail2}") - return - else: - self._log(" FAIL: Could not determine valid encoder name. Check av1an --help manually.") - return - elif ok: - self._log(" OK: av1an + VapourSynth working correctly.") - else: - # Smoke test failed for an unexpected reason (encoder binary - # missing, concat method unsupported, av1an panicked, etc.). - # Previously this was logged as "non-fatal" and the encode - # proceeded anyway — which produced the "chunks but never - # saves a file" symptom because every file then failed at - # the same point. Now we treat unknown smoke failures as - # hard blocks and offer the user ffmpeg fallback if the - # selected codec is available, otherwise abort. - self._log(f" FAIL: av1an smoke test failed:") - for line in detail.splitlines()[:12]: - self._log(f" {line}") - # If ffmpeg has the matching encoder, offer fallback; - # otherwise abort with an actionable message. - codec_idx_pre = self.codec_combo.currentIndex() - if 0 <= codec_idx_pre < len(VIDEO_CODECS): - vc = VIDEO_CODECS[codec_idx_pre] - lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 - if self.env.ffmpeg_libs.get(lib_key, False): - self._log(f" FFmpeg has {vc.ffmpeg_encoder} — offering fallback.") - use_ffmpeg_fallback = self._handle_vs_incompat() - if not use_ffmpeg_fallback: - return - else: - self._log( - f" ABORT: ffmpeg also lacks {vc.ffmpeg_encoder}. " - f"Install the encoder binary (e.g. SvtAv1EncApp, vpxenc, x265) " - f"or use the REBUILD FROM GIT button." - ) - return - else: - self._log(" ABORT: invalid codec selection.") - return - elif not use_av1an: - # v4.2.0: default path — skip av1an entirely, use ffmpeg. - # This is the reliable path that works on any distro with - # ffmpeg + libsvtav1/libvpx/libx265 installed. No VapourSynth - # dependency, no chunk-method selection, no SvtAv1EncApp CLI - # quirks. Single-pass ffmpeg per file. - self._log("Encode mode: ffmpeg-only (default). Pass --use-av1an for chunk-parallel.") - use_ffmpeg_fallback = True - - codec_idx = self.codec_combo.currentIndex() - audio_idx = self.audio_combo.currentIndex() - container_idx = self.container_combo.currentIndex() - - # Safety: clamp codec_idx to valid range - if not (0 <= codec_idx < len(VIDEO_CODECS)): - self._log(f"ERROR: Invalid codec index {codec_idx}. Resetting to AV1 (SVT-AV1).") - codec_idx = 0 - self.codec_combo.blockSignals(True) - self.codec_combo.setCurrentIndex(0) - self.codec_combo.blockSignals(False) - - selected_codec = VIDEO_CODECS[codec_idx] - self._log(f"Codec: {selected_codec.label} (av1an encoder: {selected_codec.av1an_encoder})") - - # v4.6.0: read the ENGINE selector and surface the resolved engine - # before the queue starts. The worker re-resolves against the same - # probe data; this line tells the user what is about to happen. - engine_sel = ("auto", "gpu", "cpu", "hybrid")[self.engine_combo.currentIndex()] \ - if hasattr(self, "engine_combo") else "auto" - if hasattr(self, "engine_combo"): - # Always write UI state (CLI --engine pre-selects the combo; - # unchecking round-trips the same way as the other toggles). - self.env.av1an_flags["engine"] = engine_sel - gpu_preview, gpu_api = resolve_gpu_encoder(engine_sel, selected_codec, self.env) - if gpu_preview: - self._log(f"Engine: GPU — {gpu_preview} ({gpu_api} hardware encode)") - elif engine_sel == "gpu": - gpu_enc = selected_codec.gpu_encoder or "(none for this codec)" - self._log(f"Engine: GPU requested but {gpu_enc} unavailable — will use CPU.") - elif engine_sel == "hybrid": - self._log("Engine: hybrid — GPU lane + CPU lane concurrently (splits the queue by size).") - else: - self._log("Engine: CPU (software encoders)") - - # v4.4.4: read the inline-scale checkbox into env.av1an_flags so - # EncoderWorker.__init__'s fallback path picks it up (matches the - # pattern used by --use-av1an, --verbose, --skip-existing, etc.). - # Always write the UI state — this lets the user override a CLI - # --inline-scale by unchecking the box. The CLI flag pre-checks - # the box in launch_gui(), so the round-trip is consistent: - # CLI --inline-scale → checkbox pre-checked → re-read as True. - if hasattr(self, "inline_scale_check"): - self.env.av1an_flags["inline_scale"] = self.inline_scale_check.isChecked() - if self.inline_scale_check.isChecked(): - self._log("Inline scale: enabled (no intermediate file for scaling).") - - # v4.7.0: kwargs shared by every lane of the queue (single - # worker, or the GPU + CPU lanes in hybrid mode). - common = dict( - in_dir=in_dir, - out_dir=out_dir, - video_codec=selected_codec, - audio_profile=AUDIO_PROFILES[audio_idx], - container=CONTAINER_PROFILES[container_idx], - crf=self.crf_knob.intValue(), - preset_label=self.preset_combo.currentText(), - delete_source=self.del_check.isChecked(), - extensions=self._parse_extensions(), - resolution=self._get_current_resolution(), - audio_level_db=self.vol_knob.value(), - subtitle_lang=SUBTITLE_OPTIONS[self.subs_combo.currentIndex()][1], - force=self.force_check.isChecked(), # v5-01 - ) - - # ── v4.7.0: hybrid (GPU + CPU lanes run concurrently) ── - if engine_sel == "hybrid": - if self._start_hybrid(common, use_ffmpeg_fallback, use_av1an): - self.btn_run.setEnabled(False) - self.btn_run.setText("RUNNING...") - self.btn_stop.setEnabled(True) - self.btn_rebuild.setEnabled(False) - for w in self.workers: - w.start() - return - self._log("Hybrid unavailable — falling back to a single CPU queue.") - engine_sel = "cpu" - - self.worker = EncoderWorker( - **common, - env=self.env, - use_ffmpeg_fallback=use_ffmpeg_fallback, - engine=engine_sel, # v4.6.0: Auto/GPU/CPU engine selector - ) - self._set_workers([self.worker]) - - self.btn_run.setEnabled(False) - self.btn_run.setText("RUNNING...") - self.btn_stop.setEnabled(True) - self.btn_rebuild.setEnabled(False) - self.worker.start() - - @Slot() - def _on_engine_changed(self, index: int): - """v4.8.1: ENGINE = CPU disables the GPU dropdown (the choice - would have no effect); every other engine keeps it live.""" - self._update_gpu_combo_state() - - def _update_gpu_combo_state(self): - cpu_only = self.engine_combo.currentIndex() == 2 # "Engine: CPU" - self.gpu_combo.setEnabled(not cpu_only) - self.gpu_combo.setToolTip( - "ENGINE is CPU — the GPU choice has no effect." - if cpu_only else - "Hardware encoder capability class.\n" - "Auto-detect matches your card via nvidia-smi/lspci and the\n" - "live encode probe decides what actually works. Forcing a\n" - "profile also extends the REBUILD FROM GIT dependency tree\n" - "with that GPU's packages (nv-codec-headers / VAAPI / QSV).\n" - "'None (CPU-only encode)' opts out even when a GPU exists." - ) - - @Slot() - def _on_gpu_profile_changed(self, index: int): - """v4.8.0: persist the GPU capability-profile selection so both - the engine resolution and the rebuild dep tree pick it up.""" - if not getattr(self, "env", None): - return # UI build phase — env probe hasn't run yet - if index <= 0: - self.env.av1an_flags["gpu_profile"] = "auto" - return - from .gpu_profiles import GPU_PROFILES - self.env.av1an_flags["gpu_profile"] = GPU_PROFILES[index - 1].key - self._log(f"GPU profile: {GPU_PROFILES[index - 1].key}") - - def _set_workers(self, workers: list) -> None: - """v4.7.0: register the active lane worker(s) and wire their - signals. STOP iterates every lane; _on_finished aggregates the - per-lane summaries into one.""" - self.workers = list(workers) - self._hybrid_pending = len(self.workers) - self._hybrid_totals = [0, 0] - for w in self.workers: - w.log_msg.connect(self._log) - w.progress_msg.connect(self._on_progress) - w.finished_queue.connect(self._on_finished) - - def _start_hybrid(self, common: dict, use_ffmpeg_fallback: bool, - use_av1an: bool) -> bool: - """v4.7.0: split the queue between a GPU lane (NVENC) and a CPU - lane (software encoders, or av1an chunk-parallel when opted in — - so NVENC + chunk workers + software can all run at once). - - Constructs both workers on success and returns True. Returns - False (with a logged reason) when hybrid cannot apply; the caller - falls back to a single-lane queue. - """ - import copy as _copy - import dataclasses as _dc - - from .encoder_worker import scan_input_files - from .hybrid_scheduler import plan_hybrid - - gpu_enc, _gpu_api = resolve_gpu_encoder("gpu", common["video_codec"], self.env) - if not gpu_enc: - self._log("Hybrid: no functional NVENC encoder for this codec family.") - return False - - files = scan_input_files(common["in_dir"], common["extensions"]) - if len(files) < 2: - self._log("Hybrid: fewer than 2 encodable files — one lane is faster than scheduling.") - return False - - sizes: dict = {} - for f in files: - try: - sizes[f] = f.stat().st_size - except OSError: - pass - plan = plan_hybrid( - files, gpu_enc, True, self.env.cpu.logical_threads, sizes=sizes, - ) - if plan is None or not plan.gpu_files or not plan.cpu_files: - self._log("Hybrid: size split degenerated to one lane — single lane is faster.") - return False - - self._log( - f"HYBRID: GPU lane = {len(plan.gpu_files)} file(s) via {gpu_enc} | " - f"CPU lane = {len(plan.cpu_files)} file(s) " - f"(budget {plan.cpu_budget_threads} threads" - f"{', av1an chunk-parallel' if use_av1an else ''})" - ) - self._log( - " Note: lanes use different encoders — GPU-lane files get NVENC " - "quality, CPU-lane files get software-encoder quality." - ) - - # CPU lane budget: hold back threads for the GPU lane's - # decode/scale/mux processes, then let the lane self-budget its - # av1an workers / ffmpeg threads from the reduced topology. - cpu_env = _copy.copy(self.env) - cpu_env.cpu = _dc.replace( - self.env.cpu, logical_threads=plan.cpu_budget_threads - ) - - gpu_worker = EncoderWorker( - **common, - env=self.env, - use_ffmpeg_fallback=True, # GPU runs via single-pass ffmpeg - engine="gpu", - file_subset=plan.gpu_files, - lane="gpu", - ) - cpu_worker = EncoderWorker( - **common, - env=cpu_env, - use_ffmpeg_fallback=use_ffmpeg_fallback, - engine="cpu", - file_subset=plan.cpu_files, - lane="cpu", - ffmpeg_threads=plan.cpu_budget_threads, - ) - self.worker = cpu_worker # primary handle (back-compat) - self._set_workers([gpu_worker, cpu_worker]) - return True - - def _handle_vs_incompat(self) -> bool: - """Handle detected VSScript ABI incompatibility. - - Shows a dialog with options: - 1. Rebuild VapourSynth + av1an from git (resolves root cause) - 2. Use ffmpeg fallback (works now, no chunk-parallel) - 3. Cancel - - Returns True if we should use ffmpeg fallback (option 2), - False if user cancelled or chose to rebuild (rebuild - starts async and does NOT return here — the user - will click ENCODE again after it completes). - """ - self._log(" FAIL: av1an cannot initialize VSScript API.") - self._log(" The av1an binary was compiled against a different VapourSynth version.") - - # Check ffmpeg fallback availability - codec_idx = self.codec_combo.currentIndex() - video_codec = VIDEO_CODECS[codec_idx] - ffmpeg_enc = video_codec.ffmpeg_encoder - ffmpeg_lib_key = ffmpeg_lib_key_for(ffmpeg_enc) # v3: OTC-007 - fallback_possible = self.env.ffmpeg_libs.get(ffmpeg_lib_key, False) - - if fallback_possible: - btn_rebuild = QPushButton(" Rebuild from Git ") - btn_rebuild.setObjectName("btnRebuild") - btn_fallback = QPushButton(" Use ffmpeg Fallback ") - btn_fallback.setObjectName("btnRun") - btn_cancel = QPushButton(" Cancel ") - btn_cancel.setObjectName("btnStop") - - dlg = QMessageBox(self) - dlg.setWindowTitle("av1an + VapourSynth Version Mismatch") - dlg.setText( - "av1an cannot initialize VapourSynth — the installed versions\n" - "have an ABI incompatibility (common with distro packages).\n\n" - f"Choose how to proceed:" - ) - dlg.setInformativeText( - "• Rebuild from Git — compiles both from source (~10-30 min).\n" - " Fixes the root cause. Requires sudo for install.\n" - f"• ffmpeg Fallback — encode with ffmpeg ({ffmpeg_enc}) now.\n" - " No chunk-parallel mode but output quality is identical." - ) - dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole) - dlg.addButton(btn_fallback, QMessageBox.ButtonRole.YesRole) - dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole) - - dlg.exec() - clicked = dlg.clickedButton() - - if clicked == btn_rebuild: - self._log("") - self._log("User chose: Rebuild VapourSynth + av1an from git.") - self._start_git_rebuild() - return False # don't start encoding — user will retry after build - elif clicked == btn_fallback: - self._log("") - self._log(f"FALLBACK: Switching to pure ffmpeg ({ffmpeg_enc}) encoding.") - self._log( - " Note: ffmpeg single-pass mode (no chunk-parallel). " - "Slower for large files but produces identical output." - ) - self._log(" Use the REBUILD FROM GIT button to fix av1an for chunk-parallel mode.") - self._log("") - return True - else: - # Cancel - self._log("Cancelled by user.") - return False - else: - # No ffmpeg fallback available — offer rebuild or hard cancel - btn_rebuild = QPushButton(" Rebuild from Git ") - btn_rebuild.setObjectName("btnRebuild") - btn_cancel = QPushButton(" Cancel ") - btn_cancel.setObjectName("btnStop") - - dlg = QMessageBox(self) - dlg.setWindowTitle("av1an + VapourSynth Version Mismatch") - dlg.setText( - "av1an cannot initialize VapourSynth — ABI incompatibility.\n\n" - f"ffmpeg also lacks '{ffmpeg_enc}' — no fallback possible.\n" - "You must rebuild to proceed." - ) - dlg.setIcon(QMessageBox.Icon.Critical) - dlg.addButton(btn_rebuild, QMessageBox.ButtonRole.AcceptRole) - dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole) - - dlg.exec() - clicked = dlg.clickedButton() - - if clicked == btn_rebuild: - self._log("") - self._log("User chose: Rebuild VapourSynth + av1an from git (no fallback available).") - self._start_git_rebuild() - else: - self._log("Cancelled by user.") - return False - - def _start_git_rebuild(self, build_vs: bool = True, build_av1an: bool = True, - build_ffmpeg_iamf: bool = False): - """Start the SourceBuildWorker thread.""" - components = [] - if build_vs: components.append("VapourSynth") - if build_av1an: components.append("av1an") - if build_ffmpeg_iamf: components.append("ffmpeg+libiamf") - self._log(f"Starting source build ({' + '.join(components) if components else 'none'})...") - self._log("Builds to ~/.local/ and ~/.cargo/bin/ — sudo only if build deps are missing.") - if build_ffmpeg_iamf: - self._log(" NOTE: ffmpeg build takes 10-20 min. App must be restarted after.") - self.btn_run.setEnabled(False) - self.btn_rebuild.setEnabled(False) - self.btn_stop.setEnabled(False) - self.status_label.setText("Building from git... (see log)") - - self._build_worker = SourceBuildWorker( - build_vs=build_vs, build_av1an=build_av1an, - build_ffmpeg_iamf=build_ffmpeg_iamf, - gpu_profile_key=self.env.av1an_flags.get("gpu_profile", "auto"), - ) - self._build_worker.log_msg.connect(self._log) - self._build_worker.build_done.connect(self._on_build_done) - self._build_worker.start() - - @Slot(bool, str) - def _on_build_done(self, success: bool, message: str): - """Called when SourceBuildWorker finishes.""" - self._log("") - if success: - self._log(f"BUILD SUCCESS: {message}") - self._log("Re-probing environment to pick up new binaries...") - QApplication.processEvents() - - # Ensure LD_LIBRARY_PATH is set in the main process too. - # - # INTENTIONAL os.environ mutation (the ONE kept after the - # v3-08 refactor). SourceBuildWorker no longer mutates - # os.environ — it accumulates env changes in its private - # self._build_env dict and passes that to subprocess.run. - # But that dict dies with the worker thread. The UI thread - # must update its OWN os.environ so the next - # probe_environment() call — which spawns ffmpeg/av1an - # subprocesses that inherit os.environ — can dlopen the - # freshly-built VapourSynth / libiamf shared libraries from - # ~/.local/lib. Without this, the rebuilt binaries would - # fail to load their dependent libs. - local_lib = str(Path.home() / ".local" / "lib") - existing_ld = os.environ.get("LD_LIBRARY_PATH", "") - if local_lib not in existing_ld: - os.environ["LD_LIBRARY_PATH"] = f"{local_lib}:{existing_ld}".rstrip(":") - - # Re-probe environment with fresh data - self.env = probe_environment() - - # Run smoke test again to verify the fix - if self.env.av1an_path and self.env.ffmpeg_path: - svt_name = self.env.av1an_flags.get("svt_name", "svt_av1") - ok, detail = _av1an_vsscript_smoke_test( - self.env.av1an_path, - self.env.ffmpeg_path, - self.env.av1an_flags, - svt_name=svt_name, - ) - if ok: - self._log("VERIFIED: av1an + VapourSynth now working correctly!") - self._log("Click START PROCESSING to encode.") - elif "INVALID_ENCODER" in detail: - # Re-probe encoder name with the fresh binary - self._log(" Re-detecting encoder name from fresh build...") - new_name = _detect_av1an_svt_encoder(self.env.av1an_path) - if new_name and new_name != svt_name: - self.env.av1an_flags["svt_name"] = new_name - self._log(f" Corrected encoder name: '{svt_name}' -> '{new_name}'") - ok2, detail2 = _av1an_vsscript_smoke_test( - self.env.av1an_path, self.env.ffmpeg_path, - self.env.av1an_flags, svt_name=new_name, - ) - if ok2: - self._log("VERIFIED: av1an + VapourSynth now working correctly!") - self._log("Click START PROCESSING to encode.") - else: - self._log(f"WARNING: Smoke test still fails: {detail2}") - else: - self._log(f"WARNING: Could not auto-fix encoder name. Smoke test: {detail}") - else: - self._log(f"WARNING: Build completed but smoke test still fails: {detail}") - self._log("You may need to log out/in or restart the app for library changes to take effect.") - - # Update status bar - distro = self.env.distro - cpu = self.env.cpu - vs_info = f" | VS{self.env.vs_version}" if self.env.vs_version else "" - fb_encs = [] - for vc in VIDEO_CODECS: - lib_key = ffmpeg_lib_key_for(vc.ffmpeg_encoder) # v3: OTC-007 - if self.env.ffmpeg_libs.get(lib_key, False): - fb_encs.append(vc.ffmpeg_encoder) - fb_info = f" | ffmpeg-fb:{'+'.join(fb_encs)}" if fb_encs else "" - self.status_label.setText( - f"{distro.name} | {cpu.physical_cores}C/{cpu.logical_threads}T | " - f"av1an v{self.env.av1an_version or '?'} | ffmpeg v{self.env.ffmpeg_version or '?'}{vs_info}{fb_info}" - ) - else: - self._log(f"BUILD FAILED: {message}") - self._log("Try running the build manually in a terminal, or use ffmpeg fallback.") - self.status_label.setText("Build failed — check log") - - self.btn_run.setEnabled(True) - self.btn_rebuild.setEnabled(True) - - @Slot() - def _manual_rebuild(self): - """Handle the REBUILD FROM GIT button click (manual trigger).""" - btn_vs_av1an = QPushButton(" VapourSynth + av1an ") - btn_vs_av1an.setObjectName("btnRebuild") - btn_vs_only = QPushButton(" VapourSynth only ") - btn_vs_only.setObjectName("btnRebuild") - btn_av1an_only = QPushButton(" av1an only ") - btn_av1an_only.setObjectName("btnRebuild") - btn_ffmpeg_iamf = QPushButton(" ffmpeg + IAMF ") - btn_ffmpeg_iamf.setObjectName("btnRebuild") - btn_cancel = QPushButton(" Cancel ") - btn_cancel.setObjectName("btnStop") - - dlg = QMessageBox(self) - dlg.setWindowTitle("Rebuild from Git") - dlg.setText( - "Select which components to rebuild from git source.\n\n" - "• VapourSynth — installs to ~/.local (needs sudo for build deps)\n" - "• av1an — builds via cargo, copies to ~/.cargo/bin (needs sudo for build deps)\n" - "• ffmpeg + IAMF — builds libiamf + ffmpeg with --enable-libiamf,\n" - " installs to ~/.local/bin/ffmpeg (shadows system ffmpeg).\n" - " Required to use the IAMF audio codec. ~10-20 min build time.\n\n" - "Build times: VapourSynth ~2-5 min, av1an ~10-30 min, ffmpeg ~10-20 min" - ) - dlg.addButton(btn_vs_av1an, QMessageBox.ButtonRole.AcceptRole) - dlg.addButton(btn_vs_only, QMessageBox.ButtonRole.YesRole) - dlg.addButton(btn_av1an_only, QMessageBox.ButtonRole.NoRole) - dlg.addButton(btn_ffmpeg_iamf, QMessageBox.ButtonRole.ActionRole) - dlg.addButton(btn_cancel, QMessageBox.ButtonRole.RejectRole) - - dlg.exec() - clicked = dlg.clickedButton() - - if clicked == btn_vs_av1an: - self._start_git_rebuild(build_vs=True, build_av1an=True) - elif clicked == btn_vs_only: - self._start_git_rebuild(build_vs=True, build_av1an=False) - elif clicked == btn_av1an_only: - self._start_git_rebuild(build_vs=False, build_av1an=True) - elif clicked == btn_ffmpeg_iamf: - self._start_git_rebuild(build_vs=False, build_av1an=False, - build_ffmpeg_iamf=True) - - @Slot(str, int, int) - def _on_progress(self, filename: str, current: int, total: int): - self.status_label.setText(f"Processing {current}/{total}: {filename}") - - @Slot(int, int) - def _on_finished(self, ok: int, fail: int): - # v4.7.0: hybrid lanes finish independently — aggregate the - # per-lane summaries and only finalize when the LAST lane exits. - if len(self.workers) > 1: - self._hybrid_totals[0] += ok - self._hybrid_totals[1] += fail - self._hybrid_pending -= 1 - if self._hybrid_pending > 0: - self._log( - f"Lane done — {ok} ok, {fail} failed. " - f"Waiting for the other lane..." - ) - return - ok, fail = self._hybrid_totals - self._hybrid_totals = [0, 0] - - self.btn_run.setEnabled(True) - self.btn_run.setText("START PROCESSING") - self.btn_stop.setEnabled(False) - self.status_label.setText(f"Done — {ok} succeeded, {fail} failed") - - if fail > 0: - self._log(f"WARNING: {fail} file(s) failed. Check log above for details.") - if ok > 0: - self._log(f"All {ok} file(s) archived successfully.") - - @Slot() - def _stop_process(self): - if any(w.isRunning() for w in self.workers): - self._log("STOP: Exiting queue after current file finishes...") - for w in self.workers: - w.stop() - self.btn_stop.setEnabled(False) - - - - -# ────────────────────────────────────────────── -# GUI ENTRY POINT -# ────────────────────────────────────────────── - -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: - """Create the QApplication, show the OpenCodecMaster window, run the Qt event loop. - - This is the GUI entry point invoked by ``cli.main()`` when no - ``--version`` / ``--dry-run`` / ``--verify-only`` flag is given, - and by ``python -m opentranscode``. Returns the Qt event-loop - exit code (0 on clean shutdown). - - v5-01: *force* pre-checks the "Force (skip validation)" checkbox - in the UI. This is a convenience for users who want to skip ffprobe - validation on launch (e.g. for the rare edge case where ffprobe - fails but the file is actually valid). The checkbox can still be - toggled manually in the UI. - - v4.0.0: *chunk_method* overrides av1an's chunk-method selection. - When not None, the value is written to ``env.av1an_flags[ - "chunk_method_override"]`` after the environment probe runs. - "auto" clears any override the probe set; other values ("select", - "hybrid", "ffms2", etc.) force that method. Useful for forcing - "select" to avoid the Hybrid chunk method's failure on phone- - recorded MP4s with sparse keyframes (the "works up until near the - end, never saves chunks into a full file" bug). - - v4.1.0: *max_workers* / *threads_per_worker* override the - intelligent worker-count computation in EncoderWorker. When None, - EncoderWorker derives them from CPU topology so - ``worker_count * threads_per_worker <= logical_threads - 1`` - (preventing the thread-oversubscription hard-lock on high-core- - count machines). When set, the values are stored on - ``env.av1an_flags`` and picked up by EncoderWorker.__init__'s - fallback path — no ui_window.py code changes needed beyond this - launch_gui signature. - - v4.2.0: *use_av1an* opts INTO the av1an chunk-parallel path. The - default is False (ffmpeg-only), which is more reliable across - distros. av1an was too fragile: y4m pipe breaks on phone-recorded - MP4s, 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. Pass use_av1an=True only if you have - a known-good av1an+VapourSynth setup and want chunk-parallel. - - Equivalent to the v3 ``if __name__ == "__main__":`` block. - """ - app = QApplication(sys.argv if argv is None else argv) - window = OpenCodecMaster() - if force and hasattr(window, "force_check"): - window.force_check.setChecked(True) - # v4.0.0: apply --chunk-method override AFTER the window's env probe - # has run (in __init__). The override is written to env.av1an_flags - # so every EncoderWorker spawned from this point picks it up. - if chunk_method is not None and hasattr(window, "env"): - if chunk_method == "auto": - window.env.av1an_flags.pop("chunk_method_override", None) - window._log(f"CLI override: chunk method = auto (cleared probe setting)") - else: - window.env.av1an_flags["chunk_method_override"] = chunk_method - window._log(f"CLI override: chunk method = {chunk_method}") - # v4.1.0: store --max-workers / --threads-per-worker on env so - # EncoderWorker.__init__ picks them up via its fallback path. The - # default (None on both) lets EncoderWorker auto-compute from CPU - # topology. - if hasattr(window, "env"): - if max_workers is not None: - window.env.av1an_flags["max_workers"] = int(max_workers) - window._log(f"CLI override: max_workers = {max_workers}") - if threads_per_worker is not None: - window.env.av1an_flags["threads_per_worker"] = int(threads_per_worker) - window._log(f"CLI override: threads_per_worker = {threads_per_worker}") - # v4.2.0: store --use-av1an flag. The default is False - # (ffmpeg-only). When True, the av1an pre-flight + smoke test - # runs as before. When False (default), the smoke test is - # skipped and use_ffmpeg_fallback is set to True directly, - # short-circuiting the entire av1an code path. - window.env.av1an_flags["use_av1an"] = bool(use_av1an) - if not use_av1an: - window._log("Encode mode: ffmpeg-only (default). Use --use-av1an for chunk-parallel.") - else: - window._log("Encode mode: av1an chunk-parallel (opt-in via --use-av1an).") - # v4.2.1: store --verbose flag. Default False = quiet log - # (per-file success/fail + final summary). True = full tech - # detail (CMD:, live tail, DIAGNOSIS blocks, etc.). - window.env.av1an_flags["verbose"] = bool(verbose) - if verbose: - window._log("Verbose log: enabled (CMD:, live tail, DIAGNOSIS, etc.).") - # v4.3.0: store --skip-existing flag. Default True = skip files - # whose output already exists with a matching codec. --force-reencode - # sets this to False. - window.env.av1an_flags["skip_existing"] = bool(skip_existing) - if skip_existing: - window._log("Skip-existing: enabled (use --force-reencode to disable).") - else: - window._log("Skip-existing: disabled (re-encoding all files).") - # v4.4.0: store --timeout flag. Default 86400s = 24h. - window.env.av1an_flags["encode_timeout"] = int(timeout) - if timeout != 86400: - window._log(f"Per-file timeout: {timeout}s") - # v4.4.4: store --inline-scale flag. Default False (use CRF-16 - # pre-scale intermediate, robust). When True, the scale/pad filter - # chain is passed directly to av1an via --ffmpeg-filter-args, - # skipping the intermediate file entirely. Pre-checks the UI - # checkbox so the user sees the state. The user can still toggle - # the checkbox manually — the UI value is re-read at encode time - # in _start_process. - window.env.av1an_flags["inline_scale"] = bool(inline_scale) - if inline_scale and hasattr(window, "inline_scale_check"): - window.inline_scale_check.setChecked(True) - window._log("Inline scale: enabled via --inline-scale (no intermediate for scaling).") - # v4.6.0: store --engine (auto|gpu|cpu). "auto" (default) uses the - # NVENC GPU encoder when the selected codec family has one and the - # live encode test passed. Pre-selects the ENGINE combo so the - # user sees the state; the combo is re-read at encode time, so the - # user can still change it per run. - if gpu_profile != "auto": - window.env.av1an_flags["gpu_profile"] = gpu_profile - if hasattr(window, "gpu_combo"): - _gpu_keys = ["auto"] + [gp.key for gp in GPU_PROFILES] - if gpu_profile in _gpu_keys: - window.gpu_combo.setCurrentIndex(_gpu_keys.index(gpu_profile)) - window._log(f"GPU profile: {gpu_profile} (via --gpu-profile).") - if engine in ("gpu", "cpu", "hybrid"): - window.env.av1an_flags["engine"] = engine - if hasattr(window, "engine_combo"): - window.engine_combo.setCurrentIndex( - {"gpu": 1, "cpu": 2, "hybrid": 3}[engine] - ) - window._log(f"Engine: {engine.upper()} (via --engine).") - else: - window.env.av1an_flags["engine"] = "auto" - gpu = getattr(window.env, "gpu", None) - if gpu is not None and gpu.has_gpu: - window._log( - f"Engine: auto — GPU detected ({gpu.name or 'NVIDIA'}), " - f"NVENC ready: {', '.join(gpu.usable_encoders)}." - ) - else: - window._log("Engine: auto — no usable GPU encoder, CPU encoders will be used.") - window.show() - return sys.exit(app.exec()) diff --git a/opentranscode/widgets/__init__.py b/opentranscode/widgets/__init__.py deleted file mode 100755 index 11aca92..0000000 --- a/opentranscode/widgets/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Widget subpackage for opentranscode UI components. -""" - -from .radio_knob import RadioKnob - -__all__ = ["RadioKnob"] diff --git a/opentranscode/widgets/radio_knob.py b/opentranscode/widgets/radio_knob.py deleted file mode 100755 index 50a875a..0000000 --- a/opentranscode/widgets/radio_knob.py +++ /dev/null @@ -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)) - diff --git a/pyproject.toml b/pyproject.toml index 613878d..a9333a2 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/tests/conftest.py b/tests/conftest.py index 974472f..f3b4108 100755 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 # ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/test_chunk_method_retry.py b/tests/test_chunk_method_retry.py index a5d0ff7..af1340c 100755 --- a/tests/test_chunk_method_retry.py +++ b/tests/test_chunk_method_retry.py @@ -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): diff --git a/tests/test_concurrent_workers.py b/tests/test_concurrent_workers.py index 4b91cd9..003700f 100755 --- a/tests/test_concurrent_workers.py +++ b/tests/test_concurrent_workers.py @@ -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 diff --git a/tests/test_ffmpeg_fallback.py b/tests/test_ffmpeg_fallback.py index 2165375..5ce094a 100755 --- a/tests/test_ffmpeg_fallback.py +++ b/tests/test_ffmpeg_fallback.py @@ -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}") diff --git a/tests/test_force_validation.py b/tests/test_force_validation.py index ce14aa2..9fc818c 100755 --- a/tests/test_force_validation.py +++ b/tests/test_force_validation.py @@ -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}") diff --git a/tests/test_gpu_engine.py b/tests/test_gpu_engine.py index df2b7eb..d854bff 100644 --- a/tests/test_gpu_engine.py +++ b/tests/test_gpu_engine.py @@ -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"), diff --git a/tests/test_gpu_profiles.py b/tests/test_gpu_profiles.py index f4c2a28..5379734 100644 --- a/tests/test_gpu_profiles.py +++ b/tests/test_gpu_profiles.py @@ -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 diff --git a/tests/test_hybrid_scheduler.py b/tests/test_hybrid_scheduler.py index 9fe428d..c230544 100644 --- a/tests/test_hybrid_scheduler.py +++ b/tests/test_hybrid_scheduler.py @@ -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 diff --git a/tests/test_inline_scale.py b/tests/test_inline_scale.py index ba46092..564bb78 100644 --- a/tests/test_inline_scale.py +++ b/tests/test_inline_scale.py @@ -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. diff --git a/tests/test_large_file_fixes.py b/tests/test_large_file_fixes.py index aece2ae..6f429f0 100644 --- a/tests/test_large_file_fixes.py +++ b/tests/test_large_file_fixes.py @@ -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) diff --git a/tests/test_launch_env_pending.py b/tests/test_launch_env_pending.py new file mode 100644 index 0000000..d9c1245 --- /dev/null +++ b/tests/test_launch_env_pending.py @@ -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" diff --git a/tests/test_massive_files.py b/tests/test_massive_files.py index d76c3fd..1c59882 100755 --- a/tests/test_massive_files.py +++ b/tests/test_massive_files.py @@ -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). diff --git a/tests/test_package_structure.py b/tests/test_package_structure.py index c64a3c3..04e9193 100755 --- a/tests/test_package_structure.py +++ b/tests/test_package_structure.py @@ -1,254 +1,132 @@ +"""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", } # ───────────────────────────────────────────────────────────────────────────── -# Tests +# Tests # ───────────────────────────────────────────────────────────────────────────── -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 diff --git a/tests/test_skip_existing.py b/tests/test_skip_existing.py index 6369ca9..bd0f235 100755 --- a/tests/test_skip_existing.py +++ b/tests/test_skip_existing.py @@ -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). diff --git a/tests/test_source_builder.py b/tests/test_source_builder.py index 62ac923..95e8b25 100644 --- a/tests/test_source_builder.py +++ b/tests/test_source_builder.py @@ -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 diff --git a/tests/test_theora_ogv.py b/tests/test_theora_ogv.py new file mode 100644 index 0000000..78ee8fe --- /dev/null +++ b/tests/test_theora_ogv.py @@ -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 diff --git a/tests/test_title_clean.py b/tests/test_title_clean.py new file mode 100644 index 0000000..3847bb8 --- /dev/null +++ b/tests/test_title_clean.py @@ -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` / `_x` 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)) diff --git a/tests/test_use_av1an_flag.py b/tests/test_use_av1an_flag.py index da10e00..06f0109 100755 --- a/tests/test_use_av1an_flag.py +++ b/tests/test_use_av1an_flag.py @@ -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 diff --git a/tests/test_verbose_flag.py b/tests/test_verbose_flag.py index b6083ce..0c199c4 100755 --- a/tests/test_verbose_flag.py +++ b/tests/test_verbose_flag.py @@ -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).