#!/usr/bin/env python3 """App Store screenshots — "solid color + 3D-tilted device" style. Each panel is independent: a solid background colour, a large device in a bezel warped with a perspective (3D) tilt so a corner comes forward and the device bleeds off an edge, plus bold text (a top headline or a bottom-left feature list). Inspired by the reference the user shared. Requirements: pip install Pillow numpy Usage: python frame_tilt.py layout.json """ from __future__ import annotations import json, sys from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFilter, ImageFont FONT_TRY = [ ("/System/Library/Fonts/SFNS.ttf", 0), ("/System/Library/Fonts/HelveticaNeue.ttc", 0), ] CJK_TRY = [("/System/Library/Fonts/ヒラギノ角ゴシック W7.ttc", 0), ("/System/Library/Fonts/PingFang.ttc", 0)] _FC = {} def _cjk(s): return any(ord(c) > 0x2E00 for c in s) def font(size, text="", heavy=True): for path, idx in (CJK_TRY if _cjk(text) else []) + FONT_TRY: if not Path(path).exists(): continue key = (path, idx, size, heavy) if key in _FC: return _FC[key] try: f = ImageFont.truetype(path, size, index=idx) if heavy: try: names = [n.decode() if isinstance(n, bytes) else n for n in f.get_variation_names()] for w in ("Black", "Heavy", "Bold"): m = [n for n in names if w.lower() in n.lower()] if m: f.set_variation_by_name(m[0]); break except Exception: pass _FC[key] = f return f except Exception: continue 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 rounded_mask(size, r): m = Image.new("L", size, 0) ImageDraw.Draw(m).rounded_rectangle([0, 0, size[0], size[1]], radius=r, fill=255) return m def build_device(shot, target_w): bezel = max(8, int(target_w * 0.028)) 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.05) 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)) dev.paste(screen, (bezel, bezel), rounded_mask((sw, sh), scr_r)) return dev def find_coeffs(dst, src): m = [] for s, d in zip(src, dst): m.append([d[0], d[1], 1, 0, 0, 0, -s[0] * d[0], -s[0] * d[1]]) m.append([0, 0, 0, d[0], d[1], 1, -s[1] * d[0], -s[1] * d[1]]) A = np.array(m, dtype=float) B = np.array(src).reshape(8) return np.linalg.solve(A, B) def perspective(img, yaw=0.28, pitch=0.10, pad_ratio=0.9): """Warp an RGBA device to look 3D-rotated. yaw>0 turns the LEFT edge away (left edge shorter/inset); pitch tilts the top back.""" w, h = img.size pad = int(max(w, h) * pad_ratio) canvas = Image.new("RGBA", (w + pad * 2, h + pad * 2), (0, 0, 0, 0)) canvas.alpha_composite(img, (pad, pad)) W, H = canvas.size # source corners (TL, TR, BR, BL) of the padded canvas around the device src = [(pad, pad), (pad + w, pad), (pad + w, pad + h), (pad, pad + h)] dx = int(w * yaw) # horizontal foreshortening on the far (left) side dyt = int(h * pitch) # vertical inset on the far edge dst = [ (pad + dx, pad + dyt), # TL pulled right + down (pad + w, pad), # TR anchor (pad + w, pad + h), # BR anchor (pad + dx, pad + h - dyt), # BL pulled right + up ] coeffs = find_coeffs(dst, src) out = canvas.transform((W, H), Image.PERSPECTIVE, coeffs, Image.BICUBIC) return out.crop(out.getbbox()) def with_shadow(rgba, blur, alpha, off): pad = blur * 3 c = Image.new("RGBA", (rgba.width + pad * 2, rgba.height + pad * 2), (0, 0, 0, 0)) dark = Image.new("RGBA", rgba.size, (0, 0, 0, 0)) dark.putalpha(rgba.split()[3].point(lambda p: int(p * alpha))) c.alpha_composite(dark, (pad + off[0], pad + off[1])) c = c.filter(ImageFilter.GaussianBlur(blur)) c.alpha_composite(rgba, (pad, pad)) return c def wrap(d, text, f, maxw): words, lines, cur = text.split(), [], "" for w in words: t = (cur + " " + w).strip() if d.textlength(t, font=f) <= maxw or not cur: cur = t else: lines.append(cur); cur = w if cur: lines.append(cur) return lines def draw_headline(canvas, text, x, y, maxw, size, color, align="left"): d = ImageDraw.Draw(canvas) f = font(size, text) lines = wrap(d, text, f, maxw) asc, desc = f.getmetrics(); lh = int((asc + desc) * 0.98) for ln in lines: w = d.textlength(ln, font=f) px = x if align == "left" else (x - w if align == "right" else x - w / 2) d.text((px, y), ln, font=f, fill=color) y += lh return y def main(): cfg = json.loads(Path(sys.argv[1]).read_text()) outdir = Path(cfg["out_dir"]); outdir.mkdir(parents=True, exist_ok=True) previews = [] for i, p in enumerate(cfg["panels"]): W, H = cfg["width"], cfg["height"] canvas = Image.new("RGBA", (W, H), hexrgb(p["bg"]) + (255,)) dv = p["device"] shot = Image.open(dv["image"]).convert("RGB") dev = build_device(shot, int(W * dv["scale"])) dev = perspective(dev, yaw=dv.get("yaw", 0.26), pitch=dv.get("pitch", 0.08)) dev = with_shadow(dev, blur=int(W * 0.03), alpha=0.28, off=(int(W * 0.01), int(W * 0.02))) cx = int(dv["cx"] * W); cy = int(dv["cy"] * H) canvas.alpha_composite(dev, (cx - dev.width // 2, cy - dev.height // 2)) t = p.get("text", {}) if t: col = hexrgb(t.get("color", "#111111")) draw_headline(canvas, t["headline"], int(t.get("x", 0.07) * W), int(t.get("y", 0.06) * H), int(W * t.get("maxw", 0.8)), int(W * t.get("size", 0.11)), col, t.get("align", "left")) out = canvas.convert("RGB") fn = outdir / f"{cfg.get('prefix','tilt')}_{i+1:02d}.png" out.save(fn) previews.append(out) # contact preview tw = 460; th = int(tw * cfg["height"] / cfg["width"]); pad = 16 sheet = Image.new("RGB", (len(previews) * tw + (len(previews) + 1) * pad, th + pad * 2), (28, 28, 28)) for i, im in enumerate(previews): sheet.paste(im.resize((tw, th)), (pad + i * (tw + pad), pad)) sheet.save(cfg.get("preview", str(outdir / "_preview.png"))) print(f"OK {len(previews)} panels -> {outdir}") if __name__ == "__main__": main()