← back to Homesonspec
apps/mobile/scripts/vshots/frame-and-caption.py
101 lines
#!/usr/bin/env python3
"""Compose an App Store screenshot: navy canvas + white headline on top,
the raw app screenshot framed (rounded corners + soft shadow) below.
No app chrome is occluded. Output = 1320x2868 (6.9").
Usage: frame-and-caption.py <raw.png> <out.png> "<caption>"
"""
import sys, os
from PIL import Image, ImageDraw, ImageFont, ImageFilter
# Output canvas — override via OUT_W/OUT_H env (default 6.9" 1320x2868; ASC 6.7" slot = 1284x2778).
W = int(os.environ.get("OUT_W", 1320))
H = int(os.environ.get("OUT_H", 2868))
_s = H / 2868.0 # scale all proportional constants off the design height
NAVY = (20, 44, 84) # slightly deeper than app navy for contrast
NAVY2 = (12, 28, 56)
WHITE = (255, 255, 255)
CAP_TOP, CAP_BOT = int(150*_s), int(470*_s) # headline band (text region)
SHOT_W = int(W * 0.812) # framed screenshot width
RADIUS = int(66*_s)
PAD_X = int(96 * (W/1320.0))
def font(px, bold=True):
paths = ([
"/System/Library/Fonts/SFNSDisplay-Bold.otf",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/Library/Fonts/Arial Bold.ttf",
] if bold else [
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
])
for p in paths:
try: return ImageFont.truetype(p, px)
except Exception: continue
return ImageFont.load_default()
def wrap(draw, text, fnt, maxw):
words, lines, cur = text.split(), [], ""
for w in words:
t = (cur + " " + w).strip()
if draw.textlength(t, font=fnt) <= maxw: cur = t
else:
if cur: lines.append(cur)
cur = w
if cur: lines.append(cur)
return lines
def rounded(img, rad):
mask = Image.new("L", img.size, 0)
ImageDraw.Draw(mask).rounded_rectangle([0,0,img.size[0]-1,img.size[1]-1], rad, fill=255)
out = Image.new("RGBA", img.size, (0,0,0,0))
out.paste(img, (0,0), mask)
return out
def main():
raw, outp, caption = sys.argv[1], sys.argv[2], sys.argv[3]
# vertical navy gradient canvas
canvas = Image.new("RGB", (W, H), NAVY)
top = Image.new("RGB", (W, H), NAVY2)
grad = Image.new("L", (1, H))
for y in range(H):
grad.putpixel((0, y), int(255 * (y / H)))
canvas = Image.composite(canvas, top, grad.resize((W, H)))
draw = ImageDraw.Draw(canvas)
# headline
fnt = font(int(78*_s))
lines = wrap(draw, caption, fnt, W - 2*PAD_X)
lh = int(fnt.size * 1.16)
total = lh * len(lines)
y = CAP_TOP + ((CAP_BOT - CAP_TOP) - total)//2
for ln in lines:
w = draw.textlength(ln, font=fnt)
draw.text(((W - w)//2, y), ln, font=fnt, fill=WHITE)
y += lh
# framed screenshot
shot = Image.open(raw).convert("RGB")
scale = SHOT_W / shot.width
sh = int(shot.height * scale)
avail = H - CAP_BOT - 70
if sh > avail: # keep it inside the canvas
sh = avail; sw = int(shot.width * (avail / shot.height))
else:
sw = SHOT_W
shot = shot.resize((sw, sh), Image.LANCZOS)
shot = rounded(shot, RADIUS)
sx = (W - sw)//2; sy = CAP_BOT + (avail - sh)//2
# soft shadow
shadow = Image.new("RGBA", (W, H), (0,0,0,0))
sd = ImageDraw.Draw(shadow)
sd.rounded_rectangle([sx, sy+18, sx+sw, sy+sh+18], RADIUS, fill=(0,0,0,150))
shadow = shadow.filter(ImageFilter.GaussianBlur(34))
canvas = Image.alpha_composite(canvas.convert("RGBA"), shadow)
canvas.paste(shot, (sx, sy), shot)
canvas.convert("RGB").save(outp, "PNG")
print("wrote", outp, canvas.size)
if __name__ == "__main__":
main()