A single-file, batch transcoding GUI for Linux built with PySide6. Wraps av1an for chunk-parallel AV1/VP9/x265 encoding with a retro-futuristic MMD3 media console aesthetic.

This commit is contained in:
Jeremy Anderson 2026-07-13 08:05:06 -04:00
commit a1168945f4
35 changed files with 64892 additions and 0 deletions

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/>.

143
README.md Executable file
View File

@ -0,0 +1,143 @@
# OpenTranscode(v4)
v4 adds: real end-to-end stability verification (caught a real bug!), CLI argument parsing, package split into `opentranscode/`, and `pyproject.toml` ready for PyPI (publishing deferred until maintainer confirms stability).
## Quick start
### Option A: Install as a package (recommended)
```bash
cd /path/to/this/directory
pip install -e . # editable install (dev)
# OR
pip install -e ".[dev]" # with pytest + build tools
# Now you can run it three ways:
opentranscode # console entry point
python -m opentranscode # module entry point
python -m opentranscode --version # → opentranscode 3.0.0
```
### Option B: Run the single-file v3 (backwards compat)
```bash
python open-transcode.v3.py # the v3 single-file version still works
```
### Option C: Verify your environment without encoding
```bash
opentranscode --dry-run # probe env + smoke test, no GUI, no encode
opentranscode --verify-only /path/to/existing_output.mkv # re-verify an output
```
## Files
| Path | Description |
|------|-------------|
| `opentranscode/` | **v4 package** — 16 modules, 5,744 lines. Importable as `import opentranscode`. |
| `pyproject.toml` | PEP 621 build config. Entry point: `opentranscode = opentranscode.__main__:main`. Ready for `pip install -e .` and `python -m build`. **Not yet published to PyPI.** |
| `tests/` | 70 tests across 10 files: 24 mocked unit tests + 10 real-ffmpeg e2e tests + 36 package-structure tests. All pass in ~9s. |
| `open-transcode.v3.py` | Single-file v3 (5,370 lines). Kept for backwards compat + as the test target for the mocked tests. |
| `open-transcode.v2.py` | Single-file v2 (4,874 lines). Kept for diff reference. |
| `OpenTranscode_QA_Report.pdf` | The master QA report (26 pages). |
| `pytest.ini` | pytest config (also in pyproject.toml). |
| `README.md` | This file. |
## v4 changes (3 items)
| ID | Severity | Description |
|----|----------|-------------|
| v4-01 | **Critical bug fix** | Real end-to-end tests caught a bug in `_probe_ffmpeg_libs`: the search strings for `libsvtav1` and `libaom` were wrong (used `_` instead of nothing/hyphen). This caused the probe to report False for both even when installed, which would have made `_handle_vs_incompat` tell users "ffmpeg also lacks libsvtav1" and abort — even though ffmpeg actually had it. **Fixed in v3 + v4.** The e2e test `test_probe_detects_ffmpeg_libs` is the regression guard. |
| v4-02 | Feature | CLI argument parsing: `--version`, `--dry-run`, `--verify-only PATH`, `--help`. Lets users verify their setup without committing to a full encode. |
| v4-03 | Architectural | Split single-file v3 (5,370 lines) into `opentranscode/` package (16 modules, 5,744 lines). Dependency graph is acyclic; `import opentranscode` works without PySide6 installed (lazy imports). |
| v4-04 | Packaging | `pyproject.toml` — PEP 621 compliant, entry point `opentranscode = opentranscode.__main__:main`. `pip install -e .` works. `python -m build` produces sdist+wheel. **NOT published to PyPI** (per maintainer request). |
| v4-05 | Test coverage | 36 new package-structure tests verifying: metadata, submodule imports, public API surface, CLI parser, `FFMPEG_LIB_KEY_MAP`, `DISTRO_REGISTRY` (6 entries), entry points. |
| v4-06 | Docs | This README. |
## The critical v4-01 fix (in detail)
The e2e test suite runs REAL ffmpeg encodes and verifies the output files.
The first run caught this bug:
```python
# v3 (BUGGY):
checks = [
("libsvtav1", ["libsvt_av1", "svt_av1"]), # WRONG — ffmpeg prints "libsvtav1"
("libaom", ["libaom_av1", "aom_av1"]), # WRONG — ffmpeg prints "libaom-av1"
...
]
# v4 (FIXED):
checks = [
("libsvtav1", ["libsvtav1 ", "libsvt_av1", "svt_av1 "]), # correct + backward compat
("libaom", ["libaom-av1 ", "libaom_av1", "aom_av1 "]),
...
]
```
**Impact without the fix:** When av1an's VSScript is broken (the common
case that triggers the ffmpeg fallback path), the user clicks ENCODE,
the smoke test correctly fails, the code checks if ffmpeg has
`libsvtav1` to offer fallback — but `_probe_ffmpeg_libs` returns False
(because the search string doesn't match), so the user sees:
> ABORT: ffmpeg also lacks libsvtav1. Install the encoder binary
> (e.g. SvtAv1EncApp, vpxenc, x265) or use the REBUILD FROM GIT button.
...even though `ffmpeg -encoders` clearly shows `libsvtav1` is available.
The user would then spend time installing SvtAv1EncApp or rebuilding
from git, neither of which is necessary. With the fix, the fallback
dialog correctly offers "Use ffmpeg Fallback" and the encode proceeds.
## Test suite
```bash
cd /path/to/this/directory
python -m pytest tests/ -v
# 70 tests, ~9 seconds:
# 24 mocked unit tests (test_smoke_test, test_encode_pipeline, etc.)
# 10 real-ffmpeg e2e tests (test_e2e_real_encode) — requires ffmpeg + ffprobe
# 36 package-structure tests (test_package_structure)
```
The e2e tests generate a real 2-second test video with ffmpeg, run the
full EncoderWorker pipeline on it (AV1→MKV, x265→MKV, VP9→WebM), and
verify the output file exists, is non-empty, has the correct codec, and
has the expected duration. **If these tests pass, the "actually producing
files" requirement is met.**
## What's deferred
Per the maintainer's request, **PyPI publishing is on hold** until
production stability is confirmed on a real desktop Linux system with
av1an + VapourSynth installed. The `pyproject.toml` is ready; when the
maintainer is ready to publish:
```bash
python -m build # produces dist/opentranscode-3.0.0.tar.gz + .whl
twine upload dist/* # publishes to PyPI
```
## Verification (run these to confirm v4 works)
```bash
# 1. Package imports cleanly
python -c "import opentranscode; print(opentranscode.__version__)" # → 3.0.0
# 2. CLI works
python -m opentranscode --version # → opentranscode 3.0.0
python -m opentranscode --help # → usage
python -m opentranscode --dry-run # → env probe report (if ffmpeg installed)
# 3. All tests pass
python -m pytest tests/ -q # → 70 passed in ~9s
# 4. Install works
pip install -e . # → installs opentranscode + PySide6
opentranscode --version # → opentranscode 3.0.0
# 5. Single-file v3 still works (backwards compat)
python open-transcode.v3.py # → launches GUI (if PySide6 + display)
```

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

File diff suppressed because it is too large Load Diff

5932
open-transcode.py Executable file

File diff suppressed because it is too large Load Diff

56
opentranscode/__init__.py Normal file
View File

@ -0,0 +1,56 @@
"""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 a pure code-organization refactor of the single-file
``open-transcode.v3.py`` script (QA item v4-03). Behavior is identical to
v3; the v3 script is preserved alongside this package for test-back-compat.
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__ = "3.2.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) -> int:
"""Launch the OpenTranscode GUI.
Thin wrapper around ``opentranscode.ui_window.launch_gui``; imported
lazily so that ``import opentranscode`` does not pull in PySide6.
v5-01: *force* pre-checks the "Force (skip validation)" checkbox.
"""
from .ui_window import launch_gui as _launch
return _launch(argv, force=force)

26
opentranscode/__main__.py Normal file
View File

@ -0,0 +1,26 @@
"""``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``.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
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())

162
opentranscode/cli.py Normal file
View File

@ -0,0 +1,162 @@
"""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.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
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.",
)
return parser
def run_dry_run() -> 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()
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 ""))
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()
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.
from .ui_window import launch_gui
return launch_gui(force=args.force)

View File

@ -0,0 +1,264 @@
"""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).
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
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
params_fn: Callable[[int, int], str] # (crf, preset) -> av1an video-params string
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
@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 = ""
@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``.
"""
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},
),
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},
),
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},
),
]
AUDIO_PROFILES: list[AudioProfile] = [
AudioProfile(label="Opus (96k)", params=["-c:a", "libopus", "-b:a", "96k"],
ffmpeg_encoder_name="libopus"),
AudioProfile(label="Opus (128k)", params=["-c:a", "libopus", "-b:a", "128k"],
ffmpeg_encoder_name="libopus"),
AudioProfile(label="Opus (64k)", params=["-c:a", "libopus", "-b:a", "64k"],
ffmpeg_encoder_name="libopus"),
AudioProfile(label="Vorbis (128k)", params=["-c:a", "libvorbis", "-b:a", "128k"],
ffmpeg_encoder_name="libvorbis"),
AudioProfile(label="Vorbis (192k)", params=["-c:a", "libvorbis", "-b:a", "192k"],
ffmpeg_encoder_name="libvorbis"),
AudioProfile(label="FLAC (lossless)", params=["-c:a", "flac"],
ffmpeg_encoder_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",
),
]
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"}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

725
opentranscode/env_probe.py Normal file
View File

@ -0,0 +1,725 @@
"""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``).
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
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"
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
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}"

View File

@ -0,0 +1,123 @@
"""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.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
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 Normal 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()

View File

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

View File

@ -0,0 +1,549 @@
"""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).
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
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

View File

@ -0,0 +1,115 @@
"""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.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
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}"

322
opentranscode/ui_theme.py Normal file
View File

@ -0,0 +1,322 @@
"""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.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
# ──────────────────────────────────────────────
# 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;
}
"""

1274
opentranscode/ui_window.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
"""Widget subpackage for opentranscode UI components.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
from .radio_knob import RadioKnob
__all__ = ["RadioKnob"]

View File

@ -0,0 +1,285 @@
"""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.
Extracted from ``open-transcode.v3.py`` (QA item v4-03 package split).
This is a pure code-organization refactor; behavior is identical to v3.
"""
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))

125
pyproject.toml Normal file
View File

@ -0,0 +1,125 @@
# pyproject.toml — OpenTranscode v3.0.0
#
# PEP 621 compliant. Ready for `pip install -e .` (dev) and
# `python -m build` (sdist+wheel). NOT yet published to PyPI — the
# maintainer wants to confirm production stability first.
#
# Once stable, publish with:
# python -m build
# twine upload dist/*
#
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "opentranscode"
version = "3.2.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 :: 4 - Beta",
"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 Normal 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

362
tests/conftest.py Normal file
View File

@ -0,0 +1,362 @@
"""
Shared pytest fixtures and PySide6 stubs for the OpenTranscode v3 test suite.
QA item: v3-10 pytest integration tests for ``scripts/open-transcode.v3.py``.
Why this file exists
--------------------
The v3 module (``scripts/open-transcode.v3.py``) is a single-file 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 v3 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 v3 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()
# ─────────────────────────────────────────────────────────────────────────────
# v3 module loader
# ─────────────────────────────────────────────────────────────────────────────
V3_SCRIPT_PATH = Path("/home/z/my-project/scripts/open-transcode.v3.py")
@pytest.fixture(scope="session")
def v3_module():
"""Load ``scripts/open-transcode.v3.py`` as a Python module.
The filename contains a dot and a dash (both 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 V3_SCRIPT_PATH.is_file():
pytest.skip(f"v3 script not found at {V3_SCRIPT_PATH}")
spec = importlib.util.spec_from_file_location(
"open_transcode_v3", str(V3_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(v3_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 = v3_module.EnvProbe
DistroProfile = v3_module.DistroProfile
CpuTopology = v3_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
v3 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(v3_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 = v3_module.EncoderWorker.__new__(v3_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

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 v3 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(v3_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(v3_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(v3_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(v3_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(v3_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 v3 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(v3_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(v3_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(v3_module, env=mock_env, audio_level_db=-14.0)
gain = worker._analyze_audio_loudness(Path("/fake/broken.mkv"))
assert gain is None

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 v3). 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(v3_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.
v3_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=v3_module.VIDEO_CODECS[0],
audio_profile=v3_module.AUDIO_PROFILES[0],
container=v3_module.CONTAINER_PROFILES[0],
crf=30,
preset_label="Medium (6)",
delete_source=False,
env=mock_env,
extensions={".mkv"},
resolution=v3_module.RESOLUTION_PRESETS[0], # "Original" (no scaling)
)
worker1 = v3_module.EncoderWorker(**common_kwargs)
worker2 = v3_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()

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.v3.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(v3_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 = v3_module.OpenCodecMaster.__new__(v3_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(v3_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(
v3_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(v3_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(
v3_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(v3_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(
v3_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(v3_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(
v3_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(v3_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(
v3_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(v3_module):
"""Rule 5: VP9 + MP4 -> WARNING.
VP9 in MP4 has uneven player support WebM is the canonical VP9
container.
"""
master = _make_master_for_compat(
v3_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

View File

@ -0,0 +1,654 @@
"""
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
import pytest
# ─────────────────────────────────────────────────────────────────────────────
# Module loading — the v3 file has a dash in its name, can't use import
# ─────────────────────────────────────────────────────────────────────────────
V3_PATH = Path(__file__).resolve().parent.parent / "open-transcode.v3.py"
def _load_v3_module():
if not V3_PATH.exists():
pytest.skip(f"v3 source not found at {V3_PATH}")
spec = importlib.util.spec_from_file_location("open_transcode_v3", str(V3_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 v3_module():
"""Load the v3 module once per module run."""
return _load_v3_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(v3_module, real_ffmpeg, real_ffprobe, tmp_path):
"""Build a minimal EnvProbe with real ffmpeg/ffprobe paths."""
return v3_module.EnvProbe(
distro=v3_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=v3_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, v3_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 v3_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 v3_module.AUDIO_PROFILES if a.label == "Opus (96k)"),
None,
)
assert opus_audio is not None
mkv_container = next(
(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv"),
None,
)
assert mkv_container is not None
original_resolution = next(
(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original"),
None,
)
assert original_resolution is not None
worker = v3_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, v3_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 v3_module.VIDEO_CODECS if c.label == "x265 (HEVC)"),
None,
)
assert x265_codec is not None
opus_audio = next(
(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)"),
None,
)
mkv_container = next(
(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv"),
None,
)
original_resolution = next(
(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original"),
None,
)
worker = v3_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, v3_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 v3_module.VIDEO_CODECS if c.label == "VP9"),
None,
)
assert vp9_codec is not None
opus_audio = next(
(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)"),
None,
)
webm_container = next(
(c for c in v3_module.CONTAINER_PROFILES if c.ext == "webm"),
None,
)
original_resolution = next(
(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original"),
None,
)
worker = v3_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, v3_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 v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mp4_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mp4")
original_resolution = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
worker = v3_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, v3_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 v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_resolution = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
worker = v3_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 v3 fix).
"""
def test_smoke_returns_false_when_av1an_missing(
self, v3_module, real_ffmpeg, tmp_path
):
"""If av1an is not installed, smoke test must return False.
This is the v3 fix for OTC-001. v1 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 = v3_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, v3_module, real_ffmpeg):
"""probe_environment() must find the real ffmpeg on this system."""
env = v3_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, v3_module, real_ffprobe):
"""probe_environment() must find the real ffprobe on this system."""
env = v3_module.probe_environment()
assert env.ffprobe_path is not None
assert "ffprobe" in env.ffprobe_path
def test_probe_detects_ffmpeg_libs(self, v3_module, real_ffmpeg):
"""probe_environment() must detect the codecs ffmpeg was built with."""
env = v3_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, v3_module):
"""probe_environment() must detect a distro family."""
env = v3_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

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(v3_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(v3_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(v3_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(v3_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(v3_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(v3_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

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 single-file v3.
"""
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 "3.2.0" in result.stdout

234
tests/test_smoke_test.py Normal 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 v3 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(v3_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 = v3_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(v3_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 = v3_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(v3_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 = v3_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(v3_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.
v3 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 = v3_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. v3 must return False here."
)
assert detail.startswith("SMOKE_FAIL"), (
f"Expected SMOKE_FAIL detail prefix, got: {detail!r}"
)
assert "rc=1" in detail
# v3 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(v3_module, monkeypatch):
"""``subprocess.TimeoutExpired`` raised
-> returns ``(False, "SMOKE_TIMEOUT: ...")``.
v3 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 = v3_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 Normal 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 v3 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(v3_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(v3_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
# v3 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(v3_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(v3_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 Normal 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(v3_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(v3_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(v3_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(v3_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(v3_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(v3_module, env=mock_env)
idx, codec = worker._find_subtitle_stream(
Path("/fake/movie.mkv"), lang="eng",
)
assert idx is None
assert codec == ""

352
tests/test_v5_behavior.py Normal 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
# ─────────────────────────────────────────────────────────────────────────────
V3_PATH = Path(__file__).resolve().parent.parent / "open-transcode.v3.py"
def _load_v3_module():
if not V3_PATH.exists():
pytest.skip(f"v3 source not found at {V3_PATH}")
_install_pyside6_stubs()
spec = importlib.util.spec_from_file_location("open_transcode_v3", str(V3_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 v3_module():
return _load_v3_module()
# ─────────────────────────────────────────────────────────────────────────────
# Tests
# ─────────────────────────────────────────────────────────────────────────────
class TestIdentifyFileType:
"""v5-03: _identify_file_type() runs `file -b` and returns the type string."""
def test_identifies_text_file(self, v3_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 = v3_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, v3_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 = v3_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, v3_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 = v3_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, v3_module, tmp_path):
"""Nonexistent file should return empty string (not crash)."""
f = tmp_path / "does_not_exist.bin"
result = v3_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, v3_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 = v3_module.EncoderWorker.__new__(v3_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, v3_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 = v3_module.EncoderWorker.__new__(v3_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, v3_module):
"""After 3 consecutive failures, _check_consecutive_failures
should set self._stop = True."""
worker = v3_module.EncoderWorker.__new__(v3_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, v3_module):
"""A success should reset the consecutive failure counter."""
worker = v3_module.EncoderWorker.__new__(v3_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, v3_module):
"""If already stopped (user clicked STOP), don't abort again."""
worker = v3_module.EncoderWorker.__new__(v3_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, v3_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(V3_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, v3_module):
"""The diagnostic section should call _identify_file_type for
the streams/invalid-data patterns."""
src = Path(V3_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, v3_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 v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
env = v3_module.probe_environment()
worker = v3_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)

369
tests/test_v6_behavior.py Normal file
View File

@ -0,0 +1,369 @@
"""
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
V3_PATH = Path(__file__).resolve().parent.parent / "open-transcode.v3.py"
def _load_v3_module():
if not V3_PATH.exists():
pytest.skip(f"v3 source not found at {V3_PATH}")
_install_pyside6_stubs()
spec = importlib.util.spec_from_file_location("open_transcode_v3", str(V3_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 v3_module():
return _load_v3_module()
class TestCanFfmpegFallback:
"""v6-01: _can_ffmpeg_fallback checks if ffmpeg has the encoder."""
def test_returns_true_when_ffmpeg_has_encoder(self, v3_module):
"""When ffmpeg_libs has the encoder, _can_ffmpeg_fallback returns True."""
worker = v3_module.EncoderWorker.__new__(v3_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, v3_module):
"""When ffmpeg_libs does NOT have the encoder, returns False."""
worker = v3_module.EncoderWorker.__new__(v3_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, v3_module):
"""The error_patterns table should include the 'split scores' pattern."""
src = Path(V3_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, v3_module):
"""v6-03: SUMMARY + Average Speed detection for concat failures."""
src = Path(V3_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, v3_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 v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
env = v3_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 = v3_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
# 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, v3_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 v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
# Build env with libsvtav1=False — can't fallback
env = v3_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 = v3_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, v3_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 v3_module.VIDEO_CODECS if c.label == "AV1 (SVT-AV1)")
opus_audio = next(a for a in v3_module.AUDIO_PROFILES if a.label == "Opus (96k)")
mkv_container = next(c for c in v3_module.CONTAINER_PROFILES if c.ext == "mkv")
original_res = next(r for r in v3_module.RESOLUTION_PRESETS if r.category == "original")
env = v3_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 = v3_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)