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
|
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:
|
||||||
|
|
|
||||||
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
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue