Add video stream copy remuxing
This commit is contained in:
+201
-35
@@ -27,6 +27,7 @@ from tools.video_encode_output import (
|
||||
ffmpeg_colorspace_from_matrix,
|
||||
pixel_format_bit_depth,
|
||||
print_encoder_statistics,
|
||||
remux_video,
|
||||
render_audio_wav,
|
||||
)
|
||||
from tools.video_timestamp_encode import encode_video_with_timestamps
|
||||
@@ -37,6 +38,7 @@ from tools.video_encode_plan import (
|
||||
encoded_duration_seconds,
|
||||
has_avisynth_speed_changes,
|
||||
normal_deleted_frame_count,
|
||||
preserves_entire_source_timeline,
|
||||
should_preserve_video_timestamps,
|
||||
should_use_audio_segments,
|
||||
timeline_frame_starts,
|
||||
@@ -83,6 +85,8 @@ class EncodingOutputSpec:
|
||||
fps: float
|
||||
begin_utc: str
|
||||
summary_lines: tuple[str, ...]
|
||||
video_codec: str | None
|
||||
audio_codecs: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -171,9 +175,23 @@ def encode_validated_video_only(
|
||||
container_extension=codec_options.extension,
|
||||
)
|
||||
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
|
||||
copy_available = _video_copy_available(inputs, timelines, clip_infos, probes_by_path)
|
||||
if codec_options.codec == "copy" and not copy_available:
|
||||
raise RuntimeError(
|
||||
"Video stream copy is only available when every validated script preserves "
|
||||
"the source video unchanged."
|
||||
)
|
||||
warnings = _stream_copy_warnings(
|
||||
extension=codec_options.extension,
|
||||
video_codec=codec_options.codec,
|
||||
audio_mode=audio_options.mode,
|
||||
specs=specs,
|
||||
)
|
||||
if warnings:
|
||||
raise RuntimeError("Cannot start with stream-copy container warnings: " + "; ".join(warnings))
|
||||
if codec_options.codec == "libx264" and any(spec.bit_depth > 10 for spec in specs):
|
||||
raise RuntimeError("x264 cannot encode this batch because at least one output is above 10-bit")
|
||||
needs_timecodes = any(
|
||||
needs_timecodes = codec_options.codec != "copy" and any(
|
||||
should_preserve_video_timestamps(timelines[item.path.resolve()])
|
||||
for item in inputs
|
||||
)
|
||||
@@ -197,15 +215,18 @@ def encode_validated_video_only(
|
||||
else "original names"
|
||||
)
|
||||
)
|
||||
print(
|
||||
"Codec: "
|
||||
f"{codec_options.codec}, CRF {codec_options.crf}, "
|
||||
f"preset {codec_options.preset}, "
|
||||
f"profile {codec_options.profile or 'none'}, "
|
||||
f"level {codec_options.level or 'none'}, "
|
||||
f"threads {codec_options.threads if codec_options.threads is not None else 'auto'}, "
|
||||
f"{codec_options.extension}"
|
||||
)
|
||||
if codec_options.codec == "copy":
|
||||
print(f"Codec: copy video stream, {codec_options.extension}")
|
||||
else:
|
||||
print(
|
||||
"Codec: "
|
||||
f"{codec_options.codec}, CRF {codec_options.crf}, "
|
||||
f"preset {codec_options.preset}, "
|
||||
f"profile {codec_options.profile or 'none'}, "
|
||||
f"level {codec_options.level or 'none'}, "
|
||||
f"threads {codec_options.threads if codec_options.threads is not None else 'auto'}, "
|
||||
f"{codec_options.extension}"
|
||||
)
|
||||
print(f"Audio: {_format_audio_options(audio_options)}")
|
||||
|
||||
output_paths = _plan_batch_outputs(
|
||||
@@ -225,6 +246,44 @@ def encode_validated_video_only(
|
||||
if avs_runner != avs_runners.default:
|
||||
print(f" runner: {avs_runner}")
|
||||
clip_info = clip_infos.get(item.path.resolve())
|
||||
probe = probes_by_path[item.path.resolve()]
|
||||
if codec_options.codec == "copy":
|
||||
output = output_paths[item.path.resolve()]
|
||||
if audio_options.mode == "copy":
|
||||
audio_source, source_has_audio = item.path, probe.audio_stream_count > 0
|
||||
else:
|
||||
audio_source, source_has_audio = prepare_audio_source(
|
||||
avs_runners=avs_runners,
|
||||
root=root,
|
||||
item=item,
|
||||
original_has_audio=probe.audio_stream_count > 0,
|
||||
audio_options=audio_options,
|
||||
)
|
||||
result = remux_video(
|
||||
ffmpeg=ffmpeg,
|
||||
source=item.path,
|
||||
audio_source=audio_source,
|
||||
output=output,
|
||||
audio_options=audio_options,
|
||||
source_has_audio=source_has_audio,
|
||||
frame_count=len(timeline.frames),
|
||||
progress_label=f"script {item_index}/{len(inputs)}: {item.collapsed_relative}",
|
||||
)
|
||||
validate_encoded_video(
|
||||
ffprobe,
|
||||
result.output,
|
||||
timeline,
|
||||
expected_duration=timeline.duration_seconds,
|
||||
expected_timestamps=timeline_frame_starts(timeline),
|
||||
expected_audio=audio_options.mode != "none" and source_has_audio,
|
||||
)
|
||||
copy_video_metadata(exiftool, item.path, result.output)
|
||||
write_video_end_timestamp(
|
||||
exiftool, result.output, probe, timeline, timeline.duration_seconds
|
||||
)
|
||||
encoded_items.append(EncodedItem(input_item=item, output=result.output))
|
||||
print(f" remuxed output: {result.output}")
|
||||
continue
|
||||
output_bit_depth = clip_info.bit_depth if clip_info is not None else 8
|
||||
pixel_format = choose_video_pixel_format(
|
||||
ffmpeg,
|
||||
@@ -269,7 +328,6 @@ def encode_validated_video_only(
|
||||
"timestamp-aware muxing"
|
||||
)
|
||||
continue
|
||||
probe = probes_by_path[item.path.resolve()]
|
||||
output_duration = (
|
||||
timeline.duration_seconds
|
||||
if preserve_video_timestamps
|
||||
@@ -372,12 +430,15 @@ def ask_interactive_encoding_settings(
|
||||
index = 0
|
||||
has_speed_changes = has_avisynth_speed_changes(timelines, clip_infos)
|
||||
specs = _encoding_specs(inputs, timelines, clip_infos, probes_by_path)
|
||||
answers["_video_copy_available"] = _video_copy_available(
|
||||
inputs, timelines, clip_infos, probes_by_path
|
||||
)
|
||||
|
||||
def answer(key: str, value: object) -> None:
|
||||
old_value = answers.get(key)
|
||||
answers[key] = value
|
||||
if key == "codec" and old_value is not None and old_value != value:
|
||||
_forget(answers, {"crf", "profile", "level"})
|
||||
_forget(answers, {"crf", "preset", "profile", "level", "threads"})
|
||||
if key == "container" and old_value is not None and old_value != value:
|
||||
_forget(answers, {"audio", "audio_bitrate"})
|
||||
if key == "timestamp_names" and value is False:
|
||||
@@ -422,12 +483,12 @@ def ask_interactive_encoding_settings(
|
||||
),
|
||||
codec_options=VideoCodecOptions(
|
||||
codec=codec,
|
||||
crf=int(answers["crf"]),
|
||||
preset=str(answers["preset"]),
|
||||
crf=int(answers.get("crf", 21)),
|
||||
preset=str(answers.get("preset", "slow")),
|
||||
extension=str(answers["container"]),
|
||||
profile=_none_if_none(str(answers["profile"])),
|
||||
level=_none_if_none(str(answers["level"])),
|
||||
threads=_threads_from_answer(answers["threads"]),
|
||||
profile=_none_if_none(str(answers.get("profile", "none"))),
|
||||
level=_none_if_none(str(answers.get("level", "none"))),
|
||||
threads=_threads_from_answer(answers.get("threads", "auto")),
|
||||
),
|
||||
audio_options=AudioEncodeOptions(
|
||||
mode=audio_mode,
|
||||
@@ -459,7 +520,10 @@ def _encoding_steps(answers: dict[str, object], has_speed_changes: bool) -> list
|
||||
steps = ["timestamp_names"]
|
||||
if answers.get("timestamp_names", True):
|
||||
steps.append("timezone")
|
||||
steps.extend(["container", "codec", "crf", "preset", "profile", "level", "threads", "audio"])
|
||||
steps.extend(["container", "codec"])
|
||||
if answers.get("codec") != "copy":
|
||||
steps.extend(["crf", "preset", "profile", "level", "threads"])
|
||||
steps.append("audio")
|
||||
if answers.get("audio") in {"aac", "opus"}:
|
||||
steps.append("audio_bitrate")
|
||||
if has_speed_changes and answers.get("audio") in {"aac", "opus"}:
|
||||
@@ -504,13 +568,11 @@ def _encoding_table_keys(answers: dict[str, object], has_speed_changes: bool) ->
|
||||
"timestamp_names",
|
||||
"container",
|
||||
"codec",
|
||||
"crf",
|
||||
"preset",
|
||||
"profile",
|
||||
"level",
|
||||
"threads",
|
||||
"audio",
|
||||
]
|
||||
if _default_answer("codec", answers, []) != "copy":
|
||||
index = keys.index("audio")
|
||||
keys[index:index] = ["crf", "preset", "profile", "level", "threads"]
|
||||
if _default_answer("timestamp_names", answers, []):
|
||||
keys.insert(1, "timezone")
|
||||
if _default_answer("audio", answers, []) in {"aac", "opus"}:
|
||||
@@ -536,12 +598,21 @@ def _question_for_key(
|
||||
default = _default_answer(key, answers, specs)
|
||||
return f"Output filename timezone [{timezone_to_string(default)}]: "
|
||||
if key == "container":
|
||||
return (
|
||||
prompt = (
|
||||
f"Output container [{_container_label(_default_answer(key, answers, specs))}] "
|
||||
"(mp4/mkv): "
|
||||
)
|
||||
warnings = _stream_copy_warnings(
|
||||
extension=str(_default_answer(key, answers, specs)),
|
||||
video_codec=str(_default_answer("codec", answers, specs)),
|
||||
audio_mode=str(_default_answer("audio", answers, specs)),
|
||||
specs=specs,
|
||||
)
|
||||
return prompt + (light_red(" ".join(warnings)) if warnings else "")
|
||||
if key == "codec":
|
||||
options = ["x264", "x265"]
|
||||
if answers.get("_video_copy_available"):
|
||||
options.append("copy")
|
||||
if _x264_unavailable(specs):
|
||||
options[0] = red_strikethrough(options[0])
|
||||
return (
|
||||
@@ -574,9 +645,7 @@ def _question_for_key(
|
||||
if key == "threads":
|
||||
return f"Encoder threads [{_default_answer(key, answers, specs)}] (auto or positive integer): "
|
||||
if key == "audio":
|
||||
if answers.get("container") == ".mkv":
|
||||
return f"Audio [{_default_answer(key, answers, specs)}] (opus/aac/flac/copy/none): "
|
||||
return f"Audio [{_default_answer(key, answers, specs)}] (aac/copy/none): "
|
||||
return f"Audio [{_default_answer(key, answers, specs)}] (opus/aac/flac/copy/none): "
|
||||
if key == "audio_bitrate":
|
||||
return f"{str(answers['audio']).upper()} audio bitrate [{_default_answer(key, answers, specs)}]: "
|
||||
if key == "audio_pitch":
|
||||
@@ -596,7 +665,19 @@ def _parse_answer(
|
||||
value = raw or str(default)
|
||||
lowered = value.strip().lower()
|
||||
if key == "confirm":
|
||||
return lowered not in {"n", "no", "0", "false", "off"}
|
||||
confirmed = lowered not in {"n", "no", "0", "false", "off"}
|
||||
warnings = _stream_copy_warnings(
|
||||
extension=str(_default_answer("container", answers, specs)),
|
||||
video_codec=str(_default_answer("codec", answers, specs)),
|
||||
audio_mode=str(_default_answer("audio", answers, specs)),
|
||||
specs=specs,
|
||||
)
|
||||
if confirmed and warnings:
|
||||
raise ValueError(
|
||||
"Change the container or stream-copy choices before starting: "
|
||||
+ "; ".join(warnings)
|
||||
)
|
||||
return confirmed
|
||||
if key == "timestamp_names":
|
||||
return lowered not in {"n", "no", "0", "false", "off"}
|
||||
if key == "timezone":
|
||||
@@ -616,7 +697,14 @@ def _parse_answer(
|
||||
return "libx264"
|
||||
if lowered in {"x265", "h265", "hevc", "libx265"}:
|
||||
return "libx265"
|
||||
raise ValueError("Codec must be x264 or x265.")
|
||||
if lowered in {"copy", "c"}:
|
||||
if not answers.get("_video_copy_available"):
|
||||
raise ValueError(
|
||||
"Video copy is unavailable because a validated script changes "
|
||||
"video frames or properties."
|
||||
)
|
||||
return "copy"
|
||||
raise ValueError("Codec must be x264, x265, or copy.")
|
||||
if key == "crf":
|
||||
try:
|
||||
return int(value)
|
||||
@@ -643,7 +731,7 @@ def _parse_answer(
|
||||
raise ValueError("Threads must be auto or a positive integer.")
|
||||
return threads
|
||||
if key == "audio":
|
||||
return _parse_audio(value, str(answers["container"]))
|
||||
return _parse_audio(value)
|
||||
if key == "audio_bitrate":
|
||||
return value
|
||||
if key == "audio_pitch":
|
||||
@@ -730,6 +818,10 @@ def _encoding_specs(
|
||||
probe=probe,
|
||||
)
|
||||
),
|
||||
video_codec=probe.codec,
|
||||
audio_codecs=tuple(
|
||||
stream.codec for stream in probe.audio_streams if stream.codec
|
||||
),
|
||||
)
|
||||
)
|
||||
return specs
|
||||
@@ -798,6 +890,70 @@ def _x264_unavailable(specs: list[EncodingOutputSpec]) -> bool:
|
||||
return any(spec.bit_depth > 10 for spec in specs)
|
||||
|
||||
|
||||
def _video_copy_available(
|
||||
inputs: list[VideoInput],
|
||||
timelines: dict[Path, OutputTimeline],
|
||||
clip_infos: dict[Path, AvisynthClipInfo],
|
||||
probes_by_path: dict[Path, VideoProbe],
|
||||
) -> bool:
|
||||
for item in inputs:
|
||||
path = item.path.resolve()
|
||||
timeline = timelines[path]
|
||||
clip_info = clip_infos.get(path)
|
||||
probe = probes_by_path[path]
|
||||
if clip_info is None or not preserves_entire_source_timeline(timeline):
|
||||
return False
|
||||
if avisynth_duration_differs(clip_info, timeline):
|
||||
return False
|
||||
if (
|
||||
clip_info.width != probe.width
|
||||
or clip_info.height != probe.height
|
||||
or clip_info.bit_depth != probe.bit_depth
|
||||
or chroma_family(clip_info.chroma) != chroma_family(probe.pixel_format)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_MP4_VIDEO_CODECS = {"av1", "h264", "hevc", "mjpeg", "mpeg4", "vp9"}
|
||||
_MP4_AUDIO_CODECS = {"aac", "ac3", "alac", "eac3", "flac", "mp3", "opus"}
|
||||
|
||||
|
||||
def _stream_copy_warnings(
|
||||
*,
|
||||
extension: str,
|
||||
video_codec: str,
|
||||
audio_mode: str,
|
||||
specs: list[EncodingOutputSpec],
|
||||
) -> list[str]:
|
||||
if extension != ".mp4":
|
||||
return []
|
||||
|
||||
warnings: list[str] = []
|
||||
if video_codec == "copy":
|
||||
unsupported_video = sorted(
|
||||
{
|
||||
spec.video_codec or "unknown"
|
||||
for spec in specs
|
||||
if spec.video_codec not in _MP4_VIDEO_CODECS
|
||||
}
|
||||
)
|
||||
if unsupported_video:
|
||||
warnings.append("does not support " + ", ".join(unsupported_video) + " video")
|
||||
if audio_mode == "copy":
|
||||
unsupported_audio = sorted(
|
||||
{
|
||||
codec
|
||||
for spec in specs
|
||||
for codec in spec.audio_codecs
|
||||
if codec not in _MP4_AUDIO_CODECS
|
||||
}
|
||||
)
|
||||
if unsupported_audio:
|
||||
warnings.append("does not support " + ", ".join(unsupported_audio) + " audio")
|
||||
return warnings
|
||||
|
||||
|
||||
def _begin_utc(probe: VideoProbe, timeline: OutputTimeline) -> str:
|
||||
try:
|
||||
return output_begin_timestamp(probe, timeline, timezone.utc).isoformat()
|
||||
@@ -813,7 +969,7 @@ def _parse_option(name: str, value: str, options: list[str]) -> str:
|
||||
raise ValueError(f"{name.title()} must be one of: {', '.join(options)}")
|
||||
|
||||
|
||||
def _parse_audio(value: str, container: str) -> str:
|
||||
def _parse_audio(value: str) -> str:
|
||||
lowered = value.strip().lower()
|
||||
if lowered in {"aac", "a"}:
|
||||
return "aac"
|
||||
@@ -821,11 +977,11 @@ def _parse_audio(value: str, container: str) -> str:
|
||||
return "copy"
|
||||
if lowered in {"none", "no", "n"}:
|
||||
return "none"
|
||||
if container == ".mkv" and lowered in {"opus", "o"}:
|
||||
if lowered in {"opus", "o"}:
|
||||
return "opus"
|
||||
if container == ".mkv" and lowered in {"flac", "f"}:
|
||||
if lowered in {"flac", "f"}:
|
||||
return "flac"
|
||||
raise ValueError("Unsupported audio choice for this container.")
|
||||
raise ValueError("Audio must be aac, opus, flac, copy, or none.")
|
||||
|
||||
|
||||
def _yes_no_suffix(default: bool) -> str:
|
||||
@@ -867,7 +1023,15 @@ def _setting_value(
|
||||
if key == "timezone":
|
||||
return timezone_to_string(value)
|
||||
if key == "container":
|
||||
return _container_label(value)
|
||||
warnings = _stream_copy_warnings(
|
||||
extension=str(value),
|
||||
video_codec=str(_default_answer("codec", answers, specs)),
|
||||
audio_mode=str(_default_answer("audio", answers, specs)),
|
||||
specs=specs,
|
||||
)
|
||||
return _container_label(value) + (
|
||||
" " + light_red("; ".join(warnings)) if warnings else ""
|
||||
)
|
||||
if key == "codec":
|
||||
return _codec_label(value)
|
||||
if key in {"profile", "level"} and value not in {"none", "minimum"}:
|
||||
@@ -940,6 +1104,8 @@ def _default_output_timezone(probes: list[VideoProbe]) -> timezone:
|
||||
|
||||
|
||||
def _codec_label(value: object) -> str:
|
||||
if value == "copy":
|
||||
return "copy"
|
||||
return "x264" if value == "libx264" else "x265"
|
||||
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user