OpenTranscode/opentranscode/cli.py

362 lines
17 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Command-line interface for opentranscode.
Provides three flags:
- ``--version`` — print the package version and exit (0).
- ``--dry-run`` — probe the environment, run the av1an VSScript
smoke test if av1an is available, print a report, and exit. Does
NOT launch the GUI and does NOT encode anything.
- ``--verify-only PATH`` — re-verify an existing output file's size,
resolution, and duration via ffprobe, without re-encoding.
With no flag, ``main()`` defers to ``ui_window.launch_gui()``.
Heavy imports (``env_probe``, ``ffprobe_utils``, ``ui_window``) are
deferred into the bodies of ``run_dry_run`` / ``run_verify_only`` /
the no-flag branch so that ``--version`` does not pull in PySide6.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
def build_parser() -> argparse.ArgumentParser:
"""Build the CLI argument parser."""
parser = argparse.ArgumentParser(
prog="opentranscode",
description="Open-source batch video transcoder (av1an + ffmpeg)",
)
parser.add_argument(
"--version", action="store_true",
help="Print version and exit",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Probe environment, run smoke test, print report — but do "
"NOT launch GUI or encode anything",
)
parser.add_argument(
"--verify-only", metavar="PATH",
help="Re-verify an existing output file (size, resolution, "
"duration checks) without re-encoding",
)
# v5-01: --force pre-checks the "Force (skip validation)" checkbox in
# the GUI. This is a convenience flag — the checkbox can also be toggled
# manually in the UI.
parser.add_argument(
"--force", action="store_true",
help="Pre-check the 'Force (skip validation)' checkbox in the GUI. "
"Skips ffprobe pre-validation and attempts encode even for "
"files ffprobe cannot read. WARNING: invalid files will waste "
"the full per-file timeout before failing.",
)
# v4.0.0: --chunk-method overrides av1an's chunk-method selection. Useful
# for debugging the "works up until near the end, never saves chunks
# into a full file" bug (Hybrid chunk method on phone-recorded MP4s).
# When set, the value is written to env.av1an_flags["chunk_method_override"]
# before the GUI launches, so every EncoderWorker picks it up.
parser.add_argument(
"--chunk-method", metavar="METHOD",
choices=["auto", "select", "hybrid", "segment", "ffms2",
"lsmash", "bestsource", "dgdecnv"],
help="Force av1an to use a specific chunk method. 'select' is the "
"most reliable (uses VapourSynth's select() filter) but slowest. "
"'hybrid' (av1an's default when no VS plugins) fails on phone-"
"recorded MP4s with sparse keyframes. 'ffms2'/'lsmash'/"
"'bestsource' require the corresponding VapourSynth plugin. "
"'auto' lets av1an decide (default).",
)
# v4.1.0: intelligent chunking overrides. When neither flag is given,
# EncoderWorker computes (worker_count, threads_per_worker) from CPU
# topology so worker_count * threads_per_worker <= logical_threads - 1.
# This prevents the thread-oversubscription hard-lock that v4.0.0 hit
# on high-core-count machines (13 workers × 28 threads = 364 threads
# on 28 logical CPUs → kernel scheduler drowns).
parser.add_argument(
"--max-workers", type=int, metavar="N",
help="Cap chunk-parallel worker count (av1an's --workers). When "
"omitted, computed from CPU topology (budget // 4 threads per "
"worker, capped at physical_cores - 1). Set lower than the "
"auto-computed value if the box hard-locks even with the "
"thread cap, or higher if you have fast storage and want "
"more parallelism. Combine with --threads-per-worker to "
"fully override the auto math.",
)
parser.add_argument(
"--threads-per-worker", type=int, metavar="N",
help="Per-encoder thread cap (passed to SvtAv1EncApp / vpxenc / "
"x265 via --video-params --threads N). When omitted, computed "
"as max(1, budget // worker_count). Default behavior caps "
"total active threads at logical_threads - 1 (one for OS/UI). "
"Set higher if you have few large files and want each chunk "
"to use more cores; set to 1 for maximum chunk parallelism "
"on memory-bandwidth-bound workloads.",
)
# v4.2.0: --use-av1an opts INTO the av1an chunk-parallel path. The
# default is now ffmpeg-only — av1an was too fragile across distros
# (y4m pipe breaks, SvtAv1EncApp CLI quirks like rejecting --threads,
# VapourSynth plugin issues, output buffering making it look hung).
# ffmpeg's libsvtav1 is invoked as a library, accepts -threads
# correctly, doesn't need VapourSynth, and produces immediate progress
# output. av1an is still available for users who specifically want
# scene-detection-based chunk-parallel encoding.
parser.add_argument(
"--use-av1an", action="store_true",
help="Use av1an chunk-parallel encoding (opt-in). Default is "
"ffmpeg-only, which is more reliable across distros. av1an "
"requires VapourSynth + source plugins (lsmash/ffms2/"
"bestsource) for fast chunk-parallel; without them it "
"falls back to the slow 'select' chunk method. Only use "
"--use-av1an if you have a working av1an+VapourSynth setup "
"and want scene-detection-based chunk-parallel encoding.",
)
# v4.2.1: --verbose re-enables the tech-detail log output that v4.2.1
# suppressed by default. Default is quiet — just per-file success/fail
# + final summary. --verbose brings back the CMD: lines, live tail of
# av1an/ffmpeg stderr, DIAGNOSIS blocks, resolution map, pre-flight
# validation table, and the 30s heartbeat.
parser.add_argument(
"--verbose", action="store_true",
help="Verbose log output. Default is quiet — only per-file "
"success/fail + final summary. --verbose brings back the "
"CMD: lines, live tail of av1an/ffmpeg stderr (frame= 67 "
"fps= 12 ...), DIAGNOSIS blocks, resolution map, pre-flight "
"validation table, and the 30s heartbeat.",
)
# v4.3.0: --skip-existing is the default. When the output file
# already exists AND its video+audio codec matches the selected
# encoder (verified via ffprobe), the file is skipped instead of
# re-encoded. --force-reencode disables this for users who want
# to re-encode at a different CRF/preset with the same codec.
parser.add_argument(
"--skip-existing", dest="skip_existing", action="store_true",
default=True,
help="Skip files whose output already exists with a matching "
"video+audio codec (default). Probes the output with "
"ffprobe and compares codec_name against the selected "
"encoder. Skipped files are reported in the final summary "
"as 'Skipped: N' and do NOT count as success or failure.",
)
parser.add_argument(
"--force-reencode", dest="skip_existing", action="store_false",
help="Re-encode every file, even if the output already exists "
"with a matching codec. Use this when you want to change "
"CRF/preset at the same codec — the skip-existing check "
"doesn't verify encoder settings, only the codec itself.",
)
# v4.4.0: --timeout sets the per-file encode timeout (seconds).
# Default 86400s = 24h, up from v4.0.0's 7200s = 2h. A 30GB 1080p
# BluRay rip at SVT-AV1 preset 6 takes 4-10 hours; the old 2h
# timeout killed massive-file encodes partway through. The STOP
# button handles user-initiated aborts; this is just a safety net
# for truly wedged processes.
parser.add_argument(
"--timeout", type=int, metavar="SECONDS", default=86400,
help="Per-file encode timeout in seconds (default 86400 = 24h). "
"A 30GB BluRay rip at SVT-AV1 preset 6 can take 4-10 hours; "
"the old default (7200s = 2h) killed massive-file encodes. "
"The STOP button handles user-initiated aborts; this timeout "
"is just a safety net for truly wedged processes. Set to 0 "
"for no timeout (not recommended — a wedged encode would "
"hang the queue forever).",
)
return parser
def run_dry_run(
chunk_method: str | None = None,
max_workers: int | None = None,
threads_per_worker: int | None = None,
) -> int:
"""Run the dry-run: probe env + smoke test, print report, return exit code."""
# Deferred imports so --version never pulls in PySide6 or runs the
# environment probe.
from . import __version__
from .env_probe import _av1an_vsscript_smoke_test, probe_environment
print(f"opentranscode {__version__} — dry-run environment probe")
print("=" * 60)
env = probe_environment()
# v4.0.0: --chunk-method CLI override takes precedence over the
# env_probe auto-detection. "auto" means "let av1an decide" (clears
# any override the probe set).
cli_chunk_method_note = ""
if chunk_method is not None:
if chunk_method == "auto":
env.av1an_flags.pop("chunk_method_override", None)
cli_chunk_method_note = " (CLI: auto — cleared probe setting)"
else:
env.av1an_flags["chunk_method_override"] = chunk_method
cli_chunk_method_note = f" (CLI: {chunk_method})"
# v4.1.0: --max-workers / --threads-per-worker are stored on
# env.av1an_flags so EncoderWorker picks them up via __init__'s
# fallback path (no ui_window.py code changes needed).
if max_workers is not None:
env.av1an_flags["max_workers"] = max_workers
if threads_per_worker is not None:
env.av1an_flags["threads_per_worker"] = threads_per_worker
print(f"Distro: {env.distro.name} (family={env.distro.family}, "
f"v{env.distro.version_id})")
print(f"CPU: {env.cpu.model_name}"
f"{env.cpu.physical_cores} physical / {env.cpu.logical_threads} logical")
print(f"av1an: {env.av1an_path or 'NOT FOUND'}"
+ (f" (v{env.av1an_version})" if env.av1an_version else ""))
print(f"ffmpeg: {env.ffmpeg_path or 'NOT FOUND'}"
+ (f" (v{env.ffmpeg_version})" if env.ffmpeg_version else ""))
print(f"ffprobe: {env.ffprobe_path or 'NOT FOUND'}")
print(f"VapourSynth: {env.vs_version or 'NOT FOUND'}"
+ (f" ({env.vs_script_lib})" if env.vs_script_lib else ""))
# v4.0.0: show VS source plugins + effective chunk method
vs_plugins = env.av1an_flags.get("vs_plugins", [])
if vs_plugins:
print(f"VS plugins: {', '.join(vs_plugins)}")
else:
print(f"VS plugins: (none — Hybrid chunk method will fail on "
f"phone-recorded MP4s)")
effective_cm = env.av1an_flags.get("chunk_method_override")
print(f"Chunk method: {effective_cm or 'auto (av1an decides)'}{cli_chunk_method_note}")
# v4.1.0: show intelligent worker math so the user can verify the
# chunk-parallel thread budget before launching a real encode.
# We instantiate EncoderWorker without starting the QThread to read
# the computed values — __init__ doesn't touch Qt, only sets attrs.
try:
from .encoder_worker import EncoderWorker
from .codec_profiles import VIDEO_CODECS, AUDIO_PROFILES, CONTAINER_PROFILES, RESOLUTION_PRESETS
from pathlib import Path
# Use a stub in_dir/out_dir — run() is never called, only the
# _compute_intelligent_worker_count method is invoked.
probe_worker = EncoderWorker(
in_dir=Path("/tmp"),
out_dir=Path("/tmp"),
video_codec=VIDEO_CODECS[0],
audio_profile=AUDIO_PROFILES[0],
container=CONTAINER_PROFILES[0],
crf=30,
preset_label="Medium (6)",
delete_source=False,
env=env,
extensions={".mkv"},
resolution=RESOLUTION_PRESETS[0],
max_workers=max_workers,
threads_per_worker=threads_per_worker,
)
wc, tpw = probe_worker._compute_intelligent_worker_count()
active = wc * tpw
reserved = max(0, env.cpu.logical_threads - active)
overrides = []
if max_workers is not None:
overrides.append(f"--max-workers={max_workers}")
if threads_per_worker is not None:
overrides.append(f"--threads-per-worker={threads_per_worker}")
override_note = f" (overrides: {', '.join(overrides)})" if overrides else " (auto)"
print(f"Workers: {wc} workers × {tpw} threads = {active} active"
f"{reserved} reserved for OS/UI{override_note}")
except Exception as e:
# Don't fail the dry-run if the worker probe hits an edge case.
print(f"Workers: (could not compute: {e})")
print("ffmpeg libs: " + ", ".join(
f"{k}={'yes' if v else 'no'}" for k, v in sorted(env.ffmpeg_libs.items())
))
if env.errors:
print("\nERRORS:")
for e in env.errors:
print(f" - {e}")
if env.warnings:
print("\nWARNINGS:")
for w in env.warnings:
print(f" - {w}")
# Smoke test only if av1an + ffmpeg are both present.
if env.av1an_path and env.ffmpeg_path:
print("\n--- av1an VSScript smoke test ---")
svt_name = (env.av1an_flags or {}).get("svt_name", "svt_av1")
ok, detail = _av1an_vsscript_smoke_test(
env.av1an_path, env.ffmpeg_path, env.av1an_flags, svt_name,
)
print(f" result: {'OK' if ok else 'FAIL'}")
print(f" detail: {detail}")
if not ok:
print("\nDry-run complete — smoke test FAILED.")
return 1
else:
print("\nSmoke test skipped (av1an or ffmpeg not found).")
print("\nDry-run complete.")
return 0 if not env.errors else 1
def run_verify_only(path: str) -> int:
"""Re-verify an existing output file via ffprobe (no re-encode)."""
import os
from .ffprobe_utils import ffprobe_duration, ffprobe_validate
target = Path(path)
if not target.is_file():
print(f"verify-only: file not found: {target}", file=sys.stderr)
return 1
ffprobe_bin = os.environ.get("FFPROBE_BIN", "ffprobe")
info = ffprobe_validate(target, ffprobe_bin)
if info is None:
print(f"verify-only: ffprobe could not read {target}", file=sys.stderr)
return 1
size = target.stat().st_size
duration = ffprobe_duration(target, ffprobe_bin)
streams = info.get("streams", [])
vstream = next((s for s in streams if s.get("codec_type") == "video"), {})
width = vstream.get("width", "?")
height = vstream.get("height", "?")
print(f"file: {target}")
print(f"size: {size} bytes ({size / 1024 / 1024:.2f} MiB)")
print(f"duration: {duration if duration is not None else '?'} s"
if duration is not None else "duration: ?")
print(f"resolution: {width}x{height}")
print("\nverify-only: OK" if size > 0 else "\nverify-only: FAIL (empty file)")
return 0 if size > 0 else 1
def main(argv: list[str] | None = None) -> int:
"""CLI entry point. Returns the process exit code."""
args = build_parser().parse_args(argv)
if args.version:
from . import __version__
print(f"opentranscode {__version__}")
return 0
if args.dry_run:
return run_dry_run(
chunk_method=args.chunk_method,
max_workers=args.max_workers,
threads_per_worker=args.threads_per_worker,
)
if args.verify_only:
return run_verify_only(args.verify_only)
# No flag (or --force) — launch GUI. --force pre-checks the Force
# checkbox; the user can still toggle it in the UI.
# v4.0.0: --chunk-method sets env.av1an_flags["chunk_method_override"]
# before the GUI launches so every EncoderWorker picks it up.
# v4.1.0: --max-workers / --threads-per-worker do the same — stored
# on env.av1an_flags and picked up by EncoderWorker.__init__'s
# fallback path (no ui_window.py changes needed).
from .ui_window import launch_gui
return launch_gui(
force=args.force,
chunk_method=args.chunk_method,
max_workers=args.max_workers,
threads_per_worker=args.threads_per_worker,
use_av1an=args.use_av1an,
verbose=args.verbose,
skip_existing=args.skip_existing,
timeout=args.timeout,
)