diff --git a/movmaker.py b/movmaker.py index 8782301..ead7135 100755 --- a/movmaker.py +++ b/movmaker.py @@ -49,6 +49,7 @@ class MovieData: place: str = "" description: str = "" captions: dict[str, str] | None = None + video_audio: set[str] | None = None @dataclass @@ -587,6 +588,12 @@ def parse_data_file(path: Path | None) -> MovieData: data.place = value elif low in {"description", "desc", "synopsis"}: 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: data.captions[key] = value i += 1 @@ -649,6 +656,20 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int 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]: try: out = capture([ @@ -770,6 +791,7 @@ def build_video( clips: list[Path], durations: list[float], audio: Path | None, + clip_audio_paths: list[Path | None], output: Path, data: MovieData, creation_date: datetime, @@ -801,6 +823,10 @@ def build_video( cmd += ["-i", str(clip)] if 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] = [] n = len(clips) @@ -936,13 +962,13 @@ def build_video( end = total_duration elif idx == 0: start = intro_duration - end = intro_duration + transition_offsets[0] + end = intro_duration + transition_offsets[0] + fade / 2 elif idx == n - 1: - start = intro_duration + transition_offsets[idx - 1] + fade + start = intro_duration + transition_offsets[idx - 1] + fade / 2 end = total_duration else: - start = intro_duration + transition_offsets[idx - 1] + fade - end = intro_duration + transition_offsets[idx] + start = intro_duration + transition_offsets[idx - 1] + fade / 2 + end = intro_duration + transition_offsets[idx] + fade / 2 if end <= start: continue 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}]" ) + 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}]"] metadata = { "title": data.title, @@ -973,11 +1041,8 @@ def build_video( if value: cmd += ["-metadata", f"{key}={value}"] - if audio: - audio_index = len(clips) - 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"] + if audio_mix_labels: + cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "192k", "-shortest"] else: 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("--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("--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("--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") @@ -1091,6 +1156,18 @@ def main(argv: list[str] | None = None) -> int: # Use probed normalized duration for accuracy. 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] + 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) audio = concat_or_mix_audio(audio_files, tmp / "music.m4a") if len(audio_files) > 1: @@ -1099,7 +1176,7 @@ def main(argv: list[str] | None = None) -> int: artist, title = audio_metadata(audio) if artist or title: 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}") return 0