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.")