ASO tooling: compositores de marketing + captura UITest
- 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
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user