from __future__ import annotations import os import sys import tempfile from dataclasses import dataclass, field, replace from datetime import datetime, timezone from pathlib import Path from tools.avisynth_render import AvisynthClipInfo from tools.avisynth_runner import AvisynthRunnerSet from tools.avisynth_workspace import ( editable_audio_script_path, rendered_audio_wav_path, visible_script_path, ) from tools.console import clear_screen, light_red, prompt_input, prompt_yes_no, red_strikethrough from tools.video_color import ColorMetadata from tools.filenames import format_rounded_filename_stem from tools.hdr10plus import extract_hdr10plus_metadata, select_hdr10plus_frames from tools.timezones import local_timezone, parse_timezone_offset from tools.timezones import timezone_to_string from tools.video_encode_output import ( ENCODER_PRESETS, AudioEncodeOptions, VideoEncodeResult, VideoEncodeRequest, VideoCodecOptions, choose_video_pixel_format, default_audio_bitrate, encode_video_only_y4m, pixel_format_bit_depth, print_encoder_statistics, remux_video, render_audio_wav, ) from tools.video_timestamp_encode import encode_video_with_timestamps from tools.video_encode_plan import ( audio_options_for_probe, audio_tempo_factor, avisynth_duration_differs, encoded_duration_seconds, has_avisynth_speed_changes, normal_deleted_frame_count, preserves_entire_source_timeline, should_preserve_video_timestamps, should_use_audio_segments, timeline_frame_starts, y4m_command_for_timeline, ) from tools.video_codec_constraints import ( VideoConstraintSpec, available_levels, available_profiles, level_supports, profile_supports, resolve_level, resolve_profile, ) from tools.video_inputs import VideoInput from tools.video_formatting import chroma_family, matrix_name from tools.video_outputs import ( EncodedItem, OutputNamingOptions, copy_video_metadata, finalize_encoded_outputs, output_begin_timestamp, planned_output_path, validate_encoded_video, validate_output_color_metadata, write_video_end_timestamp, ) from tools.video_probe import VideoProbe, is_hdr_transfer, probe_video, require_tool from tools.video_reporting import format_validation_summary_lines from tools.video_timeline import OutputTimeline @dataclass(frozen=True) class EncodingOutputSpec: label: str width: int height: int bit_depth: int chroma: str fps: float begin_utc: str summary_lines: tuple[str, ...] video_codec: str | None audio_codecs: tuple[str, ...] @dataclass(frozen=True) class EncodingSettings: naming: OutputNamingOptions codec_options: VideoCodecOptions audio_options: AudioEncodeOptions @dataclass(frozen=True) class EncodingTools: root: Path ffmpeg: Path ffprobe: str exiftool: str avs_runners: AvisynthRunnerSet mkvmerge: Path | None x264: Path | None @dataclass class EncodingAnswers: timezone_default: timezone video_copy_available: bool values: dict[str, object] = field(default_factory=dict) def get(self, key: str, default: object = None) -> object: return self.values.get(key, default) def set(self, key: str, value: object) -> None: old_value = self.values.get(key) self.values[key] = value if old_value is None or old_value == value: return resets = { "codec": {"crf", "preset", "profile", "level", "threads"}, "container": {"audio", "audio_bitrate"}, "timestamp_names": {"timezone"} if value is False else set(), "audio": {"audio_bitrate", "audio_pitch"} if value not in {"aac", "opus"} else set(), } for dependent in resets.get(key, set()): self.values.pop(dependent, None) _ENV_CONTAINERS = { "mp4": ".mp4", ".mp4": ".mp4", "m": ".mkv", "mkv": ".mkv", ".mkv": ".mkv", "matroska": ".mkv", } _ENV_CODECS = { "x264": "libx264", "h264": "libx264", "libx264": "libx264", "x265": "libx265", "h265": "libx265", "hevc": "libx265", "libx265": "libx265", "c": "copy", "copy": "copy", "streamcopy": "copy", "stream-copy": "copy", } def _plan_batch_outputs( *, inputs: list[VideoInput], root: Path, timelines: dict[Path, OutputTimeline], probes_by_path: dict[Path, VideoProbe], naming: OutputNamingOptions, extension: str, ) -> dict[Path, Path]: def plan(*, overwrite_existing: bool) -> dict[Path, Path]: reserved: set[Path] = set() return { item.path.resolve(): planned_output_path( script=visible_script_path(root, item), timeline=timelines[item.path.resolve()], probe=probes_by_path[item.path.resolve()], naming=naming, extension=extension, planned_outputs=reserved, overwrite_existing=overwrite_existing, ) for item in inputs } output_paths = plan(overwrite_existing=True) existing = sorted({path for path in output_paths.values() if path.exists()}) if not existing: return output_paths print("\nExisting output video file(s):") for path in existing: print(f" - {path}") overwrite = ( prompt_yes_no("Overwrite existing output video file(s)?", default=True) if sys.stdin.isatty() else True ) return output_paths if overwrite else plan(overwrite_existing=False) def encode_validated_video_only( root: Path, inputs: list[VideoInput], timelines: dict[Path, OutputTimeline], clip_infos: dict[Path, AvisynthClipInfo], probes_by_path: dict[Path, VideoProbe], exiftool: str, avs_runners: AvisynthRunnerSet, ffprobe: str, ) -> None: inputs = [item for item in inputs if item.path.resolve() in timelines] if not inputs: print("\nNo validated outputs to encode.") return ffmpeg = Path(require_tool("ffmpeg")).resolve() if sys.stdin.isatty() and not _encoding_env_is_set(): settings = ask_interactive_encoding_settings( inputs=inputs, timelines=timelines, clip_infos=clip_infos, probes_by_path=probes_by_path, ) else: settings = _environment_encoding_settings( _default_output_timezone([probes_by_path[item.path.resolve()] for item in inputs]) ) if settings is None: print("\nEncoding skipped.") return naming, codec_options, audio_options = ( settings.naming, settings.codec_options, settings.audio_options, ) specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path) specs_by_path = { item.path.resolve(): spec for item, spec in zip(inputs, specs) } copy_available = _video_copy_available(inputs, timelines, clip_infos, probes_by_path) if codec_options.codec == "copy" and not copy_available: raise RuntimeError( "Video stream copy is only available when every validated script preserves " "the source video unchanged." ) warnings = _stream_copy_warnings( extension=codec_options.extension, video_codec=codec_options.codec, audio_mode=audio_options.mode, specs=specs, ) if warnings: raise RuntimeError("Cannot start with stream-copy container warnings: " + "; ".join(warnings)) if codec_options.codec == "libx264" and any(spec.bit_depth > 10 for spec in specs): raise RuntimeError("x264 cannot encode this batch because at least one output is above 10-bit") needs_timecodes = codec_options.codec != "copy" and any( should_preserve_video_timestamps(timelines[item.path.resolve()]) for item in inputs ) mkvmerge = Path(require_tool("mkvmerge")).resolve() if needs_timecodes else None x264 = ( Path(require_tool("x264")).resolve() if needs_timecodes and codec_options.codec == "libx264" else None ) print(f"\nAvisynth runner: {avs_runners.default}") print(f"FFmpeg: {ffmpeg}") if mkvmerge is not None: print(f"mkvmerge: {mkvmerge}") if x264 is not None: print(f"x264: {x264}") print( "Output naming: " + ( f"timestamps in {timezone_to_string(naming.timezone_value)}" if naming.use_timestamps else "original names" ) ) if codec_options.codec == "copy": print(f"Codec: copy video stream, {codec_options.extension}") else: print( "Codec: " f"{codec_options.codec}, CRF {codec_options.crf}, " f"preset {codec_options.preset}, " f"profile {codec_options.profile or 'none'}, " f"level {codec_options.level or 'none'}, " f"threads {codec_options.threads if codec_options.threads is not None else 'auto'}, " f"{codec_options.extension}" ) print(f"Audio: {_format_audio_options(audio_options)}") output_paths = _plan_batch_outputs( inputs=inputs, root=root, timelines=timelines, probes_by_path=probes_by_path, naming=naming, extension=codec_options.extension, ) tools = EncodingTools(root, ffmpeg, ffprobe, exiftool, avs_runners, mkvmerge, x264) encoded_items: list[EncodedItem] = [] for item_index, item in enumerate(inputs, start=1): timeline = timelines[item.path.resolve()] script = visible_script_path(root, item) avs_runner = avs_runners.for_script(script) print(f"\n{item.collapsed_relative}") if avs_runner != avs_runners.default: print(f" runner: {avs_runner}") clip_info = clip_infos.get(item.path.resolve()) probe = probes_by_path[item.path.resolve()] if codec_options.codec == "copy": encoded_items.append( _remux_item( tools=tools, item=item, item_index=item_index, item_count=len(inputs), probe=probe, timeline=timeline, audio_options=audio_options, output=output_paths[item.path.resolve()], ) ) continue encoded_item = _encode_item( tools=tools, item=item, item_index=item_index, item_count=len(inputs), timeline=timeline, clip_info=clip_info, probe=probe, spec=_constraint_spec(specs_by_path[item.path.resolve()]), codec_options=codec_options, audio_options=audio_options, output=output_paths[item.path.resolve()], ) if encoded_item is not None: encoded_items.append(encoded_item) if not encoded_items: print("\nNo outputs encoded.") return finalize_encoded_outputs(root, encoded_items) def _remux_item( *, tools: EncodingTools, item: VideoInput, item_index: int, item_count: int, probe: VideoProbe, timeline: OutputTimeline, audio_options: AudioEncodeOptions, output: Path, ) -> EncodedItem: if audio_options.mode == "copy": audio_source, source_has_audio = item.path, probe.audio_stream_count > 0 else: audio_source, source_has_audio = prepare_audio_source( avs_runners=tools.avs_runners, root=tools.root, item=item, original_has_audio=probe.audio_stream_count > 0, audio_options=audio_options, ) result = remux_video( ffmpeg=tools.ffmpeg, source=item.path, audio_source=audio_source, output=output, audio_options=audio_options, source_has_audio=source_has_audio, frame_count=len(timeline.frames), progress_label=f"script {item_index}/{item_count}: {item.collapsed_relative}", ) return _finalize_encoded_item( ffprobe=tools.ffprobe, exiftool=tools.exiftool, item=item, probe=probe, timeline=timeline, result=result, expected_duration=timeline.duration_seconds, expected_timestamps=timeline_frame_starts(timeline), expected_audio=audio_options.mode != "none" and source_has_audio, label="remuxed", ) def _encode_item( *, tools: EncodingTools, item: VideoInput, item_index: int, item_count: int, timeline: OutputTimeline, clip_info: AvisynthClipInfo | None, probe: VideoProbe, spec: VideoConstraintSpec, codec_options: VideoCodecOptions, audio_options: AudioEncodeOptions, output: Path, ) -> EncodedItem | None: if probe.hdr_dynamic and not probe.hdr10plus and timeline.transfer in {16, 18}: raise RuntimeError( f"{item.collapsed_relative} has dynamic HDR metadata that is not HDR10+. " "Stream copy preserves it, but re-encoding it is not implemented yet." ) if is_hdr_transfer(probe.color_transfer) and timeline.transfer is None: raise RuntimeError( f"{item.collapsed_relative} is HDR, but the Avisynth runner did not report " "output color properties. Use the current bundled runner before encoding HDR." ) output_bit_depth = clip_info.bit_depth if clip_info else 8 pixel_format = choose_video_pixel_format( tools.ffmpeg, codec_options.codec, output_bit_depth, clip_info.chroma if clip_info else None, ) options = _resolve_codec_options( codec_options, replace(spec, bit_depth=pixel_format_bit_depth(pixel_format)) ) if options.profile or options.level: print( f" constraints: profile {options.profile or 'none'}, " f"level {options.level or 'none'}" ) color = ColorMetadata( timeline.matrix, timeline.primaries, timeline.transfer, timeline.color_range ) static_hdr = ( (probe.mastering_display, probe.max_content_light, probe.max_frame_average_light, timeline.transfer == 16) if timeline.transfer in {16, 18} else None ) if output_bit_depth >= 10: print( f" video: {output_bit_depth}-bit Avisynth output -> " f"{pixel_format_bit_depth(pixel_format)}-bit {pixel_format}" ) if color.colorspace: print(f" color space: {matrix_name(timeline.matrix)} -> ffmpeg {color.colorspace}") preserve_timestamps = should_preserve_video_timestamps(timeline) if not preserve_timestamps and timeline.timing_kind != "cfr": print(" skipped encode: output timeline is not CFR") return None if not preserve_timestamps and timeline.dropped_frame_count and ( clip_info is not None and avisynth_duration_differs(clip_info, timeline) ): print(" skipped encode: drop_frame plus Avisynth speed change needs timestamp-aware muxing") return None output_duration = ( timeline.duration_seconds if preserve_timestamps else encoded_duration_seconds(clip_info, timeline) ) effective_audio = audio_options_for_probe( audio_options, probe, timeline, output_duration, normal_deleted_frames=normal_deleted_frame_count(timeline), ) audio_source, source_has_audio = prepare_audio_source( avs_runners=tools.avs_runners, root=tools.root, item=item, original_has_audio=probe.audio_stream_count > 0, audio_options=effective_audio, ) dynamic_hdr, dynamic_hdr_temp = _dynamic_hdr_metadata(tools, item, probe, timeline, options) script = visible_script_path(tools.root, item) request = VideoEncodeRequest( y4m_command=y4m_command_for_timeline( tools.avs_runners.for_script(script), timeline, force_timeline_rate=preserve_timestamps ), ffmpeg=tools.ffmpeg, script=script, audio_source=audio_source, output=output, options=options, audio_options=effective_audio, audio_start=timeline.frames[0].start, audio_duration=timeline.duration_seconds, audio_segments=timeline.audio_segments if should_use_audio_segments(timeline, effective_audio) else None, audio_tempo=audio_tempo_factor(timeline, output_duration), audio_sample_rate=probe.audio_sample_rate, source_has_audio=source_has_audio, allow_audio_copy=effective_audio.mode == "copy", pixel_format=pixel_format, colorspace=color.colorspace, color_metadata=color, static_hdr=static_hdr, dynamic_hdr10plus=dynamic_hdr, frame_count=len(timeline.frames), progress_label=f"script {item_index}/{item_count}: {item.collapsed_relative}", ) try: if preserve_timestamps: if tools.mkvmerge is None: raise RuntimeError("mkvmerge is required for timestamp-aware encoding") result = encode_video_with_timestamps( request=request, mkvmerge=tools.mkvmerge, x264=tools.x264, frame_durations=[frame.duration for frame in timeline.frames], ) else: result = encode_video_only_y4m(request) finally: if dynamic_hdr_temp is not None: dynamic_hdr_temp.cleanup() return _finalize_encoded_item( ffprobe=tools.ffprobe, exiftool=tools.exiftool, item=item, probe=probe, timeline=timeline, result=result, expected_duration=output_duration, expected_timestamps=timeline_frame_starts(timeline) if preserve_timestamps else None, expected_audio=effective_audio.mode != "none" and source_has_audio, label="encoded", ) def _dynamic_hdr_metadata( tools: EncodingTools, item: VideoInput, probe: VideoProbe, timeline: OutputTimeline, options: VideoCodecOptions, ) -> tuple[Path | None, tempfile.TemporaryDirectory[str] | None]: if not (probe.hdr10plus and timeline.transfer in {16, 18}): return None, None if options.codec != "libx265": raise RuntimeError( f"{item.collapsed_relative} has HDR10+ metadata. HDR-preserving re-encoding " "requires x265; use MBT_ToSDR for SDR output." ) if probe.codec != "hevc": raise RuntimeError(f"HDR10+ metadata extraction currently requires HEVC input: {item.path}") temporary = tempfile.TemporaryDirectory(prefix="mbt-hdr10plus-") root = Path(temporary.name) source_json = root / "source.hdr10plus.json" output_json = root / "selected.hdr10plus.json" try: extract_hdr10plus_metadata( ffmpeg=tools.ffmpeg, hdr10plus_tool=Path(require_tool("hdr10plus_tool")), source=item.path, output=source_json, ) select_hdr10plus_frames( source_json=source_json, source_frames=[frame.source_frame for frame in timeline.frames], output_json=output_json, ) except Exception: temporary.cleanup() raise print(f" HDR10+: preserving metadata for {len(timeline.frames)} output frame(s)") return output_json, temporary def _finalize_encoded_item( *, ffprobe: str, exiftool: str, item: VideoInput, probe: VideoProbe, timeline: OutputTimeline, result: VideoEncodeResult, expected_duration: float, expected_timestamps: list[float] | None, expected_audio: bool, label: str, ) -> EncodedItem: validate_encoded_video( ffprobe, result.output, timeline, expected_duration=expected_duration, expected_timestamps=expected_timestamps, expected_audio=expected_audio, ) if result.encoder_log: print_encoder_statistics(result.encoder_log) copy_video_metadata(exiftool, item.path, result.output) validate_output_color_metadata( result.output, probe_video(ffprobe, result.output, {}), timeline, expected_hdr10plus=probe.hdr10plus and timeline.transfer in {16, 18}, ) write_video_end_timestamp(exiftool, result.output, probe, timeline) print(f" {label} output: {result.output}") return EncodedItem(input_item=item, output=result.output) def ask_interactive_encoding_settings( *, inputs: list[VideoInput], timelines: dict[Path, OutputTimeline], clip_infos: dict[Path, AvisynthClipInfo], probes_by_path: dict[Path, VideoProbe], ) -> EncodingSettings | None: answers = EncodingAnswers( timezone_default=_default_output_timezone( [probes_by_path[item.path.resolve()] for item in inputs] ), video_copy_available=_video_copy_available( inputs, timelines, clip_infos, probes_by_path ), ) index = 0 has_speed_changes = has_avisynth_speed_changes( timelines, clip_infos, probes_by_path ) specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path) while True: steps = _encoding_steps(answers, has_speed_changes) index = max(0, min(index, len(steps) - 1)) key = steps[index] _render_encoding_screen(answers, key, specs, has_speed_changes) raw = prompt_input(_question_for_key(key, answers, specs)).strip() if raw.lower() in {"b", "back"}: index = max(0, index - 1) continue try: value = _parse_answer(key, raw, answers, specs) except ValueError as exc: print(exc) prompt_input("Press Enter to retry...") continue answers.set(key, value) if key == "confirm" and value is False: return None if index == len(steps) - 1: break index += 1 codec = answers.get("codec") assert isinstance(codec, str) container = str(answers.get("container", ".mp4")) audio_mode = answers.get("audio", "aac" if container == ".mp4" else "opus") assert isinstance(audio_mode, str) return EncodingSettings( naming=OutputNamingOptions( use_timestamps=bool(answers.get("timestamp_names")), timezone_value=answers.get("timezone", answers.timezone_default), ), codec_options=VideoCodecOptions( codec=codec, crf=int(answers.get("crf", 21)), preset=str(answers.get("preset", "slow")), extension=container, profile=_none_if_none(str(answers.get("profile", "none"))), level=_none_if_none(str(answers.get("level", "none"))), threads=_threads_from_answer(answers.get("threads", "auto")), ), audio_options=AudioEncodeOptions( mode=audio_mode, bitrate=str(answers.get("audio_bitrate") or default_audio_bitrate(audio_mode)), preserve_pitch=bool(answers.get("audio_pitch", True)), ), ) def _encoding_env_is_set() -> bool: names = { "MBT_VIDEO_TIMESTAMP_NAMES", "MBT_VIDEO_TIMEZONE", "MBT_VIDEO_CONTAINER", "MBT_VIDEO_CODEC", "MBT_VIDEO_CRF", "MBT_VIDEO_PRESET", "MBT_VIDEO_PROFILE", "MBT_VIDEO_LEVEL", "MBT_VIDEO_THREADS", "MBT_VIDEO_AUDIO", "MBT_VIDEO_AUDIO_BITRATE", "MBT_VIDEO_AUDIO_PITCH", } return any(os.environ.get(name) for name in names) def _environment_encoding_settings(default_timezone: timezone) -> EncodingSettings: timestamp_names = _env_is_truthy("MBT_VIDEO_TIMESTAMP_NAMES") timezone_value = default_timezone if timestamp_names and (raw_timezone := os.environ.get("MBT_VIDEO_TIMEZONE", "").strip()): try: timezone_value = parse_timezone_offset(raw_timezone) except ValueError as exc: raise RuntimeError(f"Invalid MBT_VIDEO_TIMEZONE: {exc}") from exc container = _environment_choice( "MBT_VIDEO_CONTAINER", "mp4", _ENV_CONTAINERS, "mp4 or mkv", ) codec = _environment_choice( "MBT_VIDEO_CODEC", "x265", _ENV_CODECS, "x264, x265, or copy", ) crf = _environment_int("MBT_VIDEO_CRF", 16 if codec == "libx264" else 21) preset = os.environ.get("MBT_VIDEO_PRESET", "slow").strip().lower() or "slow" if preset not in ENCODER_PRESETS: raise RuntimeError(f"MBT_VIDEO_PRESET must be one of: {'/'.join(ENCODER_PRESETS)}") threads = _environment_threads() audio_mode = _parse_audio(os.environ.get("MBT_VIDEO_AUDIO", "none")) preserve_pitch = _environment_audio_pitch() return EncodingSettings( naming=OutputNamingOptions(timestamp_names, timezone_value), codec_options=VideoCodecOptions( codec=codec, crf=crf, preset=preset, extension=container, profile=_optional_environment("MBT_VIDEO_PROFILE"), level=_optional_environment("MBT_VIDEO_LEVEL"), threads=threads, ), audio_options=AudioEncodeOptions( mode=audio_mode, bitrate=os.environ.get("MBT_VIDEO_AUDIO_BITRATE", "").strip() or default_audio_bitrate(audio_mode), preserve_pitch=preserve_pitch, ), ) def _env_is_truthy(name: str) -> bool: return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "y", "on"} def _environment_choice( name: str, default: str, values: dict[str, str], allowed: str, ) -> str: value = os.environ.get(name, default).strip().lower() try: return values[value] except KeyError as exc: raise RuntimeError(f"{name} must be {allowed}") from exc def _environment_int(name: str, default: int) -> int: value = os.environ.get(name, "").strip() try: return int(value) if value else default except ValueError as exc: raise RuntimeError(f"{name} must be an integer") from exc def _environment_threads() -> int | None: value = os.environ.get("MBT_VIDEO_THREADS", "").strip().lower() if not value or value == "auto": return None threads = _environment_int("MBT_VIDEO_THREADS", 0) if threads < 1: raise RuntimeError("MBT_VIDEO_THREADS must be auto or a positive integer") return threads def _environment_audio_pitch() -> bool: value = os.environ.get("MBT_VIDEO_AUDIO_PITCH", "").strip().lower() if not value or value in {"preserve", "preserved", "keep", "same"}: return True if value in {"shift", "change", "changed", "speed"}: return False raise RuntimeError("MBT_VIDEO_AUDIO_PITCH must be preserve or shift") def _optional_environment(name: str) -> str | None: return os.environ.get(name, "").strip() or None def _encoding_steps(answers: EncodingAnswers, has_speed_changes: bool) -> list[str]: steps = ["timestamp_names"] if answers.get("timestamp_names", True): steps.append("timezone") steps.extend(["container", "codec"]) if answers.get("codec") != "copy": steps.extend(["crf", "preset", "profile", "level", "threads"]) steps.append("audio") if answers.get("audio") in {"aac", "opus"}: steps.append("audio_bitrate") if has_speed_changes and answers.get("audio") in {"aac", "opus"}: steps.append("audio_pitch") steps.append("confirm") return steps def _render_encoding_screen( answers: EncodingAnswers, current_key: str, specs: list[EncodingOutputSpec], has_speed_changes: bool, ) -> None: clear_screen() print("Validated clips:") for spec in specs: print(f"\n{spec.label}") for line in spec.summary_lines: print(f" {line}") print("") print("Encoding settings:") rows = [ ( key, _setting_label(key), _setting_value(key, _default_answer(key, answers, specs), answers, specs), ) for key in _encoding_table_keys(answers, has_speed_changes) ] width = max(len(label) for _, label, _ in rows) for key, label, value in rows: marker = ">" if key == current_key else " " print(f"{marker} {label.ljust(width)} {value}") print("\nType b to go back.\n") if current_key == "codec" and _x264_unavailable(specs): print(light_red("x264 is unavailable because this batch contains output above 10-bit.\n")) def _encoding_table_keys(answers: EncodingAnswers, has_speed_changes: bool) -> list[str]: keys = [ "timestamp_names", "container", "codec", "audio", ] if _default_answer("codec", answers, []) != "copy": index = keys.index("audio") keys[index:index] = ["crf", "preset", "profile", "level", "threads"] if _default_answer("timestamp_names", answers, []): keys.insert(1, "timezone") if _default_answer("audio", answers, []) in {"aac", "opus"}: keys.append("audio_bitrate") if has_speed_changes and _default_answer("audio", answers, []) in {"aac", "opus"}: keys.append("audio_pitch") return keys def _question_for_key( key: str, answers: EncodingAnswers, specs: list[EncodingOutputSpec], ) -> str: if key == "timestamp_names": default = bool(_default_answer(key, answers, specs)) preview = _filename_preview(answers, specs, limit=1) return ( "Name encoded files from edited beginning timestamp? " f"({preview}) ({_yes_no_suffix(default)}): " ) if key == "timezone": default = _default_answer(key, answers, specs) return f"Output filename timezone [{timezone_to_string(default)}]: " if key == "container": prompt = ( f"Output container [{_container_label(_default_answer(key, answers, specs))}] " "(mp4/mkv): " ) warnings = _stream_copy_warnings( extension=str(_default_answer(key, answers, specs)), video_codec=str(_default_answer("codec", answers, specs)), audio_mode=str(_default_answer("audio", answers, specs)), specs=specs, ) return prompt + (light_red(" ".join(warnings)) if warnings else "") if key == "codec": options = ["x264", "x265"] if answers.video_copy_available: options.append("copy") if _x264_unavailable(specs): options[0] = red_strikethrough(options[0]) return ( f"Video codec [{_codec_label(_default_answer(key, answers, specs))}] " f"({'/'.join(options)}): " ) if key == "crf": return f"CRF [{_default_answer(key, answers, specs)}]: " if key == "preset": default = _default_answer(key, answers, specs) return f"Encoder preset [{default}] ({'/'.join(ENCODER_PRESETS)}): " if key in {"profile", "level"}: codec = str(answers.get("codec")) profile = key == "profile" options = _profile_options(codec, specs) if profile else _level_options(codec, specs) unsupported = _not_supported_by_all(codec, options, specs, profile=profile) return key.title() + " " + _options_prompt( options, str(_default_answer(key, answers, specs)), unsupported, ) + ": " if key == "threads": return f"Encoder threads [{_default_answer(key, answers, specs)}] (auto or positive integer): " if key == "audio": return f"Audio [{_default_answer(key, answers, specs)}] (opus/aac/flac/copy/none): " if key == "audio_bitrate": return f"{str(answers.get('audio')).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: " if key == "audio_pitch": return "Speed-changed audio pitch [preserve] (preserve/shift): " if key == "confirm": return "Start encoding with these settings? (Y/n): " raise ValueError(f"Unknown setting: {key}") def _parse_answer( key: str, raw: str, answers: EncodingAnswers, specs: list[EncodingOutputSpec], ) -> object: default = _default_answer(key, answers, specs) value = raw or str(default) lowered = value.strip().lower() if key == "confirm": confirmed = lowered not in {"n", "no", "0", "false", "off"} warnings = _stream_copy_warnings( extension=str(_default_answer("container", answers, specs)), video_codec=str(_default_answer("codec", answers, specs)), audio_mode=str(_default_answer("audio", answers, specs)), specs=specs, ) if confirmed and warnings: raise ValueError( "Change the container or stream-copy choices before starting: " + "; ".join(warnings) ) return confirmed if key == "timestamp_names": return lowered not in {"n", "no", "0", "false", "off"} if key == "timezone": return parse_timezone_offset(value) if raw else default if key == "container": if lowered in {"mp4", ".mp4"}: return ".mp4" if lowered in {"m", "mkv", ".mkv"}: return ".mkv" raise ValueError("Container must be mp4 or mkv.") if key == "codec": if lowered in {"x264", "h264", "libx264"}: if _x264_unavailable(specs): raise ValueError( "x264 is unavailable because at least one validated output is above 10-bit." ) return "libx264" if lowered in {"x265", "h265", "hevc", "libx265"}: return "libx265" if lowered in {"copy", "c"}: if not answers.video_copy_available: raise ValueError( "Video copy is unavailable because a validated script changes " "video frames or properties." ) return "copy" raise ValueError("Codec must be x264, x265, or copy.") if key == "crf": try: return int(value) except ValueError as exc: raise ValueError("CRF must be an integer.") from exc if key == "preset": if lowered not in ENCODER_PRESETS: raise ValueError(f"Preset must be one of: {'/'.join(ENCODER_PRESETS)}.") return lowered if key in {"profile", "level"}: options = ( _profile_options(str(answers.get("codec")), specs) if key == "profile" else _level_options(str(answers.get("codec")), specs) ) return _parse_option(key, value, options) if key == "threads": if lowered == "auto": return "auto" try: threads = int(value) except ValueError as exc: raise ValueError("Threads must be auto or a positive integer.") from exc if threads < 1: raise ValueError("Threads must be auto or a positive integer.") return threads if key == "audio": return _parse_audio(value) if key == "audio_bitrate": return value if key == "audio_pitch": if lowered in {"preserve", "p", "keep", "same"}: return True if lowered in {"shift", "s", "change", "speed"}: return False raise ValueError("Audio pitch must be preserve or shift.") raise ValueError(f"Unknown setting: {key}") def _default_answer( key: str, answers: EncodingAnswers, specs: list[EncodingOutputSpec], ) -> object: value = answers.get(key) if value is not None: return value codec = str(answers.get("codec", "libx265")) if key == "confirm": return True if key == "timestamp_names": return True if key == "timezone": return answers.timezone_default if key == "container": return ".mp4" if key == "codec": return "libx265" if key == "crf": return 16 if codec == "libx264" else 21 if key == "preset": return "slow" if key == "profile": return "minimum" if key == "level": return "minimum" if key == "threads": return "auto" if key == "audio": return "aac" if answers.get("container", ".mp4") == ".mp4" else "opus" if key == "audio_bitrate": return default_audio_bitrate(str(answers.get("audio", "aac"))) if key == "audio_pitch": return "preserve" raise ValueError(f"Unknown setting: {key}") def _encoding_specs( inputs: list[VideoInput], timelines: dict[Path, OutputTimeline], clip_infos: dict[Path, AvisynthClipInfo], probes_by_path: dict[Path, VideoProbe], ) -> list[EncodingOutputSpec]: specs: list[EncodingOutputSpec] = [] for item in inputs: timeline = timelines[item.path.resolve()] clip_info = clip_infos.get(item.path.resolve()) probe = probes_by_path[item.path.resolve()] width = clip_info.width if clip_info else (probe.width or 0) height = clip_info.height if clip_info else (probe.height or 0) bit_depth = clip_info.bit_depth if clip_info else (probe.bit_depth or 8) chroma = chroma_family(clip_info.chroma if clip_info else probe.pixel_format) fps = len(timeline.frames) / timeline.duration_seconds if timeline.duration_seconds > 0 else 0 specs.append( EncodingOutputSpec( label=str(item.collapsed_relative), width=width, height=height, bit_depth=bit_depth, chroma=chroma, fps=fps, begin_utc=_begin_utc(probe, timeline), summary_lines=tuple( format_validation_summary_lines( output_frame_count=( clip_info.frames if clip_info is not None else len(timeline.frames) + timeline.dropped_frame_count ), dropped_frame_count=timeline.dropped_frame_count, timeline=timeline, clip_info=clip_info, probe=probe, ) ), video_codec=probe.codec, audio_codecs=tuple( stream.codec for stream in probe.audio_streams if stream.codec ), ) ) return specs def _profile_options(codec: str, specs: list[EncodingOutputSpec]) -> list[str]: constraints = [_constraint_spec(spec) for spec in specs] return ["none", "minimum", *available_profiles(codec, constraints)] def _level_options(codec: str, specs: list[EncodingOutputSpec]) -> list[str]: constraints = [_constraint_spec(spec) for spec in specs] return ["none", "minimum", *available_levels(codec, constraints)] def _not_supported_by_all( codec: str, options: list[str], specs: list[EncodingOutputSpec], *, profile: bool, ) -> set[str]: support = profile_supports if profile else level_supports constraints = [_constraint_spec(spec) for spec in specs] return { option for option in options if option not in {"none", "minimum"} and not all(support(codec, option, spec) for spec in constraints) } def _constraint_spec(spec: EncodingOutputSpec) -> VideoConstraintSpec: return VideoConstraintSpec( width=spec.width, height=spec.height, bit_depth=spec.bit_depth, chroma=spec.chroma, fps=spec.fps, ) def _resolve_codec_options( options: VideoCodecOptions, spec: VideoConstraintSpec, ) -> VideoCodecOptions: return replace( options, profile=resolve_profile(options.codec, options.profile, spec), level=resolve_level(options.codec, options.level, spec), ) def _x264_unavailable(specs: list[EncodingOutputSpec]) -> bool: return any(spec.bit_depth > 10 for spec in specs) def _video_copy_available( inputs: list[VideoInput], timelines: dict[Path, OutputTimeline], clip_infos: dict[Path, AvisynthClipInfo], probes_by_path: dict[Path, VideoProbe], ) -> bool: for item in inputs: path = item.path.resolve() timeline = timelines[path] clip_info = clip_infos.get(path) probe = probes_by_path[path] if clip_info is None or not preserves_entire_source_timeline(timeline): return False if avisynth_duration_differs(clip_info, timeline): return False if ( clip_info.width != probe.width or clip_info.height != probe.height or clip_info.bit_depth != probe.bit_depth or chroma_family(clip_info.chroma) != chroma_family(probe.pixel_format) ): return False return True _MP4_VIDEO_CODECS = {"av1", "h264", "hevc", "mjpeg", "mpeg4", "vp9"} _MP4_AUDIO_CODECS = {"aac", "ac3", "alac", "eac3", "flac", "mp3", "opus"} def _stream_copy_warnings( *, extension: str, video_codec: str, audio_mode: str, specs: list[EncodingOutputSpec], ) -> list[str]: if extension != ".mp4": return [] warnings: list[str] = [] if video_codec == "copy": unsupported_video = sorted( { spec.video_codec or "unknown" for spec in specs if spec.video_codec not in _MP4_VIDEO_CODECS } ) if unsupported_video: warnings.append("does not support " + ", ".join(unsupported_video) + " video") if audio_mode == "copy": unsupported_audio = sorted( { codec for spec in specs for codec in spec.audio_codecs if codec not in _MP4_AUDIO_CODECS } ) if unsupported_audio: warnings.append("does not support " + ", ".join(unsupported_audio) + " audio") return warnings def _begin_utc(probe: VideoProbe, timeline: OutputTimeline) -> str: try: return output_begin_timestamp(probe, timeline, timezone.utc).isoformat() except RuntimeError: return "unknown" def _parse_option(name: str, value: str, options: list[str]) -> str: lowered = value.strip().lower() matches = [option for option in options if option.lower() == lowered] if matches: return matches[0] raise ValueError(f"{name.title()} must be one of: {', '.join(options)}") def _parse_audio(value: str) -> str: lowered = value.strip().lower() if lowered in {"aac", "a", "reencode", "re-encode"}: return "aac" if lowered in {"copy", "c", "streamcopy", "stream-copy"}: return "copy" if lowered in {"none", "no", "n", "off", "0"}: return "none" if lowered in {"opus", "o", "libopus"}: return "opus" if lowered in {"flac", "f", "lossless"}: return "flac" raise ValueError("Audio must be aac, opus, flac, copy, or none.") def _yes_no_suffix(default: bool) -> str: return "Y/n" if default else "y/N" def _setting_label(key: str) -> str: return { "timestamp_names": "timestamp names", "timezone": "timezone", "container": "container", "codec": "codec", "crf": "CRF", "preset": "preset", "profile": "profile", "level": "level", "threads": "threads", "audio": "audio", "audio_bitrate": "audio bitrate", "audio_pitch": "audio pitch", }.get(key, key) def _setting_value( key: str, value: object, answers: EncodingAnswers, specs: list[EncodingOutputSpec], ) -> str: if key == "timestamp_names": if not value: return "no (original filename)" return f"yes ({_filename_preview(answers, specs, limit=3)})" if key == "timezone": return timezone_to_string(value) if key == "container": warnings = _stream_copy_warnings( extension=str(value), video_codec=str(_default_answer("codec", answers, specs)), audio_mode=str(_default_answer("audio", answers, specs)), specs=specs, ) return _container_label(value) + ( " " + light_red("; ".join(warnings)) if warnings else "" ) if key == "codec": return _codec_label(value) if key in {"profile", "level"} and value not in {"none", "minimum"}: codec = str(answers.get("codec", "libx265")) unsupported = _not_supported_by_all( codec, [str(value)], specs, profile=key == "profile", ) return light_red(value) if unsupported else str(value) if key == "audio_pitch": return "preserve" if value else "shift" return str(value) def _filename_preview( answers: EncodingAnswers, specs: list[EncodingOutputSpec], *, limit: int, ) -> str: if not specs: return "unknown" timezone_value = _default_answer("timezone", answers, specs) assert hasattr(timezone_value, "utcoffset") stems = [ _filename_preview_for_timezone(spec, timezone_value) for spec in specs[:limit] ] if len(specs) > limit: stems.append("...") return ", ".join(stems) def _filename_preview_for_timezone( spec: EncodingOutputSpec, timezone_value: timezone, ) -> str: begin = _parse_iso_datetime(spec.begin_utc) if begin is None: return "unknown" return format_rounded_filename_stem(begin.astimezone(timezone_value)) def _parse_iso_datetime(value: str) -> datetime | None: if value == "unknown": return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return None def _container_label(value: object) -> str: return "MP4" if value == ".mp4" else "MKV" def _default_output_timezone(probes: list[VideoProbe]) -> timezone: offsets = { probe.source_timezone.utcoffset(None) for probe in probes if probe.source_timezone is not None } if len(offsets) == 1: offset = offsets.pop() if offset is not None: return timezone(offset) return local_timezone() def _codec_label(value: object) -> str: if value == "copy": return "copy" return "x264" if value == "libx264" else "x265" def _options_prompt( options: list[str], default: str, unsupported: set[str] | None = None, ) -> str: unsupported = unsupported or set() displayed = [light_red(option) if option in unsupported else option for option in options] return f"[{default}] ({'/'.join(displayed)})" def _none_if_none(value: str) -> str | None: return None if value == "none" else value def _threads_from_answer(value: object) -> int | None: return None if str(value).strip().lower() == "auto" else int(value) def prepare_audio_source( *, avs_runners: AvisynthRunnerSet, root: Path, item: VideoInput, original_has_audio: bool, audio_options: AudioEncodeOptions, ) -> tuple[Path, bool]: if audio_options.mode == "none": return item.path, False audio_script = editable_audio_script_path(root, item) if not audio_script.exists(): return item.path, original_has_audio avs_runner = avs_runners.for_script(audio_script) wav_path = rendered_audio_wav_path(root, item) print(f" audio: rendering {audio_script.name}") render_audio_wav(avs_runner=avs_runner, script=audio_script, output=wav_path) return wav_path, True def _format_audio_options(options: AudioEncodeOptions) -> str: if options.mode == "none": return "none" if options.mode in {"aac", "opus"}: pitch = "preserve pitch" if options.preserve_pitch else "shift pitch with speed" return f"{options.mode.upper()} {options.bitrate}, {pitch}" return {"flac": "FLAC", "copy": "copy with best-effort beginning/end trim"}.get( options.mode, options.mode )