199 lines
8.2 KiB
Python
199 lines
8.2 KiB
Python
"""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).",
|
|
)
|
|
return parser
|
|
|
|
|
|
def run_dry_run(chunk_method: str | 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})"
|
|
|
|
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}")
|
|
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)
|
|
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.
|
|
from .ui_window import launch_gui
|
|
return launch_gui(force=args.force, chunk_method=args.chunk_method)
|