Add Avisynth video encoding workflow

This commit is contained in:
ajp_anton
2026-07-21 00:41:00 +00:00
parent f50f465465
commit 49296d6f45
37 changed files with 9487 additions and 76 deletions
+354
View File
@@ -0,0 +1,354 @@
#!/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))