Add video stream copy remuxing
This commit is contained in:
@@ -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.
|
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:
|
The repo includes source for a small bundled Avisynth runner in `tools/avisynth_runner/`. Build it on Linux with:
|
||||||
|
|
||||||
```bash
|
```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.
|
- 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.
|
- 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.
|
- 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.
|
- 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:
|
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_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_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_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_CRF=16` sets CRF. Interactive defaults are 16 for x264 and 21 for x265.
|
||||||
- `MBT_VIDEO_PRESET=slow` sets the encoder preset.
|
- `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_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_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_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_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.
|
- `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`
|
- mixed normal frame deletion plus `MBT_Drop`
|
||||||
- `MBT_Drop(start,end)` and `MBT_DropEvery(...)` helper behavior
|
- `MBT_Drop(start,end)` and `MBT_DropEvery(...)` helper behavior
|
||||||
- MKV output with Opus audio
|
- MKV output with Opus audio
|
||||||
|
- MP4 video/audio stream-copy remuxing
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
+201
-35
@@ -27,6 +27,7 @@ from tools.video_encode_output import (
|
|||||||
ffmpeg_colorspace_from_matrix,
|
ffmpeg_colorspace_from_matrix,
|
||||||
pixel_format_bit_depth,
|
pixel_format_bit_depth,
|
||||||
print_encoder_statistics,
|
print_encoder_statistics,
|
||||||
|
remux_video,
|
||||||
render_audio_wav,
|
render_audio_wav,
|
||||||
)
|
)
|
||||||
from tools.video_timestamp_encode import encode_video_with_timestamps
|
from tools.video_timestamp_encode import encode_video_with_timestamps
|
||||||
@@ -37,6 +38,7 @@ from tools.video_encode_plan import (
|
|||||||
encoded_duration_seconds,
|
encoded_duration_seconds,
|
||||||
has_avisynth_speed_changes,
|
has_avisynth_speed_changes,
|
||||||
normal_deleted_frame_count,
|
normal_deleted_frame_count,
|
||||||
|
preserves_entire_source_timeline,
|
||||||
should_preserve_video_timestamps,
|
should_preserve_video_timestamps,
|
||||||
should_use_audio_segments,
|
should_use_audio_segments,
|
||||||
timeline_frame_starts,
|
timeline_frame_starts,
|
||||||
@@ -83,6 +85,8 @@ class EncodingOutputSpec:
|
|||||||
fps: float
|
fps: float
|
||||||
begin_utc: str
|
begin_utc: str
|
||||||
summary_lines: tuple[str, ...]
|
summary_lines: tuple[str, ...]
|
||||||
|
video_codec: str | None
|
||||||
|
audio_codecs: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -171,9 +175,23 @@ def encode_validated_video_only(
|
|||||||
container_extension=codec_options.extension,
|
container_extension=codec_options.extension,
|
||||||
)
|
)
|
||||||
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
|
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):
|
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")
|
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()])
|
should_preserve_video_timestamps(timelines[item.path.resolve()])
|
||||||
for item in inputs
|
for item in inputs
|
||||||
)
|
)
|
||||||
@@ -197,15 +215,18 @@ def encode_validated_video_only(
|
|||||||
else "original names"
|
else "original names"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
print(
|
if codec_options.codec == "copy":
|
||||||
"Codec: "
|
print(f"Codec: copy video stream, {codec_options.extension}")
|
||||||
f"{codec_options.codec}, CRF {codec_options.crf}, "
|
else:
|
||||||
f"preset {codec_options.preset}, "
|
print(
|
||||||
f"profile {codec_options.profile or 'none'}, "
|
"Codec: "
|
||||||
f"level {codec_options.level or 'none'}, "
|
f"{codec_options.codec}, CRF {codec_options.crf}, "
|
||||||
f"threads {codec_options.threads if codec_options.threads is not None else 'auto'}, "
|
f"preset {codec_options.preset}, "
|
||||||
f"{codec_options.extension}"
|
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)}")
|
print(f"Audio: {_format_audio_options(audio_options)}")
|
||||||
|
|
||||||
output_paths = _plan_batch_outputs(
|
output_paths = _plan_batch_outputs(
|
||||||
@@ -225,6 +246,44 @@ def encode_validated_video_only(
|
|||||||
if avs_runner != avs_runners.default:
|
if avs_runner != avs_runners.default:
|
||||||
print(f" runner: {avs_runner}")
|
print(f" runner: {avs_runner}")
|
||||||
clip_info = clip_infos.get(item.path.resolve())
|
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
|
output_bit_depth = clip_info.bit_depth if clip_info is not None else 8
|
||||||
pixel_format = choose_video_pixel_format(
|
pixel_format = choose_video_pixel_format(
|
||||||
ffmpeg,
|
ffmpeg,
|
||||||
@@ -269,7 +328,6 @@ def encode_validated_video_only(
|
|||||||
"timestamp-aware muxing"
|
"timestamp-aware muxing"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
probe = probes_by_path[item.path.resolve()]
|
|
||||||
output_duration = (
|
output_duration = (
|
||||||
timeline.duration_seconds
|
timeline.duration_seconds
|
||||||
if preserve_video_timestamps
|
if preserve_video_timestamps
|
||||||
@@ -372,12 +430,15 @@ def ask_interactive_encoding_settings(
|
|||||||
index = 0
|
index = 0
|
||||||
has_speed_changes = has_avisynth_speed_changes(timelines, clip_infos)
|
has_speed_changes = has_avisynth_speed_changes(timelines, clip_infos)
|
||||||
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
|
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:
|
def answer(key: str, value: object) -> None:
|
||||||
old_value = answers.get(key)
|
old_value = answers.get(key)
|
||||||
answers[key] = value
|
answers[key] = value
|
||||||
if key == "codec" and old_value is not None and old_value != 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:
|
if key == "container" and old_value is not None and old_value != value:
|
||||||
_forget(answers, {"audio", "audio_bitrate"})
|
_forget(answers, {"audio", "audio_bitrate"})
|
||||||
if key == "timestamp_names" and value is False:
|
if key == "timestamp_names" and value is False:
|
||||||
@@ -422,12 +483,12 @@ def ask_interactive_encoding_settings(
|
|||||||
),
|
),
|
||||||
codec_options=VideoCodecOptions(
|
codec_options=VideoCodecOptions(
|
||||||
codec=codec,
|
codec=codec,
|
||||||
crf=int(answers["crf"]),
|
crf=int(answers.get("crf", 21)),
|
||||||
preset=str(answers["preset"]),
|
preset=str(answers.get("preset", "slow")),
|
||||||
extension=str(answers["container"]),
|
extension=str(answers["container"]),
|
||||||
profile=_none_if_none(str(answers["profile"])),
|
profile=_none_if_none(str(answers.get("profile", "none"))),
|
||||||
level=_none_if_none(str(answers["level"])),
|
level=_none_if_none(str(answers.get("level", "none"))),
|
||||||
threads=_threads_from_answer(answers["threads"]),
|
threads=_threads_from_answer(answers.get("threads", "auto")),
|
||||||
),
|
),
|
||||||
audio_options=AudioEncodeOptions(
|
audio_options=AudioEncodeOptions(
|
||||||
mode=audio_mode,
|
mode=audio_mode,
|
||||||
@@ -459,7 +520,10 @@ def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list
|
|||||||
steps = ["timestamp_names"]
|
steps = ["timestamp_names"]
|
||||||
if answers.get("timestamp_names", True):
|
if answers.get("timestamp_names", True):
|
||||||
steps.append("timezone")
|
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"}:
|
if answers.get("audio") in {"aac", "opus"}:
|
||||||
steps.append("audio_bitrate")
|
steps.append("audio_bitrate")
|
||||||
if has_speed_changes and answers.get("audio") in {"aac", "opus"}:
|
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",
|
"timestamp_names",
|
||||||
"container",
|
"container",
|
||||||
"codec",
|
"codec",
|
||||||
"crf",
|
|
||||||
"preset",
|
|
||||||
"profile",
|
|
||||||
"level",
|
|
||||||
"threads",
|
|
||||||
"audio",
|
"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, []):
|
if _default_answer("timestamp_names", answers, []):
|
||||||
keys.insert(1, "timezone")
|
keys.insert(1, "timezone")
|
||||||
if _default_answer("audio", answers, []) in {"aac", "opus"}:
|
if _default_answer("audio", answers, []) in {"aac", "opus"}:
|
||||||
@@ -536,12 +598,21 @@ def _question_for_key(
|
|||||||
default = _default_answer(key, answers, specs)
|
default = _default_answer(key, answers, specs)
|
||||||
return f"Output filename timezone [{timezone_to_string(default)}]: "
|
return f"Output filename timezone [{timezone_to_string(default)}]: "
|
||||||
if key == "container":
|
if key == "container":
|
||||||
return (
|
prompt = (
|
||||||
f"Output container [{_container_label(_default_answer(key, answers, specs))}] "
|
f"Output container [{_container_label(_default_answer(key, answers, specs))}] "
|
||||||
"(mp4/mkv): "
|
"(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":
|
if key == "codec":
|
||||||
options = ["x264", "x265"]
|
options = ["x264", "x265"]
|
||||||
|
if answers.get("_video_copy_available"):
|
||||||
|
options.append("copy")
|
||||||
if _x264_unavailable(specs):
|
if _x264_unavailable(specs):
|
||||||
options[0] = red_strikethrough(options[0])
|
options[0] = red_strikethrough(options[0])
|
||||||
return (
|
return (
|
||||||
@@ -574,9 +645,7 @@ def _question_for_key(
|
|||||||
if key == "threads":
|
if key == "threads":
|
||||||
return f"Encoder threads [{_default_answer(key, answers, specs)}] (auto or positive integer): "
|
return f"Encoder threads [{_default_answer(key, answers, specs)}] (auto or positive integer): "
|
||||||
if key == "audio":
|
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)}] (opus/aac/flac/copy/none): "
|
|
||||||
return f"Audio [{_default_answer(key, answers, specs)}] (aac/copy/none): "
|
|
||||||
if key == "audio_bitrate":
|
if key == "audio_bitrate":
|
||||||
return f"{str(answers['audio']).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: "
|
return f"{str(answers['audio']).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: "
|
||||||
if key == "audio_pitch":
|
if key == "audio_pitch":
|
||||||
@@ -596,7 +665,19 @@ def _parse_answer(
|
|||||||
value = raw or str(default)
|
value = raw or str(default)
|
||||||
lowered = value.strip().lower()
|
lowered = value.strip().lower()
|
||||||
if key == "confirm":
|
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":
|
if key == "timestamp_names":
|
||||||
return lowered not in {"n", "no", "0", "false", "off"}
|
return lowered not in {"n", "no", "0", "false", "off"}
|
||||||
if key == "timezone":
|
if key == "timezone":
|
||||||
@@ -616,7 +697,14 @@ def _parse_answer(
|
|||||||
return "libx264"
|
return "libx264"
|
||||||
if lowered in {"x265", "h265", "hevc", "libx265"}:
|
if lowered in {"x265", "h265", "hevc", "libx265"}:
|
||||||
return "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":
|
if key == "crf":
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
@@ -643,7 +731,7 @@ def _parse_answer(
|
|||||||
raise ValueError("Threads must be auto or a positive integer.")
|
raise ValueError("Threads must be auto or a positive integer.")
|
||||||
return threads
|
return threads
|
||||||
if key == "audio":
|
if key == "audio":
|
||||||
return _parse_audio(value, str(answers["container"]))
|
return _parse_audio(value)
|
||||||
if key == "audio_bitrate":
|
if key == "audio_bitrate":
|
||||||
return value
|
return value
|
||||||
if key == "audio_pitch":
|
if key == "audio_pitch":
|
||||||
@@ -730,6 +818,10 @@ def _encoding_specs(
|
|||||||
probe=probe,
|
probe=probe,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
video_codec=probe.codec,
|
||||||
|
audio_codecs=tuple(
|
||||||
|
stream.codec for stream in probe.audio_streams if stream.codec
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return specs
|
return specs
|
||||||
@@ -798,6 +890,70 @@ def _x264_unavailable(specs: list[EncodingOutputSpec]) -> bool:
|
|||||||
return any(spec.bit_depth > 10 for spec in specs)
|
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:
|
def _begin_utc(probe: VideoProbe, timeline: OutputTimeline) -> str:
|
||||||
try:
|
try:
|
||||||
return output_begin_timestamp(probe, timeline, timezone.utc).isoformat()
|
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)}")
|
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()
|
lowered = value.strip().lower()
|
||||||
if lowered in {"aac", "a"}:
|
if lowered in {"aac", "a"}:
|
||||||
return "aac"
|
return "aac"
|
||||||
@@ -821,11 +977,11 @@ def _parse_audio(value: str, container: str) -> str:
|
|||||||
return "copy"
|
return "copy"
|
||||||
if lowered in {"none", "no", "n"}:
|
if lowered in {"none", "no", "n"}:
|
||||||
return "none"
|
return "none"
|
||||||
if container == ".mkv" and lowered in {"opus", "o"}:
|
if lowered in {"opus", "o"}:
|
||||||
return "opus"
|
return "opus"
|
||||||
if container == ".mkv" and lowered in {"flac", "f"}:
|
if lowered in {"flac", "f"}:
|
||||||
return "flac"
|
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:
|
def _yes_no_suffix(default: bool) -> str:
|
||||||
@@ -867,7 +1023,15 @@ def _setting_value(
|
|||||||
if key == "timezone":
|
if key == "timezone":
|
||||||
return timezone_to_string(value)
|
return timezone_to_string(value)
|
||||||
if key == "container":
|
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":
|
if key == "codec":
|
||||||
return _codec_label(value)
|
return _codec_label(value)
|
||||||
if key in {"profile", "level"} and value not in {"none", "minimum"}:
|
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:
|
def _codec_label(value: object) -> str:
|
||||||
|
if value == "copy":
|
||||||
|
return "copy"
|
||||||
return "x264" if value == "libx264" else "x265"
|
return "x264" if value == "libx264" else "x265"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
def encode_video_only_y4m(
|
||||||
*,
|
*,
|
||||||
y4m_command: list[str],
|
y4m_command: list[str],
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ def normal_deleted_frame_count(timeline: OutputTimeline) -> int:
|
|||||||
return missing_source_frames(timeline.consumed_source_frames)
|
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:
|
def missing_source_frames_between_kept(timeline: OutputTimeline) -> int:
|
||||||
return missing_source_frames([frame.source_frame for frame in timeline.frames])
|
return missing_source_frames([frame.source_frame for frame in timeline.frames])
|
||||||
|
|
||||||
|
|||||||
+14
-19
@@ -175,12 +175,12 @@ def ask_video_codec_options() -> VideoCodecOptions:
|
|||||||
extension = _prompt_container_extension()
|
extension = _prompt_container_extension()
|
||||||
if codec is None:
|
if codec is None:
|
||||||
codec = _prompt_codec()
|
codec = _prompt_codec()
|
||||||
if crf is None:
|
if codec != "copy" and crf is None:
|
||||||
default_crf = 16 if codec == "libx264" else 21
|
default_crf = 16 if codec == "libx264" else 21
|
||||||
crf = _prompt_int(f"CRF [{default_crf}]: ", default=default_crf)
|
crf = _prompt_int(f"CRF [{default_crf}]: ", default=default_crf)
|
||||||
if not preset:
|
if codec != "copy" and not preset:
|
||||||
preset = _prompt_preset(default="slow")
|
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)
|
threads = _prompt_threads(default=None)
|
||||||
else:
|
else:
|
||||||
extension = extension or ".mp4"
|
extension = extension or ".mp4"
|
||||||
@@ -190,8 +190,8 @@ def ask_video_codec_options() -> VideoCodecOptions:
|
|||||||
|
|
||||||
return VideoCodecOptions(
|
return VideoCodecOptions(
|
||||||
codec=codec,
|
codec=codec,
|
||||||
crf=crf,
|
crf=crf if crf is not None else 21,
|
||||||
preset=preset,
|
preset=preset or "slow",
|
||||||
extension=extension,
|
extension=extension,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
level=level,
|
level=level,
|
||||||
@@ -249,7 +249,6 @@ def ask_audio_options(
|
|||||||
preserve_pitch = ask_audio_pitch_option(has_speed_changes=has_speed_changes)
|
preserve_pitch = ask_audio_pitch_option(has_speed_changes=has_speed_changes)
|
||||||
if env_mode:
|
if env_mode:
|
||||||
mode = _normalize_audio_mode(env_mode)
|
mode = _normalize_audio_mode(env_mode)
|
||||||
_validate_audio_container(mode, container_extension)
|
|
||||||
return AudioEncodeOptions(
|
return AudioEncodeOptions(
|
||||||
mode=mode,
|
mode=mode,
|
||||||
bitrate=env_bitrate or default_audio_bitrate(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)
|
return AudioEncodeOptions(mode="none", preserve_pitch=preserve_pitch)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if container_extension == ".mkv":
|
prompt = "Audio: Enter=" + (
|
||||||
prompt = "Audio: Enter=Opus, a=AAC, f=FLAC, c=copy/trim audio, n=no audio: "
|
"Opus" if container_extension == ".mkv" else "AAC"
|
||||||
else:
|
) + ", a=AAC, o=Opus, f=FLAC, c=copy/trim audio, n=no audio: "
|
||||||
prompt = "Audio: Enter=AAC re-encode, c=copy/trim audio, n=no audio: "
|
|
||||||
answer = prompt_input(prompt).strip().lower()
|
answer = prompt_input(prompt).strip().lower()
|
||||||
if not answer:
|
if not answer:
|
||||||
mode = "opus" if container_extension == ".mkv" else "aac"
|
mode = "opus" if container_extension == ".mkv" else "aac"
|
||||||
@@ -271,10 +269,10 @@ def ask_audio_options(
|
|||||||
if answer in {"a", "aac"}:
|
if answer in {"a", "aac"}:
|
||||||
mode = "aac"
|
mode = "aac"
|
||||||
break
|
break
|
||||||
if container_extension == ".mkv" and answer in {"o", "opus"}:
|
if answer in {"o", "opus"}:
|
||||||
mode = "opus"
|
mode = "opus"
|
||||||
break
|
break
|
||||||
if container_extension == ".mkv" and answer in {"f", "flac"}:
|
if answer in {"f", "flac"}:
|
||||||
mode = "flac"
|
mode = "flac"
|
||||||
break
|
break
|
||||||
if answer in {"c", "copy"}:
|
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")
|
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:
|
def _container_extension_from_env() -> str | None:
|
||||||
raw = os.environ.get("MBT_VIDEO_CONTAINER", "").strip().lower()
|
raw = os.environ.get("MBT_VIDEO_CONTAINER", "").strip().lower()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -369,7 +362,7 @@ def _codec_from_env() -> str | None:
|
|||||||
|
|
||||||
def _prompt_codec() -> str:
|
def _prompt_codec() -> str:
|
||||||
while True:
|
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:
|
if not answer:
|
||||||
return "libx265"
|
return "libx265"
|
||||||
try:
|
try:
|
||||||
@@ -383,7 +376,9 @@ def _normalize_codec(value: str) -> str:
|
|||||||
return "libx264"
|
return "libx264"
|
||||||
if value in {"x265", "h265", "hevc", "libx265"}:
|
if value in {"x265", "h265", "hevc", "libx265"}:
|
||||||
return "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:
|
def _int_from_env(name: str) -> int | None:
|
||||||
|
|||||||
@@ -232,6 +232,30 @@ class VideoEncodeEndToEndTests(unittest.TestCase):
|
|||||||
self.assertFalse(list((WORKSPACE / ".source").rglob("*.frames.csv")))
|
self.assertFalse(list((WORKSPACE / ".source").rglob("*.frames.csv")))
|
||||||
self.assertFalse(list((WORKSPACE / ".source").rglob("*.runner.log")))
|
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:
|
def test_vfr_mp4_trim_preserves_location_and_adjusts_end_timestamp(self) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
||||||
source = self.copy_fixture("20260602_222842.mp4", Path(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.assertEqual(completed.returncode, 0, completed.stderr)
|
||||||
self.assertIn(expected, completed.stdout)
|
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:
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
||||||
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
source = self.copy_fixture("C0011.MP4", Path(temp_dir))
|
||||||
script = self.create_workspace(source)
|
script = self.create_workspace(source)
|
||||||
self.replace_user_script_body(script, "Trim(last, 10, 49)\nlast")
|
self.replace_user_script_body(script, "Trim(last, 10, 49)\nlast")
|
||||||
self.run_video_encode(
|
with self.assertRaises(subprocess.CalledProcessError):
|
||||||
source,
|
self.run_video_encode(
|
||||||
encode=True,
|
source,
|
||||||
extra_env={"MBT_VIDEO_CONTAINER": "mp4", "MBT_VIDEO_AUDIO": "copy"},
|
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:
|
def test_mkv_opus_output(self) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
with tempfile.TemporaryDirectory(prefix="mbt-video-e2e-") as temp_dir:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from tools.video_timestamp_encode import write_timecode_v2, x264_command
|
|||||||
from tools.video_encode_plan import (
|
from tools.video_encode_plan import (
|
||||||
missing_source_frames_between_kept,
|
missing_source_frames_between_kept,
|
||||||
normal_deleted_frame_count,
|
normal_deleted_frame_count,
|
||||||
|
preserves_entire_source_timeline,
|
||||||
y4m_command_for_timeline,
|
y4m_command_for_timeline,
|
||||||
)
|
)
|
||||||
from tools.video_codec_constraints import (
|
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_options import OutputNamingOptions
|
||||||
from tools.video_outputs import output_begin_timestamp, planned_output_path
|
from tools.video_outputs import output_begin_timestamp, planned_output_path
|
||||||
from tools.video_batch_encode import (
|
from tools.video_batch_encode import (
|
||||||
|
EncodingOutputSpec,
|
||||||
_default_output_timezone,
|
_default_output_timezone,
|
||||||
_default_answer,
|
_default_answer,
|
||||||
_encoding_steps,
|
_encoding_steps,
|
||||||
|
_parse_audio,
|
||||||
_parse_answer,
|
_parse_answer,
|
||||||
_question_for_key,
|
_question_for_key,
|
||||||
|
_stream_copy_warnings,
|
||||||
)
|
)
|
||||||
from tools.video_probe import AudioStreamProbe, VideoProbe, _video_timezone, summarize_frame_timing
|
from tools.video_probe import AudioStreamProbe, VideoProbe, _video_timezone, summarize_frame_timing
|
||||||
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
|
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
|
||||||
@@ -109,6 +113,89 @@ def make_probe(
|
|||||||
|
|
||||||
|
|
||||||
class VideoCodecConstraintTests(unittest.TestCase):
|
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:
|
def test_pixel_format_preserves_ten_bit_output(self) -> None:
|
||||||
formats = [
|
formats = [
|
||||||
(8, "yuv420p"),
|
(8, "yuv420p"),
|
||||||
|
|||||||
Reference in New Issue
Block a user