Add video stream copy remuxing

This commit is contained in:
ajp_anton
2026-07-22 22:51:00 +00:00
parent 49296d6f45
commit d60ce51b14
7 changed files with 404 additions and 68 deletions
+201 -35
View File
@@ -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"
+54
View File
@@ -168,6 +168,60 @@ def render_audio_wav(
)
def remux_video(
*,
ffmpeg: Path,
source: Path,
audio_source: Path | None,
output: Path,
audio_options: AudioEncodeOptions,
source_has_audio: bool,
frame_count: int | None = None,
progress_label: str | None = None,
) -> VideoEncodeResult:
"""Copy the source video stream while optionally copying or encoding audio."""
output.parent.mkdir(parents=True, exist_ok=True)
command = [
str(ffmpeg), "-y", "-hide_banner", "-loglevel", "info", "-nostats",
"-progress", "pipe:2", "-i", str(source), "-map", "0:v:0", "-c:v", "copy",
]
if audio_options.mode == "none" or not source_has_audio:
command.append("-an")
else:
source_index = 0
if audio_source is not None and audio_source.resolve() != source.resolve():
command.extend(["-i", str(audio_source)])
source_index = 1
command.extend(["-map", f"{source_index}:a:0"])
if audio_options.mode == "copy":
command.extend(["-c:a", "copy"])
else:
add_audio_encoder_options(command, audio_options)
command.append(str(output))
process = subprocess.Popen(
command,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
encoder_log = read_ffmpeg_progress(
process,
total_frames=frame_count,
label=f"Remuxing {progress_label or output.name}",
)
if process.returncode != 0:
raise RuntimeError(
f"FFmpeg remux failed for {source} with exit {process.returncode}:\n{encoder_log}"
)
return VideoEncodeResult(
script=source,
output=output,
renderer_returncode=0,
ffmpeg_returncode=process.returncode,
encoder_log=encoder_log,
)
def encode_video_only_y4m(
*,
y4m_command: list[str],
+11
View File
@@ -13,6 +13,17 @@ def normal_deleted_frame_count(timeline: OutputTimeline) -> int:
return missing_source_frames(timeline.consumed_source_frames)
def preserves_entire_source_timeline(timeline: OutputTimeline) -> bool:
return (
not timeline.beginning_trimmed
and not timeline.ending_trimmed
and not timeline.dropped_frame_count
and len(timeline.frames) == timeline.source_frame_count
and [frame.source_frame for frame in timeline.frames]
== list(range(timeline.source_frame_count))
)
def missing_source_frames_between_kept(timeline: OutputTimeline) -> int:
return missing_source_frames([frame.source_frame for frame in timeline.frames])
+14 -19
View File
@@ -175,12 +175,12 @@ def ask_video_codec_options() -> VideoCodecOptions:
extension = _prompt_container_extension()
if codec is None:
codec = _prompt_codec()
if crf is None:
if codec != "copy" and crf is None:
default_crf = 16 if codec == "libx264" else 21
crf = _prompt_int(f"CRF [{default_crf}]: ", default=default_crf)
if not preset:
if codec != "copy" and not preset:
preset = _prompt_preset(default="slow")
if "MBT_VIDEO_THREADS" not in os.environ:
if codec != "copy" and "MBT_VIDEO_THREADS" not in os.environ:
threads = _prompt_threads(default=None)
else:
extension = extension or ".mp4"
@@ -190,8 +190,8 @@ def ask_video_codec_options() -> VideoCodecOptions:
return VideoCodecOptions(
codec=codec,
crf=crf,
preset=preset,
crf=crf if crf is not None else 21,
preset=preset or "slow",
extension=extension,
profile=profile,
level=level,
@@ -249,7 +249,6 @@ def ask_audio_options(
preserve_pitch = ask_audio_pitch_option(has_speed_changes=has_speed_changes)
if env_mode:
mode = _normalize_audio_mode(env_mode)
_validate_audio_container(mode, container_extension)
return AudioEncodeOptions(
mode=mode,
bitrate=env_bitrate or default_audio_bitrate(mode),
@@ -260,10 +259,9 @@ def ask_audio_options(
return AudioEncodeOptions(mode="none", preserve_pitch=preserve_pitch)
while True:
if container_extension == ".mkv":
prompt = "Audio: Enter=Opus, a=AAC, f=FLAC, c=copy/trim audio, n=no audio: "
else:
prompt = "Audio: Enter=AAC re-encode, c=copy/trim audio, n=no audio: "
prompt = "Audio: Enter=" + (
"Opus" if container_extension == ".mkv" else "AAC"
) + ", a=AAC, o=Opus, f=FLAC, c=copy/trim audio, n=no audio: "
answer = prompt_input(prompt).strip().lower()
if not answer:
mode = "opus" if container_extension == ".mkv" else "aac"
@@ -271,10 +269,10 @@ def ask_audio_options(
if answer in {"a", "aac"}:
mode = "aac"
break
if container_extension == ".mkv" and answer in {"o", "opus"}:
if answer in {"o", "opus"}:
mode = "opus"
break
if container_extension == ".mkv" and answer in {"f", "flac"}:
if answer in {"f", "flac"}:
mode = "flac"
break
if answer in {"c", "copy"}:
@@ -329,11 +327,6 @@ def _normalize_audio_mode(value: str) -> str:
raise RuntimeError("MBT_VIDEO_AUDIO must be aac, opus, flac, copy, or none")
def _validate_audio_container(mode: str, container_extension: str) -> None:
if container_extension == ".mp4" and mode in {"opus", "flac"}:
raise RuntimeError("MP4 output supports aac, copy, or none audio in this tool")
def _container_extension_from_env() -> str | None:
raw = os.environ.get("MBT_VIDEO_CONTAINER", "").strip().lower()
if not raw:
@@ -369,7 +362,7 @@ def _codec_from_env() -> str | None:
def _prompt_codec() -> str:
while True:
answer = prompt_input("Video codec: x264, Enter=x265: ").strip().lower()
answer = prompt_input("Video codec: x264, Enter=x265, c=copy: ").strip().lower()
if not answer:
return "libx265"
try:
@@ -383,7 +376,9 @@ def _normalize_codec(value: str) -> str:
return "libx264"
if value in {"x265", "h265", "hevc", "libx265"}:
return "libx265"
raise ValueError("codec must be x264 or x265")
if value in {"c", "copy", "streamcopy", "stream-copy"}:
return "copy"
raise ValueError("codec must be x264, x265, or copy")
def _int_from_env(name: str) -> int | None: