Show per-file captions

This commit is contained in:
hbrain 2026-05-24 21:23:32 +00:00
parent 0c1ee1c42a
commit 24f226aa75

View file

@ -19,6 +19,7 @@ import shlex
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -208,6 +209,14 @@ def media_date(path: Path) -> datetime:
def slugify(value: str) -> str: def slugify(value: str) -> str:
replacements = {
"æ": "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")
value = value.strip().lower() value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "_", value) value = re.sub(r"[^a-z0-9]+", "_", value)
value = value.strip("_") value = value.strip("_")
@ -348,6 +357,7 @@ def build_video(
audio: Path | None, audio: Path | None,
output: Path, output: Path,
data: MovieData, data: MovieData,
captions: list[str],
fade: float, fade: float,
audio_fade: float, audio_fade: float,
width: int, width: int,
@ -368,16 +378,19 @@ def build_video(
n = len(clips) n = len(clips)
total_duration = durations[0] total_duration = durations[0]
transition_offsets: list[float] = []
if n == 1: if n == 1:
current = "0:v" current = "0:v"
else: else:
current = "v01" current = "v01"
offset = max(0.0, durations[0] - fade) offset = max(0.0, durations[0] - fade)
transition_offsets.append(offset)
filters.append(f"[0:v][1:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{current}]") filters.append(f"[0:v][1:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{current}]")
total_duration = durations[0] + durations[1] - fade total_duration = durations[0] + durations[1] - fade
for i in range(2, n): for i in range(2, n):
next_label = f"v{i:02d}" next_label = f"v{i:02d}"
offset = max(0.0, total_duration - fade) offset = max(0.0, total_duration - fade)
transition_offsets.append(offset)
filters.append(f"[{current}][{i}:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{next_label}]") filters.append(f"[{current}][{i}:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{next_label}]")
current = next_label current = next_label
total_duration += durations[i] - fade total_duration += durations[i] - fade
@ -388,8 +401,8 @@ def build_video(
title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf") title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf")
stamp_font = str(Path.home() / ".local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf") stamp_font = str(Path.home() / ".local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf")
if title_lines: if title_lines:
title_font_size = max(50, height // 12) title_font_size = max(60, height // 10)
subtitle_font_size = max(28, height // 24) subtitle_font_size = max(34, height // 20)
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))" title_alpha = "if(lt(t\\,5)\\,1\\,if(lt(t\\,6)\\,6-t\\,0))"
@ -397,47 +410,74 @@ def build_video(
title_y = f"(h-text_h)/2-{title_font_size // 2}" title_y = f"(h-text_h)/2-{title_font_size // 2}"
filters.append( 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}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y={title_y}:"
f"fontsize={title_font_size}:text_spacing=4:fontcolor=white:borderw=3:bordercolor=black:" 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]" f"alpha='{title_alpha}':enable='between(t,0,6)'[title0]"
) )
filters.append( 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}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2+1:y={title_y}:"
f"fontsize={title_font_size}:text_spacing=4:fontcolor=white:borderw=3:bordercolor=black:" 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]" f"alpha='{title_alpha}':enable='between(t,0,6)'[title0b]"
) )
filters.append( 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}':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=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:"
f"alpha='{title_alpha}':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='{title_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}:text_spacing=4:fontcolor=white:borderw=3:bordercolor=black:" 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]" f"alpha='{title_alpha}':enable='between(t,0,6)'[title0]"
) )
filters.append( 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}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2+1:y=(h-text_h)/2:"
f"fontsize={title_font_size}:text_spacing=4:fontcolor=white:borderw=3:bordercolor=black:" 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}]" 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}]")
stamp_size = max(11, height // 80) caption_label = title_label
caption_size = max(18, height // 42)
for idx, caption in enumerate(captions):
if not caption:
continue
if n == 1:
start = 0.0
end = total_duration
elif idx == 0:
start = 0.0
end = transition_offsets[0]
elif idx == n - 1:
start = transition_offsets[idx - 1] + fade
end = total_duration
else:
start = transition_offsets[idx - 1] + fade
end = transition_offsets[idx]
if end <= start:
continue
next_label = f"caption{idx}"
filters.append(
f"[{caption_label}]drawtext=fontfile='{stamp_font}':text='{drawtext_escape(caption)}':x=(w-text_w)/2:y=h-text_h-62:"
f"fontsize={caption_size}:fontcolor=white:borderw=1:bordercolor=black:"
f"enable='between(t,{start:.3f},{end:.3f})'[{next_label}]"
)
caption_label = next_label
stamp_size = max(14, height // 65)
stamp_lines = [x for x in [data.title, data.date, data.place] if x] stamp_lines = [x for x in [data.title, data.date, data.place] if x]
stamp_text = drawtext_escape(" | ".join(stamp_lines)) stamp_text = drawtext_escape(" | ".join(stamp_lines))
stamp_left_label = "stamp_left" stamp_left_label = "stamp_left"
if stamp_text: if stamp_text:
filters.append( filters.append(
f"[{title_label}]drawtext=fontfile='{stamp_font}':text='{stamp_text}':x=24:y=h-text_h-18:" f"[{caption_label}]drawtext=fontfile='{stamp_font}':text='{stamp_text}':x=24:y=h-text_h-18:"
f"fontsize={stamp_size}:fontcolor=white:borderw=3:bordercolor=black[{stamp_left_label}]" f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{stamp_left_label}]"
) )
else: else:
filters.append(f"[{title_label}]copy[{stamp_left_label}]") filters.append(f"[{caption_label}]copy[{stamp_left_label}]")
filters.append( 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}':text='@bubulescu':x=w-text_w-24:y=h-text_h-18:"
f"fontsize={stamp_size}:fontcolor=white:borderw=3:bordercolor=black[{final_label}]" f"fontsize={stamp_size}:fontcolor=white:borderw=1:bordercolor=black[{final_label}]"
) )
cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"] cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"]
@ -529,8 +569,9 @@ def main(argv: list[str] | None = None) -> int:
# Use probed normalized duration for accuracy. # Use probed normalized duration for accuracy.
durations = [ffprobe_duration(out) for out in clips] durations = [ffprobe_duration(out) for out in clips]
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") audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
build_video(clips, durations, audio, output, data, fade, audio_fade, width, height, fps, preset, crf) build_video(clips, durations, audio, output, data, captions, fade, audio_fade, width, height, fps, preset, crf)
print(f"Done: {output}") print(f"Done: {output}")
return 0 return 0