diff --git a/README.md b/README.md index 4e01999..efb9bca 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/tools/video_batch_encode.py b/tools/video_batch_encode.py index 557ae52..91b8f2b 100644 --- a/tools/video_batch_encode.py +++ b/tools/video_batch_encode.py @@ -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" diff --git a/tools/video_encode_output.py b/tools/video_encode_output.py index 2413285..6958140 100644 --- a/tools/video_encode_output.py +++ b/tools/video_encode_output.py @@ -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], diff --git a/tools/video_encode_plan.py b/tools/video_encode_plan.py index 8570a5b..52154fb 100644 --- a/tools/video_encode_plan.py +++ b/tools/video_encode_plan.py @@ -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]) diff --git a/tools/video_options.py b/tools/video_options.py index 1476573..56f12b8 100644 --- a/tools/video_options.py +++ b/tools/video_options.py @@ -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: diff --git a/unit_tests/test_video_e2e.py b/unit_tests/test_video_e2e.py index 19d9d3f..28ce083 100644 --- a/unit_tests/test_video_e2e.py +++ b/unit_tests/test_video_e2e.py @@ -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,21 +383,17 @@ 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") - 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") + with self.assertRaises(subprocess.CalledProcessError): + self.run_video_encode( + source, + encode=True, + extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "copy"}, + ) def test_mkv_opus_output(self) -> None: with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir: diff --git a/unit_tests/test_video_logic.py b/unit_tests/test_video_logic.py index 842b3a6..25964f5 100644 --- a/unit_tests/test_video_logic.py +++ b/unit_tests/test_video_logic.py @@ -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"),