diff --git a/movmaker.py b/movmaker.py index 88e91cb..15e7297 100755 --- a/movmaker.py +++ b/movmaker.py @@ -208,15 +208,19 @@ def media_date(path: Path) -> datetime: return dt -def slugify(value: str) -> str: +def ascii_safe(value: str) -> str: replacements = { - "æ": "ae", "Æ": "ae", - "ø": "o", "Ø": "o", - "å": "a", "Å": "a", - "đ": "d", "Đ": "d", + "æ": "ae", "Æ": "Ae", + "ø": "o", "Ø": "O", + "å": "a", "Å": "A", + "đ": "d", "Đ": "D", } value = "".join(replacements.get(ch, ch) for ch in value) - value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + return unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + + +def slugify(value: str) -> str: + value = ascii_safe(value) value = value.strip().lower() value = re.sub(r"[^a-z0-9]+", "_", value) value = value.strip("_") @@ -316,8 +320,13 @@ def parse_data_file(path: Path | None) -> MovieData: def drawtext_escape(text: str) -> str: - # Escape for ffmpeg drawtext text='...' - return text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'").replace("%", "\\%") + # Escape for ffmpeg drawtext text=... without surrounding quotes. + return ( + text.replace("\\", "\\\\") + .replace(":", "\\:") + .replace(",", "\\,") + .replace("%", "\\%") + ) def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int, preset: str, crf: int) -> None: @@ -369,17 +378,29 @@ def build_video( audio: Path | None, output: Path, data: MovieData, + creation_date: datetime, captions: list[str], fade: float, audio_fade: float, + audio_fade_in: float, + video_fade: float, width: int, height: int, fps: int, preset: str, crf: int, + text_dir: Path, ) -> None: output.parent.mkdir(parents=True, exist_ok=True) + text_file_count = 0 + def textfile_arg(text: str) -> str: + nonlocal text_file_count + path = text_dir / f"drawtext_{text_file_count:04d}.txt" + text_file_count += 1 + path.write_text(ascii_safe(text), encoding="utf-8") + return f"textfile='{path}':expansion=none" + cmd = [tool_path(FFMPEG, "ffmpeg"), "-y"] for clip in clips: cmd += ["-i", str(clip)] @@ -407,6 +428,15 @@ def build_video( current = next_label total_duration += durations[i] - fade + fade_label = current + if video_fade > 0: + fade_out_start = max(0.0, total_duration - video_fade) + fade_label = "video_faded" + filters.append( + f"[{current}]fade=t=in:st=0:d={video_fade:.3f},fade=t=out:st={fade_out_start:.3f}:d={video_fade:.3f}[{fade_label}]" + ) + current = fade_label + title_lines = [x for x in [data.title, data.date, data.place] if x] final_label = "vout" title_label = "titled" @@ -422,28 +452,28 @@ def build_video( if subtitle: title_y = f"(h-text_h)/2-{title_font_size // 2}" filters.append( - f"[{current}]drawtext=fontfile='{title_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y={title_y}:" + f"[{current}]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2:y={title_y}:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"alpha='{title_alpha}':enable='between(t,0,6)'[title0]" ) filters.append( - f"[title0]drawtext=fontfile='{title_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2+1:y={title_y}:" + f"[title0]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2+1:y={title_y}:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"alpha='{title_alpha}':enable='between(t,0,6)'[title0b]" ) filters.append( - f"[title0b]drawtext=fontfile='{title_font}':text='{drawtext_escape(subtitle)}':x=(w-text_w)/2:y=(h-text_h)/2+{subtitle_font_size}:" + f"[title0b]drawtext=fontfile='{title_font}':{textfile_arg(subtitle)}:x=(w-text_w)/2:y=(h-text_h)/2+{subtitle_font_size}:" f"fontsize={subtitle_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:" f"alpha='{title_alpha}':enable='between(t,0,6)'[{title_label}]" ) else: filters.append( - f"[{current}]drawtext=fontfile='{title_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y=(h-text_h)/2:" + f"[{current}]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2:y=(h-text_h)/2:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"alpha='{title_alpha}':enable='between(t,0,6)'[title0]" ) filters.append( - f"[title0]drawtext=fontfile='{title_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2+1:y=(h-text_h)/2:" + f"[title0]drawtext=fontfile='{title_font}':{textfile_arg(main_title)}:x=(w-text_w)/2+1:y=(h-text_h)/2:" f"fontsize={title_font_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=3:shadowy=3:" f"alpha='{title_alpha}':enable='between(t,0,6)'[{title_label}]" ) @@ -481,13 +511,13 @@ def build_video( y_expr = f"h-text_h-62-{block_height - line_idx * line_gap}" next_label = f"caption{idx}_{line_idx}" filters.append( - f"[{caption_label}]drawtext=fontfile='{stamp_bold_font}':text='{drawtext_escape(caption_line)}':x=(w-text_w)/2:y={y_expr}:" + f"[{caption_label}]drawtext=fontfile='{stamp_bold_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:" f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:" f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{next_label}]" ) bold_label = f"caption{idx}_{line_idx}b" filters.append( - f"[{next_label}]drawtext=fontfile='{stamp_bold_font}':text='{drawtext_escape(caption_line)}':x=(w-text_w)/2+1:y={y_expr}:" + f"[{next_label}]drawtext=fontfile='{stamp_bold_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2+1:y={y_expr}:" f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:" f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{bold_label}]" ) @@ -499,22 +529,33 @@ def build_video( stamp_left_label = "stamp_left" if stamp_text: filters.append( - f"[{caption_label}]drawtext=fontfile='{stamp_font}':text='{stamp_text}':x=24:y=h-text_h-18:" + f"[{caption_label}]drawtext=fontfile='{stamp_font}':{textfile_arg(' | '.join(stamp_lines))}:x=24:y=h-text_h-18:" f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{stamp_left_label}]" ) else: filters.append(f"[{caption_label}]copy[{stamp_left_label}]") filters.append( - f"[{stamp_left_label}]drawtext=fontfile='{stamp_font}':text='@bubulescu':x=w-text_w-24:y=h-text_h-18:" + f"[{stamp_left_label}]drawtext=fontfile='{stamp_font}':{textfile_arg('@bubulescu')}:x=w-text_w-24:y=h-text_h-18:" f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{final_label}]" ) cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"] + metadata = { + "title": ascii_safe(data.title), + "date": ascii_safe(data.date), + "location": ascii_safe(data.place), + "artist": "@bubulescu", + "comment": "Created with movmaker", + "creation_time": creation_date.isoformat(), + } + for key, value in metadata.items(): + 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:.3f},afade=t=out:st={audio_fade_out_start:.3f}:d={audio_fade:.3f}" + 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: cmd += ["-an"] @@ -530,7 +571,9 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--out-dir", type=Path, default=Path("out"), help="directory for auto-named output videos") parser.add_argument("--image-duration", type=float, default=6.0, help="seconds each image is visible") 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-in/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("--video-fade", type=float, default=1.5, help="video fade-in/fade-out duration in seconds") parser.add_argument("--first-transition-at", type=float, default=12.0, help="seconds before first transition starts") 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") @@ -554,10 +597,13 @@ def main(argv: list[str] | None = None) -> int: crf = 28 media, audio_files, data = scan_input(args.input, args.image_duration) + first_media_date = media_date(media[0].path) if not data.date: - data.date = display_date(media_date(media[0].path)) + data.date = display_date(first_media_date) fade = min(args.fade, max(0.0, args.image_duration - 0.1)) audio_fade = max(0.0, args.audio_fade) + audio_fade_in = max(0.0, args.audio_fade_in) + video_fade = max(0.0, args.video_fade) output = args.output or default_output_path(media[0].path, data, args.out_dir) @@ -602,7 +648,7 @@ def main(argv: list[str] | None = None) -> int: captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media] audio = concat_or_mix_audio(audio_files, tmp / "music.m4a") - build_video(clips, durations, audio, output, data, captions, fade, audio_fade, width, height, fps, preset, crf) + build_video(clips, durations, audio, output, data, first_media_date, captions, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp) print(f"Done: {output}") return 0