Add font styling and faster preview options

This commit is contained in:
hbrain 2026-05-24 20:22:22 +00:00
parent 54a474f278
commit ffc1974343
2 changed files with 86 additions and 26 deletions

View file

@ -18,6 +18,40 @@ Install on Debian/Ubuntu/Raspberry Pi OS:
sudo apt install ffmpeg 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 ## Input folder
Example: Example:

View file

@ -10,6 +10,7 @@ Put images, videos, audio files, and optionally data.txt in one directory.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import concurrent.futures
import json import json
import math import math
import os import os
@ -298,7 +299,7 @@ def drawtext_escape(text: str) -> str:
return text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'").replace("%", "\\%") 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 = ( vf = (
f"scale={width}:{height}:force_original_aspect_ratio=decrease," f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black," 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", tool_path(FFMPEG, "ffmpeg"), "-y",
"-loop", "1", "-t", f"{item.duration:.3f}", "-i", str(item.path), "-loop", "1", "-t", f"{item.duration:.3f}", "-i", str(item.path),
"-an", "-vf", vf, "-an", "-vf", vf,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:v", "libx264", "-preset", preset, "-crf", str(crf),
str(out), str(out),
] ]
else: else:
@ -318,7 +319,7 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int
tool_path(FFMPEG, "ffmpeg"), "-y", tool_path(FFMPEG, "ffmpeg"), "-y",
"-i", str(item.path), "-i", str(item.path),
"-an", "-vf", vf, "-an", "-vf", vf,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:v", "libx264", "-preset", preset, "-crf", str(crf),
str(out), str(out),
] ]
run(cmd) run(cmd)
@ -352,6 +353,8 @@ def build_video(
width: int, width: int,
height: int, height: int,
fps: int, fps: int,
preset: str,
crf: int,
) -> None: ) -> None:
output.parent.mkdir(parents=True, exist_ok=True) 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] title_lines = [x for x in [data.title, data.date, data.place] if x]
final_label = "vout" final_label = "vout"
title_label = "titled" title_label = "titled"
bold_font = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf")
normal_font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" stamp_font = str(Path.home() / ".local/share/fonts/google/Montserrat%5Bwght%5D.ttf")
if title_lines: if title_lines:
title_font_size = max(44, height // 14) title_font_size = max(44, height // 14)
subtitle_font_size = max(28, height // 24) subtitle_font_size = max(28, height // 24)
main_title = data.title or title_lines[0] main_title = data.title or title_lines[0]
subtitle = " | ".join(x for x in [data.date, data.place] if x) 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: if subtitle:
filters.append( 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"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( 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"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: else:
filters.append( 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"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: else:
filters.append(f"[{current}]copy[{title_label}]") filters.append(f"[{current}]copy[{title_label}]")
@ -415,14 +419,14 @@ def build_video(
stamp_left_label = "stamp_left" stamp_left_label = "stamp_left"
if stamp_text: if stamp_text:
filters.append( filters.append(
f"[{title_label}]drawtext=text='{stamp_text}':x=24:y=h-text_h-18:" f"[{title_label}]drawtext=fontfile='{stamp_font}':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"fontsize={stamp_size}:fontcolor=white@0.92:borderw=2:bordercolor=black@0.85[{stamp_left_label}]"
) )
else: else:
filters.append(f"[{title_label}]copy[{stamp_left_label}]") filters.append(f"[{title_label}]copy[{stamp_left_label}]")
filters.append( filters.append(
f"[{stamp_left_label}]drawtext=text='marijo@novosel.dk':x=w-text_w-24:y=h-text_h-18:" 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.75:borderw=2:bordercolor=black@0.75[{final_label}]" 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}]"] cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"]
@ -435,7 +439,7 @@ def build_video(
else: else:
cmd += ["-an"] 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) 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("--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("--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("--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) args = parser.parse_args(argv)
require_tool("ffmpeg") require_tool("ffmpeg")
require_tool("ffprobe") require_tool("ffprobe")
width, height = args.resolution 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) media, audio_files, data = scan_input(args.input, args.image_duration)
fade = min(args.fade, max(0.0, args.image_duration - 0.1)) fade = min(args.fade, max(0.0, args.image_duration - 0.1))
audio_fade = max(0.0, args.audio_fade) 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: with tempfile.TemporaryDirectory(prefix="movmaker-") as tmp_name:
tmp = Path(tmp_name) tmp = Path(tmp_name)
clips: list[Path] = [] jobs = max(1, args.jobs)
durations: list[float] = [] clips = [tmp / f"clip_{idx:04d}.mp4" for idx in range(len(media))]
for idx, item in enumerate(media): if jobs == 1:
out = tmp / f"clip_{idx:04d}.mp4" for item, out in zip(media, clips):
print(f"Preparing {item.kind}: {item.path.name}") print(f"Preparing {item.kind}: {item.path.name}")
normalize_clip(item, out, width, height, args.fps) normalize_clip(item, out, width, height, fps, preset, crf)
clips.append(out) else:
# Use probed normalized duration for accuracy. print(f"Preparing clips with {jobs} parallel jobs")
durations.append(ffprobe_duration(out)) 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") 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}") print(f"Done: {output}")
return 0 return 0