Add Avisynth video encoding workflow
This commit is contained in:
+354
@@ -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))
|
||||
Reference in New Issue
Block a user