19e80c912b
- testCaptureScreenshots en PortfolioJournalUITests: captura tabs con datos demo, idioma vía SCREENSHOT_LANG (usa el modo --screenshots del commit siguiente) - Scripts/aso: compositores de screenshots de App Store (frame_layout con perspectiva 3D + panorama multi-panel, variantes premium/tilt), layouts JSON iPhone/iPad, y asc.py (helper API App Store Connect para estado/screenshots) - Fix test roto pre-existente: OnboardingQuickStartView.appSettingsURL restaurado Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
301 lines
12 KiB
Python
301 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Layout-driven App Store panorama framer.
|
|
|
|
Reads a JSON layout describing a wide canvas (N panels) with a dark, premium
|
|
background, freely-placed angled device mockups (which may span multiple panels),
|
|
and per-panel text. Slices the result into individual App Store slots.
|
|
|
|
Requirements: pip install Pillow
|
|
|
|
Usage: python frame_layout.py layout.json
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
|
|
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.26, pitch=0.08, pad_ratio=0.95):
|
|
"""Warp an RGBA device to look 3D-rotated. yaw>0 turns the LEFT edge away
|
|
(shorter/inset); pitch tilts the top back. Corner comes forward."""
|
|
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
|
|
src = [(pad, pad), (pad + w, pad), (pad + w, pad + h), (pad, pad + h)]
|
|
dx = int(w * abs(yaw))
|
|
dyt = int(h * pitch)
|
|
if yaw >= 0: # left edge recedes (corner forward on the right)
|
|
dst = [(pad + dx, pad + dyt), (pad + w, pad),
|
|
(pad + w, pad + h), (pad + dx, pad + h - dyt)]
|
|
else: # right edge recedes (corner forward on the left)
|
|
dst = [(pad, pad), (pad + w - dx, pad + dyt),
|
|
(pad + w - dx, pad + h - dyt), (pad, pad + h)]
|
|
coeffs = find_coeffs(dst, src)
|
|
out = canvas.transform((W, H), Image.PERSPECTIVE, coeffs, Image.BICUBIC)
|
|
return out.crop(out.getbbox())
|
|
|
|
# Prettier / bolder headline fonts, best first. (path, ttc_index)
|
|
FONT_TRY = [
|
|
("/System/Library/Fonts/SFNSDisplay.ttf", 0), # SF Pro Display (serious, modern)
|
|
("/System/Library/Fonts/SFNS.ttf", 0),
|
|
("/System/Library/Fonts/HelveticaNeue.ttc", 0),
|
|
("/System/Library/Fonts/Avenir Next.ttc", 0),
|
|
("/Library/Fonts/Arial Unicode.ttf", 0),
|
|
]
|
|
CJK_TRY = [
|
|
("/System/Library/Fonts/ヒラギノ角ゴシック W7.ttc", 0),
|
|
("/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc", 0),
|
|
("/System/Library/Fonts/PingFang.ttc", 0),
|
|
]
|
|
_FONT_CACHE = {}
|
|
|
|
|
|
def _cjk(s):
|
|
return any(ord(c) > 0x2E00 for c in s)
|
|
|
|
|
|
def font(size, text="", heavy=False):
|
|
cands = (CJK_TRY if _cjk(text) else []) + FONT_TRY
|
|
for path, idx in cands:
|
|
if not Path(path).exists():
|
|
continue
|
|
key = (path, idx, size, heavy)
|
|
if key in _FONT_CACHE:
|
|
return _FONT_CACHE[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 want in ("Black", "Heavy", "ExtraBold", "Bold"):
|
|
m = [n for n in names if want.lower() in n.lower()]
|
|
if m:
|
|
f.set_variation_by_name(m[0])
|
|
break
|
|
except Exception:
|
|
pass
|
|
_FONT_CACHE[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 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:
|
|
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=(18, 18, 20, 255))
|
|
d.rounded_rectangle([3, 3, W - 3, H - 3], radius=dev_r, outline=(80, 82, 90, 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, blur, alpha, off):
|
|
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, (0, 0, 0, 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.7)))
|
|
|
|
|
|
def make_dark_bg(TW, H, W, n, accent, accent2):
|
|
top, bottom = (17, 24, 39), (9, 14, 27) # deep navy gradient
|
|
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((TW, H)).convert("RGBA")
|
|
for i in range(n):
|
|
cx = W * (i + 0.5)
|
|
c1 = accent if i % 2 == 0 else accent2
|
|
base = Image.composite(Image.new("RGBA", (TW, H), c1 + (255,)), base,
|
|
_blob(TW, H, cx, H * 0.26, W * 0.62, 170).point(lambda p: int(p * 0.55)))
|
|
c2 = accent2 if i % 2 == 0 else accent
|
|
base = Image.composite(Image.new("RGBA", (TW, H), c2 + (255,)), base,
|
|
_blob(TW, H, cx, H * 0.94, W * 0.66, 140).point(lambda p: int(p * 0.40)))
|
|
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 _line_with_shadow(canvas, x, y, text, fnt, fill, blur, sh_alpha=150, off=(0, 8)):
|
|
"""Draw one text line with a soft drop shadow for depth/legibility."""
|
|
d = ImageDraw.Draw(canvas)
|
|
l, t, r, b = d.textbbox((0, 0), text, font=fnt)
|
|
pad = blur * 3
|
|
layer = Image.new("RGBA", (r - l + pad * 2, b - t + pad * 2), (0, 0, 0, 0))
|
|
ld = ImageDraw.Draw(layer)
|
|
ld.text((pad - l, pad - t), text, font=fnt, fill=(0, 0, 0, sh_alpha))
|
|
layer = layer.filter(ImageFilter.GaussianBlur(blur))
|
|
canvas.alpha_composite(layer, (int(x - pad + off[0]), int(y - pad + off[1])))
|
|
ImageDraw.Draw(canvas).text((x, y), text, font=fnt, fill=fill)
|
|
|
|
|
|
def draw_badge(canvas, cx, top_y, panel_w, text, accent):
|
|
"""Small rounded 'NEW'-style pill in the accent color, centered at cx."""
|
|
d = ImageDraw.Draw(canvas)
|
|
f = font(int(panel_w * 0.038), text, heavy=True)
|
|
tw = d.textlength(text, font=f)
|
|
asc, desc = f.getmetrics(); th = asc + desc
|
|
padx, pady = int(panel_w * 0.032), int(panel_w * 0.016)
|
|
w = tw + padx * 2; h = th + pady * 2
|
|
x0 = cx - w / 2
|
|
d.rounded_rectangle([x0, top_y, x0 + w, top_y + h], radius=h // 2, fill=accent + (255,))
|
|
d.text((cx, top_y + h / 2), text, font=f, anchor="mm", fill=(255, 255, 255, 255))
|
|
return top_y + h
|
|
|
|
|
|
def draw_text_block(canvas, cx, top_y, panel_w, headline, hcolor,
|
|
badge="", accent=(16, 185, 129), max_lines=2, hscale=0.122):
|
|
d = ImageDraw.Draw(canvas)
|
|
y = top_y
|
|
if badge:
|
|
y = draw_badge(canvas, cx, y, panel_w, badge, accent) + int(panel_w * 0.028)
|
|
maxw = int(panel_w * 0.88)
|
|
size = int(panel_w * hscale) # big, punchy headline
|
|
fnt = font(size, headline, heavy=True)
|
|
lines = wrap(d, headline, fnt, maxw)
|
|
while size > int(panel_w * hscale * 0.55):
|
|
fnt = font(size, headline, heavy=True)
|
|
lines = wrap(d, headline, fnt, maxw)
|
|
if len(lines) <= max_lines and max(d.textlength(l, font=fnt) for l in lines) <= maxw:
|
|
break
|
|
size -= 3
|
|
asc, desc = fnt.getmetrics()
|
|
lh = int((asc + desc) * 1.0)
|
|
for ln in lines:
|
|
w = d.textlength(ln, font=fnt)
|
|
_line_with_shadow(canvas, cx - w / 2, y, ln, fnt, hcolor + (255,),
|
|
blur=int(panel_w * 0.012), sh_alpha=170, off=(0, int(panel_w * 0.008)))
|
|
y += lh
|
|
return y
|
|
|
|
|
|
def main():
|
|
cfg = json.loads(Path(sys.argv[1]).read_text())
|
|
W = cfg["panel_width"]; H = cfg["height"]; n = cfg["n_panels"]
|
|
TW = W * n
|
|
accent = hexrgb(cfg.get("accent", "#3B82F6"))
|
|
accent2 = hexrgb(cfg.get("accent2", "#10B981"))
|
|
canvas = make_dark_bg(TW, H, W, n, accent, accent2)
|
|
|
|
hcolor = (255, 255, 255)
|
|
scolor = (206, 219, 240)
|
|
|
|
# devices first (so text can sit above), then text on top
|
|
for dv in cfg["devices"]:
|
|
shot = Image.open(dv["image"]).convert("RGB")
|
|
dev = build_device(shot, int(W * dv["scale"]))
|
|
if "yaw" in dv:
|
|
dev = perspective(dev, yaw=dv.get("yaw", 0.26), pitch=dv.get("pitch", 0.08))
|
|
if dv.get("roll"): # in-plane rotation -> diagonal, dynamic placement
|
|
dev = dev.rotate(dv["roll"], expand=True, resample=Image.BICUBIC)
|
|
if "angle" in dv and "yaw" not in dv:
|
|
dev = dev.rotate(dv["angle"], expand=True, resample=Image.BICUBIC)
|
|
dev = with_shadow(dev, blur=int(W * dv.get("shadow_blur", 0.045)),
|
|
alpha=dv.get("shadow_alpha", 0.55), off=(0, int(W * 0.05)))
|
|
cxp = dv["cx_panels"] * W
|
|
if "top" in dv:
|
|
dy = int(dv["top"] * H)
|
|
else:
|
|
dy = int(dv["cy"] * H - dev.height / 2)
|
|
canvas.alpha_composite(dev, (int(cxp - dev.width / 2), dy))
|
|
|
|
if cfg.get("top_scrim"):
|
|
band = int(H * 0.36)
|
|
col = Image.new("RGBA", (1, H), (0, 0, 0, 0))
|
|
cp = col.load()
|
|
for y in range(H):
|
|
a = int(175 * max(0.0, 1 - y / band)) if y < band else 0
|
|
cp[0, y] = (5, 9, 18, a)
|
|
canvas.alpha_composite(col.resize((TW, H)))
|
|
|
|
for tx in cfg["texts"]:
|
|
cx = (tx["cx_panels"]) * W
|
|
draw_text_block(canvas, cx, int(H * tx.get("top", 0.05)), W,
|
|
tx.get("headline", ""), hcolor,
|
|
badge=tx.get("badge", ""), accent=accent2,
|
|
hscale=cfg.get("hscale", 0.122))
|
|
|
|
out = canvas.convert("RGB")
|
|
outdir = Path(cfg["out_dir"]); outdir.mkdir(parents=True, exist_ok=True)
|
|
for i in range(n):
|
|
out.crop((i * W, 0, (i + 1) * W, H)).save(outdir / f"{cfg.get('out_prefix','iphone')}_{i+1:02d}.png")
|
|
if cfg.get("preview"):
|
|
scale = min(1.0, 1700 / TW)
|
|
out.resize((int(TW * scale), int(H * scale)), Image.LANCZOS).save(cfg["preview"])
|
|
print(f"OK {n} panels -> {outdir} font-check below")
|
|
# report which headline font was chosen
|
|
f = font(80, "Ag")
|
|
print("headline font:", getattr(f, "path", "default"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|