A batch transcoding GUI for Linux built with PySide6

This commit is contained in:
Jeremy Anderson 2026-07-25 17:32:24 -04:00
commit 1df68fe785
43 changed files with 68982 additions and 0 deletions

278
CHANGELOG.md Normal file
View File

@ -0,0 +1,278 @@
# Changelog
All notable changes to OpenTranscode. Versions follow semantic versioning.
## [4.5.0] — 2026-07-26 (master)
### Overview
Master release consolidating the v4.4.4 large-file fix with all prior
v4.4.x stability work. Targets the **"every large file fails"** symptom
reported on files from 1.1GB to 20GB, where the lossless pre-scale
intermediate was exhausting the temp partition and presenting as cryptic
`ffmpeg error (rc=234)` messages (the `rc=234` was a truncated 300-char
stderr snippet — the real error was "No space left on device").
### Fixed — "ffmpeg error (rc=234)" on 10GB+ source files with scaling
- **Pre-scale intermediate changed from CRF 0 to CRF 16.** The old CRF-0
(mathematically lossless) libx265 intermediate produced 2-4× source
size temp files: a 20GB BluRay rip generated a 60-80GB intermediate,
exhausted the temp partition, and crashed. CRF 16 is visually lossless
for archival purposes and produces 0.5-0.8× source size intermediates
(a 20GB source → ~10-15GB intermediate instead of 60GB). The single
1.6GB→768MB file that succeeded in the user's batch was the only one
small enough that the lossless intermediate fit on disk.
- **New `--inline-scale` flag** skips the pre-scale intermediate
entirely. The scale/pad filter chain is passed directly to av1an via
`--ffmpeg-filter-args`. Zero intermediate file, one fewer encode pass.
Toggleable via the new "Inline scale (no intermediate)" checkbox in
the UI options row. Default OFF — the intermediate path is more
robust against av1an/VapourSynth filter-arg quirks on older builds.
Enable when scaling large files (≥10GB) to save disk and time.
- The disk-space pre-check (`_check_disk_space`) now correctly handles
the inline-scale path: no intermediate is created, so the 2-3× source
temp-space warning is suppressed.
### Carried forward from v4.4.x
- v4.4.3: `AttributeError: 'EncoderWorker' object has no attribute 'verbose'`
crash on START in the launcher script. Added UI toggle for av1an
(checkbox in the options row, equivalent to `--use-av1an`).
- v4.4.2: Live tail of av1an/ffmpeg stderr was spamming the log in
quiet mode. "FAIL: av1an exit code 1" appeared even when the ffmpeg
fallback succeeded (confusing "FAIL then OK" double-status).
- v4.4.1: Default log output reduced to two lines per file (start
banner + finish status). Heartbeat and disk-space warnings require
`--verbose`.
- v4.4.0: Per-file timeout raised from 2h to 24h (configurable via
`--timeout`). 5%-of-source integrity check replaced with absolute
1KB minimum (false-positived on high-bitrate BluRay sources). Disk-
space pre-check warns (not aborts) when free space < source size.
### Tests added
- `tests/test_inline_scale.py` (13 tests): verifies the CLI flag, the
`launch_gui` signature, the `EncoderWorker.inline_scale` attribute
flow, the `_prepare_input` gating, the `_encode_one`
`--ffmpeg-filter-args` injection, the CRF-16 (not CRF-0) intermediate,
and that the launcher script mirror stays in sync.
### Files touched in v4.4.4 (carried into 4.5.0)
- `opentranscode/encoder_worker.py` — CRF 16 + `inline_scale` gate +
`--ffmpeg-filter-args` injection in av1an cmd.
- `open-transcode.py` — mirror of all the above (launcher script).
- `opentranscode/cli.py``--inline-scale` flag.
- `opentranscode/ui_window.py` — UI checkbox + `launch_gui` signature +
state plumbing through `env.av1an_flags["inline_scale"]`.
- `opentranscode/__init__.py``launch_gui` wrapper signature updated.
### Upgrade notes
- Default behavior for files **without** a target resolution is
unchanged — no intermediate is created either way.
- Default behavior for files **with** a target resolution is now
CRF-16 intermediate (was CRF-0). Output quality is unchanged for
archival purposes; intermediate size drops ~60-75%.
- For maximum speed on large files with scaling, enable `--inline-scale`
or check the "Inline scale (no intermediate)" box in the UI. Test on
a small file first if you're on an older av1an build (pre-0.5.2) to
confirm `--ffmpeg-filter-args` is accepted.
## [4.4.4] — 2026-07-26
### Fixed — "ffmpeg error (rc=234)" on 10GB+ source files with scaling
- **Pre-scale intermediate changed from CRF 0 to CRF 16.** The old CRF-0
(mathematically lossless) libx265 intermediate produced 2-4× source
size temp files: a 20GB BluRay rip generated a 60-80GB intermediate,
exhausted the temp partition, and crashed with cryptic
`ffmpeg error (rc=234)` messages (the rc=234 came from a truncated
300-char stderr snippet — the real error was "No space left on
device"). CRF 16 is visually lossless for archival purposes and
produces 0.5-0.8× source size intermediates (a 20GB source →
~10-15GB intermediate instead of 60GB).
- **New `--inline-scale` flag** skips the pre-scale intermediate
entirely. The scale/pad filter chain is passed directly to av1an via
`--ffmpeg-filter-args`. Zero intermediate file, one fewer encode pass.
Toggleable via the new "Inline scale (no intermediate)" checkbox in
the UI options row. Default OFF — the intermediate path is more
robust against av1an/VapourSynth filter-arg quirks on older builds.
Enable when scaling large files (≥10GB) to save disk and time.
- The disk-space pre-check (`_check_disk_space`) now correctly handles
the inline-scale path: no intermediate is created, so the 2-3× source
temp-space warning is suppressed.
### Tests added
- `tests/test_inline_scale.py` (13 tests): verifies the CLI flag, the
`launch_gui` signature, the `EncoderWorker.inline_scale` attribute
flow, the `_prepare_input` gating, the `_encode_one`
`--ffmpeg-filter-args` injection, the CRF-16 (not CRF-0) intermediate,
and that the launcher script mirror stays in sync.
## [4.4.3] — 2026-07-25
### Fixed
- `AttributeError: 'EncoderWorker' object has no attribute 'verbose'` crash
on START in the launcher script's `EncoderWorker.__init__`. The launcher
script now sets `self.verbose` from `env.av1an_flags["verbose"]`, matching
the package's behavior.
### Added
- UI toggle for av1an: a new "av1an (chunk-parallel)" checkbox in the
options row. Default OFF = ffmpeg-only. The CLI flag `--use-av1an`
still works; the UI toggle takes precedence when set.
### Changed
- Documentation terminology: `open-transcode.py` is consistently called
"the launcher script" (not "single-file script"). It mirrors the
16-module `opentranscode/` package; calling it "single-file" was
misleading.
## [4.4.2] — 2026-07-25
### Fixed
- Live tail of av1an/ffmpeg stderr (`│ Encoding: 1373/1376 Frames @ 51.70 fps...`)
was spamming the log in quiet mode in the launcher script. The package
had this gated behind `--verbose` since v4.1.1; the launcher script
now matches.
- "FAIL: av1an exit code 1" appeared in the log even when the ffmpeg
fallback succeeded, producing a confusing "FAIL then OK" double-status.
The av1an failure line now goes to `_vlog` (verbose only); the user
sees only the final outcome (OK or `FAIL: av1an + ffmpeg both failed`).
## [4.4.1] — 2026-07-25
### Changed
- Default log output reduced to two lines per file: start banner + finish
status. Heartbeat (`... 30s elapsed`) and disk-space warnings now
require `--verbose`. The user asked for "start + finish, nothing else";
this delivers exactly that.
## [4.4.0] — 2026-07-25
### Added — massive-file support (30GB+ BluRay rips)
- **Per-file timeout raised from 2h to 24h**, configurable via
`--timeout SECONDS`. A 30GB 1080p BluRay rip at SVT-AV1 preset 6
takes 4-10 hours; the old 2h timeout killed massive-file encodes
partway through.
- **5%-of-source integrity check replaced with absolute 1KB minimum**.
The old check false-positived on high-bitrate sources (50GB BluRay →
5% = 2.5GB, but valid AV1 at CRF 32 produces 1-2GB for a 2-hour movie).
The real integrity gate is the duration check (≥95% of source).
- **Disk-space pre-check** warns (not aborts) if free space < source size.
When scaling, also checks the temp partition (lossless intermediate
can be 2-3x source size).
### Changed — log noise reduction
- Combined `[N/total] filename` banner + status into a single line:
`[1/180] filename.mkv — OK: 1.6MB -> 1.3MB (81%)` (was two lines).
- Disk-space warnings no longer fire for skipped files (the check now
runs after the skip-existing check).
## [4.3.0] — 2026-07-25
### Added — skip-existing detection
- Probes the output file with ffprobe before encoding. If the output
exists with a matching video+audio codec (and matching resolution when
scaling is requested), the file is skipped. Default ON; use
`--force-reencode` to disable.
- Added `ffprobe_codec_name` field to `VideoCodecProfile` and
`AudioProfile` (av1/vp9/hevc, opus/vorbis/flac/iamf).
- Final summary now includes `Skipped: N` count.
### Fixed — heartbeat regression
- v4.2.1 gated the 30-second heartbeat behind `--verbose`, causing the
"hangs on first transcode, forever timer" symptom in quiet mode. The
heartbeat is now always user-facing (one line per 30 seconds during
long encodes). The live tail of `frame= 67 fps= 12...` stays gated.
## [4.2.1] — 2026-07-25
### Changed — quiet mode by default
- Tech-detail log lines gated behind `--verbose`. Default output is
two lines per file: start banner + finish status.
- Gated: CMD: lines, live tail of av1an/ffmpeg stderr, DIAGNOSIS blocks,
resolution map, pre-flight validation table, heartbeat, disk-space
warnings, RETRY messages, file-type detection details.
## [4.2.0] — 2026-07-25
### Changed — ffmpeg is the default encode path
- av1an chunk-parallel was too fragile across distros (y4m pipe breaks,
SvtAv1EncApp CLI rejects `--threads`, VapourSynth plugin issues,
output buffering making it look hung). The default encode path is now
ffmpeg-only. av1an is opt-in via `--use-av1an`.
- The av1an pre-flight smoke test is skipped entirely when av1an is
not requested. `_on_run_clicked` sets `use_ffmpeg_fallback = True`
directly, short-circuiting the av1an code path.
## [4.1.2] — 2026-07-25
### Fixed
- Removed the `--threads N` injection into av1an's `--video-params`
string (introduced in v4.1.0). `SvtAv1EncApp` (the standalone CLI
av1an invokes per-chunk) does not accept `--threads` — only `--lp`
(logical processors). The result was `Unprocessed tokens: --threads`
→ every chunk failed 3x → no av1an output. Thread capping now lives
in av1an's `--workers` flag (chunk-parallel count) and in `-threads`
for the ffmpeg fallback path (where libsvtav1 is a library).
- `params_fn` signature returned to `(crf, preset) -> str` (v4.0.0 form).
## [4.1.1] — 2026-07-25
### Added
- **Live progress tail** — av1an's stdout/stderr emits to the GUI log
as it arrives (handles both `\n` log lines and `\r` progress bar
updates as line boundaries).
- **30-second heartbeat**`... still encoding (Xs elapsed)` every
30 seconds so the user knows the encode is alive.
### Changed
- `IDEAL_THREADS_PER_WORKER` raised from 4 to 6 for better per-chunk
SVT-AV1 throughput. On a 28-thread Xeon, the split changed from
6×4=24 to 4×6=24 (same total, better per-chunk latency).
## [4.1.0] — 2026-07-25
### Added — intelligent chunking
- `_compute_intelligent_worker_count()` computes `(worker_count,
threads_per_worker)` such that `worker_count * threads_per_worker <=
logical_threads - 1`. Prevents thread oversubscription on high-core-
count machines (13 workers × 28 threads = 364 active on 28 logical CPUs
→ kernel scheduler drowned → hard lock).
- Per-encoder `--threads N` cap injected into `--video-params`.
- CLI flags `--max-workers N` and `--threads-per-worker N` for overrides.
## [4.0.0] — 2026-07-25
### Fixed — "works up until near the end, never saves chunks into a full file"
- av1an auto-selects the Hybrid chunk method when no VapourSynth source
plugins are installed. Hybrid fails on phone-recorded MP4s with sparse
keyframes (scene boundaries rarely align with I-frames → segment muxer
splits mid-GOP → decoder errors → y4m pipe breaks → encoder reads EOF
→ every chunk fails after 3 retries → no output file).
- `env_probe` now probes for VapourSynth source plugins (`lsmash`,
`ffms2`, `bestsource`, `dgdecnv`). When none are found, pre-sets
`chunk_method_override = "select"` to avoid the wasted first attempt.
- `_encode_one` accepts a `chunk_method` parameter for retry. When av1an
fails with the y4m break pattern, it recursively retries with
`--chunk-method select` and caches that choice for subsequent files.
- `--chunk-method {auto,select,hybrid,segment,ffms2,lsmash,bestsource,dgdecnv}`
CLI flag for forcing a specific chunk method.
### Package split
- Refactored the monolithic `open-transcode.py` into a 16-module
`opentranscode/` package. The launcher script is preserved for
backwards compatibility and as the test target for mocked tests.
- `pyproject.toml` for `pip install -e .` and `python -m build`.
---
[4.5.0]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.5.0
[4.4.4]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.4.4
[4.4.3]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.4.3
[4.4.2]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.4.2
[4.4.1]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.4.1
[4.4.0]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.4.0
[4.3.0]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.3.0
[4.2.1]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.2.1
[4.2.0]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.2.0
[4.1.2]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.1.2
[4.1.1]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.1.1
[4.1.0]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.1.0
[4.0.0]: https://git.dcos.net/dcosnet/OpenTranscode/releases/tag/v4.0.0

21
LICENSE Executable file
View File

@ -0,0 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
See https://www.gnu.org/licenses/agpl-3.0.txt for the full license text.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.

243
README.md Executable file
View File

@ -0,0 +1,243 @@
# 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
optional av1an chunk-parallel path for users with a working VapourSynth
setup.
- **Default encoder**: ffmpeg + libsvtav1 (reliable across distros)
- **Optional encoder**: av1an chunk-parallel (opt-in via UI toggle)
- **Codecs**: AV1 (SVT-AV1), VP9, x265 (HEVC) video; Opus, Vorbis, FLAC, IAMF audio
- **Containers**: MKV, WebM, MP4
- **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
- **Subtitle mux**: Optional English subtitle passthrough
- **Source management**: Optional verified-source deletion after encode
## Requirements
- Linux (POSIX)
- Python ≥ 3.12
- ffmpeg (with libsvtav1, libvpx, libx265, libopus, libvorbis, flac)
- ffprobe
- PySide6 (for the GUI)
- Optional: av1an + VapourSynth (only if using the av1an toggle)
## 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.5.0
python -m opentranscode --help
```
### 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
```
## Usage
### Default (ffmpeg-only, recommended)
```bash
opentranscode
```
- Encodes with `ffmpeg -c:v libsvtav1` (or libvpx-vp9 / libx265 based on selection)
- Single-pass per file
- Reliable across distros; no VapourSynth dependency
### Skip-existing (default ON)
Files whose output already exists with a matching video+audio codec are
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
```
### Verbose logging
Default log output is minimal — two lines per file (start + finish):
```
Found 180 file(s) to process.
[1/180] filename.mkv
[1/180] filename.mkv — OK: 1.6MB -> 1.3MB (81%)
[2/180] already_done.mkv — SKIP (already av1/opus)
[3/180] next.mkv
[3/180] next.mkv — OK: 2.4MB -> 1.8MB (75%)
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
```
### Massive-file support
For 30GB+ BluRay rips:
- **24-hour per-file timeout** (configurable via `--timeout SECONDS`)
- **1KB absolute integrity minimum** (no false "output too small" failures
on high-bitrate sources — duration check is the real gate)
- **Disk-space warnings** (verbose only) for output and temp partitions
```bash
opentranscode --timeout 36000 # 10h per-file timeout
```
### av1an chunk-parallel (opt-in)
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`
When av1an fails per-file (concat failures, scene-detection panics),
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
```
opentranscode [--version] [--dry-run] [--verify-only PATH] [--force]
[--chunk-method METHOD] [--max-workers N] [--threads-per-worker N]
[--use-av1an] [--verbose] [--skip-existing | --force-reencode]
[--timeout SECONDS]
```
| Flag | Default | Description |
|------|---------|-------------|
| `--version` | — | Print version and exit |
| `--dry-run` | — | Probe environment + smoke test, no encode |
| `--verify-only PATH` | — | Re-verify an existing output file |
| `--force` | off | Skip ffprobe pre-validation |
| `--chunk-method METHOD` | auto | Force av1an chunk method (select, hybrid, ffms2, lsmash, bestsource, dgdecnv) |
| `--max-workers N` | auto | Cap chunk-parallel worker count |
| `--threads-per-worker N` | auto | Per-encoder thread cap |
| `--use-av1an` | off | Use av1an chunk-parallel (UI toggle also available) |
| `--verbose` | off | Full tech-detail log output |
| `--skip-existing` | on | Skip files whose output has matching codec |
| `--force-reencode` | off | Re-encode everything |
| `--timeout SECONDS` | 86400 | Per-file encode timeout (24h default) |
## 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, AudioProfile, ContainerProfile tables
├── encoder_worker.py # QThread-based per-file encode pipeline
├── env_probe.py # Distro + binary + library + av1an detection
├── 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/ # 149 tests across 14 files
```
### Encode pipeline
1. **Environment probe** — detects distro, ffmpeg/ffprobe/av1an paths,
encoder library availability, VapourSynth + source plugins, CPU topology
2. **Pre-flight validation** — ffprobe scans all input files; reports valid
vs invalid counts before encoding starts
3. **Per-file pipeline**:
- `_validate_file` — ffprobe pre-check (skip if invalid)
- `_check_disk_space` — warn (verbose) if free space < source size
- `_output_already_encoded` — skip if output exists with matching codec
- `_prepare_input` — pre-scale (if requested) or symlink to temp
- `_encode_one` — dispatch to ffmpeg (default) or av1an (opt-in)
- `_run_with_stop_check` — subprocess with STOP-button interrupt support
- `_verify_and_finalize` — duration check (≥95%), subtitle mux, source deletion
4. **Final cleanup** — sweep per-worker temp dir, delete verified sources
### Thread safety
- Each `EncoderWorker` runs in its own `QThread`
- Per-worker temp subdirectory (`~/.cache/OpenTranscode/tmp/worker-<pid>/`)
created with mode 0700 (SEI CERT FIO09-C)
- Process-group signaling (`start_new_session=True` + `os.killpg`) reaches
av1an's child encoders (SvtAv1EncApp / vpxenc / x265)
- Drainer threads read stdout/stderr continuously to prevent pipe-buffer
deadlock (same pattern as `subprocess.run._communicate`)
### Coding standards
The codebase follows:
- **PEP 868** — parameterized type hints (`dict[str, object]`, not `Dict[str, object]`)
- **SEI CERT** — MSC04-C (single source of truth for diagnostics), FIO09-C
(secure temp directory), ERR01-C (narrow exception scope), STR09-C (no
substring matches for encoder names)
- **POSIX**`start_new_session=True` for process-group signaling,
`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
## Testing
```bash
python -m pytest tests/ -q # 149 tests, ~10s
python -m pytest tests/ -v # verbose
python -m pytest tests/ -k "skip_existing" # subset
```
Test categories:
- **Smoke tests** — av1an VSScript compatibility probe
- **Encoder pipeline**`_validate_file`, `_prepare_input`, `_verify_and_finalize`
- **Real-encode e2e** — generates real test videos with ffmpeg, runs the full pipeline
- **Chunk-method retry** — y4m pipe break recovery
- **Stop button** — SIGTERM/SIGKILL on process group
- **Concurrent workers** — per-PID temp directory isolation
- **Skip-existing** — codec matching, ffprobe failure, resolution mismatch
- **Massive files** — timeout flag, 1KB integrity threshold, disk-space checks
- **Package structure** — public API surface, submodule imports, CLI parser
## License
AGPL-3.0-or-later. See [LICENSE](LICENSE).
Third-party tools invoked (not bundled): ffmpeg, ffprobe, av1an,
VapourSynth, SvtAv1EncApp, vpxenc, x265, mkvmerge. Licenses flow
through from upstream.
## Project
- **Repository**: https://git.dcos.net/dcosnet/OpenTranscode
- **Issues**: https://git.dcos.net/dcosnet/OpenTranscode/issues
- **Changelog**: [CHANGELOG.md](CHANGELOG.md)

49181
logs/av1an.log.2026-07-13 Executable file

File diff suppressed because it is too large Load Diff

6611
open-transcode.py Executable file

File diff suppressed because it is too large Load Diff

111
opentranscode/__init__.py Executable file
View File

@ -0,0 +1,111 @@
"""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.5.0"
__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) -> 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.
"""
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,
)

23
opentranscode/__main__.py Executable file
View File

@ -0,0 +1,23 @@
"""``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())

383
opentranscode/cli.py Executable file
View File

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

286
opentranscode/codec_profiles.py Executable file
View File

@ -0,0 +1,286 @@
"""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
# ──────────────────────────────────────────────
# 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 = ""
@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"]
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
),
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
),
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
),
]
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",
}
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"}

123
opentranscode/cpu_topology.py Executable file
View File

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

328
opentranscode/distro_probe.py Executable file
View File

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

2180
opentranscode/encoder_worker.py Executable file

File diff suppressed because it is too large Load Diff

821
opentranscode/env_probe.py Executable file
View File

@ -0,0 +1,821 @@
"""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 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
# ──────────────────────────────────────────────
# 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"))
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 "]),
]
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)
# 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"
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: <name>]`` 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 <ENCODER> ... [default: svt-av1]
m2 = re.search(
r"--encoder\s+<ENCODER>.*?\[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.
"""
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(":")
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"))
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}"

121
opentranscode/ffprobe_utils.py Executable file
View File

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

234
opentranscode/keepawake.py Executable file
View File

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

248
opentranscode/license_registry.py Executable file
View File

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

546
opentranscode/source_builder.py Executable file
View File

@ -0,0 +1,546 @@
"""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.
Pure stdlib + PySide6 (no internal package dependencies).
"""
import os
import re
import shutil
import signal
import subprocess
import time
from pathlib import Path
from PySide6.QtCore import QThread, Signal
# ──────────────────────────────────────────────
# 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):
super().__init__()
self.build_vs = build_vs
self.build_av1an = build_av1an
self.build_ffmpeg_iamf = build_ffmpeg_iamf
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:
# ── Install build dependencies (may need one sudo prompt) ──
self.log_msg.emit("")
self.log_msg.emit("=== Installing build dependencies ===")
all_deps = [
"meson", "ninja", "gcc", "pkg-config", "git",
"nasm", "yasm", "cmake", "python", "make",
]
need_rust = self.build_av1an and not shutil.which("cargo")
if need_rust:
all_deps.append("rust")
# Only invoke sudo if at least one dep is missing
missing = [d for d in all_deps if not shutil.which(d)]
if missing:
self.log_msg.emit(f" Missing: {', '.join(missing)} — installing via pacman")
rc, _ = self._sudo_cmd(
["pacman", "-S", "--needed", "--noconfirm"] + all_deps,
timeout=300, label="pacman build-deps",
)
if rc != 0:
self.log_msg.emit(" (some deps may already be installed — continuing)")
else:
self.log_msg.emit(" All build dependencies already installed.")
# 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()
# ── 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 LD_LIBRARY_PATH includes local VS libs ──
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)
self.log_msg.emit(f" Set LD_LIBRARY_PATH to include {local_lib}")
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 _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

112
opentranscode/temp_manager.py Executable file
View File

@ -0,0 +1,112 @@
"""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) -> 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).
"""
base = _get_app_temp_dir()
sub = base / f"worker-{worker_pid}"
_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}"

319
opentranscode/ui_theme.py Executable file
View File

@ -0,0 +1,319 @@
"""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;
}
"""

1439
opentranscode/ui_window.py Executable file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

128
pyproject.toml Executable file
View File

@ -0,0 +1,128 @@
# pyproject.toml — OpenTranscode v4.5
#
# 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]
name = "opentranscode"
version = "4.5.0"
description = "Open-source batch video transcoder wrapping av1an + ffmpeg with a PySide6 GUI"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "AGPL-3.0-or-later" }
authors = [
{ name = "Jeremy Anderson", email = "dcos@dcos.net" },
]
maintainers = [
{ name = "Jeremy Anderson", email = "dcos@dcos.net" },
]
keywords = [
"av1an",
"ffmpeg",
"av1",
"vp9",
"x265",
"hevc",
"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",
"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]
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",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]

23
pytest.ini Executable file
View File

@ -0,0 +1,23 @@
[pytest]
# OpenTranscode v3 — pytest configuration.
#
# Test suite for QA item v3-10. Run from this directory (the parent of
# `tests/`) with:
#
# python -m pytest tests/ -v
#
# All tests are mock-based — no real av1an / ffmpeg install required.
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
# Show short test summary on failure, and don't truncate diff output.
addopts = -ra --tb=short
# Filter out noise from PySide6 stub installation (if a stub attribute is
# accessed that doesn't exist on the real Qt class, MagicMock swallows it
# silently — that's intentional, not a warning we need to see).
filterwarnings =
ignore::DeprecationWarning

359
tests/conftest.py Executable file
View File

@ -0,0 +1,359 @@
"""
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.
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
package is not available. The stubs provide:
- Real Python base classes for ``QThread``, ``QWidget``, ``QMainWindow``
so that ``class EncoderWorker(QThread)`` and ``class OpenCodecMaster(
QMainWindow)`` succeed at module load time. The stub ``__init__`` methods
accept any args/kwargs so ``super().__init__()`` calls in the real
``__init__`` methods don't raise.
- ``MagicMock`` for everything else (``QApplication``, ``QVBoxLayout``,
``QFont``, ``Qt`` enum, ``Signal``, ``Slot``, etc.) so attribute access
and instantiation are no-ops.
If a real PySide6 IS installed, the stubs are NOT installed and the open-transcode module
loads against the real Qt classes. All tests in this suite work in both
modes they either instantiate ``EncoderWorker`` via ``__new__`` (bypassing
``QThread.__init__``) or via ``__init__`` (which is safe to call because it
does not start the QThread).
"""
from __future__ import annotations
import importlib.util
import io
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# PySide6 detection + stub installation
# ─────────────────────────────────────────────────────────────────────────────
# Detect a REAL PySide6 install BEFORE installing any stubs. We use
# importlib.util.find_spec (not "import PySide6") so that we don't trigger
# PySide6's somewhat expensive C-extension load if it IS installed.
_REAL_PYSIDE6_AVAILABLE: bool = importlib.util.find_spec("PySide6") is not None
def _install_pyside6_stubs() -> None:
"""Install stub PySide6 modules in ``sys.modules``.
Idempotent: a second call is a no-op (detected via the ``_otc_stub``
marker on the fake ``PySide6`` package).
"""
if getattr(sys.modules.get("PySide6"), "_otc_stub", False):
return # already installed
class _StubBase:
"""Minimal base for stubbed Qt objects.
Accepts any args/kwargs in ``__init__`` so subclass ``__init__``
methods that call ``super().__init__(...)`` don't fail. Auto-returns
a ``MagicMock`` for any attribute not explicitly defined, so methods
like ``setObjectName``, ``resize``, ``setLayout`` are no-ops.
"""
def __init__(self, *args, **kwargs):
pass
def __getattr__(self, name):
m = MagicMock()
# Bypass __setattr__ (which would otherwise hit __getattr__ again
# for non-existent dunder lookups during interpreter bootstrapping).
object.__setattr__(self, name, m)
return m
class _StubQWidget(_StubBase):
pass
class _StubQMainWindow(_StubQWidget):
pass
class _StubQThread(_StubBase):
# QThread class-level signals (defined as MagicMock instances so
# ``worker.started.connect(...)`` works without raising).
started = MagicMock()
finished = MagicMock()
def start(self, *args, **kwargs):
pass
def wait(self, *args, **kwargs):
return True
def terminate(self):
pass
def isRunning(self):
return False
def requestInterruption(self):
pass
def isInterruptionRequested(self):
return False
# Build the fake PySide6.QtCore module.
qtcore = MagicMock()
qtcore.QThread = _StubQThread
qtcore.Qt = MagicMock()
# Signal(str) must return something with .emit(). Use a side_effect so
# each call returns a fresh MagicMock (matching the real Signal behavior
# of returning a per-class-attribute signal instance).
qtcore.Signal = MagicMock(side_effect=lambda *a, **k: MagicMock())
# Slot is used as a decorator: @Slot() -> (fn -> fn).
qtcore.Slot = lambda *a, **k: (lambda f: f)
qtcore.QTimer = MagicMock()
qtcore.QPointF = MagicMock()
qtcore.QRectF = MagicMock()
# Build the fake PySide6.QtWidgets module.
qtwidgets = MagicMock()
qtwidgets.QWidget = _StubQWidget
qtwidgets.QMainWindow = _StubQMainWindow
# Other names (QApplication, QVBoxLayout, QLabel, ...) auto-resolve to
# child MagicMocks via the parent MagicMock's attribute access.
# Build the fake PySide6.QtGui module.
qtgui = MagicMock()
# Assemble the fake PySide6 package.
pyside6 = MagicMock()
pyside6._otc_stub = True # idempotency marker
pyside6.QtCore = qtcore
pyside6.QtWidgets = qtwidgets
pyside6.QtGui = qtgui
sys.modules["PySide6"] = pyside6
sys.modules["PySide6.QtCore"] = qtcore
sys.modules["PySide6.QtWidgets"] = qtwidgets
sys.modules["PySide6.QtGui"] = qtgui
if not _REAL_PYSIDE6_AVAILABLE:
_install_pyside6_stubs()
# ─────────────────────────────────────────────────────────────────────────────
# open-transcode.py module loader
# ─────────────────────────────────────────────────────────────────────────────
OPENTRANSCODE_SCRIPT_PATH = Path(__file__).resolve().parent.parent / "open-transcode.py"
@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.
"""
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)
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ─────────────────────────────────────────────────────────────────────────────
# Shared fixtures
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def tiny_test_video(tmp_path):
"""Create a 1-second 64x64 black video using ffmpeg.
If ffmpeg is not installed, returns a fake path. Tests that need a real
video file should skip themselves when this fixture returns a path that
does not exist on disk; most tests in this suite instead mock
``subprocess.run`` and never touch a real video.
"""
ffmpeg_bin = shutil.which("ffmpeg")
if ffmpeg_bin is None:
# ffmpeg not installed — return a fake path. Callers that need a
# real file should check ``.exists()`` and skip / mock accordingly.
return tmp_path / "fake_test_video.mkv"
out = tmp_path / "tiny_test_video.mkv"
try:
subprocess.run(
[
ffmpeg_bin,
"-f", "lavfi", "-i", "color=c=black:s=64x64:d=1:r=24",
"-t", "1", "-pix_fmt", "yuv420p", "-an", "-y", str(out),
],
capture_output=True, text=True, timeout=15,
)
except (OSError, subprocess.SubprocessError):
return tmp_path / "fake_test_video.mkv"
if not out.exists():
return tmp_path / "fake_test_video.mkv"
return out
@pytest.fixture
def mock_env(opentranscode_module):
"""Return a fully-populated fake ``EnvProbe`` for testing.
Every field is set to a plausible value so tests that read ``env.X``
don't have to construct the whole distro/cpu topology themselves.
"""
EnvProbe = opentranscode_module.EnvProbe
DistroProfile = opentranscode_module.DistroProfile
CpuTopology = opentranscode_module.CpuTopology
distro = DistroProfile(
family="debian",
name="Ubuntu 24.04",
version_id="24.04",
pkg_manager="apt",
install_cmd_template="sudo apt install {packages}",
binary_extra_paths=["/usr/bin", "/usr/local/bin"],
av1an_known_encoder_names=["svt_av1", "svt-av1"],
ffmpeg_pkg="ffmpeg",
av1an_pkg="av1an",
notes="test distro profile",
)
cpu = CpuTopology(
physical_cores=4,
logical_threads=8,
threads_per_core=2,
model_name="Test CPU @ 2.0 GHz",
)
env = EnvProbe()
env.distro = distro
env.av1an_path = "/usr/bin/av1an"
env.ffmpeg_path = "/usr/bin/ffmpeg"
env.ffprobe_path = "/usr/bin/ffprobe"
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"concat_method": "ffmpeg",
"chunk_method_override": "select",
"svt_name": "svt_av1",
"has_chunk_method": True,
}
env.av1an_version = "0.5.2"
env.ffmpeg_version = "6.0"
env.ffmpeg_libs = {
"libsvtav1": True,
"libaom": True,
"libvpx": True,
"libx265": True,
"libopus": True,
"libvorbis": True,
"flac": True,
}
env.runtime_deps = {}
env.missing_dep_pkgs = []
env.vs_version = "R65"
env.vs_script_lib = "/usr/lib/x86_64-linux-gnu/libvapoursynth-script.so"
env.cpu = cpu
env.errors = []
env.warnings = []
return env
@pytest.fixture
def mock_subprocess_run(monkeypatch):
"""Patch ``subprocess.run`` to return configurable ``CompletedProcess`` objects.
Returns a mutable ``list`` that tests populate with the results they want
returned (or exceptions to raise) in call order. Each entry is either a
``subprocess.CompletedProcess`` (returned as-is), a ``BaseException``
(raised), or any other object (wrapped in a CompletedProcess with
returncode=0). Once the list is exhausted, subsequent calls return a
default rc=0 CompletedProcess.
"""
results: list = []
def fake_run(cmd, *args, **kwargs):
if results:
r = results.pop(0)
if isinstance(r, BaseException):
raise r
if isinstance(r, subprocess.CompletedProcess):
return r
return subprocess.CompletedProcess(
args=cmd, returncode=0, stdout=str(r), stderr="",
)
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", fake_run)
return results
@pytest.fixture
def mock_subprocess_popen(monkeypatch):
"""Patch ``subprocess.Popen`` for STOP-button tests.
Returns a ``MagicMock`` representing the fake subprocess. Tests configure
it (e.g. ``fake.poll.side_effect = [None, None, 0]``,
``fake.wait.side_effect = [...]``) before triggering the code under test.
``stdout`` and ``stderr`` default to empty ``StringIO`` objects so the
open-transcode module's drainer threads (in ``_run_with_stop_check``) immediately
hit EOF instead of looping forever.
"""
fake_proc = MagicMock()
fake_proc.pid = 12345
fake_proc.stdout = io.StringIO("")
fake_proc.stderr = io.StringIO("")
monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)
return fake_proc
# ─────────────────────────────────────────────────────────────────────────────
# Helper functions (importable from any test module via `from conftest import ...`)
# ─────────────────────────────────────────────────────────────────────────────
def make_minimal_worker(opentranscode_module, env=None, audio_level_db=-14.0):
"""Create an ``EncoderWorker`` without running ``__init__``.
Uses ``EncoderWorker.__new__`` to bypass QThread construction (which
would require a real Qt event loop in some setups), then sets only the
attributes the unit tests need. This is the recommended pattern for
testing the non-Qt methods of ``EncoderWorker`` (``_validate_file``,
``_analyze_audio_loudness``, ``_find_subtitle_stream``,
``_run_with_stop_check``) in isolation.
"""
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker._stop = False
worker.log_msg = MagicMock()
worker._file_res_map = {}
worker.fail_count = 0
worker._current_temps = []
worker._sources_to_delete = []
worker.success_count = 0
worker.audio_level_db = audio_level_db
worker.env = env
worker.subtitle_lang = None
worker.use_ffmpeg_fallback = False
return worker

160
tests/test_audio_loudnorm.py Executable file
View File

@ -0,0 +1,160 @@
"""
Audio loudness analysis tests for ``EncoderWorker._analyze_audio_loudness``.
QA finding: OTC-005 (dual-pass loudnorm gain computation).
``_analyze_audio_loudness`` runs ffmpeg's ``loudnorm`` filter in
analysis-only mode, parses the JSON stats block from stderr, and computes
the dB gain needed to bring the file's integrated loudness up to (or down
to) the user's target LUFS (knob value). It also clamps the gain so the
projected true peak stays below a 15%-headroom ceiling under ``target_tp``
(default -1.5 dBTP).
The 4 cases:
- Valid JSON, low input loudness -> +9.0 dB gain (no clamp).
- Valid JSON, hot true peak -> gain clamped to keep peak under ceiling.
- Silent input (input_i <= -70) -> returns None (no normalization needed).
- No JSON in stderr (loudnorm parse failure) -> returns None.
All cases mock ``subprocess.run`` so no real ffmpeg is required.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from conftest import make_minimal_worker
def _loudnorm_stderr(input_i: str, input_tp: str,
target_tp: str = "-1.5") -> str:
"""Build a realistic ffmpeg loudnorm stderr block containing a JSON stats.
ffmpeg prints a bunch of progress lines, then a JSON block at the end.
The open-transcode.py parser uses ``re.search(r'\\{[^{}]*"input_i"[^{}]*\\}', stderr,
re.DOTALL)`` to find the JSON flat (no nested braces) is required.
"""
return (
f"[Parsed_loudnorm_0 @ 0x7f] Changing target table from "
f"I=-70 to I={input_i}\n"
f"...\n"
f"{{\n"
f"\t\"input_i\" : \"{input_i}\",\n"
f"\t\"input_tp\" : \"{input_tp}\",\n"
f"\t\"input_lra\" : \"7.0\",\n"
f"\t\"input_thresh\" : \"-33.0\",\n"
f"\t\"output_i\" : \"-14.0\",\n"
f"\t\"output_tp\" : \"{target_tp}\",\n"
f"\t\"output_lra\" : \"7.0\",\n"
f"\t\"output_thresh\" : \"-24.0\",\n"
f"\t\"normalization_type\" : \"linear\",\n"
f"\t\"target_offset\" : \"-0.0\"\n"
f"}}\n"
)
def test_loudnorm_parses_json_stats(opentranscode_module, mock_env, monkeypatch):
"""input_i=-23.0, target_lufs=-14 -> gain_db = +9.0 (no clamp).
The file's true peak (-15.0 dBTP) is low enough that even with +9.0 dB
of gain the projected peak (-6.0 dBTP) stays well under the
15%-headroom ceiling (-1.275 dBTP), so no clamping happens.
"""
stderr = _loudnorm_stderr(input_i="-23.0", input_tp="-15.0")
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=subprocess.CompletedProcess(
args=["ffmpeg"], returncode=0, stdout="", stderr=stderr,
)),
)
# audio_level_db IS the target LUFS (see _analyze_audio_loudness).
worker = make_minimal_worker(opentranscode_module, env=mock_env, audio_level_db=-14.0)
gain = worker._analyze_audio_loudness(Path("/fake/audio.mkv"))
assert gain is not None
# -14.0 - (-23.0) = +9.0
assert gain == pytest.approx(9.0, abs=0.01)
def test_loudnorm_clamps_peak(opentranscode_module, mock_env, monkeypatch):
"""input_i=-14, input_tp=-0.5, target_lufs=-14 -> gain clamped.
The file's integrated loudness already equals the target (-14 LUFS), so
the raw gain would be 0 dB. But the file's true peak (-0.5 dBTP) is
already above the 15%-headroom ceiling of -1.275 dBTP, so the gain is
clamped DOWN to bring the peak under the ceiling.
ceiling = -1.5 + (|-1.5| * 0.15) = -1.5 + 0.225 = -1.275
clamped_gain = -1.275 - (-0.5) = -0.775 dB
"""
stderr = _loudnorm_stderr(input_i="-14.0", input_tp="-0.5")
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=subprocess.CompletedProcess(
args=["ffmpeg"], returncode=0, stdout="", stderr=stderr,
)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env, audio_level_db=-14.0)
gain = worker._analyze_audio_loudness(Path("/fake/hot_peak.mkv"))
assert gain is not None
# The clamped gain must be NEGATIVE (attenuation) and bring the peak
# under the -1.275 ceiling.
assert gain < 0, (
f"Expected clamped (negative) gain for input_tp=-0.5, got {gain}"
)
assert gain == pytest.approx(-0.775, abs=0.01)
# Verify the projected peak is at or under the ceiling.
projected_peak = -0.5 + gain
assert projected_peak <= -1.275 + 1e-6
def test_loudnorm_returns_none_for_silent_input(opentranscode_module, mock_env, monkeypatch):
"""input_i=-80 (silent) -> returns None (no normalization needed).
A silent file has no audible loudness to normalize; applying gain would
just amplify noise. The open-transcode.py code special-cases ``input_i <= -70``.
"""
stderr = _loudnorm_stderr(input_i="-80.0", input_tp="-99.0")
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=subprocess.CompletedProcess(
args=["ffmpeg"], returncode=0, stdout="", stderr=stderr,
)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env, audio_level_db=-14.0)
gain = worker._analyze_audio_loudness(Path("/fake/silent.mkv"))
assert gain is None
def test_loudnorm_returns_none_on_parse_failure(opentranscode_module, mock_env, monkeypatch):
"""No JSON block in stderr -> returns None (falls back to knob value).
If ffmpeg was killed, hit a parse error, or printed an unexpected
format, the regex ``\\{[^{}]*"input_i"[^{}]*\\}`` will not match.
The caller falls back to the knob's static dB value (see
``_encode_one``).
"""
stderr = (
"[Parsed_loudnorm_0 @ 0x7f] Estimating noise...\n"
"ffmpeg exited with code 1 — no JSON stats printed.\n"
)
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=subprocess.CompletedProcess(
args=["ffmpeg"], returncode=0, stdout="", stderr=stderr,
)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env, audio_level_db=-14.0)
gain = worker._analyze_audio_loudness(Path("/fake/broken.mkv"))
assert gain is None

563
tests/test_chunk_method_retry.py Executable file
View File

@ -0,0 +1,563 @@
"""
v7 behavior tests chunk-method retry on y4m pipe break.
Verifies the v4.0.0 fix for the "works up until near the end, never saves
chunks into a full file" production bug. When av1an's Hybrid chunk method
fails on a phone-recorded MP4 with sparse keyframes, every chunk's
encoder dies with:
[h264 @ 0x...] error while decoding MB 35 25
Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1
The fix retries the file with ``--chunk-method select`` (VapourSynth's
select() filter) before falling back to pure ffmpeg. This is faster than
the ffmpeg fallback (chunk-parallel still works) and produces identical-
quality output (same encoder, same params).
The production log that revealed this bug is at:
logs/av1an.log.2026-07-13
"""
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
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}")
_install_pyside6_stubs()
spec = importlib.util.spec_from_file_location("open_transcode", str(OPENTRANSCODE_PATH))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _install_pyside6_stubs():
if any(name in sys.modules for name in
("PySide6", "PySide6.QtWidgets", "PySide6.QtCore", "PySide6.QtGui")):
# Check if it's a real PySide6 or our stub. If real, leave it alone.
if getattr(sys.modules.get("PySide6"), "_otc_stub", False):
return
# Real PySide6 — don't install stubs over it
try:
importlib.util.find_spec("PySide6.QtCore")
return
except (ImportError, ValueError):
pass
import types
pyside6 = types.ModuleType("PySide6")
pyside6._otc_stub = True
qt_widgets = types.ModuleType("PySide6.QtWidgets")
qt_core = types.ModuleType("PySide6.QtCore")
qt_gui = types.ModuleType("PySide6.QtGui")
class _QThread:
def __init__(self, *a, **kw): pass
def start(self): pass
def isRunning(self): return False
def wait(self, ms=None): pass
class _Signal:
def __init__(self, *a, **kw): pass
def connect(self, *a, **kw): pass
def emit(self, *a, **kw): pass
def _Slot(*a, **kw):
def deco(fn): return fn
return deco
qt_core.QThread = _QThread
qt_core.Signal = _Signal
qt_core.Slot = _Slot
qt_core.Qt = MagicMock()
qt_core.QPointF = MagicMock()
qt_core.QRectF = MagicMock()
qt_core.QTimer = MagicMock()
class _QWidget:
def __init__(self, *a, **kw): pass
class _QMainWindow(_QWidget): pass
qt_widgets.QWidget = _QWidget
qt_widgets.QMainWindow = _QMainWindow
for name in ("QApplication", "QVBoxLayout", "QHBoxLayout", "QLabel",
"QLineEdit", "QPushButton", "QComboBox", "QCheckBox",
"QTextEdit", "QFileDialog", "QGroupBox", "QStatusBar",
"QMessageBox", "QStyleFactory"):
setattr(qt_widgets, name, MagicMock())
for n in ("QFont", "QPalette", "QColor", "QPainter", "QPen", "QBrush",
"QRadialGradient", "QFontMetrics"):
setattr(qt_gui, n, MagicMock())
pyside6.QtWidgets = qt_widgets
pyside6.QtCore = qt_core
pyside6.QtGui = qt_gui
sys.modules["PySide6"] = pyside6
sys.modules["PySide6.QtWidgets"] = qt_widgets
sys.modules["PySide6.QtCore"] = qt_core
sys.modules["PySide6.QtGui"] = qt_gui
@pytest.fixture(scope="module")
def opentranscode_module():
return _load_opentranscode_module()
# ─────────────────────────────────────────────────────────────────────────────
# Realistic stderr from a y4m-pipe-break failure (extracted from the
# production log at logs/av1an.log.2026-07-13). The key markers are:
# - "Failed to read y4m frame delimiter" (the broken y4m pipe)
# - "[h264 @ ...] error while decoding MB" (decoder failing mid-GOP)
# - "SUMMARY" + "Average Speed" (SVT-AV1's per-chunk summary block,
# printed because the encoder ran briefly on partial data before the
# pipe broke — this previously triggered the v6-03 "concat failure"
# misdiagnosis)
# ─────────────────────────────────────────────────────────────────────────────
_Y4M_BREAK_STDERR = (
"INFO encode_file: av1an_core::context: Input: 1920x1080 @ 29.763 fps, YUVJ420P, SDR\n"
"INFO encode_file: av1an_core::scenes: scenecut: found 8 scene(s) "
"[with extra_splits (298 frames): 16 scene(s)]\n"
"DEBUG encode_file: av1an_core::context: Segmenting video\n"
"DEBUG encode_file: av1an_core::context: Segment done\n"
"INFO encode_file: av1an_core::context: \n"
" Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
" [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
"WARN encode_chunk{worker_id=5 total_chunks=16 chunk_index=\"00011\"}: "
"av1an_core::broker: Encoder failed (on chunk 11):\n"
" Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
" SUMMARY -----------------------------------------------------------------\n"
" Average Speed:\t\t2.501 fps\n"
" [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
"ERROR av1an_core::broker: [chunk 4] [chunk 4] encoder failed 3 times, "
"shutting down worker: encoder crashed: exit status: 0\n"
)
class TestY4mBreakPatternInErrorTable:
"""v4.0.0: 'Failed to read y4m frame delimiter' is in the error_patterns table."""
def test_y4m_break_pattern_in_source(self, opentranscode_module):
"""The error_patterns table should include the y4m break pattern."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert "Failed to read y4m frame delimiter" in src, \
"error_patterns table should include 'Failed to read y4m frame delimiter'"
def test_v7_01_retry_logic_in_source(self, opentranscode_module):
"""The v4.0.0 retry-with-select logic should be present in _encode_one."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert 'chunk_method="select"' in src, \
"_encode_one should have a retry path that passes chunk_method='select'"
assert 'chunk_method_override' in src, \
"_encode_one should cache the working chunk_method in chunk_method_override"
def test_v6_03_diagnostic_guarded_by_y4m_check(self, opentranscode_module):
"""v4.0.0: the v6-03 SUMMARY-block concat-failure diagnostic must NOT
fire when the y4m break marker is present (otherwise it misdiagnoses
chunk-extraction failures as concat failures)."""
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, \
"v6-03 SUMMARY block diagnostic must be guarded by y4m break exclusion"
def test_vs_plugin_probe_in_source(self, opentranscode_module):
"""v4.0.0: env_probe should include the VapourSynth source plugin probe."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert "_probe_vs_source_plugins" in src, \
"env_probe should include _probe_vs_source_plugins helper"
assert "_VS_PLUGIN_PROBE_PATHS" in src, \
"env_probe should include the VS plugin path table"
class TestChunkMethodParameter:
"""v4.0.0: _encode_one accepts a chunk_method parameter for retries."""
def test_encode_one_accepts_chunk_method_kwarg(self, opentranscode_module, tmp_path):
"""_encode_one should accept chunk_method as a keyword argument."""
import inspect
sig = inspect.signature(opentranscode_module.EncoderWorker._encode_one)
params = list(sig.parameters.keys())
assert "chunk_method" in params, \
f"_encode_one should accept chunk_method parameter; got params: {params}"
# Default should be None (no override)
assert sig.parameters["chunk_method"].default is None, \
"chunk_method default should be None"
class TestY4mBreakRetry:
"""v4.0.0: When av1an fails with the y4m break pattern, retry with select."""
def test_y4m_break_triggers_select_retry(self, opentranscode_module, tmp_path):
"""When av1an fails with the y4m break pattern (and we're not already
using select), the code should retry with --chunk-method select."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
# Create a real test video
test_video = tmp_path / "input.mp4"
subprocess.run(
[ffmpeg_bin, "-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=24",
"-c:v", "libx264", "-y", str(test_video)],
capture_output=True, timeout=15,
)
if not test_video.exists():
pytest.skip("Could not generate test video")
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
# Start with NO chunk_method_override — av1an auto-selects Hybrid
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
"has_chunk_method": True,
# NOTE: chunk_method_override intentionally absent — simulates
# the production scenario where env_probe didn't pre-set it
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
# Track which chunk_method each call used
call_chunk_methods: list[str | None] = []
def mock_run(cmd, **kw):
# Extract the chunk method from the command
chunk_m = None
if "--chunk-method" in cmd:
idx = cmd.index("--chunk-method")
chunk_m = cmd[idx + 1] if idx + 1 < len(cmd) else None
call_chunk_methods.append(chunk_m)
if chunk_m is None or chunk_m == "hybrid":
# First attempt (auto/Hybrid) — fail with y4m break
return ("ok", 1, "", _Y4M_BREAK_STDERR)
else:
# Retry with select — succeed by producing a fake output
# (the actual encode is mocked; we just create the file)
# Find the output path (last arg of -o)
if "-o" in cmd:
out_idx = cmd.index("-o")
out_path = Path(cmd[out_idx + 1])
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_bytes(b"\x00" * 8192) # 8KB fake AV1 output
return ("ok", 0, "", "encoding finished")
worker._run_with_stop_check = mock_run
# Mock _ffmpeg_fallback_encode — should NOT be called (select retry succeeds first)
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
# Setup required attributes
worker._current_temps = []
worker._file_res_map = {}
worker._stop = False
worker._consecutive_fail_count = 0
worker._last_fail_pattern = None
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should have succeeded via the select retry
assert result is True, f"Expected retry to succeed. Logs: {logs}"
# Should have been called twice: once with no chunk_method (auto),
# once with chunk_method="select"
assert len(call_chunk_methods) == 2, \
f"Expected 2 calls (Hybrid fail + select retry), got {len(call_chunk_methods)}: {call_chunk_methods}"
assert call_chunk_methods[0] is None, \
f"First call should have no chunk_method (auto/Hybrid), got: {call_chunk_methods[0]}"
assert call_chunk_methods[1] == "select", \
f"Second call should use --chunk-method select, got: {call_chunk_methods[1]}"
# Should have logged the RETRY message
assert any("RETRY" in l and "select" in l for l in logs), \
f"Expected RETRY message with 'select' in logs: {logs}"
# Should NOT have called ffmpeg fallback (select retry succeeded)
worker._ffmpeg_fallback_encode.assert_not_called()
# fail_count should NOT be incremented (retry succeeded)
assert worker.fail_count == 0, \
f"fail_count should be 0 after successful select retry, got {worker.fail_count}"
# v4.0.0: the working chunk_method should be cached for subsequent files
assert env.av1an_flags.get("chunk_method_override") == "select", \
f"chunk_method_override should be cached as 'select' for subsequent files"
def test_y4m_break_no_retry_when_already_select(self, opentranscode_module, tmp_path):
"""When av1an fails with y4m break AND we're already using select,
don't retry with select again (would infinite-loop). Fall through to
the v6-01 ffmpeg fallback instead."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
test_video = tmp_path / "input.mp4"
test_video.write_bytes(b"\x00" * 1024)
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
# Already forcing select — simulates the case where the user
# passed --chunk-method select but it still failed (rare, but
# possible if VapourSynth itself is broken)
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
"has_chunk_method": True,
"chunk_method_override": "select",
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
call_count = [0]
def mock_run(cmd, **kw):
call_count[0] += 1
# Always fail with y4m break (even on select retry)
return ("ok", 1, "", _Y4M_BREAK_STDERR)
worker._run_with_stop_check = mock_run
# Mock ffmpeg fallback to succeed
def mock_ffmpeg_fallback(file_path, encode_input, output_f):
output_f.parent.mkdir(parents=True, exist_ok=True)
output_f.write_bytes(b"\x00" * 8192)
return True
worker._ffmpeg_fallback_encode = mock_ffmpeg_fallback
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker._current_temps = []
worker._stop = False
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should have succeeded via ffmpeg fallback (not select retry)
assert result is True, f"Expected ffmpeg fallback to succeed. Logs: {logs}"
# Should have been called only ONCE (no select retry since already select)
assert call_count[0] == 1, \
f"Expected 1 av1an call (no retry since already select), got {call_count[0]}"
# Should NOT have logged the select RETRY message
assert not any("RETRY" in l and "select" in l for l in logs), \
f"Should not log select RETRY when already using select: {logs}"
# Should have logged the ffmpeg fallback RETRY
assert any("RETRY" in l and "ffmpeg fallback" in l for l in logs), \
f"Expected ffmpeg fallback RETRY in logs: {logs}"
def test_y4m_break_diagnosis_not_misdiagnosed_as_concat(self, opentranscode_module, tmp_path):
"""v4.0.0: the v6-03 'concat failure' diagnostic must NOT fire when the
y4m break marker is present. The y4m break pattern's own diagnosis
should be emitted instead."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
test_video = tmp_path / "input.mp4"
test_video.write_bytes(b"\x00" * 1024)
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
# Force select so the retry doesn't happen (we just want to test
# the diagnostic message)
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
"has_chunk_method": True,
"chunk_method_override": "select",
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
def mock_run(cmd, **kw):
# Fail with y4m break (which includes SUMMARY + Average Speed)
return ("ok", 1, "", _Y4M_BREAK_STDERR)
worker._run_with_stop_check = mock_run
# Mock ffmpeg fallback to succeed
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker._current_temps = []
worker._stop = False
output_f = tmp_path / "output" / "input_archived.mkv"
worker._encode_one(test_video, test_video, output_f, 1)
# Should have emitted the y4m break diagnosis
y4m_diagnosis = any(
"y4m" in l.lower() and "DIAGNOSIS" in l for l in logs
)
assert y4m_diagnosis, \
f"Should emit y4m break DIAGNOSIS. Logs: {logs}"
# Should NOT have emitted the v6-03 concat failure diagnosis.
# The v6-03 message starts with "SVT-AV1 encoder completed successfully
# (SUMMARY block found in stderr)" — check for that unique prefix
# to avoid matching the y4m break diagnosis which itself says "this
# is NOT a concat failure".
concat_diagnosis = any(
"SVT-AV1 encoder completed successfully" in l
or "post-encode merge step crashed" in l
for l in logs
)
assert not concat_diagnosis, \
f"Should NOT emit v6-03 concat failure diagnosis for y4m break. Logs: {logs}"
class TestVSPluginProbe:
"""v4.0.0: _probe_vs_source_plugins detects installed VS plugins."""
def test_probe_returns_empty_when_no_plugins(self, opentranscode_module, tmp_path, monkeypatch):
"""When no VS plugin .so files exist in any search dir, the probe
should return an empty list."""
# Point HOME at an empty tmp dir so the home-dir search paths
# don't accidentally find real plugins
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
result = opentranscode_module._probe_vs_source_plugins()
assert result == [], \
f"Expected empty list when no plugins installed, got: {result}"
def test_probe_detects_lsmash(self, opentranscode_module, tmp_path, monkeypatch):
"""When libvslsmashsource.so is in a search dir, the probe should
return ['lsmash']."""
# Create a fake VS plugin dir with a fake lsmash .so.
# The probe searches ~/.local/lib/vapoursynth/ so we create it there.
vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
vs_dir.mkdir(parents=True)
(vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
result = opentranscode_module._probe_vs_source_plugins()
assert "lsmash" in result, \
f"Expected 'lsmash' in probe result, got: {result}"
def test_probe_detects_multiple_plugins(self, opentranscode_module, tmp_path, monkeypatch):
"""When multiple plugins are installed, the probe should find them all."""
vs_dir = tmp_path / ".local" / "lib" / "vapoursynth"
vs_dir.mkdir(parents=True)
(vs_dir / "libvslsmashsource.so").write_bytes(b"\x00" * 16)
(vs_dir / "libffms2.so").write_bytes(b"\x00" * 16)
(vs_dir / "libbestsource.so").write_bytes(b"\x00" * 16)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "share"))
result = opentranscode_module._probe_vs_source_plugins()
assert "lsmash" in result
assert "ffms2" in result
assert "bestsource" in result
assert len(result) == 3, f"Expected 3 plugins, got: {result}"
class TestCLIChunkMethodFlag:
"""v4.0.0: --chunk-method CLI flag is parsed and validated."""
def test_chunk_method_flag_accepted(self, opentranscode_module):
"""--chunk-method should be accepted by the CLI parser."""
parser = opentranscode_module.build_parser() if hasattr(opentranscode_module, "build_parser") else None
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
parser = build_parser()
# Valid values
for method in ("auto", "select", "hybrid", "segment",
"ffms2", "lsmash", "bestsource", "dgdecnv"):
args = parser.parse_args(["--chunk-method", method])
assert args.chunk_method == method, \
f"--chunk-method {method} should parse to {method}"
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
except ImportError:
pytest.skip("opentranscode.cli not importable (PySide6 missing)")
parser = build_parser()
with pytest.raises(SystemExit):
parser.parse_args(["--chunk-method", "invalid-method"])

View File

@ -0,0 +1,78 @@
"""
Concurrent-worker temp-dir isolation test.
QA finding: OTC-013 (per-worker temp dir isolation).
Each ``EncoderWorker`` is assigned its own per-PID subdirectory under the
shared app temp dir (``_worker_temp_dir(os.getpid())`` in open-transcode.py). This is
critical for concurrency: the final cleanup sweep (``_final_cleanup_sweep``)
deletes everything inside ``self._temp_dir`` and must NOT touch a sibling
worker's intermediates.
The 1 case:
- Two workers (with distinct PIDs) get distinct ``_temp_dir`` paths.
The test patches ``os.getpid`` to return distinct values for the two
``EncoderWorker()`` constructor calls (since both run in the same test
process and would otherwise share a PID), and redirects the shared app
temp dir to ``tmp_path`` so the real ``~/.cache/OpenTranscode/`` is not
touched.
"""
from __future__ import annotations
import pytest
def test_workers_get_distinct_temp_dirs(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""Two workers with different PIDs get different ``_temp_dir`` paths.
The directory naming convention is ``worker-{pid}`` under the shared
app temp dir. Distinct PIDs => distinct subdir names => no overlap,
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
# Two distinct fake PIDs for the two workers. (In production, workers
# run in separate OS processes via the distro's av1an binary, which
# itself spawns SvtAv1EncApp / vpxenc / x265 as subprocesses — each
# getting its own PID. Even within a single process, the per-PID
# subdir logic ensures concurrent workers don't collide on temp space.)
pids = iter([11111, 22222])
monkeypatch.setattr("os.getpid", lambda: next(pids))
# Build two real EncoderWorker instances via __init__. __init__ does
# NOT start the QThread (only .start() does), so this is safe in a
# headless test environment.
common_kwargs = dict(
in_dir=tmp_path / "in",
out_dir=tmp_path / "out",
video_codec=opentranscode_module.VIDEO_CODECS[0],
audio_profile=opentranscode_module.AUDIO_PROFILES[0],
container=opentranscode_module.CONTAINER_PROFILES[0],
crf=30,
preset_label="Medium (6)",
delete_source=False,
env=mock_env,
extensions={".mkv"},
resolution=opentranscode_module.RESOLUTION_PRESETS[0], # "Original" (no scaling)
)
worker1 = opentranscode_module.EncoderWorker(**common_kwargs)
worker2 = opentranscode_module.EncoderWorker(**common_kwargs)
# The critical assertion: distinct temp dirs.
assert worker1._temp_dir != worker2._temp_dir, (
f"Two concurrent workers got the same _temp_dir: {worker1._temp_dir}"
)
# Both should be subdirs of the shared app temp dir, with the per-PID
# naming convention.
assert worker1._temp_dir.parent == tmp_path
assert worker2._temp_dir.parent == tmp_path
assert worker1._temp_dir.name == "worker-11111"
assert worker2._temp_dir.name == "worker-22222"
# Both subdirs should actually exist on disk (the constructor creates
# them with mode=0o700 per SEI CERT FIO09-C).
assert worker1._temp_dir.exists()
assert worker2._temp_dir.exists()

223
tests/test_container_compat.py Executable file
View File

@ -0,0 +1,223 @@
"""
Container-compatibility rule-table tests for
``OpenCodecMaster._check_combo_compatibility``.
QA finding: OTC-012 (table-driven combo rule evaluation).
The rule table inside ``_check_combo_compatibility`` encodes 5 known
container/codec combinations and their severities:
1. x265 + WebM -> INCOMPATIBLE
2. IAMF audio + (MKV|WebM, i.e. non-MP4) -> INCOMPATIBLE
3. Vorbis + MP4 -> WARNING
4. FLAC + WebM -> WARNING
5. VP9 + MP4 -> WARNING
(IAMF + MP4 is the OK case for rule 2: not fired, no other rule fires,
empty warnings list.)
The rule table is defined as closures *inside* the method body, so we
can't test it as a free function. Instead we mock the ``OpenCodecMaster``
instance: build it via ``__new__`` (skip the heavy ``__init__`` that
constructs the whole GUI), set ``codec_combo`` / ``audio_combo`` /
``container_combo`` to ``MagicMock`` objects whose ``currentIndex()``
returns the index we want to test, and patch ``_log`` to capture warnings.
Then we call ``_check_combo_compatibility`` directly and assert on the
returned list.
The 6 cases (one per rule plus the IAMF+MP4 happy case) exercise every
predicate in the table.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Indices into the module-level VIDEO_CODECS / AUDIO_PROFILES /
# CONTAINER_PROFILES lists (defined in scripts/open-transcode.py).
# ─────────────────────────────────────────────────────────────────────────────
# VIDEO_CODECS[0] = "AV1 (SVT-AV1)" — ffmpeg_encoder="libsvtav1"
# VIDEO_CODECS[1] = "VP9" — ffmpeg_encoder="libvpx-vp9"
# VIDEO_CODECS[2] = "x265 (HEVC)" — ffmpeg_encoder="libx265"
#
# AUDIO_PROFILES[0] = "Opus (96k)" — ffmpeg_encoder_name="libopus"
# AUDIO_PROFILES[3] = "Vorbis (128k)" — ffmpeg_encoder_name="libvorbis"
# AUDIO_PROFILES[5] = "FLAC" — ffmpeg_encoder_name="flac"
# AUDIO_PROFILES[6] = "IAMF (128k)" — ffmpeg_encoder_name="libiamf"
#
# CONTAINER_PROFILES[0] = "MKV" — ext="mkv"
# CONTAINER_PROFILES[1] = "WebM" — ext="webm"
# CONTAINER_PROFILES[2] = "MP4" — ext="mp4"
def _make_master_for_compat(opentranscode_module, codec_idx, audio_idx, container_idx):
"""Build a minimal ``OpenCodecMaster`` for compatibility-rule testing.
Skips the real ``__init__`` (which constructs the whole QMainWindow UI
tree) and sets only the three combo-box attributes that
``_check_combo_compatibility`` reads. The ``_log`` method is patched to
capture warnings into ``master._logged`` so the test can also verify
what was logged (not just what was returned).
"""
master = opentranscode_module.OpenCodecMaster.__new__(opentranscode_module.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)
master._logged = logged
return master
# ─────────────────────────────────────────────────────────────────────────────
# Test cases — one per rule in the table, plus the IAMF+MP4 happy case.
# ─────────────────────────────────────────────────────────────────────────────
def test_hevc_in_webm_incompatible(opentranscode_module):
"""Rule 1: x265 + WebM -> INCOMPATIBLE.
HEVC (x265) cannot be muxed into WebM the WebM container only
supports VP8/VP9 video and Opus/Vorbis audio.
"""
master = _make_master_for_compat(
opentranscode_module,
codec_idx=2, # x265 (HEVC) — ffmpeg_encoder="libx265"
audio_idx=0, # Opus 96k (irrelevant for this rule)
container_idx=1, # WebM — ext="webm"
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("INCOMPATIBLE:") for w in warnings), (
f"Expected an INCOMPATIBLE warning for x265+WebM, got: {warnings}"
)
hevc_warning = next(w for w in warnings if w.startswith("INCOMPATIBLE:"))
assert "x265" in hevc_warning or "HEVC" in hevc_warning
assert "WebM" in hevc_warning
def test_iamf_in_mkv_incompatible(opentranscode_module):
"""Rule 2: IAMF audio + MKV -> INCOMPATIBLE.
IAMF (AOMedia Immersive Audio) requires the MP4 container MKV and
WebM cannot mux the IAMF codec.
"""
master = _make_master_for_compat(
opentranscode_module,
codec_idx=0, # AV1 (irrelevant for this rule)
audio_idx=6, # IAMF — ffmpeg_encoder_name="libiamf"
container_idx=0, # MKV — ext="mkv" (non-MP4)
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("INCOMPATIBLE:") for w in warnings), (
f"Expected an INCOMPATIBLE warning for IAMF+MKV, got: {warnings}"
)
iamf_warning = next(w for w in warnings if w.startswith("INCOMPATIBLE:"))
assert "IAMF" in iamf_warning
assert "MP4" in iamf_warning # message tells user to switch to MP4
def test_iamf_in_mp4_ok(opentranscode_module):
"""Rule 2 happy path: IAMF audio + MP4 -> no INCOMPATIBLE.
With AV1 video + IAMF audio + MP4 container, none of the 5 rules fire
(the only audio-triggered rule for MP4 is Vorbis-in-MP4; the only
video-triggered rule for MP4 is VP9-in-MP4; AV1+IAMF+MP4 hits neither).
The warnings list should be empty.
"""
master = _make_master_for_compat(
opentranscode_module,
codec_idx=0, # AV1 (not VP9, not x265)
audio_idx=6, # IAMF
container_idx=2, # MP4 (so _is_iamf_non_mp4 does not fire)
)
warnings = master._check_combo_compatibility()
assert warnings == [], (
f"Expected no warnings for AV1+IAMF+MP4, got: {warnings}"
)
def test_vorbis_in_mp4_warning(opentranscode_module):
"""Rule 3: Vorbis + MP4 -> WARNING.
Vorbis in MP4 has limited player support it works in some players
(e.g. VLC) but not in many hardware / mobile players. Opus or
MKV/WebM is the recommended alternative.
"""
master = _make_master_for_compat(
opentranscode_module,
codec_idx=0, # AV1 (not VP9, so VP9 rule doesn't fire too)
audio_idx=3, # Vorbis — ffmpeg_encoder_name="libvorbis"
container_idx=2, # MP4 — ext="mp4"
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("WARNING:") for w in warnings), (
f"Expected a WARNING for Vorbis+MP4, got: {warnings}"
)
assert not any(w.startswith("INCOMPATIBLE:") for w in warnings), (
f"Vorbis+MP4 is a soft warning, not a hard incompatibility: {warnings}"
)
vorbis_warning = next(w for w in warnings if w.startswith("WARNING:"))
assert "Vorbis" in vorbis_warning
def test_flac_in_webm_warning(opentranscode_module):
"""Rule 4: FLAC + WebM -> WARNING.
FLAC in WebM is rarely supported by players MKV is the recommended
container for FLAC audio.
"""
master = _make_master_for_compat(
opentranscode_module,
codec_idx=0, # AV1 (not x265, so HEVC rule doesn't fire too)
audio_idx=5, # FLAC — ffmpeg_encoder_name="flac"
container_idx=1, # WebM — ext="webm"
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("WARNING:") for w in warnings), (
f"Expected a WARNING for FLAC+WebM, got: {warnings}"
)
assert not any(w.startswith("INCOMPATIBLE:") for w in warnings), (
f"FLAC+WebM is a soft warning, not a hard incompatibility: {warnings}"
)
flac_warning = next(w for w in warnings if w.startswith("WARNING:"))
assert "FLAC" in flac_warning
def test_vp9_in_mp4_warning(opentranscode_module):
"""Rule 5: VP9 + MP4 -> WARNING.
VP9 in MP4 has uneven player support WebM is the canonical VP9
container.
"""
master = _make_master_for_compat(
opentranscode_module,
codec_idx=1, # VP9 — ffmpeg_encoder="libvpx-vp9"
audio_idx=0, # Opus (not Vorbis, so Vorbis rule doesn't fire too)
container_idx=2, # MP4 — ext="mp4"
)
warnings = master._check_combo_compatibility()
assert any(w.startswith("WARNING:") for w in warnings), (
f"Expected a WARNING for VP9+MP4, got: {warnings}"
)
assert not any(w.startswith("INCOMPATIBLE:") for w in warnings), (
f"VP9+MP4 is a soft warning, not a hard incompatibility: {warnings}"
)
vp9_warning = next(w for w in warnings if w.startswith("WARNING:"))
assert "VP9" in vp9_warning

808
tests/test_e2e_real_encode.py Executable file
View File

@ -0,0 +1,808 @@
"""
End-to-end integration test REAL ffmpeg encode pipeline.
This is the critical stability gate for v4. Unlike the mocked tests in
test_smoke_test.py etc., this test:
1. Generates a real 2-second test video using ffmpeg
2. Runs the actual EncoderWorker on it (ffmpeg fallback path, since
av1an is not installed in CI)
3. Verifies the output file EXISTS, is non-empty, has the correct
container, has a valid video stream, and has the expected duration
If this test passes, the "actually producing files" requirement is met.
Skip conditions:
- Skips if ffmpeg is not in PATH (CI without media tools)
- Skips if ffprobe is not in PATH (needed for verification)
- The av1an path is tested separately if av1an is available; otherwise
only the ffmpeg fallback path is exercised.
Covers QA findings: OTC-001 (regression), OTC-003 (movflags fix), and
the overall "chunks but never saves a file" defect class.
"""
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Module loading — the open-transcode.py file has a dash in its name, can't use import
# ─────────────────────────────────────────────────────────────────────────────
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}")
spec = importlib.util.spec_from_file_location("open_transcode", str(OPENTRANSCODE_PATH))
mod = importlib.util.module_from_spec(spec)
# Stub PySide6 so the import doesn't fail in headless CI
_install_pyside6_stubs()
spec.loader.exec_module(mod)
return mod
def _install_pyside6_stubs():
"""Install minimal PySide6 stubs if PySide6 isn't installed."""
if any(name in sys.modules for name in
("PySide6", "PySide6.QtWidgets", "PySide6.QtCore", "PySide6.QtGui")):
return
import types
from unittest.mock import MagicMock
pyside6 = types.ModuleType("PySide6")
qt_widgets = types.ModuleType("PySide6.QtWidgets")
qt_core = types.ModuleType("PySide6.QtCore")
qt_gui = types.ModuleType("PySide6.QtGui")
# QThread needs to be a real class so EncoderWorker can inherit from it
class _QThread:
def __init__(self, *args, **kwargs):
pass
def start(self):
pass
def isRunning(self):
return False
def wait(self, ms=None):
pass
class _Signal:
def __init__(self, *args, **kwargs):
pass
def connect(self, *args, **kwargs):
pass
def emit(self, *args, **kwargs):
pass
def _Slot(*args, **kwargs):
def decorator(fn):
return fn
return decorator
qt_core.QThread = _QThread
qt_core.Signal = _Signal
qt_core.Slot = _Slot
qt_core.Qt = MagicMock()
qt_core.QPointF = MagicMock()
qt_core.QRectF = MagicMock()
qt_core.QTimer = MagicMock()
# QtWidgets — most are MagicMock, but QMainWindow/QWidget need to be
# real base classes so OpenCodecMaster can inherit (we don't actually
# instantiate it in the e2e test, but the module-level class def must succeed)
class _QWidget:
def __init__(self, *args, **kwargs):
pass
class _QMainWindow(_QWidget):
pass
qt_widgets.QWidget = _QWidget
qt_widgets.QMainWindow = _QMainWindow
for name in ("QApplication", "QVBoxLayout", "QHBoxLayout", "QLabel",
"QLineEdit", "QPushButton", "QComboBox", "QCheckBox",
"QTextEdit", "QFileDialog", "QGroupBox", "QStatusBar",
"QMessageBox", "QStyleFactory"):
setattr(qt_widgets, name, MagicMock())
qt_gui.QFont = MagicMock()
qt_gui.QPalette = MagicMock()
qt_gui.QColor = MagicMock()
qt_gui.QPainter = MagicMock()
qt_gui.QPen = MagicMock()
qt_gui.QBrush = MagicMock()
qt_gui.QRadialGradient = MagicMock()
qt_gui.QFontMetrics = MagicMock()
pyside6.QtWidgets = qt_widgets
pyside6.QtCore = qt_core
pyside6.QtGui = qt_gui
sys.modules["PySide6"] = pyside6
sys.modules["PySide6.QtWidgets"] = qt_widgets
sys.modules["PySide6.QtCore"] = qt_core
sys.modules["PySide6.QtGui"] = qt_gui
@pytest.fixture(scope="module")
def opentranscode_module():
"""Load the open-transcode module once per module run."""
return _load_opentranscode_module()
@pytest.fixture
def real_ffmpeg():
"""Skip test if ffmpeg is not installed."""
if not shutil.which("ffmpeg"):
pytest.skip("ffmpeg not in PATH — skipping real-encode e2e test")
return shutil.which("ffmpeg")
@pytest.fixture
def real_ffprobe():
"""Skip test if ffprobe is not installed."""
if not shutil.which("ffprobe"):
pytest.skip("ffprobe not in PATH — cannot verify output")
return shutil.which("ffprobe")
@pytest.fixture
def test_video(tmp_path, real_ffmpeg):
"""Generate a 2-second 320x240 test video with audio."""
video_path = tmp_path / "test_input.mp4"
cmd = [
real_ffmpeg,
"-f", "lavfi",
"-i", "testsrc=duration=2:size=320x240:rate=24",
"-f", "lavfi",
"-i", "sine=frequency=440:duration=2",
"-c:v", "libx264",
"-preset", "ultrafast",
"-c:a", "aac",
"-b:a", "64k",
"-y",
str(video_path),
]
res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if res.returncode != 0 or not video_path.exists():
pytest.skip(f"Could not generate test video: {res.stderr[-200:]}")
return video_path
@pytest.fixture
def mock_env(opentranscode_module, real_ffmpeg, real_ffprobe, tmp_path):
"""Build a minimal EnvProbe with real ffmpeg/ffprobe paths."""
return opentranscode_module.EnvProbe(
distro=opentranscode_module.detect_distro(),
av1an_path=shutil.which("av1an"), # None if not installed
ffmpeg_path=real_ffmpeg,
ffprobe_path=real_ffprobe,
av1an_flags={"concat_method": "ffmpeg"},
ffmpeg_version="test",
ffmpeg_libs={
"libsvtav1": True,
"libvpx": True,
"libx265": True,
"libopus": True,
"libvorbis": True,
"flac": True,
},
cpu=opentranscode_module.CpuTopology(
physical_cores=max(1, (os.cpu_count() or 2) - 1),
logical_threads=os.cpu_count() or 2,
threads_per_core=2,
model_name="Test CPU",
),
)
# ─────────────────────────────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────────────────────────────
class TestRealEncodePipeline:
"""End-to-end tests that actually encode video and verify the output."""
def test_ffmpeg_fallback_produces_av1_mkv(
self, opentranscode_module, mock_env, test_video, tmp_path, real_ffprobe
):
"""CRITICAL: ffmpeg fallback path must produce a real AV1/MKV file.
This is the test that would have caught OTC-001 ('chunks but never
saves a file') if it had existed in v1. We generate a real 2-second
video, run the ffmpeg fallback encoder on it (AV1 MKV), and verify:
1. The output file exists
2. The output file is non-empty (>1KB)
3. The output file has a valid video stream (ffprobe can read it)
4. The video codec is AV1
5. The duration is >= 95% of source (1.9s for a 2s source)
"""
# Build an EncoderWorker in ffmpeg-fallback mode
in_dir = test_video.parent
out_dir = tmp_path / "output"
out_dir.mkdir()
# Pick the AV1 codec profile
av1_codec = next(
(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)"),
None,
)
assert av1_codec is not None, "AV1 codec profile not found"
opus_audio = next(
(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)"),
None,
)
assert opus_audio is not None
mkv_container = next(
(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv"),
None,
)
assert mkv_container is not None
original_resolution = next(
(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original"),
None,
)
assert original_resolution is not None
worker = opentranscode_module.EncoderWorker(
in_dir=in_dir,
out_dir=out_dir,
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=mock_env,
extensions={".mp4"},
resolution=original_resolution,
audio_level_db=0.0,
use_ffmpeg_fallback=True, # critical — bypass av1an
subtitle_lang=None,
)
# Collect log messages
logs: list[str] = []
worker.log_msg.connect = lambda fn: setattr(worker, "_log_fn", fn)
# Patch the log_msg signal emit to capture messages
original_emit = worker.log_msg.emit
worker.log_msg.emit = lambda msg: logs.append(msg)
# Run the worker synchronously (bypass QThread.start)
worker.run()
# Find the output file
output_files = list(out_dir.rglob("*_archived.mkv"))
assert len(output_files) == 1, f"Expected 1 output, got {len(output_files)}. Logs:\n" + "\n".join(logs)
output_f = output_files[0]
# 1. File exists
assert output_f.exists(), f"Output file does not exist: {output_f}"
# 2. File is non-empty (>1KB — a 2s AV1 video should be at least a few KB)
size = output_f.stat().st_size
assert size > 1024, f"Output file too small: {size} bytes. Logs:\n" + "\n".join(logs[-10:])
# 3. ffprobe can read it
probe_cmd = [
real_ffprobe, "-v", "quiet", "-print_format", "json",
"-show_streams", "-show_format", str(output_f),
]
probe_res = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
assert probe_res.returncode == 0, f"ffprobe failed: {probe_res.stderr}"
import json
probe_data = json.loads(probe_res.stdout)
# 4. Video stream is AV1
video_streams = [s for s in probe_data.get("streams", [])
if s.get("codec_type") == "video"]
assert len(video_streams) == 1, f"Expected 1 video stream, got {len(video_streams)}"
assert video_streams[0].get("codec_name") == "av1", \
f"Expected AV1 codec, got {video_streams[0].get('codec_name')}"
# 5. Duration is >= 95% of source (1.9s for 2s source)
source_dur = float(subprocess.run(
[real_ffprobe, "-v", "quiet", "-print_format", "json",
"-show_format", str(test_video)],
capture_output=True, text=True, timeout=10,
).stdout and subprocess.run(
[real_ffprobe, "-v", "quiet", "-print_format", "json",
"-show_format", str(test_video)],
capture_output=True, text=True, timeout=10,
).stdout and "0") or "0"
# Simpler: just probe both
src_probe = subprocess.run(
[real_ffprobe, "-v", "quiet", "-print_format", "json",
"-show_format", str(test_video)],
capture_output=True, text=True, timeout=10,
)
src_data = json.loads(src_probe.stdout)
src_dur = float(src_data.get("format", {}).get("duration", 0))
out_dur = float(probe_data.get("format", {}).get("duration", 0))
assert out_dur >= src_dur * 0.95, \
f"Duration check failed: source={src_dur}s, output={out_dur}s (need >= {src_dur * 0.95:.2f}s)"
# 6. Success count incremented
assert worker.success_count == 1, \
f"Expected success_count=1, got {worker.success_count}. Logs:\n" + "\n".join(logs[-15:])
assert worker.fail_count == 0, \
f"Expected fail_count=0, got {worker.fail_count}. Logs:\n" + "\n".join(logs[-15:])
def test_ffmpeg_fallback_produces_x265_mkv(
self, opentranscode_module, mock_env, test_video, tmp_path, real_ffprobe
):
"""Same as above but with x265 (HEVC) to verify codec flexibility."""
in_dir = test_video.parent
out_dir = tmp_path / "output_x265"
out_dir.mkdir()
x265_codec = next(
(c for c in opentranscode_module.VIDEO_CODECS if c.label == "x265 (HEVC)"),
None,
)
assert x265_codec is not None
opus_audio = next(
(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)"),
None,
)
mkv_container = next(
(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv"),
None,
)
original_resolution = next(
(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original"),
None,
)
worker = opentranscode_module.EncoderWorker(
in_dir=in_dir,
out_dir=out_dir,
video_codec=x265_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=28,
preset_label="Fast (9)",
delete_source=False,
env=mock_env,
extensions={".mp4"},
resolution=original_resolution,
audio_level_db=0.0,
use_ffmpeg_fallback=True,
subtitle_lang=None,
)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker.run()
output_files = list(out_dir.rglob("*_archived.mkv"))
assert len(output_files) == 1, \
f"Expected 1 output, got {len(output_files)}. Logs:\n" + "\n".join(logs)
output_f = output_files[0]
assert output_f.exists()
assert output_f.stat().st_size > 1024
# Verify codec is hevc
probe_res = subprocess.run(
[real_ffprobe, "-v", "quiet", "-print_format", "json",
"-show_streams", str(output_f)],
capture_output=True, text=True, timeout=10,
)
import json
data = json.loads(probe_res.stdout)
video_streams = [s for s in data.get("streams", [])
if s.get("codec_type") == "video"]
assert len(video_streams) == 1
assert video_streams[0].get("codec_name") == "hevc"
assert worker.success_count == 1
assert worker.fail_count == 0
def test_ffmpeg_fallback_produces_vp9_webm(
self, opentranscode_module, mock_env, test_video, tmp_path, real_ffprobe
):
"""VP9 → WebM — verify container flexibility."""
in_dir = test_video.parent
out_dir = tmp_path / "output_vp9"
out_dir.mkdir()
vp9_codec = next(
(c for c in opentranscode_module.VIDEO_CODECS if c.label == "VP9"),
None,
)
assert vp9_codec is not None
opus_audio = next(
(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)"),
None,
)
webm_container = next(
(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "webm"),
None,
)
original_resolution = next(
(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original"),
None,
)
worker = opentranscode_module.EncoderWorker(
in_dir=in_dir,
out_dir=out_dir,
video_codec=vp9_codec,
audio_profile=opus_audio,
container=webm_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=mock_env,
extensions={".mp4"},
resolution=original_resolution,
audio_level_db=0.0,
use_ffmpeg_fallback=True,
subtitle_lang=None,
)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker.run()
output_files = list(out_dir.rglob("*_archived.webm"))
assert len(output_files) == 1, \
f"Expected 1 output, got {len(output_files)}. Logs:\n" + "\n".join(logs)
output_f = output_files[0]
assert output_f.exists()
assert output_f.stat().st_size > 1024
# Verify codec is vp9
probe_res = subprocess.run(
[real_ffprobe, "-v", "quiet", "-print_format", "json",
"-show_streams", str(output_f)],
capture_output=True, text=True, timeout=10,
)
import json
data = json.loads(probe_res.stdout)
video_streams = [s for s in data.get("streams", [])
if s.get("codec_type") == "video"]
assert len(video_streams) == 1
assert video_streams[0].get("codec_name") == "vp9"
assert worker.success_count == 1
assert worker.fail_count == 0
class TestMovflagsFix:
"""Verify the v2 movflags fix (OTC-003): -movflags +faststart only for MP4."""
def test_movflags_present_for_mp4(
self, opentranscode_module, mock_env, test_video, tmp_path, real_ffprobe
):
"""MP4 output should include -movflags +faststart in the ffmpeg command."""
# We can verify this by checking the log output of an MP4 encode
# VP9 in MP4 is technically warning-level but not blocked, so we
# use AV1 in MP4 — but AV1 in MP4 needs the AV1 codec, which
# ffmpeg's libsvtav1 supports.
in_dir = test_video.parent
out_dir = tmp_path / "output_mp4"
out_dir.mkdir()
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mp4_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mp4")
original_resolution = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
worker = opentranscode_module.EncoderWorker(
in_dir=in_dir,
out_dir=out_dir,
video_codec=av1_codec,
audio_profile=opus_audio,
container=mp4_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=mock_env,
extensions={".mp4"},
resolution=original_resolution,
audio_level_db=0.0,
use_ffmpeg_fallback=True,
subtitle_lang=None,
)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker.run()
# Find the CMD log line — it should contain -movflags +faststart for MP4
cmd_lines = [l for l in logs if "ffmpeg" in l.lower() and "-movflags" in l]
# Note: the worker doesn't log the full CMD line for ffmpeg fallback
# (only for av1an), so we verify indirectly: the output file exists
# and has the faststart-optimized moov atom placement.
output_files = list(out_dir.rglob("*_archived.mp4"))
assert len(output_files) == 1
assert output_files[0].stat().st_size > 1024
assert worker.success_count == 1
def test_movflags_absent_for_mkv(
self, opentranscode_module, mock_env, test_video, tmp_path, real_ffprobe
):
"""MKV output should NOT include -movflags (it's MP4-only)."""
# We verify by checking that the MKV encode succeeds (if -movflags
# was passed, ffmpeg would emit a warning but still succeed; the
# important thing is that the encode works for both containers).
in_dir = test_video.parent
out_dir = tmp_path / "output_mkv"
out_dir.mkdir()
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_resolution = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
worker = opentranscode_module.EncoderWorker(
in_dir=in_dir,
out_dir=out_dir,
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=mock_env,
extensions={".mp4"},
resolution=original_resolution,
audio_level_db=0.0,
use_ffmpeg_fallback=True,
subtitle_lang=None,
)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker.run()
output_files = list(out_dir.rglob("*_archived.mkv"))
assert len(output_files) == 1
assert worker.success_count == 1
class TestRealSmokeTest:
"""Test the _av1an_vsscript_smoke_test function with real ffmpeg.
Even without av1an, we can verify that the smoke test correctly
detects the av1an-missing case and returns False (the fix).
"""
def test_smoke_returns_false_when_av1an_missing(
self, opentranscode_module, real_ffmpeg, tmp_path
):
"""If av1an is not installed, smoke test must return False.
This is the fix for OTC-001. v1 (pre-fix) would have returned True here
(masking the failure), causing every subsequent file to fail.
"""
if shutil.which("av1an"):
pytest.skip("av1an is installed — this test only runs when av1an is MISSING")
# Use a fake av1an path — the function will try to run it and fail
fake_av1an = "/usr/local/bin/av1an_does_not_exist"
result = opentranscode_module._av1an_vsscript_smoke_test(
av1an_bin=fake_av1an,
ffmpeg_bin=real_ffmpeg,
av1an_flags={"worker": "--workers", "video_params": "--video-params",
"audio_params": "--audio-params"},
svt_name="svt_av1",
timeout=10,
)
ok, detail = result
# The smoke test should return False because av1an doesn't exist
assert ok is False, \
f"Smoke test should return False when av1an is missing, got True. Detail: {detail}"
# And the detail should mention the failure
assert any(marker in detail for marker in
("SMOKE_BIN_MISSING", "SMOKE_FAIL", "SMOKE_OS_ERROR",
"No such file", "not found")), \
f"Detail should mention the missing binary, got: {detail}"
class TestEnvironmentProbe:
"""Test probe_environment() against the real system."""
def test_probe_finds_real_ffmpeg(self, opentranscode_module, real_ffmpeg):
"""probe_environment() must find the real ffmpeg on this system."""
env = opentranscode_module.probe_environment()
assert env.ffmpeg_path is not None, "ffmpeg_path should be set"
assert "ffmpeg" in env.ffmpeg_path
# ffmpeg_version should be populated
assert env.ffmpeg_version is not None
assert len(env.ffmpeg_version) > 0
def test_probe_finds_real_ffprobe(self, opentranscode_module, real_ffprobe):
"""probe_environment() must find the real ffprobe on this system."""
env = opentranscode_module.probe_environment()
assert env.ffprobe_path is not None
assert "ffprobe" in env.ffprobe_path
def test_probe_detects_ffmpeg_libs(self, opentranscode_module, real_ffmpeg):
"""probe_environment() must detect the codecs ffmpeg was built with."""
env = opentranscode_module.probe_environment()
# This system has libsvtav1, libx265, libvpx, libopus (verified above)
assert env.ffmpeg_libs.get("libsvtav1", False), "libsvtav1 should be detected"
assert env.ffmpeg_libs.get("libx265", False), "libx265 should be detected"
assert env.ffmpeg_libs.get("libvpx", False), "libvpx should be detected"
assert env.ffmpeg_libs.get("libopus", False), "libopus should be detected"
def test_probe_distro_detection(self, opentranscode_module):
"""probe_environment() must detect a distro family."""
env = opentranscode_module.probe_environment()
# We're on Debian 14 (per the ffmpeg version string)
assert env.distro.family in ("debian", "arch", "redhat", "suse", "nixos", "unknown")
assert env.distro.name # not empty
class TestV7Y4mBreakRecovery:
"""v4.0.0: Real-ffmpeg e2e test for the chunk-method retry path.
This test simulates the production bug: av1an's Hybrid chunk method
fails on a phone-recorded MP4 with "Failed to read y4m frame delimiter",
and the v4.0.0 fix retries with --chunk-method select. We mock av1an
(since it's not installed in CI) but use real ffmpeg to generate the
test video and verify the output file is valid.
The test verifies the END-TO-END recovery path:
1. av1an "fails" with the y4m break pattern (mocked)
2. _encode_one detects the pattern and retries with select
3. The retry "succeeds" (mocked, but produces a real output file
via ffmpeg so _verify_and_finalize can validate it)
4. The output file passes ffprobe validation
5. success_count is incremented
"""
def test_y4m_break_recovery_produces_valid_output(
self, opentranscode_module, mock_env, test_video, tmp_path, real_ffprobe
):
"""When av1an fails with y4m break, the select-method retry should
produce a valid output file that passes ffprobe validation."""
# Configure env to use av1an (not ffmpeg fallback) with NO
# chunk_method_override — simulates the production scenario
mock_env.av1an_path = "/usr/bin/av1an"
mock_env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
"has_chunk_method": True,
# chunk_method_override intentionally absent — simulates
# the production bug where env_probe didn't pre-set it
}
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
worker = opentranscode_module.EncoderWorker(
in_dir=test_video.parent,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=mock_env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False, # av1an mode — will retry with select
)
# Realistic y4m break stderr (extracted from the production log)
y4m_stderr = (
"INFO encode_file: Input: 1920x1080 @ 29.763 fps\n"
"DEBUG encode_file: Segmenting video\n"
"WARN encode_chunk: Encoder failed (on chunk 11):\n"
" Encoding Failed to read y4m frame delimiter. Read broken. EOF: 1\n"
" [h264 @ 0x55da365b30c0] error while decoding MB 35 25\n"
" SUMMARY -----------------------------------------------------------------\n"
" Average Speed:\t\t2.501 fps\n"
"ERROR av1an_core::broker: encoder failed 3 times, shutting down worker\n"
)
call_count = [0]
def mock_run(cmd, **kw):
call_count[0] += 1
chunk_m = None
if "--chunk-method" in cmd:
idx = cmd.index("--chunk-method")
chunk_m = cmd[idx + 1]
if chunk_m is None:
# First call (auto/Hybrid) — fail with y4m break
return ("ok", 1, "", y4m_stderr)
elif chunk_m == "select":
# Retry with select — succeed by running REAL ffmpeg to
# produce a valid output file that _verify_and_finalize
# can validate with ffprobe.
out_idx = cmd.index("-o")
out_path = Path(cmd[out_idx + 1])
out_path.parent.mkdir(parents=True, exist_ok=True)
# Use real ffmpeg to encode the test video to AV1/MKV
real_ffmpeg = mock_env.ffmpeg_path
encode_cmd = [
real_ffmpeg, "-i", str(test_video),
"-c:v", "libsvtav1", "-preset", "8", "-crf", "32",
"-c:a", "libopus", "-b:a", "96k",
"-y", str(out_path),
]
subprocess.run(encode_cmd, capture_output=True, timeout=30)
return ("ok", 0, "", "encoding finished")
else:
return ("ok", 1, "", "unexpected chunk method")
worker._run_with_stop_check = mock_run
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs: list[str] = []
worker.log_msg.emit = lambda msg: logs.append(msg)
# Run the full pipeline (not just _encode_one)
worker.run()
# Should have called av1an exactly twice (Hybrid fail + select success)
assert call_count[0] == 2, \
f"Expected 2 av1an calls, got {call_count[0]}. Logs:\n" + "\n".join(logs)
# Should have logged the RETRY message
assert any("RETRY" in l and "select" in l for l in logs), \
f"Expected select RETRY in logs:\n" + "\n".join(logs)
# Should have succeeded
assert worker.success_count == 1, \
f"Expected success_count=1, got {worker.success_count}. Logs:\n" + "\n".join(logs[-15:])
assert worker.fail_count == 0, \
f"Expected fail_count=0, got {worker.fail_count}. Logs:\n" + "\n".join(logs[-15:])
# The output file should exist and be valid
output_files = list((tmp_path / "output").rglob("*_archived.mkv"))
assert len(output_files) == 1, \
f"Expected 1 output file, got {len(output_files)}. Logs:\n" + "\n".join(logs)
output_f = output_files[0]
assert output_f.exists()
assert output_f.stat().st_size > 1024, \
f"Output file too small: {output_f.stat().st_size} bytes"
# ffprobe should be able to read it
probe_cmd = [
real_ffprobe, "-v", "quiet", "-print_format", "json",
"-show_streams", str(output_f),
]
probe_res = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=10)
assert probe_res.returncode == 0, f"ffprobe failed: {probe_res.stderr}"
import json
data = json.loads(probe_res.stdout)
video_streams = [s for s in data.get("streams", [])
if s.get("codec_type") == "video"]
assert len(video_streams) == 1
assert video_streams[0].get("codec_name") == "av1", \
f"Expected av1 codec, got {video_streams[0].get('codec_name')}"
# v4.0.0: the working chunk_method should be cached for subsequent files
assert mock_env.av1an_flags.get("chunk_method_override") == "select", \
"chunk_method_override should be cached as 'select' after successful retry"

110
tests/test_encode_pipeline.py Executable file
View File

@ -0,0 +1,110 @@
"""
Encoder pipeline tests for ``EncoderWorker._validate_file``.
QA finding: OTC-002 (pre-encode validation coverage).
``_validate_file`` runs ffprobe on a candidate input and returns a 4-tuple
``(skip, info, src_w, src_h)``. It must SKIP files that have no video stream
or are too short (<0.5s) otherwise the av1an/ffmpeg encode would crash
mid-pipeline or hang on a degenerate input.
The 3 cases here cover:
- No video stream (audio-only file mistakenly placed in input dir).
- Sub-0.5s duration (truncated / corrupted capture).
- Valid 1080p video should pass through with src_w/src_h extracted.
All 3 mock ``subprocess.run`` so the tests run without a real ffprobe
binary. The worker is instantiated via ``__new__`` (no QThread.start).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
from conftest import make_minimal_worker
def _ffprobe_completed_process(payload: dict) -> subprocess.CompletedProcess:
"""Wrap a dict as a ffprobe-style CompletedProcess (stdout=JSON)."""
return subprocess.CompletedProcess(
args=["ffprobe"], returncode=0,
stdout=json.dumps(payload), stderr="",
)
def test_validate_file_skips_no_video_stream(opentranscode_module, mock_env, monkeypatch):
"""ffprobe returns JSON with no video stream -> skip=True."""
ffprobe_json = {
"streams": [
{"index": 0, "codec_type": "audio", "codec_name": "aac"},
],
"format": {"duration": "10.0", "name": "mov,mp4,m4a,3gp,3g2,mj2"},
}
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_completed_process(ffprobe_json)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env)
skip, info, src_w, src_h = worker._validate_file(Path("/fake/audio-only.mkv"))
assert skip is True
assert info is None
assert src_w is None
assert src_h is None
# The skip path increments fail_count so the queue summary is accurate.
assert worker.fail_count == 1
def test_validate_file_skips_short_duration(opentranscode_module, mock_env, monkeypatch):
"""ffprobe returns duration=0.3 (<0.5s threshold) -> skip=True."""
ffprobe_json = {
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "h264",
"width": 1920, "height": 1080},
],
"format": {"duration": "0.3"},
}
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_completed_process(ffprobe_json)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env)
skip, info, src_w, src_h = worker._validate_file(Path("/fake/short.mkv"))
assert skip is True
assert info is None
assert src_w is None
assert src_h is None
assert worker.fail_count == 1
def test_validate_file_accepts_valid_video(opentranscode_module, mock_env, monkeypatch):
"""ffprobe returns a valid video stream + duration=10.0
-> skip=False, src_w=1920, src_h=1080.
"""
ffprobe_json = {
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "h264",
"width": 1920, "height": 1080},
{"index": 1, "codec_type": "audio", "codec_name": "aac"},
],
"format": {"duration": "10.0"},
}
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_completed_process(ffprobe_json)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env)
skip, info, src_w, src_h = worker._validate_file(Path("/fake/valid.mkv"))
assert skip is False
assert info is not None
assert src_w == 1920
assert src_h == 1080
assert worker.fail_count == 0

372
tests/test_ffmpeg_fallback.py Executable file
View File

@ -0,0 +1,372 @@
"""
v6 behavior tests per-file av1anffmpeg fallback.
Verifies that when av1an fails for a specific file (concat failure,
scene-detection panic, or other per-file issue), the encoder automatically
retries with the ffmpeg fallback path.
"""
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
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}")
_install_pyside6_stubs()
spec = importlib.util.spec_from_file_location("open_transcode", str(OPENTRANSCODE_PATH))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _install_pyside6_stubs():
if any(name in sys.modules for name in
("PySide6", "PySide6.QtWidgets", "PySide6.QtCore", "PySide6.QtGui")):
return
import types
pyside6 = types.ModuleType("PySide6")
qt_widgets = types.ModuleType("PySide6.QtWidgets")
qt_core = types.ModuleType("PySide6.QtCore")
qt_gui = types.ModuleType("PySide6.QtGui")
class _QThread:
def __init__(self, *a, **kw): pass
def start(self): pass
def isRunning(self): return False
def wait(self, ms=None): pass
class _Signal:
def __init__(self, *a, **kw): pass
def connect(self, *a, **kw): pass
def emit(self, *a, **kw): pass
def _Slot(*a, **kw):
def deco(fn): return fn
return deco
qt_core.QThread = _QThread
qt_core.Signal = _Signal
qt_core.Slot = _Slot
qt_core.Qt = MagicMock()
qt_core.QPointF = MagicMock()
qt_core.QRectF = MagicMock()
qt_core.QTimer = MagicMock()
class _QWidget:
def __init__(self, *a, **kw): pass
class _QMainWindow(_QWidget): pass
qt_widgets.QWidget = _QWidget
qt_widgets.QMainWindow = _QMainWindow
for name in ("QApplication", "QVBoxLayout", "QHBoxLayout", "QLabel",
"QLineEdit", "QPushButton", "QComboBox", "QCheckBox",
"QTextEdit", "QFileDialog", "QGroupBox", "QStatusBar",
"QMessageBox", "QStyleFactory"):
setattr(qt_widgets, name, MagicMock())
for n in ("QFont", "QPalette", "QColor", "QPainter", "QPen", "QBrush",
"QRadialGradient", "QFontMetrics"):
setattr(qt_gui, n, MagicMock())
pyside6.QtWidgets = qt_widgets
pyside6.QtCore = qt_core
pyside6.QtGui = qt_gui
sys.modules["PySide6"] = pyside6
sys.modules["PySide6.QtWidgets"] = qt_widgets
sys.modules["PySide6.QtCore"] = qt_core
sys.modules["PySide6.QtGui"] = qt_gui
@pytest.fixture(scope="module")
def opentranscode_module():
return _load_opentranscode_module()
class TestCanFfmpegFallback:
"""v6-01: _can_ffmpeg_fallback checks if ffmpeg has the encoder."""
def test_returns_true_when_ffmpeg_has_encoder(self, opentranscode_module):
"""When ffmpeg_libs has the encoder, _can_ffmpeg_fallback returns True."""
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker.video_codec = MagicMock()
worker.video_codec.ffmpeg_encoder = "libsvtav1"
worker.env = MagicMock()
worker.env.ffmpeg_libs = {"libsvtav1": True, "libx265": True}
assert worker._can_ffmpeg_fallback() is True
def test_returns_false_when_ffmpeg_lacks_encoder(self, opentranscode_module):
"""When ffmpeg_libs does NOT have the encoder, returns False."""
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker.video_codec = MagicMock()
worker.video_codec.ffmpeg_encoder = "libsvtav1"
worker.env = MagicMock()
worker.env.ffmpeg_libs = {"libsvtav1": False, "libx265": True}
assert worker._can_ffmpeg_fallback() is False
class TestErrorPatternsIncludeSplitScores:
"""v6-02: 'split scores is not empty' panic is in the error_patterns table."""
def test_split_scores_pattern_in_source(self, opentranscode_module):
"""The error_patterns table should include the 'split scores' pattern."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert "split scores is not empty" in src, \
"error_patterns table should include 'split scores is not empty'"
def test_summary_detection_in_source(self, opentranscode_module):
"""v6-03: SUMMARY + Average Speed detection for concat failures."""
src = Path(OPENTRANSCODE_PATH).read_text()
assert "SUMMARY" in src and "Average Speed" in src, \
"Should detect encoder SUMMARY block for concat failure diagnosis"
class TestPerFileFallbackRetry:
"""v6-01: When av1an fails, retry with ffmpeg fallback."""
def test_av1an_failure_triggers_ffmpeg_retry(self, opentranscode_module, tmp_path):
"""When av1an fails (non-systematic) and ffmpeg has the encoder,
the code should retry with _ffmpeg_fallback_encode."""
# Create a real test video
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
test_video = tmp_path / "input.mp4"
subprocess.run(
[ffmpeg_bin, "-f", "lavfi", "-i", "testsrc=duration=1:size=64x64:rate=24",
"-c:v", "libx264", "-y", str(test_video)],
capture_output=True, timeout=15,
)
if not test_video.exists():
pytest.skip("Could not generate test video")
# Build a worker in av1an mode (not ffmpeg fallback)
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
# Set fake av1an path + flags so the command builder doesn't crash
env.av1an_path = "/usr/bin/av1an"
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False, # av1an mode — will fail and retry
)
# Mock _run_with_stop_check to simulate av1an failure
# (return a SUMMARY block in stderr + non-zero exit code = concat failure)
def mock_run(cmd, **kw):
# Simulate av1an encoding all frames then failing at concat
fake_stderr = (
"Encoding: 24/24 Frames @ 3.52 fps\n"
"SUMMARY -----------------------------------------\n"
"Total Frames\t\tFrame Rate\t\tByte Count\n"
" 24\t\t23.98 fps\t\t 12345\n\n"
"Average Speed:\t\t3.598 fps\n"
"SvtMalloc[info]: you have no memory leak\n\n"
"source pipe stderr:\n\n"
"ffmpeg pipe stderr:\n\n"
)
return ("ok", 1, "", fake_stderr)
worker._run_with_stop_check = mock_run
# Mock _ffmpeg_fallback_encode to simulate success
def mock_ffmpeg_fallback(file_path, encode_input, output_f):
# Create the output file so the verify step passes
output_f.parent.mkdir(parents=True, exist_ok=True)
output_f.write_bytes(b"\x00" * 1024) # 1KB fake output
return True
worker._ffmpeg_fallback_encode = mock_ffmpeg_fallback
logs = []
worker.log_msg.emit = lambda msg: logs.append(msg)
# Set up _current_temps and _file_res_map (needed by _process_one_file)
worker._current_temps = []
worker._file_res_map = {}
worker._stop = False
worker._consecutive_fail_count = 0
worker._last_fail_pattern = None
# v4.4.2: enable verbose so _vlog messages (RETRY, RETRY OK) appear
# in the logs list this test asserts against.
worker.verbose = True
# Call _encode_one directly
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should have retried with ffmpeg and succeeded
assert result is True, f"Expected retry to succeed. Logs: {logs}"
# Should have logged the RETRY message
assert any("RETRY" in l and "ffmpeg fallback" in l for l in logs), \
f"Expected RETRY message in logs: {logs}"
# Should have logged RETRY OK
assert any("RETRY OK" in l for l in logs), \
f"Expected RETRY OK in logs: {logs}"
# fail_count should NOT be incremented (retry succeeded)
assert worker.fail_count == 0, \
f"fail_count should be 0 after successful retry, got {worker.fail_count}"
def test_av1an_failure_no_retry_when_ffmpeg_lacks_encoder(self, opentranscode_module, tmp_path):
"""When av1an fails and ffmpeg does NOT have the encoder,
no retry should happen just increment fail_count and return False."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
test_video = tmp_path / "input.mp4"
test_video.write_bytes(b"\x00" * 1024) # fake video
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
# Build env with libsvtav1=False — can't fallback
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
}
env.ffmpeg_libs["libsvtav1"] = False
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
# Mock av1an failure
def mock_run(cmd, **kw):
return ("ok", 1, "", "some av1an error")
worker._run_with_stop_check = mock_run
# Mock _ffmpeg_fallback_encode — should NOT be called
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker._current_temps = []
worker._stop = False
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should fail (no retry possible)
assert result is False
assert worker.fail_count == 1
# _ffmpeg_fallback_encode should NOT have been called
worker._ffmpeg_fallback_encode.assert_not_called()
# Should NOT have logged RETRY
assert not any("RETRY" in l for l in logs)
def test_systematic_failure_does_not_retry(self, opentranscode_module, tmp_path):
"""When av1an fails with a systematic issue (VSScript API),
self._stop is set and no retry should happen."""
ffmpeg_bin = shutil.which("ffmpeg")
if not ffmpeg_bin:
pytest.skip("ffmpeg not available")
test_video = tmp_path / "input.mp4"
test_video.write_bytes(b"\x00" * 1024)
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
env.av1an_path = "/usr/bin/av1an"
env.av1an_flags = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"svt_name": "svt-av1",
"concat_method": "ffmpeg",
}
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=tmp_path / "output",
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=False,
)
# Mock av1an VSScript failure — this sets self._stop = True
def mock_run(cmd, **kw):
return ("ok", 1, "", "Failed to get VSScript API")
worker._run_with_stop_check = mock_run
worker._ffmpeg_fallback_encode = MagicMock(return_value=True)
logs = []
worker.log_msg.emit = lambda msg: logs.append(msg)
worker._current_temps = []
worker._stop = False # will be set by the pattern matcher
output_f = tmp_path / "output" / "input_archived.mkv"
result = worker._encode_one(test_video, test_video, output_f, 1)
# Should fail — systematic issue, no retry
assert result is False
assert worker._stop is True, "VSScript failure should set _stop"
# _ffmpeg_fallback_encode should NOT have been called (self._stop is True)
worker._ffmpeg_fallback_encode.assert_not_called()
# Should NOT have logged RETRY
assert not any("RETRY" in l for l in logs)

352
tests/test_force_validation.py Executable file
View File

@ -0,0 +1,352 @@
"""
v5 behavior tests skip-invalid-files, early-abort, file-type diagnostics.
These tests verify the 3 root-cause fixes from v5:
- v5-01: Invalid files are SKIPPED, not "attempted anyway"
- v5-02: 3 consecutive failures auto-abort the queue
- v5-03: `file` command output in diagnostics reveals HTML/text/data
- v5-04: Pre-flight validation pass reports valid/invalid counts
"""
import importlib.util
import os
import shutil
import subprocess
import sys
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Module loading — same pattern as the other test files
# ─────────────────────────────────────────────────────────────────────────────
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}")
_install_pyside6_stubs()
spec = importlib.util.spec_from_file_location("open_transcode", str(OPENTRANSCODE_PATH))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _install_pyside6_stubs():
if any(name in sys.modules for name in
("PySide6", "PySide6.QtWidgets", "PySide6.QtCore", "PySide6.QtGui")):
return
import types
pyside6 = types.ModuleType("PySide6")
qt_widgets = types.ModuleType("PySide6.QtWidgets")
qt_core = types.ModuleType("PySide6.QtCore")
qt_gui = types.ModuleType("PySide6.QtGui")
class _QThread:
def __init__(self, *a, **kw): pass
def start(self): pass
def isRunning(self): return False
def wait(self, ms=None): pass
class _Signal:
def __init__(self, *a, **kw): pass
def connect(self, *a, **kw): pass
def emit(self, *a, **kw): pass
def _Slot(*a, **kw):
def deco(fn): return fn
return deco
qt_core.QThread = _QThread
qt_core.Signal = _Signal
qt_core.Slot = _Slot
qt_core.Qt = MagicMock()
qt_core.QPointF = MagicMock()
qt_core.QRectF = MagicMock()
qt_core.QTimer = MagicMock()
class _QWidget:
def __init__(self, *a, **kw): pass
class _QMainWindow(_QWidget): pass
qt_widgets.QWidget = _QWidget
qt_widgets.QMainWindow = _QMainWindow
for name in ("QApplication", "QVBoxLayout", "QHBoxLayout", "QLabel",
"QLineEdit", "QPushButton", "QComboBox", "QCheckBox",
"QTextEdit", "QFileDialog", "QGroupBox", "QStatusBar",
"QMessageBox", "QStyleFactory"):
setattr(qt_widgets, name, MagicMock())
qt_gui.QFont = MagicMock()
qt_gui.QPalette = MagicMock()
qt_gui.QColor = MagicMock()
qt_gui.QPainter = MagicMock()
qt_gui.QPen = MagicMock()
qt_gui.QBrush = MagicMock()
qt_gui.QRadialGradient = MagicMock()
qt_gui.QFontMetrics = MagicMock()
pyside6.QtWidgets = qt_widgets
pyside6.QtCore = qt_core
pyside6.QtGui = qt_gui
sys.modules["PySide6"] = pyside6
sys.modules["PySide6.QtWidgets"] = qt_widgets
sys.modules["PySide6.QtCore"] = qt_core
sys.modules["PySide6.QtGui"] = qt_gui
@pytest.fixture(scope="module")
def opentranscode_module():
return _load_opentranscode_module()
# ─────────────────────────────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────────────────────────────
class TestIdentifyFileType:
"""v5-03: _identify_file_type() runs `file -b` and returns the type string."""
def test_identifies_text_file(self, opentranscode_module, tmp_path):
"""A .txt file should be identified as 'ASCII text' or similar."""
f = tmp_path / "test.txt"
f.write_text("This is not a video file, just plain text.")
result = opentranscode_module._identify_file_type(f)
# `file` should identify it as text
assert "text" in result.lower() or "ascii" in result.lower(), \
f"Expected text/ASCII in result, got: {result}"
def test_identifies_html_file(self, opentranscode_module, tmp_path):
"""An HTML file should be identified as 'HTML document' — the classic
failed yt-dlp download scenario."""
f = tmp_path / "fake_video.mp4"
f.write_text("<!DOCTYPE html><html><body>Video unavailable</body></html>")
result = opentranscode_module._identify_file_type(f)
assert "HTML" in result or "text" in result.lower(), \
f"Expected HTML/text in result, got: {result}"
def test_identifies_real_mp4(self, opentranscode_module, tmp_path, real_ffmpeg=None):
"""A real MP4 should be identified as 'ISO Media' or 'MP4'."""
if not shutil.which("ffmpeg"):
pytest.skip("ffmpeg not available")
f = tmp_path / "real.mp4"
subprocess.run(
["ffmpeg", "-f", "lavfi", "-i", "testsrc=duration=0.1:size=32x32:rate=1",
"-c:v", "libx264", "-y", str(f)],
capture_output=True, timeout=10,
)
if not f.exists():
pytest.skip("Could not generate test MP4")
result = opentranscode_module._identify_file_type(f)
assert "ISO Media" in result or "MP4" in result or "Media" in result, \
f"Expected ISO Media/MP4 in result, got: {result}"
def test_returns_empty_for_nonexistent_file(self, opentranscode_module, tmp_path):
"""Nonexistent file should return empty string (not crash)."""
f = tmp_path / "does_not_exist.bin"
result = opentranscode_module._identify_file_type(f)
# Should not crash; may return empty or an error string
assert isinstance(result, str)
class TestSkipInvalidFiles:
"""v5-01: Invalid files are SKIPPED, not 'attempted anyway'."""
def test_validate_file_skips_when_ffprobe_returns_none(
self, opentranscode_module, tmp_path
):
"""When ffprobe can't read a file and force=False, _validate_file
should return skip=True."""
# Create a fake "video" file that's actually text
fake_video = tmp_path / "fake.mp4"
fake_video.write_text("<!DOCTYPE html><html>Not a video</html>")
# Build a minimal EncoderWorker via __new__ to bypass __init__
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker.force = False # v5-01: force=False (default)
worker.fail_count = 0
worker._file_res_map = {}
worker.env = MagicMock()
worker.env.ffprobe_path = shutil.which("ffprobe") or "/usr/bin/ffprobe"
logs = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: logs.append(msg)
skip, info, src_w, src_h = worker._validate_file(fake_video)
assert skip is True, "Should skip invalid file when force=False"
assert info is None
assert worker.fail_count == 1, "Should increment fail_count"
# Should mention SKIP in the log
assert any("SKIP" in l for l in logs), \
f"Expected SKIP in logs, got: {logs}"
# Should mention the file type (HTML/text)
assert any("HTML" in l or "text" in l.lower() for l in logs), \
f"Expected file type info in logs, got: {logs}"
def test_validate_file_proceeds_when_force_true(
self, opentranscode_module, tmp_path
):
"""When force=True, _validate_file should NOT skip — it should
log a WARN and proceed (return skip=False)."""
fake_video = tmp_path / "fake.mp4"
fake_video.write_text("<!DOCTYPE html><html>Not a video</html>")
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker.force = True # v5-01: force=True overrides validation
worker.fail_count = 0
worker._file_res_map = {}
worker.env = MagicMock()
worker.env.ffprobe_path = shutil.which("ffprobe") or "/usr/bin/ffprobe"
logs = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: logs.append(msg)
skip, info, src_w, src_h = worker._validate_file(fake_video)
assert skip is False, "Should NOT skip when force=True"
assert worker.fail_count == 0, "Should NOT increment fail_count"
# Should log a WARN about attempting anyway
assert any("WARN" in l and "force" in l.lower() for l in logs), \
f"Expected WARN about force in logs, got: {logs}"
class TestConsecutiveFailureAbort:
"""v5-02: 3 consecutive failures auto-abort the queue."""
def test_aborts_after_three_consecutive_failures(self, opentranscode_module):
"""After 3 consecutive failures, _check_consecutive_failures
should set self._stop = True."""
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker._stop = False
worker._consecutive_fail_count = 0
worker._last_fail_pattern = None
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: None
# 3 consecutive failures
worker._check_consecutive_failures(Path("f1.mp4"), accepted=False)
assert worker._consecutive_fail_count == 1
assert worker._stop is False
worker._check_consecutive_failures(Path("f2.mp4"), accepted=False)
assert worker._consecutive_fail_count == 2
assert worker._stop is False
worker._check_consecutive_failures(Path("f3.mp4"), accepted=False)
assert worker._consecutive_fail_count == 3
assert worker._stop is True, "Should auto-abort after 3 consecutive failures"
def test_success_resets_counter(self, opentranscode_module):
"""A success should reset the consecutive failure counter."""
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker._stop = False
worker._consecutive_fail_count = 2 # already had 2 failures
worker._last_fail_pattern = None
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: None
# Success
worker._check_consecutive_failures(Path("ok.mp4"), accepted=True)
assert worker._consecutive_fail_count == 0, "Success should reset counter"
assert worker._stop is False
def test_does_not_double_abort(self, opentranscode_module):
"""If already stopped (user clicked STOP), don't abort again."""
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker._stop = True # already stopped
worker._consecutive_fail_count = 0
worker._last_fail_pattern = None
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: None
worker._check_consecutive_failures(Path("f.mp4"), accepted=False)
# Should increment but NOT emit the ABORT message (already stopped)
assert worker._consecutive_fail_count == 1
class TestErrorPatternsIncludeStreams:
"""v5-03: 'missing field streams' and 'Invalid data found' are in the
error_patterns table."""
def test_error_patterns_table_has_streams_pattern(self, opentranscode_module):
"""The error_patterns table in _encode_one should include the
'missing field streams' pattern. We verify by checking the source
code (the table is a local variable, not accessible from outside)."""
# Read the source and check for the pattern
src = Path(OPENTRANSCODE_PATH).read_text()
assert "missing field `streams`" in src, \
"error_patterns table should include 'missing field streams'"
assert "Invalid data found when processing input" in src, \
"error_patterns table should include 'Invalid data found'"
def test_identify_file_type_called_in_diagnostic(self, opentranscode_module):
"""The diagnostic section should call _identify_file_type for
the streams/invalid-data patterns."""
src = Path(OPENTRANSCODE_PATH).read_text()
# The _identify_file_type call should be inside the diagnostic block
assert "_identify_file_type(file_path)" in src, \
"Diagnostic should call _identify_file_type"
class TestPreFlightValidation:
"""v5-04: Pre-flight validation pass reports valid/invalid counts."""
def test_run_aborts_when_all_files_invalid(self, opentranscode_module, tmp_path):
"""When ALL files are invalid and force=False, run() should abort
immediately without entering the encode loop."""
# Create 3 fake "video" files (actually text)
for i in range(3):
(tmp_path / f"fake{i}.mp4").write_text(
f"<!DOCTYPE html><html>Not a video {i}</html>"
)
out_dir = tmp_path / "output"
out_dir.mkdir()
av1_codec = next(c for c in opentranscode_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in opentranscode_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in opentranscode_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in opentranscode_module.RESOLUTION_PRESETS if r.category == "original")
env = opentranscode_module.probe_environment()
worker = opentranscode_module.EncoderWorker(
in_dir=tmp_path,
out_dir=out_dir,
video_codec=av1_codec,
audio_profile=opus_audio,
container=mkv_container,
crf=32,
preset_label="Fast (4)",
delete_source=False,
env=env,
extensions={".mp4"},
resolution=original_res,
use_ffmpeg_fallback=True,
force=False, # v5-01: default — should skip invalid files
)
logs = []
worker.log_msg.emit = lambda msg: logs.append(msg)
# Run the worker — should abort in pre-flight validation
worker.run()
# Should NOT have entered the encode loop (no "[1/3] Encoding" message)
assert not any("[1/3] Encoding" in l for l in logs), \
"Should not enter encode loop when all files are invalid"
# Should have the PRE-FLIGHT VALIDATION section
assert any("PRE-FLIGHT VALIDATION" in l for l in logs), \
f"Expected PRE-FLIGHT VALIDATION in logs"
# Should have the ABORT message
assert any("ABORT" in l and "invalid" in l.lower() for l in logs), \
f"Expected ABORT message about invalid files"
# Should report 0 valid, 3 invalid
assert any("Valid files: 0" in l for l in logs)
assert any("Invalid files: 3" in l for l in logs)

178
tests/test_inline_scale.py Normal file
View File

@ -0,0 +1,178 @@
"""
v4.4.4: inline-scale + CRF-16 intermediate tests.
Two changes that prevent failures on 10GB+ source files:
1. Pre-scale intermediate changed from CRF 0 (mathematically lossless,
2-4× source size) to CRF 16 (visually lossless, 0.5-0.8× source size).
The old CRF-0 intermediate was producing 60-80GB temp files for 20GB
sources, exhausting disk and crashing the encode with mysterious
"ffmpeg error (rc=234)" messages.
2. New --inline-scale flag (UI checkbox "Inline scale (no intermediate)")
skips the pre-scale intermediate entirely. The scale/pad filter chain
is passed directly to av1an via --ffmpeg-filter-args, eliminating the
intermediate file (zero extra disk usage, one fewer encode pass).
"""
from __future__ import annotations
import inspect
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ── CLI flag ─────────────────────────────────────────────────────────────────
def test_cli_inline_scale_default_off():
"""Without --inline-scale, the flag defaults to False."""
from opentranscode.cli import 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
args = build_parser().parse_args(["--inline-scale"])
assert args.inline_scale is True
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
sig = inspect.signature(launch_gui)
assert "inline_scale" in sig.parameters
# Default must be False — the intermediate path is the safe default.
assert sig.parameters["inline_scale"].default is False
# ── EncoderWorker picks up inline_scale from env ─────────────────────────────
def test_worker_inline_scale_default_false(opentranscode_module, mock_env):
"""EncoderWorker defaults inline_scale to False when env.av1an_flags
has no 'inline_scale' key."""
# Ensure no stale value from a previous test
mock_env.av1an_flags.pop("inline_scale", None)
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
# Replicate the __init__ line that reads the flag
worker.inline_scale = bool(mock_env.av1an_flags.get("inline_scale", False))
assert worker.inline_scale is False
def test_worker_inline_scale_from_env(opentranscode_module, mock_env):
"""EncoderWorker picks up inline_scale=True from env.av1an_flags."""
mock_env.av1an_flags["inline_scale"] = True
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker.inline_scale = bool(mock_env.av1an_flags.get("inline_scale", False))
assert worker.inline_scale is True
# ── _prepare_input respects inline_scale ─────────────────────────────────────
def test_prepare_input_skips_prescale_when_inline_scale(opentranscode_module):
"""When inline_scale=True, _prepare_input does NOT run the pre-scale
block (no CRF-16 intermediate file is created). The encode_input
stays as the original file_path."""
src = inspect.getsource(opentranscode_module.EncoderWorker._prepare_input)
# The pre-scale block must be gated by `not self.inline_scale`
assert "not self.inline_scale" in src, (
"_prepare_input must gate the pre-scale block on `not self.inline_scale`"
)
def test_prepare_input_runs_prescale_when_not_inline_scale(opentranscode_module):
"""When inline_scale=False (default), _prepare_input runs the pre-scale
block as before (creating a CRF-16 intermediate)."""
src = inspect.getsource(opentranscode_module.EncoderWorker._prepare_input)
# The default code path must still produce a temp_scaled file
assert "temp_scaled" in src
assert "_temp_path_for" in src
# ── _encode_one injects --ffmpeg-filter-args when inline_scale ───────────────
def test_encode_one_injects_filter_args(opentranscode_module):
"""_encode_one must contain the --ffmpeg-filter-args injection block
that fires when self.inline_scale and self._current_scale_filter are
both set."""
src = inspect.getsource(opentranscode_module.EncoderWorker._encode_one)
assert "--ffmpeg-filter-args" in src, (
"_encode_one must inject --ffmpeg-filter-args when inline_scale is enabled"
)
assert "self.inline_scale" in src
assert "self._current_scale_filter" in src
# ── _process_one_file stashes scale_filter ───────────────────────────────────
def test_process_one_file_stashes_scale_filter(opentranscode_module):
"""_process_one_file must stash scale_filter on self._current_scale_filter
so _encode_one can read it without a signature change."""
src = inspect.getsource(opentranscode_module.EncoderWorker._process_one_file)
assert "_current_scale_filter" in src, (
"_process_one_file must stash scale_filter on self._current_scale_filter"
)
# ── CRF 16 (not CRF 0) for the pre-scale intermediate ───────────────────────
def test_prescale_uses_crf_16_not_crf_0(opentranscode_module):
"""v4.4.4: the pre-scale intermediate uses CRF 16 (visually lossless),
NOT CRF 0 (mathematically lossless). CRF 0 produced 2-4× source size
intermediates that crashed 20GB encodes by exhausting disk."""
src = inspect.getsource(opentranscode_module.EncoderWorker._prepare_input)
# Must contain CRF 16
assert '"16"' in src or "'16'" in src, (
"Pre-scale intermediate must use CRF 16 (visually lossless)"
)
# Must NOT contain CRF 0 as the encoder quality target. We check the
# specific "-crf", "0" pattern (with comma+quote) to avoid matching
# any incidental 0 in the source.
assert '"-crf", "0"' not in src and "'-crf', '0'" not in src, (
"Pre-scale intermediate must NOT use CRF 0 (mathematically lossless). "
"CRF 0 produces 2-4x source size intermediates that crash large encodes."
)
def test_prescale_uses_libx265(opentranscode_module):
"""The intermediate codec is still libx265 (HEVC) — required for
VapourSynth source plugin compatibility (ffv1 is unsupported)."""
src = inspect.getsource(opentranscode_module.EncoderWorker._prepare_input)
assert '"libx265"' in src or "'libx265'" in src
# ── open-transcode.py launcher script mirror ─────────────────────────────────
def test_launcher_script_prescale_uses_crf_16(opentranscode_module):
"""The launcher script (open-transcode.py) must mirror the CRF-16
change. The package and the launcher script must stay in sync."""
# opentranscode_module fixture loads open-transcode.py
src = inspect.getsource(opentranscode_module.EncoderWorker._prepare_input)
assert '"16"' in src or "'16'" in src, (
"Launcher script's pre-scale intermediate must use CRF 16"
)
assert '"-crf", "0"' not in src and "'-crf', '0'" not in src, (
"Launcher script must NOT use CRF 0 for pre-scale intermediate"
)
def test_launcher_script_has_inline_scale(opentranscode_module):
"""The launcher script mirrors the inline_scale flag."""
src = inspect.getsource(opentranscode_module.EncoderWorker.__init__)
assert 'inline_scale' in src, (
"Launcher script's EncoderWorker.__init__ must read inline_scale"
)
src2 = inspect.getsource(opentranscode_module.EncoderWorker._encode_one)
assert "--ffmpeg-filter-args" in src2, (
"Launcher script's _encode_one must inject --ffmpeg-filter-args"
)
src3 = inspect.getsource(opentranscode_module.EncoderWorker._prepare_input)
assert "not self.inline_scale" in src3, (
"Launcher script's _prepare_input must gate pre-scale on not self.inline_scale"
)

460
tests/test_intelligent_workers.py Executable file
View File

@ -0,0 +1,460 @@
"""
Intelligent worker-count tests (v4.1.0).
QA finding: thread oversubscription hard lock on high-core-count
machines (28-thread Xeon with v4.0.0 produced 13 workers × 28 threads =
~364 threads on 28 logical CPUs kernel scheduler drowned hard lock).
The fix is ``EncoderWorker._compute_intelligent_worker_count()``, which
returns ``(worker_count, threads_per_worker)`` such that
``worker_count * threads_per_worker <= logical_threads - 1``. The tests
here cover:
- CPU topology math for laptop / desktop / Xeon / EPYC / single-core VM.
- No-oversubscription invariant (active logical - 1) on every shape.
- --max-workers override is honored and capped by physical_cores - 1.
- --threads-per-worker override is honored.
- When BOTH overrides are set, the auto math is bypassed entirely.
- Codec params functions append ``--threads N`` when threads > 0
(and stay byte-identical to v4.0.0 when threads == 0).
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock
from conftest import make_minimal_worker
# ─────────────────────────────────────────────────────────────────────────────
# _compute_intelligent_worker_count — CPU topology math
# ─────────────────────────────────────────────────────────────────────────────
def _make_worker(opentranscode_module, env, max_workers=None, threads_per_worker=None):
"""Build an EncoderWorker via __new__ + minimum attrs needed for the
intelligent worker math. Bypasses QThread.__init__ so this runs in a
headless test environment without a real Qt event loop."""
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker.env = env
worker.max_workers = max_workers
worker.threads_per_worker_override = threads_per_worker
return worker
def _set_cpu(env, physical, logical, tpc=None):
"""Mutate an EnvProbe's CpuTopology in-place."""
env.cpu.physical_cores = physical
env.cpu.logical_threads = logical
env.cpu.threads_per_core = tpc if tpc is not None else (
logical // physical if physical > 0 else 1
)
def test_laptop_4c8t(opentranscode_module, mock_env):
"""4-core / 8-thread laptop → 1 worker × 7 threads = 7 active."""
_set_cpu(mock_env, physical=4, logical=8, tpc=2)
worker = _make_worker(opentranscode_module, mock_env)
wc, tpw = worker._compute_intelligent_worker_count()
# budget = 8 - 1 = 7. ideal_tpw=4 → target_workers = 7//4 = 1.
# tpw = 7//1 = 7. active = 1*7 = 7 ≤ 7 ✓
assert wc == 1
assert tpw == 7
assert wc * tpw <= 8 - 1
def test_desktop_8c16t(opentranscode_module, mock_env):
"""8-core / 16-thread desktop → 2 workers × 7 threads = 14 active.
v4.1.1 changed IDEAL_THREADS_PER_WORKER from 4 to 6, so the budget
(15) splits as 15//6=2 workers, 15//2=7 threads per worker.
"""
_set_cpu(mock_env, physical=8, logical=16, tpc=2)
worker = _make_worker(opentranscode_module, mock_env)
wc, tpw = worker._compute_intelligent_worker_count()
# v4.1.1: budget = 15. target = 15//6 = 2. tpw = 15//2 = 7. active = 2*7 = 14 ≤ 15 ✓
assert wc == 2
assert tpw == 7
assert wc * tpw <= 16 - 1
def test_xeon_14c28t_users_box(opentranscode_module, mock_env):
"""14-core / 28-thread Xeon (the user's box) → 4 workers × 6 threads.
This is the exact machine the v4.0.0 hard-lock happened on. With v4.0.0
behavior (worker_count = physical-1 = 13, no thread cap) each SVT-AV1
worker grabbed all 28 logical threads 13 × 28 = 364 active threads
on 28 logical CPUs kernel scheduler drowned hard lock.
With v4.1.1: 4 workers × 6 threads = 24 active, 4 reserved for OS/UI.
v4.1.0 used 6 workers × 4 threads = 24 active (same total, but 4
threads/chunk was too slow for SVT-AV1 and made the encode look
"borked" v4.1.1 gives each chunk 6 threads for better per-chunk
throughput while keeping the same total thread budget).
"""
_set_cpu(mock_env, physical=14, logical=28, tpc=2)
worker = _make_worker(opentranscode_module, mock_env)
wc, tpw = worker._compute_intelligent_worker_count()
# v4.1.1: budget = 27. target = 27//6 = 4. tpw = 27//4 = 6. active = 4*6 = 24 ✓
assert wc == 4
assert tpw == 6
assert wc * tpw == 24
assert wc * tpw <= 28 - 1
def test_epyc_32c64t(opentranscode_module, mock_env):
"""32-core / 64-thread EPYC → 10 workers × 6 threads = 60 active."""
_set_cpu(mock_env, physical=32, logical=64, tpc=2)
worker = _make_worker(opentranscode_module, mock_env)
wc, tpw = worker._compute_intelligent_worker_count()
# v4.1.1: budget = 63. target = 63//6 = 10. tpw = 63//10 = 6. active = 10*6 = 60 ≤ 63 ✓
assert wc == 10
assert tpw == 6
assert wc * tpw <= 64 - 1
def test_vm_1c2t(opentranscode_module, mock_env):
"""1-core / 2-thread VM → 1 worker × 1 thread = 1 active (degenerate)."""
_set_cpu(mock_env, physical=1, logical=2, tpc=2)
worker = _make_worker(opentranscode_module, mock_env)
wc, tpw = worker._compute_intelligent_worker_count()
# physical=1 → max_by_phys = max(1, 1-1) = max(1, 0) = 1 (because physical>1
# is False). target = min(budget//4, 1) = min(0, 1) but max(1, 0)=1.
# tpw = max(1, budget//1) = max(1, 1//1) = 1. active = 1*1 = 1 ≤ 1 ✓
assert wc == 1
assert tpw == 1
def test_single_core_no_ht(opentranscode_module, mock_env):
"""1-core / 1-thread (no HT) → 1 worker × 1 thread = 1 active."""
_set_cpu(mock_env, physical=1, logical=1, tpc=1)
worker = _make_worker(opentranscode_module, mock_env)
wc, tpw = worker._compute_intelligent_worker_count()
assert wc == 1
assert tpw == 1
# ─────────────────────────────────────────────────────────────────────────────
# No-oversubscription invariant — fuzz-ish sweep
# ─────────────────────────────────────────────────────────────────────────────
def test_no_oversubscription_across_typical_topologies(opentranscode_module, mock_env):
"""For every (physical, logical) in a sweep of plausible CPU shapes,
worker_count * threads_per_worker logical - 1."""
shapes = [
(1, 1), (1, 2), (2, 2), (2, 4),
(4, 4), (4, 8), (6, 6), (6, 12),
(8, 8), (8, 16), (12, 16), (12, 24),
(14, 28), (16, 32), (24, 48), (32, 64),
(48, 96), (64, 128),
]
for phys, logical in shapes:
_set_cpu(mock_env, physical=phys, logical=logical,
tpc=(logical // phys) if phys > 0 else 1)
worker = _make_worker(opentranscode_module, mock_env)
wc, tpw = worker._compute_intelligent_worker_count()
# Invariant: never exceed logical - 1 (one thread for OS/UI).
assert wc * tpw <= max(1, logical - 1), (
f"oversubscribed on {phys}c{logical}t: "
f"{wc} workers × {tpw} threads = {wc * tpw} > {logical - 1}"
)
# Sanity: both positive integers.
assert wc >= 1
assert tpw >= 1
# ─────────────────────────────────────────────────────────────────────────────
# Overrides — --max-workers / --threads-per-worker
# ─────────────────────────────────────────────────────────────────────────────
def test_max_workers_override_caps_worker_count(opentranscode_module, mock_env):
"""--max-workers=3 on a 28-thread Xeon → 3 workers (thread budget recomputed)."""
_set_cpu(mock_env, physical=14, logical=28, tpc=2)
worker = _make_worker(opentranscode_module, mock_env, max_workers=3)
wc, tpw = worker._compute_intelligent_worker_count()
# max_workers=3 → target_workers = min(3, 13) = 3.
# tpw = budget // 3 = 27 // 3 = 9. active = 3*9 = 27 ≤ 27 ✓
assert wc == 3
assert tpw == 9
assert wc * tpw <= 28 - 1
def test_max_workers_capped_by_physical_cores(opentranscode_module, mock_env):
"""--max-workers=99 on a 4-core machine → capped at physical_cores - 1 = 3."""
_set_cpu(mock_env, physical=4, logical=8, tpc=2)
worker = _make_worker(opentranscode_module, mock_env, max_workers=99)
wc, tpw = worker._compute_intelligent_worker_count()
# max_workers=99, but max_by_phys = 3 → target_workers = min(99, 3) = 3.
# tpw = budget // 3 = 7 // 3 = 2. active = 3*2 = 6 ≤ 7 ✓
assert wc == 3
assert tpw == 2
def test_threads_per_worker_override(opentranscode_module, mock_env):
"""--threads-per-worker=2 on a 28-thread Xeon → 2 threads per worker.
v4.1.1: with IDEAL_THREADS_PER_WORKER=6, the auto worker count is
27//6=4 (was 6 in v4.1.0 with IDEAL=4). The override only changes
threads_per_worker, not worker_count.
"""
_set_cpu(mock_env, physical=14, logical=28, tpc=2)
worker = _make_worker(opentranscode_module, mock_env, threads_per_worker=2)
wc, tpw = worker._compute_intelligent_worker_count()
# v4.1.1: target = 27//6 = 4. tpw override = 2. active = 4*2 = 8 ≤ 27 ✓
assert wc == 4
assert tpw == 2
def test_both_overrides_bypass_auto_math(opentranscode_module, mock_env):
"""When both --max-workers and --threads-per-worker are set, the auto
budget math is bypassed entirely even if it would oversubscribe."""
_set_cpu(mock_env, physical=4, logical=8, tpc=2)
worker = _make_worker(opentranscode_module, mock_env,
max_workers=10, threads_per_worker=8)
wc, tpw = worker._compute_intelligent_worker_count()
# User explicitly asked for 10×8 = 80 threads on an 8-thread box.
# The auto math is bypassed; the user gets what they asked for.
assert wc == 10
assert tpw == 8
def test_override_can_come_from_env_av1an_flags(opentranscode_module, mock_env):
"""EncoderWorker.__init__ should pick up max_workers / threads_per_worker
from env.av1an_flags when the explicit constructor args are None.
This is the path the CLI's --max-workers / --threads-per-worker flags
take: cli.main stores them on env.av1an_flags before launch_gui runs,
the GUI instantiates EncoderWorker without the explicit kwargs, and
__init__ falls back to env.av1an_flags."""
_set_cpu(mock_env, physical=14, logical=28, tpc=2)
mock_env.av1an_flags["max_workers"] = 4
mock_env.av1an_flags["threads_per_worker"] = 3
# Construct via real __init__ (uses the env fallback path).
# _temp_dir creation requires the conftest's _APP_CACHE_DIR patch —
# use make_minimal_worker to bypass __init__ and set attrs manually,
# then simulate the __init__ fallback logic inline.
worker = opentranscode_module.EncoderWorker.__new__(opentranscode_module.EncoderWorker)
worker.env = mock_env
# Mirror the __init__ fallback logic exactly:
worker.max_workers = (
mock_env.av1an_flags.get("max_workers")
if isinstance(mock_env.av1an_flags.get("max_workers"), int)
else None
)
worker.threads_per_worker_override = (
mock_env.av1an_flags.get("threads_per_worker")
if isinstance(mock_env.av1an_flags.get("threads_per_worker"), int)
else None
)
wc, tpw = worker._compute_intelligent_worker_count()
# Both overrides set → bypass auto math.
assert wc == 4
assert tpw == 3
# ─────────────────────────────────────────────────────────────────────────────
# Codec params functions — no threads= arg
# (SvtAv1EncApp CLI uses --lp, not --threads; thread capping lives
# in ffmpeg_vargs_fn and av1an's --workers)
# ─────────────────────────────────────────────────────────────────────────────
def test_av1_params_v412_no_threads_arg(opentranscode_module):
"""_av1_params takes only (crf, preset). Thread capping lives in
ffmpeg_vargs_fn (where libsvtav1 is a library) and in av1an's
--workers flag (chunk-parallel count).
"""
out = opentranscode_module._av1_params(30, 6)
assert out == "--preset 6 --crf 30 --keyint 240"
assert "--threads" not in out
def test_vp9_params_v412_no_threads_arg(opentranscode_module):
"""v4.1.2: _vp9_params takes only (crf, preset)."""
out = opentranscode_module._vp9_params(32, 2)
assert "--threads" not in out
def test_x265_params_v412_no_threads_arg(opentranscode_module):
"""v4.1.2: _x265_params takes only (crf, preset)."""
out = opentranscode_module._x265_params(28, 7)
assert "--threads" not in out
# ─────────────────────────────────────────────────────────────────────────────
# v4.1.1: live tail + heartbeat
# ─────────────────────────────────────────────────────────────────────────────
def test_live_tail_emits_lines(opentranscode_module, mock_env, monkeypatch):
"""v4.1.1: _run_with_stop_check emits each line of av1an's stdout/stderr
to the GUI log as it arrives, instead of buffering until process exit.
This is the fix for the "no activity / borked" symptom: with v4.1.0's
slower (capped-thread) encodes, the user stared at a frozen log for
10+ minutes because the drainer only emitted on process exit. v4.1.1
emits each line as av1an prints it.
"""
import io
import signal
import subprocess
from unittest.mock import MagicMock
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker._stop = False
# Patch time.sleep so the poll loop runs instantly.
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
monkeypatch.setattr("os.killpg", lambda *a, **k: None)
monkeypatch.setattr("os.getpgid", lambda pid: 99999)
# Collect emitted log messages.
emitted: list[str] = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: emitted.append(msg)
# Fake process that writes 3 lines to stderr then exits 0.
fake_proc = MagicMock()
fake_proc.pid = 12345
fake_proc.stdout = io.StringIO("")
fake_proc.stderr = io.StringIO(
"INFO encode_file: scenecut: found 8 scene(s)\n"
"DEBUG encode_file: Segmenting video\n"
"INFO encode_chunk: Encoding chunk 1\n"
)
fake_proc.poll.return_value = 0
fake_proc.wait.return_value = 0
monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)
status, rc, stdout, stderr = worker._run_with_stop_check(
cmd=["av1an", "-i", "x.mkv", "-o", "y.mkv"],
timeout=60,
)
assert status == "ok"
assert rc == 0
# The live tail should have emitted each stderr line with the pipe prefix.
tail_lines = [m for m in emitted if m.startswith("")]
assert len(tail_lines) >= 3, (
f"Expected ≥3 live-tail lines, got {len(tail_lines)}: {tail_lines}"
)
assert any("scenecut: found 8 scene(s)" in m for m in tail_lines)
assert any("Segmenting video" in m for m in tail_lines)
assert any("Encoding chunk 1" in m for m in tail_lines)
# The stderr buffer should also contain the full output.
assert "scenecut: found 8 scene(s)" in stderr
def test_live_tail_handles_carriage_return(opentranscode_module, mock_env, monkeypatch):
"""v4.1.1: live tail handles \\r (progress bar updates) as line boundaries.
av1an's progress bar uses \\r to overwrite the current line. Without
\\r handling, the live tail would buffer the entire progress bar
sequence and only emit when the final \\n arrives (which might be
never during a long encode).
"""
import io
from unittest.mock import MagicMock
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker._stop = False
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
monkeypatch.setattr("os.killpg", lambda *a, **k: None)
monkeypatch.setattr("os.getpgid", lambda pid: 99999)
emitted: list[str] = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: emitted.append(msg)
# Simulate av1an progress bar: \r-delimited updates, then \n at the end.
fake_proc = MagicMock()
fake_proc.pid = 12345
fake_proc.stdout = io.StringIO("")
fake_proc.stderr = io.StringIO(
"Encoding 10%\rEncoding 25%\rEncoding 50%\rDone\n"
)
fake_proc.poll.return_value = 0
fake_proc.wait.return_value = 0
monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)
status, rc, stdout, stderr = worker._run_with_stop_check(
cmd=["av1an", "-i", "x.mkv", "-o", "y.mkv"],
timeout=60,
)
assert status == "ok"
tail_lines = [m for m in emitted if m.startswith("")]
# Each \r-delimited segment should be emitted as a separate line.
assert any("10%" in m for m in tail_lines), (
f"Expected '10%' in tail lines: {tail_lines}"
)
assert any("25%" in m for m in tail_lines)
assert any("50%" in m for m in tail_lines)
assert any("Done" in m for m in tail_lines)
def test_heartbeat_emits_during_long_encode(opentranscode_module, mock_env, monkeypatch):
"""v4.1.1: heartbeat emits 'still encoding' every 30s during a long encode.
Without this, a slow-but-working encode looks identical to a wedged one
the user sees no output for minutes and assumes it's dead.
"""
import io
import time
from unittest.mock import MagicMock
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker._stop = False
# v4.4.1: heartbeat is gated behind --verbose. Set it True so
# the heartbeat fires during this test.
worker.verbose = True
simulated_time = [0.0]
def fake_monotonic():
return simulated_time[0]
def fake_sleep(seconds):
# Advance 31s per sleep call so the 30s heartbeat threshold is crossed.
simulated_time[0] += 31
monkeypatch.setattr("time.monotonic", fake_monotonic)
monkeypatch.setattr("time.sleep", fake_sleep)
monkeypatch.setattr("os.killpg", lambda *a, **k: None)
monkeypatch.setattr("os.getpgid", lambda pid: 99999)
emitted: list[str] = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: emitted.append(msg)
fake_proc = MagicMock()
fake_proc.pid = 12345
fake_proc.stdout = io.StringIO("")
fake_proc.stderr = io.StringIO("")
poll_count = [0]
def poll_side_effect():
poll_count[0] += 1
# Exit after 3 polls (simulating a ~90s encode with 30s sleep steps).
if poll_count[0] >= 3:
return 0
return None
fake_proc.poll.side_effect = poll_side_effect
fake_proc.wait.return_value = 0
monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)
status, rc, stdout, stderr = worker._run_with_stop_check(
cmd=["av1an", "-i", "x.mkv", "-o", "y.mkv"],
timeout=7200,
)
assert status == "ok"
# Heartbeat messages should appear (one per 30s of simulated time).
# Format: "... Ns elapsed"
heartbeat_lines = [m for m in emitted if "elapsed" in m]
assert len(heartbeat_lines) >= 1, (
f"Expected ≥1 heartbeat, got {len(heartbeat_lines)}: {heartbeat_lines}"
)
# The heartbeat should include the elapsed time.
assert any("elapsed" in m for m in heartbeat_lines)

229
tests/test_massive_files.py Executable file
View File

@ -0,0 +1,229 @@
"""
v4.4.0: massive-file support tests.
Three changes to prevent failures on 30GB+ source files:
1. Per-file timeout configurable via --timeout (default 86400s = 24h,
up from 7200s = 2h)
2. 5%-of-source integrity check replaced with absolute 1KB minimum
(old check false-positived on high-bitrate BluRay sources)
3. Disk space pre-check warns (not aborts) if free space < source size
"""
from __future__ import annotations
import shutil
from pathlib import Path
from unittest.mock import MagicMock
from conftest import make_minimal_worker
# ── --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
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
args = build_parser().parse_args(["--timeout", "3600"])
assert args.timeout == 3600
def test_launch_gui_signature_accepts_timeout():
"""launch_gui() accepts the timeout kwarg (v4.4.0)."""
import inspect
from opentranscode import launch_gui
sig = inspect.signature(launch_gui)
assert "timeout" in sig.parameters
# Default must be 86400 (24h).
assert sig.parameters["timeout"].default == 86400
# ── encode_timeout in EncoderWorker ──────────────────────────────────────────
def test_worker_default_encode_timeout_24h(opentranscode_module, mock_env):
"""EncoderWorker.__init__ defaults encode_timeout to 86400s = 24h."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
# The __init__ fallback reads env.av1an_flags["encode_timeout"];
# simulate that here.
mock_env.av1an_flags["encode_timeout"] = 86400
worker.encode_timeout = int(mock_env.av1an_flags.get("encode_timeout", 86400))
assert worker.encode_timeout == 86400
def test_worker_encode_timeout_from_env(opentranscode_module, mock_env):
"""EncoderWorker picks up encode_timeout from env.av1an_flags."""
mock_env.av1an_flags["encode_timeout"] = 14400 # 4 hours
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker.encode_timeout = int(mock_env.av1an_flags.get("encode_timeout", 86400))
assert worker.encode_timeout == 14400
# ── 1KB integrity check (replaces 5%-of-source) ─────────────────────────────
def test_integrity_check_accepts_small_but_valid_output(opentranscode_module, mock_env, tmp_path):
"""v4.4.0: a 39KB output (typical for a 2-second test video) is accepted.
The old 5%-of-source check would have rejected this if the source was
>780KB (39KB / 0.05 = 780KB). The new 1KB minimum accepts any non-empty
output with a valid container header.
"""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
# The integrity check is inline in _ffmpeg_fallback_encode and
# _encode_one, not a separate method. We test the logic directly:
# out_size > 1024 = valid; out_size <= 1024 = corrupt.
out_size_valid = 39 * 1024 # 39 KB — typical for tiny test video
out_size_corrupt = 512 # 512 bytes — definitely corrupt
assert out_size_valid > 1024
assert not (out_size_corrupt > 1024)
def test_integrity_check_rejects_sub_1kb_output():
"""v4.4.0: outputs < 1KB are rejected (can't have a valid container header)."""
# A valid MKV/WebM/MP4 header alone is ~1KB. Anything below is corrupt.
corrupt_sizes = [0, 100, 512, 1023, 1024]
for size in corrupt_sizes:
# The check is `out_size > 1024` — 1024 itself fails (not > 1024).
assert not (size > 1024), f"size {size} should fail the > 1024 check"
# ── Disk space pre-check ────────────────────────────────────────────────────
def test_disk_space_check_skips_small_files(opentranscode_module, mock_env, tmp_path):
"""v4.4.0: _check_disk_space skips the check for files < 1 GB."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker._temp_dir = tmp_path / "tmp"
worker._temp_dir.mkdir()
# Create a small source file (1 MB — under the 1 GB threshold).
source = tmp_path / "small.mkv"
source.write_bytes(b"\0" * (1024 * 1024))
output_f = tmp_path / "output" / "small_archived.mkv"
output_f.parent.mkdir()
emitted = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: emitted.append(msg)
worker._check_disk_space(source, output_f, needs_scale=False)
# No warning should be emitted for a < 1 GB file.
assert not any("WARN" in m for m in emitted), (
f"Expected no disk-space warning for small file, got: {emitted}"
)
def test_disk_space_check_warns_for_large_files(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""v4.4.0: _check_disk_space warns when free space < source size for > 1 GB files.
Uses a MOCKED source size (32 GB) instead of actually allocating 32 GB
on disk the check reads file_path.stat().st_size, which we patch.
"""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker._temp_dir = tmp_path / "tmp"
worker._temp_dir.mkdir()
# Create a tiny placeholder source file (just needs to exist on disk).
source = tmp_path / "big.mkv"
source.write_bytes(b"\0")
output_f = tmp_path / "output" / "big_archived.mkv"
output_f.parent.mkdir()
# Mock the source file's stat to report 32 GB (a BluRay rip).
fake_stat = MagicMock()
fake_stat.st_size = 32 * 1024 * 1024 * 1024 # 32 GB
monkeypatch.setattr(Path, "stat", lambda self: fake_stat)
# Mock disk_usage to report only 5 GB free (less than the 32 GB source).
fake_usage = MagicMock()
fake_usage.free = 5 * 1024 * 1024 * 1024 # 5 GB free
monkeypatch.setattr("shutil.disk_usage", lambda path: fake_usage)
emitted = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: emitted.append(msg)
worker._check_disk_space(source, output_f, needs_scale=False)
# Should emit a warning about low disk space.
warnings = [m for m in emitted if "WARN" in m and "low disk space" in m]
assert len(warnings) >= 1, (
f"Expected a low-disk-space warning, got: {emitted}"
)
def test_disk_space_check_no_warning_when_plenty_free(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""v4.4.0: _check_disk_space does NOT warn when free space > source size."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker._temp_dir = tmp_path / "tmp"
worker._temp_dir.mkdir()
# Create a tiny placeholder source file.
source = tmp_path / "big.mkv"
source.write_bytes(b"\0")
output_f = tmp_path / "output" / "big_archived.mkv"
output_f.parent.mkdir()
# Mock the source file's stat to report 32 GB.
fake_stat = MagicMock()
fake_stat.st_size = 32 * 1024 * 1024 * 1024
monkeypatch.setattr(Path, "stat", lambda self: fake_stat)
# Mock disk_usage to report 100 GB free (plenty).
fake_usage = MagicMock()
fake_usage.free = 100 * 1024 * 1024 * 1024
monkeypatch.setattr("shutil.disk_usage", lambda path: fake_usage)
emitted = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: emitted.append(msg)
worker._check_disk_space(source, output_f, needs_scale=False)
# Should NOT emit any warning.
assert not any("WARN" in m for m in emitted), (
f"Expected no warning when free space is ample, got: {emitted}"
)
def test_disk_space_check_warns_temp_when_scaling(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""v4.4.0: when scaling, also checks temp partition for the lossless intermediate."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker._temp_dir = tmp_path / "tmp"
worker._temp_dir.mkdir()
# Create a tiny placeholder source file.
source = tmp_path / "big.mkv"
source.write_bytes(b"\0")
output_f = tmp_path / "output" / "big_archived.mkv"
output_f.parent.mkdir()
# Mock the source file's stat to report 32 GB.
fake_stat = MagicMock()
fake_stat.st_size = 32 * 1024 * 1024 * 1024
monkeypatch.setattr(Path, "stat", lambda self: fake_stat)
# Mock disk_usage: output has plenty (100 GB), temp has only 20 GB
# (less than source * 2 = 64 GB needed for lossless intermediate).
def fake_disk_usage(path):
if "tmp" in str(path):
return MagicMock(free=20 * 1024 * 1024 * 1024) # 20 GB on temp
return MagicMock(free=100 * 1024 * 1024 * 1024) # 100 GB on output
monkeypatch.setattr("shutil.disk_usage", fake_disk_usage)
emitted = []
worker.log_msg = MagicMock()
worker.log_msg.emit = lambda msg: emitted.append(msg)
worker._check_disk_space(source, output_f, needs_scale=True)
# Should warn about temp space (lossless intermediate).
temp_warnings = [m for m in emitted if "temp" in m.lower() and "WARN" in m]
assert len(temp_warnings) >= 1, (
f"Expected a temp-space warning when scaling, got: {emitted}"
)

253
tests/test_package_structure.py Executable file
View File

@ -0,0 +1,253 @@
"""
Package-structure tests verify the opentranscode/ package imports cleanly
and exposes the expected public API.
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
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",
}
# ─────────────────────────────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────────────────────────────
class TestPackageMetadata:
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.
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]), \
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_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)
class TestSubmodulesImportable:
"""Every submodule in the package must import cleanly."""
@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
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)
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)"
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)
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
for ap in AUDIO_PROFILES:
assert ap.ffmpeg_encoder_name, \
f"AudioProfile '{ap.label}' has empty ffmpeg_encoder_name (OTC-012 violation)"
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
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 len(DISTRO_REGISTRY) >= 6
class TestCLIParser:
"""Verify the CLI argument parser works."""
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
assert "4.5.0" in result.stdout

193
tests/test_skip_existing.py Executable file
View File

@ -0,0 +1,193 @@
"""
v4.3.0: skip-existing detection tests.
When the output file already exists with a matching video+audio codec,
the file is skipped instead of re-encoded. This is the default
(--skip-existing); pass --force-reencode to disable.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
from conftest import make_minimal_worker
def _ffprobe_result(payload: dict) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=["ffprobe"], returncode=0,
stdout=json.dumps(payload), stderr="",
)
def _av1_opus_output() -> dict:
"""ffprobe JSON for an AV1+Opus file (matches VIDEO_CODECS[0] + AUDIO_PROFILES[0])."""
return {
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "av1",
"width": 1920, "height": 1080},
{"index": 1, "codec_type": "audio", "codec_name": "opus"},
],
"format": {"duration": "10.0"},
}
def _h264_aac_output() -> dict:
"""ffprobe JSON for an H.264+AAC file (does NOT match AV1+Opus profile)."""
return {
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "h264",
"width": 1920, "height": 1080},
{"index": 1, "codec_type": "audio", "codec_name": "aac"},
],
"format": {"duration": "10.0"},
}
def test_skip_existing_returns_false_when_output_missing(opentranscode_module, mock_env, tmp_path):
"""No output file → don't skip (proceed with encode)."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker.skip_existing = True
# Set up the codec profile so ffprobe_codec_name is populated.
worker.video_codec = opentranscode_module.VIDEO_CODECS[0] # AV1
worker.audio_profile = opentranscode_module.AUDIO_PROFILES[0] # Opus
worker.resolution = opentranscode_module.RESOLUTION_PRESETS[0] # Original
output_f = tmp_path / "nonexistent_archived.mkv"
assert not worker._output_already_encoded(tmp_path / "source.mkv", output_f)
def test_skip_existing_returns_true_when_codec_matches(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""Output exists + ffprobe reads it + codec matches → skip."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker.skip_existing = True
worker.video_codec = opentranscode_module.VIDEO_CODECS[0] # AV1 → ffprobe_codec_name="av1"
worker.audio_profile = opentranscode_module.AUDIO_PROFILES[0] # Opus → "opus"
worker.resolution = opentranscode_module.RESOLUTION_PRESETS[0] # Original (no scaling)
output_f = tmp_path / "output_archived.mkv"
output_f.write_bytes(b"fake mkv content")
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_result(_av1_opus_output())),
)
assert worker._output_already_encoded(tmp_path / "source.mkv", output_f) is True
def test_skip_existing_returns_false_when_video_codec_mismatches(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""Output exists but video codec is h264 (not av1) → don't skip."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker.skip_existing = True
worker.video_codec = opentranscode_module.VIDEO_CODECS[0] # AV1
worker.audio_profile = opentranscode_module.AUDIO_PROFILES[0] # Opus
worker.resolution = opentranscode_module.RESOLUTION_PRESETS[0]
output_f = tmp_path / "output_archived.mkv"
output_f.write_bytes(b"fake mkv content")
# ffprobe says h264/aac, but we selected AV1/Opus → mismatch → don't skip.
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_result(_h264_aac_output())),
)
assert worker._output_already_encoded(tmp_path / "source.mkv", output_f) is False
def test_skip_existing_returns_false_when_ffprobe_fails(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""Output exists but ffprobe returns None (corrupt) → don't skip."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker.skip_existing = True
worker.video_codec = opentranscode_module.VIDEO_CODECS[0]
worker.audio_profile = opentranscode_module.AUDIO_PROFILES[0]
worker.resolution = opentranscode_module.RESOLUTION_PRESETS[0]
output_f = tmp_path / "output_archived.mkv"
output_f.write_bytes(b"corrupt content")
# ffprobe returns non-zero (corrupt file).
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=subprocess.CompletedProcess(
args=["ffprobe"], returncode=1, stdout="", stderr="error",
)),
)
assert worker._output_already_encoded(tmp_path / "source.mkv", output_f) is False
def test_skip_existing_returns_false_when_no_ffprobe(opentranscode_module, mock_env, tmp_path):
"""No ffprobe available → can't verify codec → don't skip (safe default)."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker.skip_existing = True
worker.video_codec = opentranscode_module.VIDEO_CODECS[0]
worker.audio_profile = opentranscode_module.AUDIO_PROFILES[0]
worker.resolution = opentranscode_module.RESOLUTION_PRESETS[0]
output_f = tmp_path / "output_archived.mkv"
output_f.write_bytes(b"content")
# Simulate no ffprobe.
worker.env.ffprobe_path = None
assert worker._output_already_encoded(tmp_path / "source.mkv", output_f) is False
def test_skip_existing_checks_resolution_when_scaling_requested(opentranscode_module, mock_env, tmp_path, monkeypatch):
"""When scaling is requested, output resolution must match the target."""
worker = make_minimal_worker(opentranscode_module, env=mock_env)
worker.skip_existing = True
worker.video_codec = opentranscode_module.VIDEO_CODECS[0] # AV1
worker.audio_profile = opentranscode_module.AUDIO_PROFILES[0] # Opus
# Select a target resolution (720p = 1280x720, index 2).
# The mock ffprobe returns 1920x1080, so they WON'T match → don't skip.
worker.resolution = opentranscode_module.RESOLUTION_PRESETS[2] # 720p
output_f = tmp_path / "output_archived.mkv"
output_f.write_bytes(b"content")
# ffprobe says 1920x1080, but we want 1280x720 → mismatch → don't skip.
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_result(_av1_opus_output())),
)
assert worker._output_already_encoded(tmp_path / "source.mkv", output_f) is False
# ── CLI flag tests ─────────────────────────────────────────────────────────
def test_cli_skip_existing_default_true():
"""Without --force-reencode, skip_existing defaults to True."""
from opentranscode.cli import 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
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
args = build_parser().parse_args(["--skip-existing"])
assert args.skip_existing is True
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
sig = inspect.signature(launch_gui)
assert "skip_existing" in sig.parameters
# Default must be True (skip by default).
assert sig.parameters["skip_existing"].default is True

234
tests/test_smoke_test.py Executable file
View File

@ -0,0 +1,234 @@
"""
Smoke-test coverage for ``_av1an_vsscript_smoke_test``.
QA finding: OTC-001 (critical).
The v1 implementation of the av1an VSScript smoke test returned ``True`` for
any non-VSScript failure, masking real bugs (missing encoder binary, concat-
method mismatch, av1an panic, etc.). The pre-flight check therefore reported
"OK" and the per-file loop then failed for every file the
"chunks but never saves a file" symptom.
The open-transcode.py implementation (this is what we're testing) classifies failure modes
and returns ``False`` for unknown failures. The critical regression test is
``test_smoke_returns_false_on_unknown_failure``: it feeds the function a
generic rc=1 + non-VSScript stderr and verifies the function now returns
``False`` (the v1 bug was returning ``True`` here).
All 5 cases run without a real av1an / ffmpeg install: ``subprocess.run`` is
mocked via ``monkeypatch.setattr("subprocess.run", ...)`` and the smoke-test
function's ``test_in.exists()`` / ``test_out.exists()`` checks are satisfied
by the mock side-effect creating the expected files at the in/out paths that
the function passes on the command line.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _make_smoke_side_effect(scenario: str):
"""Build a side_effect callable for ``subprocess.run``.
The smoke-test function calls ``subprocess.run`` exactly twice:
1. ffmpeg gen-cmd last argument is the output path (``test_in``).
2. av1an cmd output path follows ``-o``.
For "happy" the side_effect creates both files so the function's
``Path.exists()`` checks pass. For failure scenarios it creates only
``test_in`` (so the function proceeds past the gen step) and returns
the appropriate ``CompletedProcess`` for the av1an call.
"""
call_count = [0]
def side_effect(cmd, *args, **kwargs):
i = call_count[0]
call_count[0] += 1
if i == 0:
# ffmpeg gen-cmd — always rc=0; create the test_in file so
# `test_in.exists()` returns True inside the smoke-test.
Path(cmd[-1]).write_bytes(b"\x00fake-video\x00")
return subprocess.CompletedProcess(
args=cmd, returncode=0, stdout="", stderr="",
)
# i == 1 — av1an cmd
if scenario == "happy":
# Create test_out so `test_out.exists()` returns True.
out_idx = cmd.index("-o") + 1
Path(cmd[out_idx]).write_bytes(b"\x00fake-encode\x00")
return subprocess.CompletedProcess(
args=cmd, returncode=0, stdout="", stderr="",
)
if scenario == "vsscript_incompat":
return subprocess.CompletedProcess(
args=cmd, returncode=1, stdout="",
stderr="Error: Failed to get VSScript API. ABI mismatch.",
)
if scenario == "invalid_encoder":
return subprocess.CompletedProcess(
args=cmd, returncode=1, stdout="",
stderr="error: invalid value 'foo' for '--encoder <ENCODER>'",
)
if scenario == "unknown_failure":
return subprocess.CompletedProcess(
args=cmd, returncode=1, stdout="some av1an stdout",
stderr="panic at src/encode.rs:42\nunknown failure mode",
)
if scenario == "timeout":
raise subprocess.TimeoutExpired(cmd=cmd, timeout=30)
raise ValueError(f"unknown scenario: {scenario!r}")
return side_effect
# ─────────────────────────────────────────────────────────────────────────────
# Test cases
# ─────────────────────────────────────────────────────────────────────────────
AV1AN_FLAGS = {
"worker": "--workers",
"video_params": "--video-params",
"audio_params": "--audio-params",
"concat_method": "ffmpeg",
"has_chunk_method": True,
}
def test_smoke_returns_true_on_success(opentranscode_module, monkeypatch):
"""Happy path: ffmpeg gen rc=0 + av1an rc=0 + output file exists
-> returns ``(True, "av1an VSScript init OK")``.
"""
monkeypatch.setattr(
"subprocess.run",
MagicMock(side_effect=_make_smoke_side_effect("happy")),
)
ok, detail = opentranscode_module._av1an_vsscript_smoke_test(
av1an_bin="/fake/av1an",
ffmpeg_bin="/fake/ffmpeg",
av1an_flags=AV1AN_FLAGS,
svt_name="svt_av1",
timeout=5,
)
assert ok is True
assert detail == "av1an VSScript init OK"
def test_smoke_returns_false_on_vsscript_incompat(opentranscode_module, monkeypatch):
"""stderr contains "Failed to get VSScript API"
-> returns ``(False, "VSScript_API_INCOMPAT")``.
"""
monkeypatch.setattr(
"subprocess.run",
MagicMock(side_effect=_make_smoke_side_effect("vsscript_incompat")),
)
ok, detail = opentranscode_module._av1an_vsscript_smoke_test(
av1an_bin="/fake/av1an",
ffmpeg_bin="/fake/ffmpeg",
av1an_flags=AV1AN_FLAGS,
svt_name="svt_av1",
timeout=5,
)
assert ok is False
assert detail == "VSScript_API_INCOMPAT"
def test_smoke_returns_false_on_invalid_encoder(opentranscode_module, monkeypatch):
"""stderr contains both "invalid value" and "--encoder"
-> returns ``(False, "INVALID_ENCODER: ...")``.
"""
monkeypatch.setattr(
"subprocess.run",
MagicMock(side_effect=_make_smoke_side_effect("invalid_encoder")),
)
ok, detail = opentranscode_module._av1an_vsscript_smoke_test(
av1an_bin="/fake/av1an",
ffmpeg_bin="/fake/ffmpeg",
av1an_flags=AV1AN_FLAGS,
svt_name="svt_av1",
timeout=5,
)
assert ok is False
assert detail.startswith("INVALID_ENCODER:")
assert "invalid value" in detail
assert "--encoder" in detail
def test_smoke_returns_false_on_unknown_failure(opentranscode_module, monkeypatch):
"""**CRITICAL OTC-001 REGRESSION TEST**.
A generic rc=1 with non-VSScript stderr MUST return ``False``. The v1
implementation returned ``True`` here, masking real bugs (missing encoder
binary, concat-method mismatch, av1an panic, etc.) and causing the
"chunks but never saves a file" symptom in production.
open-transcode.py must classify this as ``SMOKE_FAIL`` so the caller can offer ffmpeg
fallback or abort with an actionable message (SEI CERT ERR01-C: never
mask a failure as success).
"""
monkeypatch.setattr(
"subprocess.run",
MagicMock(side_effect=_make_smoke_side_effect("unknown_failure")),
)
ok, detail = opentranscode_module._av1an_vsscript_smoke_test(
av1an_bin="/fake/av1an",
ffmpeg_bin="/fake/ffmpeg",
av1an_flags=AV1AN_FLAGS,
svt_name="svt_av1",
timeout=5,
)
# The whole point of OTC-001: this MUST be False, never True.
assert ok is False, (
"OTC-001 REGRESSION: smoke test returned True for an unknown "
"failure. v1 had this bug and it caused 'chunks but never saves a "
"file' in production. open-transcode.py must return False here."
)
assert detail.startswith("SMOKE_FAIL"), (
f"Expected SMOKE_FAIL detail prefix, got: {detail!r}"
)
assert "rc=1" in detail
# open-transcode.py includes the FULL stderr (not just the tail) so the user can see
# the actual error and the diagnostic patterns can match on it.
assert "unknown failure mode" in detail
def test_smoke_returns_false_on_timeout(opentranscode_module, monkeypatch):
"""``subprocess.TimeoutExpired`` raised
-> returns ``(False, "SMOKE_TIMEOUT: ...")``.
open-transcode.py does NOT mask a timeout as success a hanging av1an is a real
failure that the user must be told about (SEI CERT ERR01-C).
"""
monkeypatch.setattr(
"subprocess.run",
MagicMock(side_effect=_make_smoke_side_effect("timeout")),
)
ok, detail = opentranscode_module._av1an_vsscript_smoke_test(
av1an_bin="/fake/av1an",
ffmpeg_bin="/fake/ffmpeg",
av1an_flags=AV1AN_FLAGS,
svt_name="svt_av1",
timeout=5,
)
assert ok is False
assert detail.startswith("SMOKE_TIMEOUT:")
assert "5s" in detail or "5" in detail

162
tests/test_stop_button.py Executable file
View File

@ -0,0 +1,162 @@
"""
STOP-button tests for ``EncoderWorker._run_with_stop_check``.
QA finding: OTC-013 (concurrent-worker safety + responsive STOP).
``_run_with_stop_check`` is the open-transcode.py replacement for the v2 ``subprocess.run``
calls inside the av1an and ffmpeg-fallback encode paths. It:
- Spawns the subprocess via ``Popen(start_new_session=True)`` so it can be
signaled as a *process group* (reaches av1an's child encoders —
SvtAv1EncApp / vpxenc / x265 not just the av1an parent).
- Polls ``self._stop`` every ~1 second.
- On STOP: SIGTERM the process group, wait 5s, SIGKILL if still alive.
Returns ``("stop", rc, stdout, stderr)``.
- On normal exit: returns ``("ok", rc, stdout, stderr)``.
- On overall timeout: SIGKILL the group. Returns ``("timeout", ...)``.
The 2 cases:
- STOP requested mid-encode -> status="stop", SIGTERM + SIGKILL sent
via os.killpg.
- Happy path -> status="ok", rc=0, no signals sent.
Both cases mock ``subprocess.Popen`` (via the ``mock_subprocess_popen``
fixture or directly) and ``time.sleep`` (so the 1-second poll loop runs
instantly). ``os.killpg`` and ``os.getpgid`` are also patched so no real
process-group signaling happens.
"""
from __future__ import annotations
import io
import signal
import subprocess
import time
from unittest.mock import MagicMock
import pytest
from conftest import make_minimal_worker
def test_stop_terminates_subprocess(opentranscode_module, monkeypatch):
"""STOP mid-encode -> status="stop", SIGTERM then SIGKILL sent to group.
The poll loop runs:
- Iteration 1: poll() -> None, _stop=False, sleep(1) [patched to no-op]
- Iteration 2: poll() -> None, _stop=False, sleep(1) [patched to no-op]
- Iteration 3: poll() -> None; side-effect sets _stop=True;
stop branch fires: SIGTERM via os.killpg, proc.wait(5) raises
TimeoutExpired (simulating av1an not responding to SIGTERM within
the grace period), SIGKILL via os.killpg, proc.wait(2) returns
None. status="stop", break.
"""
worker = make_minimal_worker(opentranscode_module)
worker._stop = False
# Patch time.sleep so the 1-second poll loop runs instantly.
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
# Track os.killpg calls: (pgid, signal) tuples.
killpg_calls: list[tuple[int, int]] = []
def fake_killpg(pgid, sig):
killpg_calls.append((pgid, sig))
monkeypatch.setattr("os.killpg", fake_killpg)
monkeypatch.setattr("os.getpgid", lambda pid: 99999) # fake PGID
# Build a fake Popen result. stdout/stderr are StringIO("") so the
# open-transcode module's drainer threads (which call .read(4096)) hit EOF
# immediately and exit cleanly.
fake_proc = MagicMock()
fake_proc.pid = 12345
fake_proc.stdout = io.StringIO("")
fake_proc.stderr = io.StringIO("")
poll_calls = [0]
def poll_side_effect():
poll_calls[0] += 1
# After 2 polls (i.e. on the 3rd), request STOP. This simulates
# the user clicking the STOP button while the encode is running.
if poll_calls[0] == 3:
worker._stop = True
# Always return None — the process never exits on its own; the
# STOP branch handles termination.
return None
fake_proc.poll.side_effect = poll_side_effect
# First proc.wait (after SIGTERM) raises TimeoutExpired -> triggers
# the SIGKILL escalation branch. Second proc.wait (after SIGKILL)
# returns None (process reaped).
fake_proc.wait.side_effect = [
subprocess.TimeoutExpired(cmd=["test"], timeout=5),
None,
]
monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)
status, rc, stdout, stderr = worker._run_with_stop_check(
cmd=["av1an", "-i", "x.mkv", "-o", "y.mkv"],
timeout=60,
log_prefix=" ",
)
assert status == "stop", (
f"Expected status='stop' when STOP requested, got {status!r}"
)
# SIGTERM must be sent first (graceful), then SIGKILL after the 5s
# grace period expires (simulated by the wait() TimeoutExpired).
assert (99999, signal.SIGTERM) in killpg_calls, (
f"SIGTERM not sent to process group. killpg calls: {killpg_calls}"
)
assert (99999, signal.SIGKILL) in killpg_calls, (
f"SIGKILL not sent after wait() timed out. killpg calls: {killpg_calls}"
)
# SIGTERM should come before SIGKILL (graceful before forceful).
sigterm_idx = killpg_calls.index((99999, signal.SIGTERM))
sigkill_idx = killpg_calls.index((99999, signal.SIGKILL))
assert sigterm_idx < sigkill_idx, (
f"SIGTERM must be sent before SIGKILL. calls: {killpg_calls}"
)
def test_happy_path_completes_normally(opentranscode_module, monkeypatch):
"""Encode exits normally -> status="ok", rc=0, no signals sent.
poll() returns 0 immediately (process exited cleanly). No STOP, no
timeout, no os.killpg calls.
"""
worker = make_minimal_worker(opentranscode_module)
worker._stop = False
monkeypatch.setattr("time.sleep", lambda *a, **k: None)
killpg_calls: list[tuple[int, int]] = []
monkeypatch.setattr("os.killpg", lambda pgid, sig: killpg_calls.append((pgid, sig)))
monkeypatch.setattr("os.getpgid", lambda pid: 99999)
fake_proc = MagicMock()
fake_proc.pid = 12345
fake_proc.stdout = io.StringIO("av1an progress line\n")
fake_proc.stderr = io.StringIO("")
# First poll returns 0 (process exited cleanly with success).
fake_proc.poll.return_value = 0
fake_proc.wait.return_value = 0
monkeypatch.setattr("subprocess.Popen", lambda *a, **k: fake_proc)
status, rc, stdout, stderr = worker._run_with_stop_check(
cmd=["av1an", "-i", "x.mkv", "-o", "y.mkv"],
timeout=60,
)
assert status == "ok"
assert rc == 0
assert killpg_calls == [], (
f"No signals should be sent on happy path. killpg calls: {killpg_calls}"
)
# Drainer threads should have captured the stdout content.
assert "av1an progress line" in stdout

117
tests/test_subtitle_mux.py Executable file
View File

@ -0,0 +1,117 @@
"""
Subtitle stream-selection tests for ``EncoderWorker._find_subtitle_stream``.
QA finding: OTC-006 (subtitle muxing logic).
``_find_subtitle_stream`` runs ffprobe on the source file and scans the
subtitle streams for one matching the requested language code. It prefers
"forced" disposition tracks (e.g. forced narrative subtitles for foreign
dialog) over plain tracks of the same language. Returns ``(stream_index,
codec_name)`` or ``(None, "")`` if no match.
The 3 cases:
- 2 eng subtitle streams, one forced -> returns the forced one.
- 1 non-forced eng subtitle -> falls back to that match.
- Only fra subtitles (no eng) -> returns (None, "").
All cases mock ``subprocess.run`` so no real ffprobe is required.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock
from conftest import make_minimal_worker
def _ffprobe_completed_process(payload: dict) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(
args=["ffprobe"], returncode=0,
stdout=json.dumps(payload), stderr="",
)
def test_finds_forced_subtitle(opentranscode_module, mock_env, monkeypatch):
"""2 eng subtitle streams; the one with disposition.forced=1 wins."""
ffprobe_json = {
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "h264"},
{"index": 1, "codec_type": "audio", "codec_name": "aac"},
# First eng subtitle: non-forced.
{"index": 2, "codec_type": "subtitle", "codec_name": "subrip",
"tags": {"language": "eng"},
"disposition": {"forced": 0, "default": 1}},
# Second eng subtitle: forced (e.g. forced narrative).
{"index": 3, "codec_type": "subtitle", "codec_name": "subrip",
"tags": {"language": "eng"},
"disposition": {"forced": 1, "default": 0}},
],
"format": {"duration": "120.0"},
}
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_completed_process(ffprobe_json)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env)
idx, codec = worker._find_subtitle_stream(
Path("/fake/movie.mkv"), lang="eng",
)
assert idx == 3 # forced track wins
assert codec == "subrip"
def test_falls_back_to_any_match(opentranscode_module, mock_env, monkeypatch):
"""1 non-forced eng subtitle -> returns it (no forced track to prefer)."""
ffprobe_json = {
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "h264"},
{"index": 1, "codec_type": "audio", "codec_name": "aac"},
{"index": 2, "codec_type": "subtitle", "codec_name": "ass",
"tags": {"language": "eng"},
"disposition": {"forced": 0, "default": 1}},
],
"format": {"duration": "120.0"},
}
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_completed_process(ffprobe_json)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env)
idx, codec = worker._find_subtitle_stream(
Path("/fake/movie.mkv"), lang="eng",
)
assert idx == 2
assert codec == "ass"
def test_returns_none_when_no_match(opentranscode_module, mock_env, monkeypatch):
"""Only fra subtitles, lang=eng requested -> returns (None, "")."""
ffprobe_json = {
"streams": [
{"index": 0, "codec_type": "video", "codec_name": "h264"},
{"index": 1, "codec_type": "audio", "codec_name": "aac"},
{"index": 2, "codec_type": "subtitle", "codec_name": "subrip",
"tags": {"language": "fra"},
"disposition": {"forced": 0, "default": 1}},
],
"format": {"duration": "120.0"},
}
monkeypatch.setattr(
"subprocess.run",
MagicMock(return_value=_ffprobe_completed_process(ffprobe_json)),
)
worker = make_minimal_worker(opentranscode_module, env=mock_env)
idx, codec = worker._find_subtitle_stream(
Path("/fake/movie.mkv"), lang="eng",
)
assert idx is None
assert codec == ""

51
tests/test_use_av1an_flag.py Executable file
View File

@ -0,0 +1,51 @@
"""
v4.2.0: ffmpeg-first default + --use-av1an opt-in.
QA finding: av1an chunk-parallel path was too fragile across distros.
The default encode path is now ffmpeg-only. av1an is opt-in via
``--use-av1an`` (stored on ``env.av1an_flags["use_av1an"]``).
The cases:
1. ``--use-av1an`` not given ``env.av1an_flags["use_av1an"]`` is False.
2. ``--use-av1an`` given ``env.av1an_flags["use_av1an"]`` is True.
3. CLI parser accepts the flag.
4. ``launch_gui`` propagates the flag to ``env.av1an_flags``.
"""
from __future__ import annotations
import pytest
def test_cli_flag_use_av1an_default_false():
"""Without --use-av1an, args.use_av1an is False (default)."""
from opentranscode.cli import 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
args = build_parser().parse_args(["--use-av1an"])
assert args.use_av1an is True
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
sig = inspect.signature(launch_gui)
assert "use_av1an" in sig.parameters
# Default must be False (ffmpeg-first).
assert sig.parameters["use_av1an"].default is False
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
args = build_parser().parse_args(["--dry-run", "--use-av1an"])
assert args.dry_run is True
assert args.use_av1an is True

33
tests/test_verbose_flag.py Executable file
View File

@ -0,0 +1,33 @@
"""
v4.2.1: --verbose flag (default False).
QA finding: v4.2.0's log output was too noisy. Default is now quiet
(per-file success/fail + final summary). --verbose re-enables the
tech-detail log output.
"""
from __future__ import annotations
def test_cli_flag_verbose_default_false():
"""Without --verbose, args.verbose is False (default = quiet)."""
from opentranscode.cli import 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
args = build_parser().parse_args(["--verbose"])
assert args.verbose is True
def test_launch_gui_signature_accepts_verbose():
"""launch_gui() accepts the verbose kwarg (v4.2.1)."""
import inspect
from opentranscode import launch_gui
sig = inspect.signature(launch_gui)
assert "verbose" in sig.parameters
# Default must be False (quiet by default).
assert sig.parameters["verbose"].default is False