1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113 | #!/usr/bin/env python3
"""Make a transparent 1080x1920 overlay PNG: a clean ROUNDED box (black, white text)
holding a full multi-line quote, positioned UNDER the subject's face, + a cursive
signature top-right. Overlay it on a 9:16 video with ffmpeg for a quote reel.
Usage: quote_reel_overlay.py <out.png> "<quote>" [--cy 1080] [--sig "19 Keys"]
[--box dark|light] [--size 42] [--width 900]
The quote is wrapped automatically; force line breaks with " | ".
"""
import argparse
import os
import subprocess
from PIL import Image, ImageDraw, ImageFont, ImageFilter
W, H = 1080, 1920
TEXT_FONTS = ["/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/Library/Fonts/Arial Bold.ttf", "/System/Library/Fonts/HelveticaNeue.ttc"]
SIG_FONTS = ["/System/Library/Fonts/SnellRoundhand.ttc",
"/System/Library/Fonts/Supplemental/SnellRoundhand.ttc"]
def load(paths, size):
for p in paths:
try:
return ImageFont.truetype(p, size)
except Exception:
continue
return ImageFont.load_default()
def resolve_font_file(spec):
"""spec is a font FILE path OR a family NAME. Return a usable .ttf/.otf path or None.
PIL needs a file — a family name is resolved to its file via fc-match (fontconfig)."""
if not spec:
return None
if os.path.isfile(spec):
return spec
for fc in ("fc-match", "/opt/homebrew/bin/fc-match"):
try:
r = subprocess.run([fc, "-f", "%{file}", spec],
capture_output=True, text=True, timeout=10)
p = (r.stdout or "").strip()
if p and os.path.isfile(p):
return p
except Exception:
continue
return None
def wrap(draw, text, font, maxw):
out = []
for para in text.split("|"):
words, cur = para.strip().split(), ""
for w in words:
t = (cur + " " + w).strip()
if draw.textlength(t, font=font) <= maxw:
cur = t
else:
out.append(cur); cur = w
if cur:
out.append(cur)
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("out")
ap.add_argument("quote")
ap.add_argument("--cy", type=int, default=1090) # vertical center of the box (under face)
ap.add_argument("--sig", default="19 Keys")
ap.add_argument("--box", default="dark", choices=["dark", "light"])
ap.add_argument("--size", type=int, default=42)
ap.add_argument("--width", type=int, default=900) # max text width before wrap
ap.add_argument("--radius", type=int, default=28) # box corner radius (0 = sharp/square)
ap.add_argument("--font", default=None) # family name OR file path for the quote text
a = ap.parse_args()
boxc = (0, 0, 0, 255) if a.box == "dark" else (255, 255, 255, 255)
txtc = (255, 255, 255, 255) if a.box == "dark" else (17, 17, 17, 255)
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
ff = resolve_font_file(a.font)
font = load([ff] + TEXT_FONTS if ff else TEXT_FONTS, a.size)
lines = wrap(d, a.quote, font, a.width)
lh = a.size + 18
th = lh * len(lines)
tw = max(d.textlength(ln, font=font) for ln in lines)
padx, pady = 46, 40
bw, bh = tw + 2 * padx, th + 2 * pady
bx, by = (W - bw) // 2, a.cy - bh // 2
# soft drop shadow for a clean lift
sh = Image.new("RGBA", (W, H), (0, 0, 0, 0))
ImageDraw.Draw(sh).rounded_rectangle([bx, by + 10, bx + bw, by + bh + 10], radius=a.radius, fill=(0, 0, 0, 110))
img.alpha_composite(sh.filter(ImageFilter.GaussianBlur(16)))
d = ImageDraw.Draw(img)
d.rounded_rectangle([bx, by, bx + bw, by + bh], radius=a.radius, fill=boxc)
ty = by + pady
for ln in lines:
w = d.textlength(ln, font=font)
d.text(((W - w) // 2, ty), ln, font=font, fill=txtc)
ty += lh
# cursive signature, top-right
sig = load(SIG_FONTS, 74)
sw = d.textlength(a.sig, font=sig)
d.text((W - sw - 58, 64), a.sig, font=sig, fill=(255, 255, 255, 240),
stroke_width=1, stroke_fill=(0, 0, 0, 120))
img.save(a.out)
print(f"{a.out}: {len(lines)} lines, box {bw}x{bh}")
if __name__ == "__main__":
main()
|