"""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, )