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:
commit
a1168945f4
|
|
@ -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/>.
|
||||||
|
|
@ -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)
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -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
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"]
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
@ -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__.:",
|
||||||
|
]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 == ""
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
"""
|
||||||
|
v6 behavior tests — per-file av1an→ffmpeg 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)
|
||||||
Loading…
Reference in New Issue