Add font styling and faster preview options
This commit is contained in:
parent
54a474f278
commit
ffc1974343
2 changed files with 86 additions and 26 deletions
34
README.md
34
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:
|
||||
|
|
|
|||
78
movmaker.py
78
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue