← 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()