diff --git a/README.md b/README.md index 99e5968..7ade090 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,40 @@ Install on Debian/Ubuntu/Raspberry Pi OS: sudo apt install ffmpeg ``` +## Fonts + +The default title/stamp styling uses these fonts: + +- Bebas Neue for the opening title, date, and location +- Montserrat for the bottom-left and bottom-right stamps + +Install them locally for the current user: + +```bash +mkdir -p ~/.local/share/fonts/google +curl -L -o ~/.local/share/fonts/google/BebasNeue-Regular.ttf \ + 'https://github.com/google/fonts/raw/main/ofl/bebasneue/BebasNeue-Regular.ttf' +curl -L -o ~/.local/share/fonts/google/Montserrat%5Bwght%5D.ttf \ + 'https://github.com/google/fonts/raw/main/ofl/montserrat/Montserrat%5Bwght%5D.ttf' +fc-cache -f ~/.local/share/fonts/google +``` + +Optional font also used during experimentation: + +```bash +curl -L -o ~/.local/share/fonts/google/Cinzel%5Bwght%5D.ttf \ + 'https://github.com/google/fonts/raw/main/ofl/cinzel/Cinzel%5Bwght%5D.ttf' +fc-cache -f ~/.local/share/fonts/google +``` + +Check that fonts are available: + +```bash +fc-match 'Bebas Neue' +fc-match 'Montserrat' +fc-match 'Cinzel' +``` + ## Input folder Example: diff --git a/movmaker.py b/movmaker.py index 5e10c8b..7c5cd63 100755 --- a/movmaker.py +++ b/movmaker.py @@ -10,6 +10,7 @@ Put images, videos, audio files, and optionally data.txt in one directory. from __future__ import annotations import argparse +import concurrent.futures import json import math import os @@ -298,7 +299,7 @@ def drawtext_escape(text: str) -> str: return text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'").replace("%", "\\%") -def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int) -> None: +def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int, preset: str, crf: int) -> None: vf = ( f"scale={width}:{height}:force_original_aspect_ratio=decrease," f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black," @@ -310,7 +311,7 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int tool_path(FFMPEG, "ffmpeg"), "-y", "-loop", "1", "-t", f"{item.duration:.3f}", "-i", str(item.path), "-an", "-vf", vf, - "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", + "-c:v", "libx264", "-preset", preset, "-crf", str(crf), str(out), ] else: @@ -318,7 +319,7 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int tool_path(FFMPEG, "ffmpeg"), "-y", "-i", str(item.path), "-an", "-vf", vf, - "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", + "-c:v", "libx264", "-preset", preset, "-crf", str(crf), str(out), ] run(cmd) @@ -352,6 +353,8 @@ def build_video( width: int, height: int, fps: int, + preset: str, + crf: int, ) -> None: output.parent.mkdir(parents=True, exist_ok=True) @@ -382,29 +385,30 @@ def build_video( title_lines = [x for x in [data.title, data.date, data.place] if x] final_label = "vout" title_label = "titled" - bold_font = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" - normal_font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" + title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf") + stamp_font = str(Path.home() / ".local/share/fonts/google/Montserrat%5Bwght%5D.ttf") if title_lines: title_font_size = max(44, height // 14) subtitle_font_size = max(28, height // 24) main_title = data.title or title_lines[0] subtitle = " | ".join(x for x in [data.date, data.place] if x) + title_alpha = "if(lt(t\\,5)\\,1\\,if(lt(t\\,6)\\,6-t\\,0))" if subtitle: filters.append( - f"[{current}]drawtext=fontfile='{bold_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y=(h-text_h)/2-{title_font_size // 2}:" + f"[{current}]drawtext=fontfile='{title_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y=(h-text_h)/2-{title_font_size // 2}:" f"fontsize={title_font_size}:fontcolor=white:borderw=3:bordercolor=black:" - f"enable='between(t,0,6)'[title0]" + f"alpha='{title_alpha}':enable='between(t,0,6)'[title0]" ) filters.append( - f"[title0]drawtext=fontfile='{normal_font}':text='{drawtext_escape(subtitle)}':x=(w-text_w)/2:y=(h-text_h)/2+{subtitle_font_size}:" + f"[title0]drawtext=fontfile='{title_font}':text='{drawtext_escape(subtitle)}':x=(w-text_w)/2:y=(h-text_h)/2+{subtitle_font_size}:" f"fontsize={subtitle_font_size}:fontcolor=white:borderw=3:bordercolor=black:" - f"enable='between(t,0,6)'[{title_label}]" + f"alpha='{title_alpha}':enable='between(t,0,6)'[{title_label}]" ) else: filters.append( - f"[{current}]drawtext=fontfile='{bold_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y=(h-text_h)/2:" + f"[{current}]drawtext=fontfile='{title_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y=(h-text_h)/2:" f"fontsize={title_font_size}:fontcolor=white:borderw=3:bordercolor=black:" - f"enable='between(t,0,6)'[{title_label}]" + f"alpha='{title_alpha}':enable='between(t,0,6)'[{title_label}]" ) else: filters.append(f"[{current}]copy[{title_label}]") @@ -415,14 +419,14 @@ def build_video( stamp_left_label = "stamp_left" if stamp_text: filters.append( - f"[{title_label}]drawtext=text='{stamp_text}':x=24:y=h-text_h-18:" - f"fontsize={stamp_size}:fontcolor=white@0.75:borderw=2:bordercolor=black@0.75[{stamp_left_label}]" + f"[{title_label}]drawtext=fontfile='{stamp_font}':text='{stamp_text}':x=24:y=h-text_h-18:" + f"fontsize={stamp_size}:fontcolor=white@0.92:borderw=2:bordercolor=black@0.85[{stamp_left_label}]" ) else: filters.append(f"[{title_label}]copy[{stamp_left_label}]") filters.append( - f"[{stamp_left_label}]drawtext=text='marijo@novosel.dk':x=w-text_w-24:y=h-text_h-18:" - f"fontsize={stamp_size}:fontcolor=white@0.75:borderw=2:bordercolor=black@0.75[{final_label}]" + f"[{stamp_left_label}]drawtext=fontfile='{stamp_font}':text='marijo@novosel.dk':x=w-text_w-24:y=h-text_h-18:" + f"fontsize={stamp_size}:fontcolor=white@0.92:borderw=2:bordercolor=black@0.85[{final_label}]" ) cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"] @@ -435,7 +439,7 @@ def build_video( else: cmd += ["-an"] - cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-r", str(fps), "-pix_fmt", "yuv420p", str(output)] + cmd += ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-r", str(fps), "-pix_fmt", "yuv420p", str(output)] run(cmd) @@ -450,12 +454,25 @@ def main(argv: list[str] | None = None) -> int: 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") + parser.add_argument("--preset", default="veryfast", help="x264 speed preset, e.g. ultrafast, superfast, veryfast") + parser.add_argument("--crf", type=int, default=20, help="x264 quality; lower is better/larger, higher is faster/smaller") + parser.add_argument("--jobs", type=int, default=1, help="parallel clip preparation jobs; try 2 on Raspberry Pi") + parser.add_argument("--preview", action="store_true", help="fast preview: 1280x720, 24 fps, ultrafast, CRF 28") args = parser.parse_args(argv) require_tool("ffmpeg") require_tool("ffprobe") width, height = args.resolution + fps = args.fps + preset = args.preset + crf = args.crf + if args.preview: + width, height = (1280, 720) + fps = 24 + preset = "ultrafast" + crf = 28 + media, audio_files, data = scan_input(args.input, args.image_duration) fade = min(args.fade, max(0.0, args.image_duration - 0.1)) audio_fade = max(0.0, args.audio_fade) @@ -482,18 +499,27 @@ def main(argv: list[str] | None = None) -> int: with tempfile.TemporaryDirectory(prefix="movmaker-") as tmp_name: tmp = Path(tmp_name) - clips: list[Path] = [] - durations: list[float] = [] - for idx, item in enumerate(media): - out = tmp / f"clip_{idx:04d}.mp4" - print(f"Preparing {item.kind}: {item.path.name}") - normalize_clip(item, out, width, height, args.fps) - clips.append(out) - # Use probed normalized duration for accuracy. - durations.append(ffprobe_duration(out)) + jobs = max(1, args.jobs) + clips = [tmp / f"clip_{idx:04d}.mp4" for idx in range(len(media))] + if jobs == 1: + for item, out in zip(media, clips): + print(f"Preparing {item.kind}: {item.path.name}") + normalize_clip(item, out, width, height, fps, preset, crf) + else: + print(f"Preparing clips with {jobs} parallel jobs") + with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor: + futures = [] + for item, out in zip(media, clips): + print(f"Preparing {item.kind}: {item.path.name}") + futures.append(executor.submit(normalize_clip, item, out, width, height, fps, preset, crf)) + for future in concurrent.futures.as_completed(futures): + future.result() + + # Use probed normalized duration for accuracy. + durations = [ffprobe_duration(out) for out in clips] audio = concat_or_mix_audio(audio_files, tmp / "music.m4a") - build_video(clips, durations, audio, output, data, fade, audio_fade, width, height, args.fps) + build_video(clips, durations, audio, output, data, fade, audio_fade, width, height, fps, preset, crf) print(f"Done: {output}") return 0