Files
media-batch-tools/tools/video_batch_encode.py
T

1398 lines
47 KiB
Python

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
)