Enhance Avisynth video workflow and metadata tools
This commit is contained in:
+503
-271
@@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, replace
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
@@ -14,17 +15,20 @@ from tools.avisynth_workspace import (
|
||||
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,
|
||||
ffmpeg_colorspace_from_matrix,
|
||||
pixel_format_bit_depth,
|
||||
print_encoder_statistics,
|
||||
remux_video,
|
||||
@@ -55,22 +59,18 @@ from tools.video_codec_constraints import (
|
||||
)
|
||||
from tools.video_inputs import VideoInput
|
||||
from tools.video_formatting import chroma_family, matrix_name
|
||||
from tools.video_options import (
|
||||
OutputNamingOptions,
|
||||
ask_audio_options,
|
||||
ask_output_naming_options,
|
||||
ask_video_codec_options,
|
||||
)
|
||||
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, require_tool
|
||||
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
|
||||
|
||||
@@ -96,6 +96,54 @@ class EncodingSettings:
|
||||
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],
|
||||
@@ -151,9 +199,6 @@ def encode_validated_video_only(
|
||||
print("\nNo validated outputs to encode.")
|
||||
return
|
||||
ffmpeg = Path(require_tool("ffmpeg")).resolve()
|
||||
default_timezone = _default_output_timezone(
|
||||
[probes_by_path[item.path.resolve()] for item in inputs]
|
||||
)
|
||||
if sys.stdin.isatty() and not _encoding_env_is_set():
|
||||
settings = ask_interactive_encoding_settings(
|
||||
inputs=inputs,
|
||||
@@ -161,20 +206,22 @@ def encode_validated_video_only(
|
||||
clip_infos=clip_infos,
|
||||
probes_by_path=probes_by_path,
|
||||
)
|
||||
if settings is None:
|
||||
print("\nEncoding skipped.")
|
||||
return
|
||||
naming = settings.naming
|
||||
codec_options = settings.codec_options
|
||||
audio_options = settings.audio_options
|
||||
else:
|
||||
naming = ask_output_naming_options(default_timezone=default_timezone)
|
||||
codec_options = ask_video_codec_options()
|
||||
audio_options = ask_audio_options(
|
||||
has_speed_changes=has_avisynth_speed_changes(timelines, clip_infos),
|
||||
container_extension=codec_options.extension,
|
||||
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(
|
||||
@@ -237,6 +284,7 @@ def encode_validated_video_only(
|
||||
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()]
|
||||
@@ -248,165 +296,34 @@ def encode_validated_video_only(
|
||||
clip_info = clip_infos.get(item.path.resolve())
|
||||
probe = probes_by_path[item.path.resolve()]
|
||||
if codec_options.codec == "copy":
|
||||
output = output_paths[item.path.resolve()]
|
||||
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=avs_runners,
|
||||
root=root,
|
||||
encoded_items.append(
|
||||
_remux_item(
|
||||
tools=tools,
|
||||
item=item,
|
||||
original_has_audio=probe.audio_stream_count > 0,
|
||||
item_index=item_index,
|
||||
item_count=len(inputs),
|
||||
probe=probe,
|
||||
timeline=timeline,
|
||||
audio_options=audio_options,
|
||||
output=output_paths[item.path.resolve()],
|
||||
)
|
||||
result = remux_video(
|
||||
ffmpeg=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}/{len(inputs)}: {item.collapsed_relative}",
|
||||
)
|
||||
validate_encoded_video(
|
||||
ffprobe,
|
||||
result.output,
|
||||
timeline,
|
||||
expected_duration=timeline.duration_seconds,
|
||||
expected_timestamps=timeline_frame_starts(timeline),
|
||||
expected_audio=audio_options.mode != "none" and source_has_audio,
|
||||
)
|
||||
copy_video_metadata(exiftool, item.path, result.output)
|
||||
write_video_end_timestamp(
|
||||
exiftool, result.output, probe, timeline, timeline.duration_seconds
|
||||
)
|
||||
encoded_items.append(EncodedItem(input_item=item, output=result.output))
|
||||
print(f" remuxed output: {result.output}")
|
||||
continue
|
||||
output_bit_depth = clip_info.bit_depth if clip_info is not None else 8
|
||||
pixel_format = choose_video_pixel_format(
|
||||
ffmpeg,
|
||||
codec_options.codec,
|
||||
output_bit_depth,
|
||||
clip_info.chroma if clip_info is not None else None,
|
||||
)
|
||||
spec = _constraint_spec_for_item(item, timelines, clip_infos, probes_by_path)
|
||||
encoded_spec = VideoConstraintSpec(
|
||||
width=spec.width,
|
||||
height=spec.height,
|
||||
bit_depth=pixel_format_bit_depth(pixel_format),
|
||||
chroma=spec.chroma,
|
||||
fps=spec.fps,
|
||||
)
|
||||
effective_codec_options = _resolve_codec_options(codec_options, encoded_spec)
|
||||
if effective_codec_options.profile or effective_codec_options.level:
|
||||
print(
|
||||
" constraints: "
|
||||
f"profile {effective_codec_options.profile or 'none'}, "
|
||||
f"level {effective_codec_options.level or 'none'}"
|
||||
)
|
||||
colorspace = ffmpeg_colorspace_from_matrix(timeline.matrix)
|
||||
if output_bit_depth >= 10:
|
||||
encoded_bit_depth = pixel_format_bit_depth(pixel_format)
|
||||
print(
|
||||
f" video: {output_bit_depth}-bit Avisynth output -> "
|
||||
f"{encoded_bit_depth}-bit {pixel_format}"
|
||||
)
|
||||
if colorspace is not None:
|
||||
print(f" color space: {matrix_name(timeline.matrix)} -> ffmpeg {colorspace}")
|
||||
normal_deleted_frames = normal_deleted_frame_count(timeline)
|
||||
preserve_video_timestamps = should_preserve_video_timestamps(timeline)
|
||||
if not preserve_video_timestamps and timeline.timing_kind != "cfr":
|
||||
print(" skipped encode: output timeline is not CFR")
|
||||
continue
|
||||
if not preserve_video_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"
|
||||
)
|
||||
continue
|
||||
output_duration = (
|
||||
timeline.duration_seconds
|
||||
if preserve_video_timestamps
|
||||
else encoded_duration_seconds(clip_info, timeline)
|
||||
)
|
||||
audio_tempo = audio_tempo_factor(timeline, output_duration)
|
||||
output = output_paths[item.path.resolve()]
|
||||
effective_audio_options = audio_options_for_probe(
|
||||
audio_options,
|
||||
probe,
|
||||
timeline,
|
||||
output_duration,
|
||||
normal_deleted_frames=normal_deleted_frames,
|
||||
)
|
||||
audio_segments = (
|
||||
timeline.audio_segments
|
||||
if should_use_audio_segments(timeline, effective_audio_options)
|
||||
else None
|
||||
)
|
||||
audio_source, source_has_audio = prepare_audio_source(
|
||||
avs_runners=avs_runners,
|
||||
root=root,
|
||||
encoded_item = _encode_item(
|
||||
tools=tools,
|
||||
item=item,
|
||||
original_has_audio=probe.audio_stream_count > 0,
|
||||
audio_options=effective_audio_options,
|
||||
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()],
|
||||
)
|
||||
encode_kwargs = dict(
|
||||
y4m_command=y4m_command_for_timeline(
|
||||
avs_runner,
|
||||
timeline,
|
||||
force_timeline_rate=preserve_video_timestamps,
|
||||
),
|
||||
ffmpeg=ffmpeg,
|
||||
script=script,
|
||||
audio_source=audio_source,
|
||||
output=output,
|
||||
options=effective_codec_options,
|
||||
audio_options=effective_audio_options,
|
||||
audio_start=timeline.frames[0].start,
|
||||
audio_duration=timeline.duration_seconds,
|
||||
audio_segments=audio_segments,
|
||||
audio_tempo=audio_tempo,
|
||||
audio_sample_rate=probe.audio_sample_rate,
|
||||
source_has_audio=source_has_audio,
|
||||
allow_audio_copy=effective_audio_options.mode == "copy",
|
||||
pixel_format=pixel_format,
|
||||
colorspace=colorspace,
|
||||
frame_count=len(timeline.frames),
|
||||
progress_label=(
|
||||
f"script {item_index}/{len(inputs)}: {item.collapsed_relative}"
|
||||
),
|
||||
)
|
||||
if preserve_video_timestamps:
|
||||
assert mkvmerge is not None
|
||||
result = encode_video_with_timestamps(
|
||||
mkvmerge=mkvmerge,
|
||||
x264=x264,
|
||||
frame_durations=[frame.duration for frame in timeline.frames],
|
||||
**encode_kwargs,
|
||||
)
|
||||
else:
|
||||
result = encode_video_only_y4m(**encode_kwargs)
|
||||
validate_encoded_video(
|
||||
ffprobe,
|
||||
result.output,
|
||||
timeline,
|
||||
expected_duration=output_duration,
|
||||
expected_timestamps=(
|
||||
timeline_frame_starts(timeline) if preserve_video_timestamps else None
|
||||
),
|
||||
expected_audio=(
|
||||
effective_audio_options.mode != "none" and source_has_audio
|
||||
),
|
||||
)
|
||||
print_encoder_statistics(result.encoder_log)
|
||||
copy_video_metadata(exiftool, item.path, result.output)
|
||||
write_video_end_timestamp(exiftool, result.output, probe, timeline, output_duration)
|
||||
encoded_items.append(EncodedItem(input_item=item, output=result.output))
|
||||
print(f" encoded output: {result.output}")
|
||||
if encoded_item is not None:
|
||||
encoded_items.append(encoded_item)
|
||||
|
||||
if not encoded_items:
|
||||
print("\nNo outputs encoded.")
|
||||
@@ -415,6 +332,265 @@ def encode_validated_video_only(
|
||||
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],
|
||||
@@ -422,30 +598,19 @@ def ask_interactive_encoding_settings(
|
||||
clip_infos: dict[Path, AvisynthClipInfo],
|
||||
probes_by_path: dict[Path, VideoProbe],
|
||||
) -> EncodingSettings | None:
|
||||
answers: dict[str, object] = {
|
||||
"_timezone_default": _default_output_timezone(
|
||||
answers = EncodingAnswers(
|
||||
timezone_default=_default_output_timezone(
|
||||
[probes_by_path[item.path.resolve()] for item in inputs]
|
||||
)
|
||||
}
|
||||
index = 0
|
||||
has_speed_changes = has_avisynth_speed_changes(timelines, clip_infos)
|
||||
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
|
||||
answers["_video_copy_available"] = _video_copy_available(
|
||||
inputs, timelines, clip_infos, probes_by_path
|
||||
),
|
||||
video_copy_available=_video_copy_available(
|
||||
inputs, timelines, clip_infos, probes_by_path
|
||||
),
|
||||
)
|
||||
|
||||
def answer(key: str, value: object) -> None:
|
||||
old_value = answers.get(key)
|
||||
answers[key] = value
|
||||
if key == "codec" and old_value is not None and old_value != value:
|
||||
_forget(answers, {"crf", "preset", "profile", "level", "threads"})
|
||||
if key == "container" and old_value is not None and old_value != value:
|
||||
_forget(answers, {"audio", "audio_bitrate"})
|
||||
if key == "timestamp_names" and value is False:
|
||||
_forget(answers, {"timezone"})
|
||||
if key == "audio" and value not in {"aac", "opus"}:
|
||||
_forget(answers, {"audio_bitrate", "audio_pitch"})
|
||||
|
||||
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))
|
||||
@@ -462,30 +627,28 @@ def ask_interactive_encoding_settings(
|
||||
print(exc)
|
||||
prompt_input("Press Enter to retry...")
|
||||
continue
|
||||
answer(key, value)
|
||||
answers.set(key, value)
|
||||
if key == "confirm" and value is False:
|
||||
return None
|
||||
if index == len(steps) - 1:
|
||||
break
|
||||
index += 1
|
||||
|
||||
codec = answers["codec"]
|
||||
codec = answers.get("codec")
|
||||
assert isinstance(codec, str)
|
||||
audio_mode = answers.get("audio", "aac" if answers["container"] == ".mp4" else "opus")
|
||||
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["timestamp_names"]),
|
||||
timezone_value=answers.get(
|
||||
"timezone",
|
||||
answers["_timezone_default"],
|
||||
),
|
||||
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=str(answers["container"]),
|
||||
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")),
|
||||
@@ -516,7 +679,103 @@ def _encoding_env_is_set() -> bool:
|
||||
return any(os.environ.get(name) for name in names)
|
||||
|
||||
|
||||
def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list[str]:
|
||||
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")
|
||||
@@ -533,7 +792,7 @@ def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list
|
||||
|
||||
|
||||
def _render_encoding_screen(
|
||||
answers: dict[str, object],
|
||||
answers: EncodingAnswers,
|
||||
current_key: str,
|
||||
specs: list[EncodingOutputSpec],
|
||||
has_speed_changes: bool,
|
||||
@@ -563,7 +822,7 @@ def _render_encoding_screen(
|
||||
print(light_red("x264 is unavailable because this batch contains output above 10-bit.\n"))
|
||||
|
||||
|
||||
def _encoding_table_keys(answers: dict[str, object], has_speed_changes: bool) -> list[str]:
|
||||
def _encoding_table_keys(answers: EncodingAnswers, has_speed_changes: bool) -> list[str]:
|
||||
keys = [
|
||||
"timestamp_names",
|
||||
"container",
|
||||
@@ -584,7 +843,7 @@ def _encoding_table_keys(answers: dict[str, object], has_speed_changes: bool) ->
|
||||
|
||||
def _question_for_key(
|
||||
key: str,
|
||||
answers: dict[str, object],
|
||||
answers: EncodingAnswers,
|
||||
specs: list[EncodingOutputSpec],
|
||||
) -> str:
|
||||
if key == "timestamp_names":
|
||||
@@ -611,7 +870,7 @@ def _question_for_key(
|
||||
return prompt + (light_red(" ".join(warnings)) if warnings else "")
|
||||
if key == "codec":
|
||||
options = ["x264", "x265"]
|
||||
if answers.get("_video_copy_available"):
|
||||
if answers.video_copy_available:
|
||||
options.append("copy")
|
||||
if _x264_unavailable(specs):
|
||||
options[0] = red_strikethrough(options[0])
|
||||
@@ -624,20 +883,12 @@ def _question_for_key(
|
||||
if key == "preset":
|
||||
default = _default_answer(key, answers, specs)
|
||||
return f"Encoder preset [{default}] ({'/'.join(ENCODER_PRESETS)}): "
|
||||
if key == "profile":
|
||||
codec = str(answers["codec"])
|
||||
options = _profile_options(codec, specs)
|
||||
unsupported = _not_supported_by_all(codec, options, specs, profile=True)
|
||||
return "Profile " + _options_prompt(
|
||||
options,
|
||||
str(_default_answer(key, answers, specs)),
|
||||
unsupported,
|
||||
) + ": "
|
||||
if key == "level":
|
||||
codec = str(answers["codec"])
|
||||
options = _level_options(codec, specs)
|
||||
unsupported = _not_supported_by_all(codec, options, specs, profile=False)
|
||||
return "Level " + _options_prompt(
|
||||
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,
|
||||
@@ -647,7 +898,7 @@ def _question_for_key(
|
||||
if key == "audio":
|
||||
return f"Audio [{_default_answer(key, answers, specs)}] (opus/aac/flac/copy/none): "
|
||||
if key == "audio_bitrate":
|
||||
return f"{str(answers['audio']).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: "
|
||||
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":
|
||||
@@ -658,7 +909,7 @@ def _question_for_key(
|
||||
def _parse_answer(
|
||||
key: str,
|
||||
raw: str,
|
||||
answers: dict[str, object],
|
||||
answers: EncodingAnswers,
|
||||
specs: list[EncodingOutputSpec],
|
||||
) -> object:
|
||||
default = _default_answer(key, answers, specs)
|
||||
@@ -698,7 +949,7 @@ def _parse_answer(
|
||||
if lowered in {"x265", "h265", "hevc", "libx265"}:
|
||||
return "libx265"
|
||||
if lowered in {"copy", "c"}:
|
||||
if not answers.get("_video_copy_available"):
|
||||
if not answers.video_copy_available:
|
||||
raise ValueError(
|
||||
"Video copy is unavailable because a validated script changes "
|
||||
"video frames or properties."
|
||||
@@ -714,12 +965,13 @@ def _parse_answer(
|
||||
if lowered not in ENCODER_PRESETS:
|
||||
raise ValueError(f"Preset must be one of: {'/'.join(ENCODER_PRESETS)}.")
|
||||
return lowered
|
||||
if key == "profile":
|
||||
options = _profile_options(str(answers["codec"]), specs)
|
||||
return _parse_option("profile", value, options)
|
||||
if key == "level":
|
||||
options = _level_options(str(answers["codec"]), specs)
|
||||
return _parse_option("level", value, options)
|
||||
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"
|
||||
@@ -745,18 +997,19 @@ def _parse_answer(
|
||||
|
||||
def _default_answer(
|
||||
key: str,
|
||||
answers: dict[str, object],
|
||||
answers: EncodingAnswers,
|
||||
specs: list[EncodingOutputSpec],
|
||||
) -> object:
|
||||
if key in answers:
|
||||
return answers[key]
|
||||
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.get("_timezone_default", local_timezone())
|
||||
return answers.timezone_default
|
||||
if key == "container":
|
||||
return ".mp4"
|
||||
if key == "codec":
|
||||
@@ -864,17 +1117,6 @@ def _constraint_spec(spec: EncodingOutputSpec) -> VideoConstraintSpec:
|
||||
)
|
||||
|
||||
|
||||
def _constraint_spec_for_item(
|
||||
item: VideoInput,
|
||||
timelines: dict[Path, OutputTimeline],
|
||||
clip_infos: dict[Path, AvisynthClipInfo],
|
||||
probes_by_path: dict[Path, VideoProbe],
|
||||
) -> VideoConstraintSpec:
|
||||
return _constraint_spec(
|
||||
_encoding_specs([item], timelines, clip_infos, probes_by_path)[0]
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codec_options(
|
||||
options: VideoCodecOptions,
|
||||
spec: VideoConstraintSpec,
|
||||
@@ -971,15 +1213,15 @@ def _parse_option(name: str, value: str, options: list[str]) -> str:
|
||||
|
||||
def _parse_audio(value: str) -> str:
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"aac", "a"}:
|
||||
if lowered in {"aac", "a", "reencode", "re-encode"}:
|
||||
return "aac"
|
||||
if lowered in {"copy", "c"}:
|
||||
if lowered in {"copy", "c", "streamcopy", "stream-copy"}:
|
||||
return "copy"
|
||||
if lowered in {"none", "no", "n"}:
|
||||
if lowered in {"none", "no", "n", "off", "0"}:
|
||||
return "none"
|
||||
if lowered in {"opus", "o"}:
|
||||
if lowered in {"opus", "o", "libopus"}:
|
||||
return "opus"
|
||||
if lowered in {"flac", "f"}:
|
||||
if lowered in {"flac", "f", "lossless"}:
|
||||
return "flac"
|
||||
raise ValueError("Audio must be aac, opus, flac, copy, or none.")
|
||||
|
||||
@@ -988,11 +1230,6 @@ def _yes_no_suffix(default: bool) -> str:
|
||||
return "Y/n" if default else "y/N"
|
||||
|
||||
|
||||
def _forget(answers: dict[str, object], keys: set[str]) -> None:
|
||||
for key in keys:
|
||||
answers.pop(key, None)
|
||||
|
||||
|
||||
def _setting_label(key: str) -> str:
|
||||
return {
|
||||
"timestamp_names": "timestamp names",
|
||||
@@ -1013,7 +1250,7 @@ def _setting_label(key: str) -> str:
|
||||
def _setting_value(
|
||||
key: str,
|
||||
value: object,
|
||||
answers: dict[str, object],
|
||||
answers: EncodingAnswers,
|
||||
specs: list[EncodingOutputSpec],
|
||||
) -> str:
|
||||
if key == "timestamp_names":
|
||||
@@ -1049,7 +1286,7 @@ def _setting_value(
|
||||
|
||||
|
||||
def _filename_preview(
|
||||
answers: dict[str, object],
|
||||
answers: EncodingAnswers,
|
||||
specs: list[EncodingOutputSpec],
|
||||
*,
|
||||
limit: int,
|
||||
@@ -1152,14 +1389,9 @@ def prepare_audio_source(
|
||||
def _format_audio_options(options: AudioEncodeOptions) -> str:
|
||||
if options.mode == "none":
|
||||
return "none"
|
||||
if options.mode == "aac":
|
||||
if options.mode in {"aac", "opus"}:
|
||||
pitch = "preserve pitch" if options.preserve_pitch else "shift pitch with speed"
|
||||
return f"AAC {options.bitrate}, {pitch}"
|
||||
if options.mode == "opus":
|
||||
pitch = "preserve pitch" if options.preserve_pitch else "shift pitch with speed"
|
||||
return f"Opus {options.bitrate}, {pitch}"
|
||||
if options.mode == "flac":
|
||||
return "FLAC"
|
||||
if options.mode == "copy":
|
||||
return "copy with best-effort beginning/end trim"
|
||||
return options.mode
|
||||
return f"{options.mode.upper()} {options.bitrate}, {pitch}"
|
||||
return {"flac": "FLAC", "copy": "copy with best-effort beginning/end trim"}.get(
|
||||
options.mode, options.mode
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user