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
+6 -3
View File
@@ -139,6 +139,8 @@ After validation, interactive runs switch to an encoding review screen. The top
x265 is the default video encoder. Profile and level can each be left unset, calculated as the minimum required for every clip individually, or selected manually as a requested floor. A manual value that is too low for one clip is upgraded only for that clip. Choices shown in red do not directly support every clip in the batch. x264 is unavailable when any validated Avisynth output is above 10-bit; output above 12-bit fails validation and is excluded from encoding. For x265, tier follows the level constraint: x265 tries Main tier first and may use High tier when the selected level requires it.
When every validated script preserves the complete source video timeline and its basic video properties, the video codec list also offers `copy`. This directly remuxes the source video stream, so it does not run Avisynth video effects. Audio may independently be copied or re-encoded. If a selected MP4 container cannot stream-copy a source video or audio codec, the container row shows a red warning and the run cannot start until you choose a compatible container or re-encode that stream. Use MKV for stream-copying formats such as PCM audio that are not supported by this MP4 workflow.
The repo includes source for a small bundled Avisynth runner in `tools/avisynth_runner/`. Build it on Linux with:
```bash
@@ -167,7 +169,7 @@ Audio support currently covers validated outputs that the current encoder path s
- Normal frame deletion uses segmented AAC audio cuts so removed frame durations are removed from audio too.
- Audio copy is allowed for whole-file outputs and simple beginning/end trims. FFmpeg does input-side stream-copy trimming for that case, so the cut can be limited by packet/keyframe boundaries. Normal frame deletion and speed changes still require audio re-encoding or no audio.
- If copy is requested for a trimmed or speed-changed output, the output is video-only rather than risking bad sync.
- MKV output can use Opus or FLAC audio. MP4 output intentionally stays limited to AAC, copy, or no audio for compatibility.
- MP4 and MKV can use AAC, Opus, FLAC, copied audio, or no audio. AAC remains the most widely compatible MP4 audio choice; Opus and especially FLAC in MP4 may not play on older devices or applications.
- Optional audio edits are supported by copying the editable video `.avs` to `[visible script stem]_audio.avs` next to it. The normal generated source import already includes source audio when the video has audio, so the audio edit script can be the same script shape as the video edit script. Its video output is ignored, and its whole audio output is rendered to WAV, then trimmed by Python to match the validated video timeline.
After successful encodes, the script asks what to do with the finished files:
@@ -181,12 +183,12 @@ Non-interactive encode options:
- `MBT_VIDEO_TIMESTAMP_NAMES=1` names outputs as `YYYYMMDD_HHMMSS.mp4`; `0` keeps original-style workspace names.
- `MBT_VIDEO_TIMEZONE=+03:00` selects the timezone used for timestamp filenames. If omitted, one consistent embedded source timezone is preferred; conflicting or missing source offsets fall back to the system timezone. No timezone is needed when timestamp filenames are disabled.
- `MBT_VIDEO_CONTAINER=mp4` or `mkv` selects the output container. MP4 is the default.
- `MBT_VIDEO_CODEC=x264` or `x265` selects FFmpeg `libx264` or `libx265`; x265 is the default.
- `MBT_VIDEO_CODEC=x264`, `x265`, or `copy` selects FFmpeg `libx264`, `libx265`, or direct source-video remuxing; x265 is the default. `copy` is only available for unchanged validated video timelines.
- `MBT_VIDEO_CRF=16` sets CRF. Interactive defaults are 16 for x264 and 21 for x265.
- `MBT_VIDEO_PRESET=slow` sets the encoder preset.
- `MBT_VIDEO_PROFILE=minimum` and `MBT_VIDEO_LEVEL=minimum` calculate per-clip constraints. A named profile or level acts as a requested floor. Omit either variable to apply no constraint for it.
- `MBT_VIDEO_THREADS=auto` (the default) leaves encoder thread selection automatic. Set it to a positive integer to limit x264 threads or x265 worker pools.
- `MBT_VIDEO_AUDIO=aac`, `opus`, `flac`, `copy`, or `none` selects audio handling. Opus and FLAC require MKV.
- `MBT_VIDEO_AUDIO=aac`, `opus`, `flac`, `copy`, or `none` selects audio handling. AAC, Opus, and FLAC are valid in either supported container, subject to player compatibility.
- `MBT_VIDEO_AUDIO_BITRATE=192k` sets AAC bitrate.
- `MBT_VIDEO_AUDIO_PITCH=preserve` or `shift` selects whether speed-changed AAC audio keeps pitch or shifts pitch with playback speed. The default is `preserve`.
- `MBT_VIDEO_FINAL_ACTION=leave`, `replace`, or `backup` selects the final disposition.
@@ -212,6 +214,7 @@ Current video scenarios copy fixture videos to temporary directories before proc
- mixed normal frame deletion plus `MBT_Drop`
- `MBT_Drop(start,end)` and `MBT_DropEvery(...)` helper behavior
- MKV output with Opus audio
- MP4 video/audio stream-copy remuxing
## Requirements
+191 -25
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,6 +215,9 @@ def encode_validated_video_only(
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}, "
@@ -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): "
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:
+26 -6
View File
@@ -232,6 +232,30 @@ class VideoEncodeEndToEndTests(unittest.TestCase):
self.assertFalse(list((WORKSPACE / ".source").rglob("*.frames.csv")))
self.assertFalse(list((WORKSPACE / ".source").rglob("*.runner.log")))
def test_mp4_stream_copy_remuxes_video_and_audio(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
self.create_workspace(source)
self.run_video_encode(
source,
encode=True,
extra_env={
"MBT_VIDEO_CONTAINER": "mp4",
"MBT_VIDEO_CODEC": "copy",
"MBT_VIDEO_AUDIO": "copy",
},
)
output = WORKSPACE / "20260602_222842.mp4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 170)
streams = ffprobe_json(output, self.env)["streams"]
self.assertEqual(
next(stream["codec_name"] for stream in streams if stream["codec_type"] == "video"),
"hevc",
)
self.assert_audio_codec(output, "aac")
def test_vfr_mp4_trim_preserves_location_and_adjusts_end_timestamp(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("20260602_222842.mp4", Path(temp_dir))
@@ -359,22 +383,18 @@ last
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertIn(expected, completed.stdout)
def test_simple_trim_can_copy_audio(self) -> None:
def test_incompatible_mp4_audio_copy_is_rejected(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
script = self.create_workspace(source)
self.replace_user_script_body(script, "Trim(last, 10, 49)\nlast")
with self.assertRaises(subprocess.CalledProcessError):
self.run_video_encode(
source,
encode=True,
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "copy"},
)
output = WORKSPACE / "C0011.MP4.mp4"
self.assertTrue(output.is_file(), output)
self.assert_video_stream_frames(output, 40)
self.assert_audio_codec(output, "pcm_s16be")
def test_mkv_opus_output(self) -> None:
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
+87
View File
@@ -31,6 +31,7 @@ from tools.video_timestamp_encode import write_timecode_v2, x264_command
from tools.video_encode_plan import (
missing_source_frames_between_kept,
normal_deleted_frame_count,
preserves_entire_source_timeline,
y4m_command_for_timeline,
)
from tools.video_codec_constraints import (
@@ -46,11 +47,14 @@ from tools.video_inputs import VideoInput
from tools.video_options import OutputNamingOptions
from tools.video_outputs import output_begin_timestamp, planned_output_path
from tools.video_batch_encode import (
EncodingOutputSpec,
_default_output_timezone,
_default_answer,
_encoding_steps,
_parse_audio,
_parse_answer,
_question_for_key,
_stream_copy_warnings,
)
from tools.video_probe import AudioStreamProbe, VideoProbe, _video_timezone, summarize_frame_timing
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
@@ -109,6 +113,89 @@ def make_probe(
class VideoCodecConstraintTests(unittest.TestCase):
def test_video_copy_is_only_available_for_a_full_source_timeline(self) -> None:
source = OutputTimeline(
frames=[OutputFrameTiming(index, 1, index, float(index), 1.0) for index in range(3)],
audio_segments=[(0.0, 3.0)],
consumed_source_frames=[0, 1, 2],
duration_seconds=3.0,
timing_kind="cfr",
beginning_trimmed=False,
ending_trimmed=False,
dropped_frame_count=0,
source_frame_count=3,
matrix=None,
)
trimmed = OutputTimeline(
frames=source.frames[:2],
audio_segments=[(0.0, 2.0)],
consumed_source_frames=[0, 1],
duration_seconds=2.0,
timing_kind="cfr",
beginning_trimmed=False,
ending_trimmed=True,
dropped_frame_count=0,
source_frame_count=3,
matrix=None,
)
self.assertTrue(preserves_entire_source_timeline(source))
self.assertFalse(preserves_entire_source_timeline(trimmed))
def test_mp4_stream_copy_warnings_name_incompatible_streams(self) -> None:
specs = [
EncodingOutputSpec(
label="clip.mkv",
width=1920,
height=1080,
bit_depth=8,
chroma="420",
fps=30.0,
begin_utc="unknown",
summary_lines=(),
video_codec="prores",
audio_codecs=("pcm_s16le",),
)
]
warnings = _stream_copy_warnings(
extension=".mp4",
video_codec="copy",
audio_mode="copy",
specs=specs,
)
self.assertEqual(
warnings,
["does not support prores video", "does not support pcm_s16le audio"],
)
def test_mp4_allows_opus_and_flac_audio(self) -> None:
self.assertEqual(_parse_audio("opus"), "opus")
self.assertEqual(_parse_audio("flac"), "flac")
spec = EncodingOutputSpec(
label="clip.mp4",
width=1920,
height=1080,
bit_depth=8,
chroma="420",
fps=30.0,
begin_utc="unknown",
summary_lines=(),
video_codec="h264",
audio_codecs=("flac",),
)
self.assertEqual(
_stream_copy_warnings(
extension=".mp4",
video_codec="libx265",
audio_mode="copy",
specs=[spec],
),
[],
)
def test_pixel_format_preserves_ten_bit_output(self) -> None:
formats = [
(8, "yuv420p"),