Add video stream copy remuxing
This commit is contained in:
+201
-35
@@ -27,6 +27,7 @@ from tools.video_encode_output import (
|
||||
ffmpeg_colorspace_from_matrix,
|
||||
pixel_format_bit_depth,
|
||||
print_encoder_statistics,
|
||||
remux_video,
|
||||
render_audio_wav,
|
||||
)
|
||||
from tools.video_timestamp_encode import encode_video_with_timestamps
|
||||
@@ -37,6 +38,7 @@ from tools.video_encode_plan import (
|
||||
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,
|
||||
@@ -83,6 +85,8 @@ class EncodingOutputSpec:
|
||||
fps: float
|
||||
begin_utc: str
|
||||
summary_lines: tuple[str, ...]
|
||||
video_codec: str | None
|
||||
audio_codecs: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -171,9 +175,23 @@ def encode_validated_video_only(
|
||||
container_extension=codec_options.extension,
|
||||
)
|
||||
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
|
||||
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 = any(
|
||||
needs_timecodes = codec_options.codec != "copy" and any(
|
||||
should_preserve_video_timestamps(timelines[item.path.resolve()])
|
||||
for item in inputs
|
||||
)
|
||||
@@ -197,15 +215,18 @@ def encode_validated_video_only(
|
||||
else "original names"
|
||||
)
|
||||
)
|
||||
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}"
|
||||
)
|
||||
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(
|
||||
@@ -225,6 +246,44 @@ def encode_validated_video_only(
|
||||
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":
|
||||
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,
|
||||
item=item,
|
||||
original_has_audio=probe.audio_stream_count > 0,
|
||||
audio_options=audio_options,
|
||||
)
|
||||
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,
|
||||
@@ -269,7 +328,6 @@ def encode_validated_video_only(
|
||||
"timestamp-aware muxing"
|
||||
)
|
||||
continue
|
||||
probe = probes_by_path[item.path.resolve()]
|
||||
output_duration = (
|
||||
timeline.duration_seconds
|
||||
if preserve_video_timestamps
|
||||
@@ -372,12 +430,15 @@ def ask_interactive_encoding_settings(
|
||||
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
|
||||
)
|
||||
|
||||
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", "profile", "level"})
|
||||
_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:
|
||||
@@ -422,12 +483,12 @@ def ask_interactive_encoding_settings(
|
||||
),
|
||||
codec_options=VideoCodecOptions(
|
||||
codec=codec,
|
||||
crf=int(answers["crf"]),
|
||||
preset=str(answers["preset"]),
|
||||
crf=int(answers.get("crf", 21)),
|
||||
preset=str(answers.get("preset", "slow")),
|
||||
extension=str(answers["container"]),
|
||||
profile=_none_if_none(str(answers["profile"])),
|
||||
level=_none_if_none(str(answers["level"])),
|
||||
threads=_threads_from_answer(answers["threads"]),
|
||||
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,
|
||||
@@ -459,7 +520,10 @@ def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list
|
||||
steps = ["timestamp_names"]
|
||||
if answers.get("timestamp_names", True):
|
||||
steps.append("timezone")
|
||||
steps.extend(["container", "codec", "crf", "preset", "profile", "level", "threads", "audio"])
|
||||
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"}:
|
||||
@@ -504,13 +568,11 @@ def _encoding_table_keys(answers: dict[str, object], has_speed_changes: bool) ->
|
||||
"timestamp_names",
|
||||
"container",
|
||||
"codec",
|
||||
"crf",
|
||||
"preset",
|
||||
"profile",
|
||||
"level",
|
||||
"threads",
|
||||
"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"}:
|
||||
@@ -536,12 +598,21 @@ def _question_for_key(
|
||||
default = _default_answer(key, answers, specs)
|
||||
return f"Output filename timezone [{timezone_to_string(default)}]: "
|
||||
if key == "container":
|
||||
return (
|
||||
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.get("_video_copy_available"):
|
||||
options.append("copy")
|
||||
if _x264_unavailable(specs):
|
||||
options[0] = red_strikethrough(options[0])
|
||||
return (
|
||||
@@ -574,9 +645,7 @@ def _question_for_key(
|
||||
if key == "threads":
|
||||
return f"Encoder threads [{_default_answer(key, answers, specs)}] (auto or positive integer): "
|
||||
if key == "audio":
|
||||
if answers.get("container") == ".mkv":
|
||||
return f"Audio [{_default_answer(key, answers, specs)}] (opus/aac/flac/copy/none): "
|
||||
return f"Audio [{_default_answer(key, answers, specs)}] (aac/copy/none): "
|
||||
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)}]: "
|
||||
if key == "audio_pitch":
|
||||
@@ -596,7 +665,19 @@ def _parse_answer(
|
||||
value = raw or str(default)
|
||||
lowered = value.strip().lower()
|
||||
if key == "confirm":
|
||||
return lowered not in {"n", "no", "0", "false", "off"}
|
||||
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":
|
||||
@@ -616,7 +697,14 @@ def _parse_answer(
|
||||
return "libx264"
|
||||
if lowered in {"x265", "h265", "hevc", "libx265"}:
|
||||
return "libx265"
|
||||
raise ValueError("Codec must be x264 or x265.")
|
||||
if lowered in {"copy", "c"}:
|
||||
if not answers.get("_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)
|
||||
@@ -643,7 +731,7 @@ def _parse_answer(
|
||||
raise ValueError("Threads must be auto or a positive integer.")
|
||||
return threads
|
||||
if key == "audio":
|
||||
return _parse_audio(value, str(answers["container"]))
|
||||
return _parse_audio(value)
|
||||
if key == "audio_bitrate":
|
||||
return value
|
||||
if key == "audio_pitch":
|
||||
@@ -730,6 +818,10 @@ def _encoding_specs(
|
||||
probe=probe,
|
||||
)
|
||||
),
|
||||
video_codec=probe.codec,
|
||||
audio_codecs=tuple(
|
||||
stream.codec for stream in probe.audio_streams if stream.codec
|
||||
),
|
||||
)
|
||||
)
|
||||
return specs
|
||||
@@ -798,6 +890,70 @@ 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()
|
||||
@@ -813,7 +969,7 @@ def _parse_option(name: str, value: str, options: list[str]) -> str:
|
||||
raise ValueError(f"{name.title()} must be one of: {', '.join(options)}")
|
||||
|
||||
|
||||
def _parse_audio(value: str, container: str) -> str:
|
||||
def _parse_audio(value: str) -> str:
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"aac", "a"}:
|
||||
return "aac"
|
||||
@@ -821,11 +977,11 @@ def _parse_audio(value: str, container: str) -> str:
|
||||
return "copy"
|
||||
if lowered in {"none", "no", "n"}:
|
||||
return "none"
|
||||
if container == ".mkv" and lowered in {"opus", "o"}:
|
||||
if lowered in {"opus", "o"}:
|
||||
return "opus"
|
||||
if container == ".mkv" and lowered in {"flac", "f"}:
|
||||
if lowered in {"flac", "f"}:
|
||||
return "flac"
|
||||
raise ValueError("Unsupported audio choice for this container.")
|
||||
raise ValueError("Audio must be aac, opus, flac, copy, or none.")
|
||||
|
||||
|
||||
def _yes_no_suffix(default: bool) -> str:
|
||||
@@ -867,7 +1023,15 @@ def _setting_value(
|
||||
if key == "timezone":
|
||||
return timezone_to_string(value)
|
||||
if key == "container":
|
||||
return _container_label(value)
|
||||
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"}:
|
||||
@@ -940,6 +1104,8 @@ def _default_output_timezone(probes: list[VideoProbe]) -> timezone:
|
||||
|
||||
|
||||
def _codec_label(value: object) -> str:
|
||||
if value == "copy":
|
||||
return "copy"
|
||||
return "x264" if value == "libx264" else "x265"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user