#!/usr/bin/env python3 """Connected "panorama" App Store screenshots (modern style). Composes a single wide canvas spanning N panels with a light modern background, angled iPhone mockups that overlap and cross panel boundaries, and per-panel headlines — then slices it into individual App Store slots so that, swiped in the gallery, they read as one continuous scene (à la NetWorth Pro 2). Requirements: pip install Pillow Example: python frame_panorama.py \ --inputs a.png,b.png,c.png \ --headlines "Watch your net worth grow||Log it once a month||Charts that reveal more" \ --panel-width 1320 --height 2868 \ --out-dir out --out-prefix iphone --preview out/_wide.png """ from __future__ import annotations import argparse import math from pathlib import Path from PIL import Image, ImageDraw, ImageFilter, ImageFont HEADLINE_FONTS = [ "/System/Library/Fonts/SFNSDisplay.ttf", "/System/Library/Fonts/SFNS.ttf", "/System/Library/Fonts/Supplemental/HelveticaNeue.ttc", "/Library/Fonts/Arial Unicode.ttf", ] CJK_FONTS = [ "/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/PingFang.ttc", ] def _cjk(s): return any(ord(c) > 0x2E00 for c in s) def font(size, text): for p in (CJK_FONTS if _cjk(text) else []) + HEADLINE_FONTS: if Path(p).exists(): try: return ImageFont.truetype(p, size) except Exception: pass return ImageFont.load_default() def hexrgb(h): h = h.lstrip("#") return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)) def lerp(a, b, t): return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) def rounded_mask(size, radius): m = Image.new("L", size, 0) ImageDraw.Draw(m).rounded_rectangle([0, 0, size[0], size[1]], radius=radius, fill=255) return m def build_device(shot: Image.Image, target_w: int) -> Image.Image: """iPhone mockup (RGBA): metal bezel + rounded screen + Dynamic Island.""" bezel = max(10, int(target_w * 0.030)) sw = target_w - bezel * 2 sh = int(sw * shot.height / shot.width) screen = shot.resize((sw, sh), Image.LANCZOS).convert("RGB") scr_r = int(target_w * 0.12) dev_r = scr_r + bezel W, H = target_w, sh + bezel * 2 dev = Image.new("RGBA", (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(dev) d.rounded_rectangle([0, 0, W, H], radius=dev_r, fill=(20, 20, 22, 255)) d.rounded_rectangle([2, 2, W - 2, H - 2], radius=dev_r, outline=(70, 72, 78, 255), width=3) dev.paste(screen, (bezel, bezel), rounded_mask((sw, sh), scr_r)) di_w, di_h = int(sw * 0.30), int(target_w * 0.034) dix = bezel + (sw - di_w) // 2 diy = bezel + int(target_w * 0.024) ImageDraw.Draw(dev).rounded_rectangle([dix, diy, dix + di_w, diy + di_h], radius=di_h // 2, fill=(0, 0, 0, 255)) return dev def with_shadow(rgba: Image.Image, blur: int, alpha: float, off=(0, 40)) -> Image.Image: pad = blur * 3 canvas = Image.new("RGBA", (rgba.width + pad * 2, rgba.height + pad * 2), (0, 0, 0, 0)) sh = Image.new("RGBA", rgba.size, (0, 0, 0, 0)) sh.putalpha(rgba.split()[3].point(lambda p: int(p * alpha))) dark = Image.new("RGBA", rgba.size, (10, 20, 40, 255)) dark.putalpha(sh.split()[3]) canvas.alpha_composite(dark, (pad + off[0], pad + off[1])) canvas = canvas.filter(ImageFilter.GaussianBlur(blur)) canvas.alpha_composite(rgba, (pad, pad)) return canvas def _blob(W, H, cx, cy, r, a): m = Image.new("L", (W, H), 0) ImageDraw.Draw(m).ellipse([cx - r, cy - r, cx + r, cy + r], fill=a) return m.filter(ImageFilter.GaussianBlur(int(r * 0.6))) def make_bg(W, H, accent, accent2, n=1): """Light, premium background: near-white vertical wash + large soft brand-color blobs (blue + green) for depth. `n` = number of panels (for even blob spread).""" top, bottom = (251, 252, 254), (238, 242, 248) # fast vertical gradient: build a 1px-wide column then stretch to full width col = Image.new("RGB", (1, H)) cp = col.load() for y in range(H): cp[0, y] = lerp(top, bottom, y / max(1, H - 1)) base = col.resize((W, H)).convert("RGBA") pw = W / max(1, n) for i in range(n): col = accent if i % 2 == 0 else accent2 cx = pw * (i + 0.5) base = Image.composite(Image.new("RGBA", (W, H), col + (255,)), base, _blob(W, H, cx, H * 0.20, pw * 0.42, 60).point(lambda p: int(p * 0.55))) col2 = accent2 if i % 2 == 0 else accent base = Image.composite(Image.new("RGBA", (W, H), col2 + (255,)), base, _blob(W, H, cx, H * 0.86, pw * 0.5, 45).point(lambda p: int(p * 0.5))) return base 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 or not cur: cur = t else: lines.append(cur); cur = w if cur: lines.append(cur) return lines def draw_headline(canvas, text, cx, top_y, panel_w, color, max_lines=2): """Draw a centered headline, auto-shrinking the font until it fits in `max_lines` lines within the panel margins (never clips). Returns the y coordinate just below the headline block.""" d = ImageDraw.Draw(canvas) maxw = int(panel_w * 0.82) size = int(panel_w * 0.074) fnt = font(size, text) lines = wrap(d, text, fnt, maxw) while size > int(panel_w * 0.042): fnt = font(size, text) lines = wrap(d, text, fnt, maxw) if len(lines) <= max_lines and max(d.textlength(ln, font=fnt) for ln in lines) <= maxw: break size -= 3 asc, desc = fnt.getmetrics() lh = int((asc + desc) * 1.06) for i, ln in enumerate(lines): w = d.textlength(ln, font=fnt) d.text((cx - w / 2, top_y + i * lh), ln, font=fnt, fill=color) return top_y + len(lines) * lh def main(): ap = argparse.ArgumentParser() ap.add_argument("--inputs", required=True, help="comma-separated screenshot paths") ap.add_argument("--headlines", required=True, help="|| separated, one per panel") ap.add_argument("--sublines", default="", help="|| separated, one per panel") ap.add_argument("--panel-width", type=int, required=True) ap.add_argument("--height", type=int, required=True) ap.add_argument("--out-dir", required=True) ap.add_argument("--out-prefix", default="iphone") ap.add_argument("--accent", default="#3B82F6") ap.add_argument("--accent2", default="#10B981") ap.add_argument("--device-scale", type=float, default=0.86) ap.add_argument("--preview", default="") a = ap.parse_args() inputs = [s for s in a.inputs.split(",") if s] headlines = a.headlines.split("||") sublines = a.sublines.split("||") if a.sublines else [""] * len(inputs) n = len(inputs) assert len(headlines) == n, "headlines count must match inputs" W, H = a.panel_width, a.height accent, accent2 = hexrgb(a.accent), hexrgb(a.accent2) canvas = make_bg(W * n, H, accent, accent2, n=n) text_color = (13, 22, 38) sub_color = (90, 102, 120) dev_w = int(W * a.device_scale) angles = [-6, 5, -5, 6, -5, 5] y_off = [0.0, 0.05, 0.0, 0.05, 0.0, 0.05] for i, (path, head) in enumerate(zip(inputs, headlines)): panel_cx = int(W * i + W * 0.5) hy = draw_headline(canvas, head, panel_cx, int(H * 0.052), W, text_color) sub = sublines[i] if i < len(sublines) else "" if sub.strip(): d = ImageDraw.Draw(canvas) sf = font(int(W * 0.034), sub) lines = wrap(d, sub, sf, int(W * 0.78)) asc, desc = sf.getmetrics(); lh = int((asc + desc) * 1.05) sy = hy + int(H * 0.012) for j, ln in enumerate(lines): w = d.textlength(ln, font=sf) d.text((panel_cx - w / 2, sy + j * lh), ln, font=sf, fill=sub_color) shot = Image.open(path).convert("RGB") dev = build_device(shot, dev_w) dev = with_shadow(dev, blur=int(W * 0.04), alpha=0.34, off=(0, 46)) ang = angles[i % len(angles)] dev = dev.rotate(ang, expand=True, resample=Image.BICUBIC) # nudge horizontally so adjacent devices overlap across the panel border nudge = int(W * (0.15 if i % 2 == 0 else -0.15)) dx = panel_cx + nudge - dev.width // 2 dy = int(H * (0.315 + y_off[i % len(y_off)])) if dy + dev.height > int(H * 1.04): dy = int(H * 1.04) - dev.height canvas.alpha_composite(dev, (dx, dy)) out = canvas.convert("RGB") outdir = Path(a.out_dir); outdir.mkdir(parents=True, exist_ok=True) for i in range(n): panel = out.crop((i * W, 0, (i + 1) * W, H)) panel.save(outdir / f"{a.out_prefix}_{i+1:02d}.png") if a.preview: Path(a.preview).parent.mkdir(parents=True, exist_ok=True) scale = min(1.0, 1600 / out.width) out.resize((int(out.width * scale), int(out.height * scale)), Image.LANCZOS).save(a.preview) print(f"OK {n} panels -> {outdir} (each {W}x{H})") if __name__ == "__main__": main()