355 lines
11 KiB
Python
355 lines
11 KiB
Python
#!/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))
|