#!/usr/bin/env python3 """ Prepare, validate, and encode Avisynth-edited video outputs. Drag files or directories onto this script, or run: python3 video_encode.py path/to/file/or/directory [...] The script collects supported videos, probes timing and metadata, creates an Avisynth workspace, validates frame identity metadata, and can encode supported outputs. Source videos are only modified if the final disposition explicitly replaces or moves them. """ from __future__ import annotations import json import os import subprocess import sys from pathlib import Path from tools.avisynth_render import AvisynthClipInfo from tools.avisynth_workspace import ( existing_visible_scripts, validation_script_path, visible_script_path, workspace_root, write_workspace, ) from tools.console import ProgressView, pause_if_interactive, prompt_input, prompt_yes_no from tools.exiftool import require_exiftool, run_exiftool_json from tools.filesystem import determine_working_dir from tools.video_batch_encode import encode_validated_video_only from tools.video_inputs import VideoInput, collect_video_inputs from tools.video_options import ( ask_avisynth_runners, ask_encode_video_only, ask_ffms2_plugin_path, ) from tools.video_probe import probe_video, require_tool from tools.video_reporting import print_probe_summary from tools.video_timeline import OutputTimeline from tools.video_validation_workflow import run_validations def run(argv: list[str]) -> int: if len(argv) < 2: print("Usage: python3 video_encode.py path/to/file/or/directory [...]") return 2 try: working_dir = determine_working_dir(argv[1:]) inputs = collect_video_inputs(argv[1:]) except RuntimeError as exc: print(exc) return 1 if not inputs: print("No supported video files found.") return 1 try: ffprobe = require_tool("ffprobe") exiftool = require_exiftool() except RuntimeError as exc: print(exc) return 1 print(f"Working directory: {working_dir}") print(f"Found {len(inputs)} supported video file(s).") print("\nCollapsed drag tree:") for item in inputs: print(f" - {item.collapsed_relative} -> {item.path}") try: progress = ProgressView(len(inputs), "Reading metadata and frame timing") probes = [] for index, item in enumerate(inputs, start=1): metadata_items = run_exiftool_json( exiftool, [item.path], quicktime_utc=False, ) metadata = metadata_items[0] if metadata_items else {} probes.append(probe_video(ffprobe, item.path, metadata)) progress.update(index) progress.finish() except (subprocess.CalledProcessError, json.JSONDecodeError, RuntimeError) as exc: print(f"Failed to probe videos: {exc}") return 1 print_probe_summary(inputs, probes) probes_by_path = {probe.path.resolve(): probe for probe in probes} try: ffms2_plugin_path = ask_ffms2_plugin_path() except RuntimeError as exc: print(exc) return 1 root = workspace_root(Path(__file__)) try: delete_stale_visible_scripts(root, inputs) except RuntimeError as exc: print(exc) return 1 overwrite_visible = False existing_scripts = existing_visible_scripts(root, inputs) if existing_scripts: print("\nExisting editable Avisynth scripts:") for script in existing_scripts: print(f" - {script}") if sys.stdin.isatty(): try: overwrite_visible = prompt_yes_no( "Overwrite existing editable .avs scripts?", default=False ) except RuntimeError as exc: print(exc) return 1 if not overwrite_visible: print("Keeping existing editable .avs scripts.") else: print("Keeping existing editable .avs scripts.") try: written, skipped_visible = write_workspace( root=root, items=inputs, probes_by_path=probes_by_path, overwrite_visible=overwrite_visible, ffms2_plugin_path=ffms2_plugin_path, ) except (OSError, RuntimeError) as exc: print(f"Failed to create workspace: {exc}") return 1 try: avs_runners = ask_avisynth_runners() if avs_runners is not None and skipped_visible: invalid_scripts = preflight_kept_visible_scripts( avs_runners=avs_runners, root=root, inputs=inputs, skipped_visible=skipped_visible, ) if invalid_scripts: if sys.stdin.isatty(): overwrite_invalid = prompt_yes_no( "Overwrite invalid kept editable .avs scripts?", default=True, ) else: overwrite_invalid = False if overwrite_invalid: try: rewritten, skipped_visible = write_workspace( root=root, items=inputs, probes_by_path=probes_by_path, overwrite_visible=set(invalid_scripts), ffms2_plugin_path=ffms2_plugin_path, ) except (OSError, RuntimeError) as exc: print(f"Failed to recreate workspace: {exc}") return 1 written.extend(rewritten) else: raise RuntimeError( "Kept editable scripts are invalid and were not overwritten." ) except RuntimeError as exc: print(exc) return 1 print_workspace_summary(root, inputs, written, skipped_visible) validation_mode = wait_for_script_edits() if validation_mode is None: return 0 try: timelines: dict[Path, OutputTimeline] = {} clip_infos: dict[Path, AvisynthClipInfo] = {} if avs_runners is not None: timelines, clip_infos = run_validations( avs_runners, root, inputs, probes_by_path, blank_source=validation_mode == "fast", ffms2_plugin_path=ffms2_plugin_path, ) else: print("\nValidation: skipped; no Avisynth runner path provided.") if timelines: if sys.stdin.isatty() and "MBT_ENCODE_VIDEO" not in os.environ: encode_validated_video_only( root, inputs, timelines, clip_infos, probes_by_path, exiftool, avs_runners, ffprobe, ) elif ask_encode_video_only(): encode_validated_video_only( root, inputs, timelines, clip_infos, probes_by_path, exiftool, avs_runners, ffprobe, ) except (RuntimeError, subprocess.CalledProcessError) as exc: print(f"\nVideo workflow failed: {exc}") if isinstance(exc, subprocess.CalledProcessError): if exc.stdout: print(exc.stdout) if exc.stderr: print(exc.stderr) return 1 return 0 def print_workspace_summary( root: Path, inputs: list[VideoInput], written: list[Path], skipped_visible: list[Path], ) -> None: print("\nWorkspace:") print(f" root: {root}") print(f" wrote {len(written)} file(s)") existing = {script.resolve() for script in skipped_visible} new_scripts = [ visible_script_path(root, item) for item in inputs if visible_script_path(root, item).resolve() not in existing ] if skipped_visible: print(" existing editable scripts:") for script in skipped_visible: print(f" - {script}") if new_scripts: heading = "new editable scripts:" if skipped_visible else "editable scripts:" print(f" {heading}") for script in new_scripts: print(f" - {script}") def delete_stale_visible_scripts(root: Path, inputs: list[VideoInput]) -> None: stale = stale_visible_scripts(root, inputs) if not stale: return print("\nOld editable Avisynth scripts not used by this batch:") for script in stale: print(f" - {script}") if sys.stdin.isatty(): delete = prompt_yes_no("Delete old editable .avs scripts?", default=True) else: delete = False if not delete: return for script in stale: script.unlink() def stale_visible_scripts(root: Path, inputs: list[VideoInput]) -> list[Path]: if not root.exists(): return [] current = {visible_script_path(root, item).resolve() for item in inputs} stale: list[Path] = [] for script in root.rglob("*.avs"): relative = script.relative_to(root) if relative.parts and relative.parts[0] in {".source", "edit"}: continue if script.resolve() not in current: stale.append(script) return sorted(stale) def preflight_kept_visible_scripts( *, avs_runners, root: Path, inputs: list[VideoInput], skipped_visible: list[Path], ) -> list[Path]: skipped = {path.resolve() for path in skipped_visible} invalid: list[Path] = [] for source_id, item in enumerate(inputs): visible = visible_script_path(root, item) if visible.resolve() not in skipped: continue runner = avs_runners.for_script(visible) validation_script = validation_script_path(root, item) completed = subprocess.run( [ str(runner), "--check-source", str(source_id), str(validation_script), ], capture_output=True, text=True, ) if completed.returncode == 0: continue invalid.append(visible) reason = (completed.stderr or completed.stdout).strip() or ( f"runner exited with code {completed.returncode}" ) print(f" invalid kept script: {visible}") print(f" {reason}") return invalid def wait_for_script_edits() -> str | None: if not sys.stdin.isatty(): return "fast" while True: answer = prompt_input( "\nEdit the .avs scripts now if needed. Press Enter for fast validation, " "r for real-frame validation, or q to stop after workspace creation: " ).strip().lower() if not answer or answer in {"f", "fast"}: return "fast" if answer in {"r", "real", "slow"}: return "real" if answer in {"q", "quit", "exit", "stop"}: return None print("Please press Enter, type r for real-frame validation, or type q to stop.") def main(argv: list[str]) -> int: try: return run(argv) except Exception as exc: print(f"\nVideo workflow failed unexpectedly ({type(exc).__name__}): {exc}") return 1 finally: pause_if_interactive() if __name__ == "__main__": raise SystemExit(main(sys.argv))