Support selected video clip audio

This commit is contained in:
hbrain 2026-05-26 11:03:48 +00:00
parent fa4c6dd95f
commit 8a87fe4cc6

View file

@ -49,6 +49,7 @@ class MovieData:
place: str = "" place: str = ""
description: str = "" description: str = ""
captions: dict[str, str] | None = None captions: dict[str, str] | None = None
video_audio: set[str] | None = None
@dataclass @dataclass
@ -587,6 +588,12 @@ def parse_data_file(path: Path | None) -> MovieData:
data.place = value data.place = value
elif low in {"description", "desc", "synopsis"}: elif low in {"description", "desc", "synopsis"}:
data.description = value data.description = value
elif low.endswith(" audio"):
file_key = key[:-6].strip()
if file_key and value.lower() in {"1", "yes", "true", "on"}:
if data.video_audio is None:
data.video_audio = set()
data.video_audio.add(file_key)
elif key: elif key:
data.captions[key] = value data.captions[key] = value
i += 1 i += 1
@ -649,6 +656,20 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int
run(cmd) run(cmd)
def media_has_audio(path: Path) -> bool:
try:
out = capture([
tool_path(FFPROBE, "ffprobe"), "-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=index",
"-of", "csv=p=0",
str(path),
])
return bool(out.strip())
except Exception:
return False
def audio_metadata(path: Path) -> tuple[str, str]: def audio_metadata(path: Path) -> tuple[str, str]:
try: try:
out = capture([ out = capture([
@ -770,6 +791,7 @@ def build_video(
clips: list[Path], clips: list[Path],
durations: list[float], durations: list[float],
audio: Path | None, audio: Path | None,
clip_audio_paths: list[Path | None],
output: Path, output: Path,
data: MovieData, data: MovieData,
creation_date: datetime, creation_date: datetime,
@ -801,6 +823,10 @@ def build_video(
cmd += ["-i", str(clip)] cmd += ["-i", str(clip)]
if audio: if audio:
cmd += ["-stream_loop", "-1", "-i", str(audio)] cmd += ["-stream_loop", "-1", "-i", str(audio)]
clip_audio_input_start = len(clips) + (1 if audio else 0)
for clip_audio in clip_audio_paths:
if clip_audio is not None:
cmd += ["-i", str(clip_audio)]
filters: list[str] = [] filters: list[str] = []
n = len(clips) n = len(clips)
@ -936,13 +962,13 @@ def build_video(
end = total_duration end = total_duration
elif idx == 0: elif idx == 0:
start = intro_duration start = intro_duration
end = intro_duration + transition_offsets[0] end = intro_duration + transition_offsets[0] + fade / 2
elif idx == n - 1: elif idx == n - 1:
start = intro_duration + transition_offsets[idx - 1] + fade start = intro_duration + transition_offsets[idx - 1] + fade / 2
end = total_duration end = total_duration
else: else:
start = intro_duration + transition_offsets[idx - 1] + fade start = intro_duration + transition_offsets[idx - 1] + fade / 2
end = intro_duration + transition_offsets[idx] end = intro_duration + transition_offsets[idx] + fade / 2
if end <= start: if end <= start:
continue continue
next_label = f"stamp_left_{idx}" next_label = f"stamp_left_{idx}"
@ -956,6 +982,48 @@ def build_video(
f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{final_label}]" f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{final_label}]"
) )
audio_mix_labels: list[str] = []
if audio:
audio_index = len(clips)
audio_fade_out_start = max(0.0, total_duration - audio_fade)
filters.append(
f"[{audio_index}:a:0]atrim=0:{total_duration:.3f},asetpts=PTS-STARTPTS,"
f"afade=t=in:st=0:d={audio_fade_in:.3f},afade=t=out:st={audio_fade_out_start:.3f}:d={audio_fade:.3f}[bg_audio]"
)
audio_mix_labels.append("[bg_audio]")
clip_audio_input = clip_audio_input_start
for idx, clip_audio in enumerate(clip_audio_paths):
if clip_audio is None:
continue
if n == 1:
start = intro_duration
end = total_duration
elif idx == 0:
start = intro_duration
end = intro_duration + transition_offsets[0]
elif idx == n - 1:
start = intro_duration + transition_offsets[idx - 1] + fade
end = total_duration
else:
start = intro_duration + transition_offsets[idx - 1] + fade
end = intro_duration + transition_offsets[idx]
segment = max(0.0, end - start)
if segment <= 0:
clip_audio_input += 1
continue
fade_len = min(fade, segment / 2)
delay_ms = max(0, int(start * 1000))
label = f"clip_audio_{idx}"
filters.append(
f"[{clip_audio_input}:a:0]atrim=0:{segment:.3f},asetpts=PTS-STARTPTS,"
f"afade=t=in:st=0:d={fade_len:.3f},afade=t=out:st={max(0.0, segment - fade_len):.3f}:d={fade_len:.3f},"
f"adelay={delay_ms}:all=1[{label}]"
)
audio_mix_labels.append(f"[{label}]")
clip_audio_input += 1
if audio_mix_labels:
filters.append("".join(audio_mix_labels) + f"amix=inputs={len(audio_mix_labels)}:duration=longest:normalize=0[aout]")
cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"] cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"]
metadata = { metadata = {
"title": data.title, "title": data.title,
@ -973,11 +1041,8 @@ def build_video(
if value: if value:
cmd += ["-metadata", f"{key}={value}"] cmd += ["-metadata", f"{key}={value}"]
if audio: if audio_mix_labels:
audio_index = len(clips) cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "192k", "-shortest"]
audio_fade_out_start = max(0.0, total_duration - audio_fade)
audio_filter = f"afade=t=in:st=0:d={audio_fade_in:.3f},afade=t=out:st={audio_fade_out_start:.3f}:d={audio_fade:.3f}"
cmd += ["-map", f"{audio_index}:a:0", "-af", audio_filter, "-c:a", "aac", "-b:a", "192k", "-shortest"]
else: else:
cmd += ["-an"] cmd += ["-an"]
@ -993,7 +1058,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--fade", type=float, default=3.0, help="crossfade duration in seconds") parser.add_argument("--fade", type=float, default=3.0, help="crossfade duration in seconds")
parser.add_argument("--audio-fade", type=float, default=10.0, help="music fade-out duration in seconds") parser.add_argument("--audio-fade", type=float, default=10.0, help="music fade-out duration in seconds")
parser.add_argument("--audio-fade-in", type=float, default=2.0, help="music fade-in duration in seconds") parser.add_argument("--audio-fade-in", type=float, default=2.0, help="music fade-in duration in seconds")
parser.add_argument("--video-fade", type=float, default=1.5, help="video fade-in/fade-out duration in seconds") parser.add_argument("--video-fade", type=float, default=1.0, help="video fade-in/fade-out duration in seconds")
parser.add_argument("--resolution", type=parse_resolution, default=(1920, 1080), help="output resolution, e.g. 1920x1080") parser.add_argument("--resolution", type=parse_resolution, default=(1920, 1080), help="output resolution, e.g. 1920x1080")
parser.add_argument("--fps", type=int, default=30, help="output frames per second") parser.add_argument("--fps", type=int, default=30, help="output frames per second")
parser.add_argument("--preset", default="veryfast", help="x264 speed preset, e.g. ultrafast, superfast, veryfast") parser.add_argument("--preset", default="veryfast", help="x264 speed preset, e.g. ultrafast, superfast, veryfast")
@ -1091,6 +1156,18 @@ def main(argv: list[str] | None = None) -> int:
# Use probed normalized duration for accuracy. # Use probed normalized duration for accuracy.
durations = [ffprobe_duration(out) for out in clips] durations = [ffprobe_duration(out) for out in clips]
captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media] captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media]
video_audio = data.video_audio or set()
clip_audio_paths = []
for item in media:
use_clip_audio = item.kind == "video" and (item.path.name in video_audio or str(item.path) in video_audio)
if use_clip_audio and media_has_audio(item.path):
clip_audio_paths.append(item.path)
console(f"Using video file audio: {item.path.name}")
elif use_clip_audio:
clip_audio_paths.append(None)
console(f"Video file has no audio stream: {item.path.name}")
else:
clip_audio_paths.append(None)
clip_stamps = prepare_clip_stamps(media, data) clip_stamps = prepare_clip_stamps(media, data)
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a") audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
if len(audio_files) > 1: if len(audio_files) > 1:
@ -1099,7 +1176,7 @@ def main(argv: list[str] | None = None) -> int:
artist, title = audio_metadata(audio) artist, title = audio_metadata(audio)
if artist or title: if artist or title:
console("Audio metadata: " + " - ".join(x for x in [artist, title] if x)) console("Audio metadata: " + " - ".join(x for x in [artist, title] if x))
build_video(clips, durations, audio, output, data, first_media_date, captions, clip_stamps, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp) build_video(clips, durations, audio, clip_audio_paths, output, data, first_media_date, captions, clip_stamps, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp)
console(f"Done: {output}") console(f"Done: {output}")
return 0 return 0