Add Avisynth video encoding workflow
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.avisynth_runner import (
|
||||
AvisynthRunnerSet,
|
||||
runner_set_from_env_or_bundled,
|
||||
validate_runner_path,
|
||||
)
|
||||
from tools.console import prompt_input, prompt_yes_no
|
||||
from tools.timezones import local_timezone, parse_timezone_offset, timezone_to_string
|
||||
from tools.video_encode_output import (
|
||||
ENCODER_PRESETS,
|
||||
AudioEncodeOptions,
|
||||
VideoCodecOptions,
|
||||
default_audio_bitrate,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputNamingOptions:
|
||||
use_timestamps: bool
|
||||
timezone_value: timezone
|
||||
|
||||
|
||||
def ask_ffms2_plugin_path() -> Path | None:
|
||||
env_path = os.environ.get("MBT_FFMS2_PLUGIN", "").strip()
|
||||
if env_path:
|
||||
return _validate_plugin_path(env_path)
|
||||
|
||||
if not sys.stdin.isatty() or not _env_truthy("MBT_ASK_FFMS2_PLUGIN"):
|
||||
return None
|
||||
|
||||
answer = prompt_input(
|
||||
"\nFFMS2 plugin path for Avisynth LoadPlugin "
|
||||
"(blank = use Avisynth autoload): "
|
||||
).strip()
|
||||
if not answer:
|
||||
return None
|
||||
return _validate_plugin_path(answer)
|
||||
|
||||
|
||||
def _env_truthy(name: str) -> bool:
|
||||
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
|
||||
|
||||
def _validate_plugin_path(raw_path: str) -> Path:
|
||||
plugin_path = Path(raw_path.strip().strip('"')).expanduser()
|
||||
if not plugin_path.is_file():
|
||||
raise RuntimeError(f"FFMS2 plugin file not found: {plugin_path}")
|
||||
return plugin_path.resolve()
|
||||
|
||||
|
||||
def ask_avisynth_runners() -> AvisynthRunnerSet | None:
|
||||
runners = runner_set_from_env_or_bundled()
|
||||
if runners is not None:
|
||||
return runners
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return None
|
||||
|
||||
print(
|
||||
"\nAviSynth runner is needed for validation and encoding.\n"
|
||||
"Use media-batch-tools' mbt_avs_runner executable. The script first "
|
||||
"looks for the bundled runner automatically. On Windows, the default is "
|
||||
"64-bit mbt_avs_runner.exe; 32-bit scripts can use "
|
||||
"mbt_avs_runner32.exe via a trailing #32bit marker. If you leave this "
|
||||
"blank, the workspace and editable .avs files are created, but "
|
||||
"validation and encoding stop."
|
||||
)
|
||||
answer = prompt_input(
|
||||
"AviSynth runner executable path (blank = create workspace only): "
|
||||
).strip()
|
||||
if not answer:
|
||||
return None
|
||||
runner = validate_runner_path(answer)
|
||||
return AvisynthRunnerSet(default=runner, runner64=runner)
|
||||
|
||||
|
||||
def require_avisynth_runners_for_encode() -> AvisynthRunnerSet:
|
||||
runners = runner_set_from_env_or_bundled()
|
||||
if runners is not None:
|
||||
return runners
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
raise RuntimeError(
|
||||
"Build tools/avisynth_runner/mbt_avs_runner on Linux, provide "
|
||||
"tools/avisynth_runner/mbt_avs_runner.exe on Windows, or set "
|
||||
"MBT_AVS_RUNNER before enabling video encode. For 32-bit Windows "
|
||||
"AviSynth, set MBT_AVS_RUNNER32."
|
||||
)
|
||||
|
||||
print(
|
||||
"\nEncoding needs media-batch-tools' AviSynth runner executable "
|
||||
"(mbt_avs_runner or mbt_avs_runner.exe)."
|
||||
)
|
||||
answer = prompt_input("AviSynth runner executable path: ").strip()
|
||||
if not answer:
|
||||
raise RuntimeError("Avisynth runner path is required for encoding.")
|
||||
runner = validate_runner_path(answer)
|
||||
return AvisynthRunnerSet(default=runner, runner64=runner)
|
||||
|
||||
|
||||
def ask_encode_video_only() -> bool:
|
||||
env_value = os.environ.get("MBT_ENCODE_VIDEO")
|
||||
if env_value is not None:
|
||||
return env_value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
return prompt_yes_no("Encode validated outputs now?", default=False)
|
||||
|
||||
|
||||
def ask_output_naming_options(
|
||||
*,
|
||||
default_timezone: timezone | None = None,
|
||||
) -> OutputNamingOptions:
|
||||
env_value = os.environ.get("MBT_VIDEO_TIMESTAMP_NAMES")
|
||||
if env_value is not None:
|
||||
use_timestamps = env_value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
||||
elif sys.stdin.isatty():
|
||||
use_timestamps = prompt_yes_no(
|
||||
"Name encoded files from their edited beginning timestamp?",
|
||||
default=True,
|
||||
)
|
||||
else:
|
||||
use_timestamps = False
|
||||
|
||||
default_timezone = default_timezone or local_timezone()
|
||||
env_timezone = os.environ.get("MBT_VIDEO_TIMEZONE", "").strip()
|
||||
if not use_timestamps:
|
||||
timezone_value = default_timezone
|
||||
elif env_timezone:
|
||||
try:
|
||||
timezone_value = parse_timezone_offset(env_timezone)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Invalid MBT_VIDEO_TIMEZONE: {exc}") from exc
|
||||
elif sys.stdin.isatty():
|
||||
while True:
|
||||
answer = prompt_input(
|
||||
"Output filename timezone "
|
||||
f"[{timezone_to_string(default_timezone)}]: "
|
||||
).strip()
|
||||
if not answer:
|
||||
timezone_value = default_timezone
|
||||
break
|
||||
try:
|
||||
timezone_value = parse_timezone_offset(answer)
|
||||
break
|
||||
except ValueError as exc:
|
||||
print(exc)
|
||||
else:
|
||||
timezone_value = default_timezone
|
||||
|
||||
return OutputNamingOptions(
|
||||
use_timestamps=use_timestamps,
|
||||
timezone_value=timezone_value,
|
||||
)
|
||||
|
||||
|
||||
def ask_video_codec_options() -> VideoCodecOptions:
|
||||
codec = _codec_from_env()
|
||||
extension = _container_extension_from_env()
|
||||
crf = _int_from_env("MBT_VIDEO_CRF")
|
||||
preset = os.environ.get("MBT_VIDEO_PRESET", "").strip()
|
||||
profile = _optional_env("MBT_VIDEO_PROFILE")
|
||||
level = _optional_env("MBT_VIDEO_LEVEL")
|
||||
threads = _threads_from_env()
|
||||
|
||||
if sys.stdin.isatty():
|
||||
if extension is None:
|
||||
extension = _prompt_container_extension()
|
||||
if codec is None:
|
||||
codec = _prompt_codec()
|
||||
if crf is None:
|
||||
default_crf = 16 if codec == "libx264" else 21
|
||||
crf = _prompt_int(f"CRF [{default_crf}]: ", default=default_crf)
|
||||
if not preset:
|
||||
preset = _prompt_preset(default="slow")
|
||||
if "MBT_VIDEO_THREADS" not in os.environ:
|
||||
threads = _prompt_threads(default=None)
|
||||
else:
|
||||
extension = extension or ".mp4"
|
||||
codec = codec or "libx265"
|
||||
crf = crf if crf is not None else (16 if codec == "libx264" else 21)
|
||||
preset = preset or "slow"
|
||||
|
||||
return VideoCodecOptions(
|
||||
codec=codec,
|
||||
crf=crf,
|
||||
preset=preset,
|
||||
extension=extension,
|
||||
profile=profile,
|
||||
level=level,
|
||||
threads=threads,
|
||||
)
|
||||
|
||||
|
||||
def _prompt_preset(*, default: str) -> str:
|
||||
options = "/".join(ENCODER_PRESETS)
|
||||
while True:
|
||||
value = prompt_input(f"Encoder preset [{default}] ({options}): ").strip().lower()
|
||||
if not value:
|
||||
return default
|
||||
if value in ENCODER_PRESETS:
|
||||
return value
|
||||
print(f"Preset must be one of: {options}.")
|
||||
|
||||
|
||||
def _threads_from_env() -> int | None:
|
||||
raw = os.environ.get("MBT_VIDEO_THREADS", "").strip().lower()
|
||||
if not raw or raw == "auto":
|
||||
return None
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer") from exc
|
||||
if value < 1:
|
||||
raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _prompt_threads(*, default: int | None) -> int | None:
|
||||
default_text = str(default) if default is not None else "auto"
|
||||
while True:
|
||||
value = prompt_input(f"Encoder threads [{default_text}] (auto or positive integer): ").strip().lower()
|
||||
if not value or value == "auto":
|
||||
return default
|
||||
try:
|
||||
threads = int(value)
|
||||
except ValueError:
|
||||
print("Threads must be auto or a positive integer.")
|
||||
continue
|
||||
if threads >= 1:
|
||||
return threads
|
||||
print("Threads must be auto or a positive integer.")
|
||||
|
||||
|
||||
def ask_audio_options(
|
||||
*,
|
||||
has_speed_changes: bool,
|
||||
container_extension: str,
|
||||
) -> AudioEncodeOptions:
|
||||
env_mode = os.environ.get("MBT_VIDEO_AUDIO", "").strip().lower()
|
||||
env_bitrate = os.environ.get("MBT_VIDEO_AUDIO_BITRATE", "").strip()
|
||||
preserve_pitch = ask_audio_pitch_option(has_speed_changes=has_speed_changes)
|
||||
if env_mode:
|
||||
mode = _normalize_audio_mode(env_mode)
|
||||
_validate_audio_container(mode, container_extension)
|
||||
return AudioEncodeOptions(
|
||||
mode=mode,
|
||||
bitrate=env_bitrate or default_audio_bitrate(mode),
|
||||
preserve_pitch=preserve_pitch,
|
||||
)
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return AudioEncodeOptions(mode="none", preserve_pitch=preserve_pitch)
|
||||
|
||||
while True:
|
||||
if container_extension == ".mkv":
|
||||
prompt = "Audio: Enter=Opus, a=AAC, f=FLAC, c=copy/trim audio, n=no audio: "
|
||||
else:
|
||||
prompt = "Audio: Enter=AAC re-encode, c=copy/trim audio, n=no audio: "
|
||||
answer = prompt_input(prompt).strip().lower()
|
||||
if not answer:
|
||||
mode = "opus" if container_extension == ".mkv" else "aac"
|
||||
break
|
||||
if answer in {"a", "aac"}:
|
||||
mode = "aac"
|
||||
break
|
||||
if container_extension == ".mkv" and answer in {"o", "opus"}:
|
||||
mode = "opus"
|
||||
break
|
||||
if container_extension == ".mkv" and answer in {"f", "flac"}:
|
||||
mode = "flac"
|
||||
break
|
||||
if answer in {"c", "copy"}:
|
||||
mode = "copy"
|
||||
break
|
||||
if answer in {"n", "no", "none"}:
|
||||
mode = "none"
|
||||
break
|
||||
print("Please choose Enter, c, or n.")
|
||||
|
||||
bitrate = default_audio_bitrate(mode)
|
||||
if mode in {"aac", "opus"}:
|
||||
label = "AAC" if mode == "aac" else "Opus"
|
||||
bitrate = prompt_input(f"{label} audio bitrate [{bitrate}]: ").strip() or bitrate
|
||||
return AudioEncodeOptions(mode=mode, bitrate=bitrate, preserve_pitch=preserve_pitch)
|
||||
|
||||
|
||||
def ask_audio_pitch_option(*, has_speed_changes: bool) -> bool:
|
||||
env_value = os.environ.get("MBT_VIDEO_AUDIO_PITCH", "").strip().lower()
|
||||
if env_value:
|
||||
if env_value in {"preserve", "preserved", "keep", "same"}:
|
||||
return True
|
||||
if env_value in {"shift", "change", "changed", "speed"}:
|
||||
return False
|
||||
raise RuntimeError("MBT_VIDEO_AUDIO_PITCH must be preserve or shift")
|
||||
|
||||
if not has_speed_changes or not sys.stdin.isatty():
|
||||
return True
|
||||
|
||||
while True:
|
||||
answer = prompt_input(
|
||||
"Speed-changed audio pitch: Enter=preserve pitch, s=shift with speed: "
|
||||
).strip().lower()
|
||||
if not answer or answer in {"p", "preserve", "keep", "same"}:
|
||||
return True
|
||||
if answer in {"s", "shift", "change", "speed"}:
|
||||
return False
|
||||
print("Please choose Enter or s.")
|
||||
|
||||
|
||||
def _normalize_audio_mode(value: str) -> str:
|
||||
if value in {"aac", "reencode", "re-encode"}:
|
||||
return "aac"
|
||||
if value in {"opus", "libopus"}:
|
||||
return "opus"
|
||||
if value in {"flac", "lossless"}:
|
||||
return "flac"
|
||||
if value in {"copy", "streamcopy", "stream-copy"}:
|
||||
return "copy"
|
||||
if value in {"none", "no", "off", "0"}:
|
||||
return "none"
|
||||
raise RuntimeError("MBT_VIDEO_AUDIO must be aac, opus, flac, copy, or none")
|
||||
|
||||
|
||||
def _validate_audio_container(mode: str, container_extension: str) -> None:
|
||||
if container_extension == ".mp4" and mode in {"opus", "flac"}:
|
||||
raise RuntimeError("MP4 output supports aac, copy, or none audio in this tool")
|
||||
|
||||
|
||||
def _container_extension_from_env() -> str | None:
|
||||
raw = os.environ.get("MBT_VIDEO_CONTAINER", "").strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
return _normalize_container_extension(raw)
|
||||
|
||||
|
||||
def _prompt_container_extension() -> str:
|
||||
while True:
|
||||
answer = prompt_input("Output container: Enter=MP4, m=MKV: ").strip().lower()
|
||||
if not answer:
|
||||
return ".mp4"
|
||||
try:
|
||||
return _normalize_container_extension(answer)
|
||||
except ValueError as exc:
|
||||
print(exc)
|
||||
|
||||
|
||||
def _normalize_container_extension(value: str) -> str:
|
||||
if value in {"mp4", ".mp4"}:
|
||||
return ".mp4"
|
||||
if value in {"m", "mkv", ".mkv", "matroska"}:
|
||||
return ".mkv"
|
||||
raise ValueError("container must be mp4 or mkv")
|
||||
|
||||
|
||||
def _codec_from_env() -> str | None:
|
||||
raw = os.environ.get("MBT_VIDEO_CODEC", "").strip().lower()
|
||||
if not raw:
|
||||
return None
|
||||
return _normalize_codec(raw)
|
||||
|
||||
|
||||
def _prompt_codec() -> str:
|
||||
while True:
|
||||
answer = prompt_input("Video codec: x264, Enter=x265: ").strip().lower()
|
||||
if not answer:
|
||||
return "libx265"
|
||||
try:
|
||||
return _normalize_codec(answer)
|
||||
except ValueError as exc:
|
||||
print(exc)
|
||||
|
||||
|
||||
def _normalize_codec(value: str) -> str:
|
||||
if value in {"x264", "h264", "libx264"}:
|
||||
return "libx264"
|
||||
if value in {"x265", "h265", "hevc", "libx265"}:
|
||||
return "libx265"
|
||||
raise ValueError("codec must be x264 or x265")
|
||||
|
||||
|
||||
def _int_from_env(name: str) -> int | None:
|
||||
raw = os.environ.get(name, "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"{name} must be an integer") from exc
|
||||
|
||||
|
||||
def _optional_env(name: str) -> str | None:
|
||||
value = os.environ.get(name, "").strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _prompt_int(prompt: str, *, default: int) -> int:
|
||||
while True:
|
||||
answer = prompt_input(prompt).strip()
|
||||
if not answer:
|
||||
return default
|
||||
try:
|
||||
return int(answer)
|
||||
except ValueError:
|
||||
print("Please enter an integer.")
|
||||
Reference in New Issue
Block a user