Files
InvestmentTrackerApp/Scripts/aso/frame_premium.py
T
alexandrev-tibco 19e80c912b 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
2026-07-02 22:12:31 +02:00

211 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Premium App Store marketing screenshot framer.
Upgrades over frame_screenshot.py:
- Realistic iPhone device bezel (rounded metal edge + Dynamic Island).
- Background with depth: base vertical gradient + soft radial glow + vignette.
- SF Pro headline with an optional lighter subtitle line, CJK-capable fonts.
- Soft, realistic device drop shadow.
Requirements: pip install Pillow
Example:
python frame_premium.py --input raw.png --output out.png \
--width 1320 --height 2868 \
--headline "Watch your wealth grow" --subline "Track net worth over time" \
--grad-top #3B82F6 --grad-bottom #10B981
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
# ---------- fonts ----------
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 _has_cjk(s: str) -> bool:
return any(ord(c) > 0x2E00 for c in s)
def load_font(size: int, text: str, weight_paths=None) -> ImageFont.FreeTypeFont:
paths = (CJK_FONTS if _has_cjk(text) else []) + (weight_paths or HEADLINE_FONTS)
for p in paths:
if Path(p).exists():
try:
return ImageFont.truetype(p, size)
except Exception:
continue
return ImageFont.load_default()
def hex_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return tuple(int(h[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore
def lerp(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
# ---------- background ----------
def make_background(w: int, h: int, top: tuple, bottom: tuple) -> Image.Image:
base = Image.new("RGB", (w, h))
px = base.load()
for y in range(h):
c = lerp(top, bottom, y / max(1, h - 1))
for x in range(w):
px[x, y] = c
# soft radial glow, lighter, upper-center
glow = Image.new("L", (w, h), 0)
gd = ImageDraw.Draw(glow)
cx, cy = w * 0.5, h * 0.28
r = w * 0.75
gd.ellipse([cx - r, cy - r, cx + r, cy + r], fill=90)
glow = glow.filter(ImageFilter.GaussianBlur(w * 0.18))
light = Image.new("RGB", (w, h), lerp(top, (255, 255, 255), 0.35))
base = Image.composite(light, base, glow)
# vignette (darken edges)
vig = Image.new("L", (w, h), 0)
vd = ImageDraw.Draw(vig)
vd.rectangle([0, 0, w, h], fill=70)
vd.rounded_rectangle([w * 0.06, h * 0.05, w * 0.94, h * 0.95],
radius=int(w * 0.1), fill=0)
vig = vig.filter(ImageFilter.GaussianBlur(w * 0.12))
dark = Image.new("RGB", (w, h), lerp(bottom, (0, 0, 0), 0.35))
base = Image.composite(dark, base, vig)
return base
# ---------- device ----------
def rounded_mask(size, radius) -> Image.Image:
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(screenshot: Image.Image, target_w: int) -> Image.Image:
"""Return an RGBA image of the phone (bezel + screen + island) at target_w width."""
bezel = max(10, int(target_w * 0.028)) # metal frame thickness
screen_w = target_w - bezel * 2
ar = screenshot.height / screenshot.width
screen_h = int(screen_w * ar)
shot = screenshot.resize((screen_w, screen_h), Image.LANCZOS).convert("RGB")
scr_radius = int(target_w * 0.11)
dev_radius = scr_radius + bezel
dev_w, dev_h = target_w, screen_h + bezel * 2
canvas = Image.new("RGBA", (dev_w, dev_h), (0, 0, 0, 0))
# metal frame (dark) with a subtle lighter outer edge
frame = Image.new("RGBA", (dev_w, dev_h), (0, 0, 0, 0))
fd = ImageDraw.Draw(frame)
fd.rounded_rectangle([0, 0, dev_w, dev_h], radius=dev_radius, fill=(18, 18, 20, 255))
fd.rounded_rectangle([1, 1, dev_w - 1, dev_h - 1], radius=dev_radius, outline=(60, 62, 68, 255), width=2)
canvas.alpha_composite(frame)
# screen
scr_mask = rounded_mask((screen_w, screen_h), scr_radius)
canvas.paste(shot, (bezel, bezel), scr_mask)
# Dynamic Island
di_w, di_h = int(screen_w * 0.30), int(target_w * 0.032)
di_x = bezel + (screen_w - di_w) // 2
di_y = bezel + int(target_w * 0.022)
ImageDraw.Draw(canvas).rounded_rectangle(
[di_x, di_y, di_x + di_w, di_y + di_h], radius=di_h // 2, fill=(0, 0, 0, 255)
)
return canvas
# ---------- text ----------
def wrap(draw, text, font, max_w):
words, lines, cur = text.split(), [], ""
for wd in words:
t = (cur + " " + wd).strip()
if draw.textlength(t, font=font) <= max_w or not cur:
cur = t
else:
lines.append(cur)
cur = wd
if cur:
lines.append(cur)
return lines
def draw_center_text(img, text, font, y, color, line_gap=1.12):
d = ImageDraw.Draw(img)
lines = wrap(d, text, font, int(img.width * 0.86))
asc, desc = font.getmetrics()
lh = int((asc + desc) * line_gap)
for i, ln in enumerate(lines):
w = d.textlength(ln, font=font)
d.text(((img.width - w) / 2, y + i * lh), ln, font=font, fill=color)
return y + len(lines) * lh
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--input", required=True)
ap.add_argument("--output", required=True)
ap.add_argument("--width", type=int, required=True)
ap.add_argument("--height", type=int, required=True)
ap.add_argument("--headline", required=True)
ap.add_argument("--subline", default="")
ap.add_argument("--grad-top", default="#3B82F6")
ap.add_argument("--grad-bottom", default="#10B981")
ap.add_argument("--device-scale", type=float, default=0.82)
a = ap.parse_args()
W, H = a.width, a.height
bg = make_background(W, H, hex_rgb(a.grad_top), hex_rgb(a.grad_bottom)).convert("RGBA")
# headline + subline
hfont = load_font(int(W * 0.082), a.headline)
y = int(H * 0.055)
y = draw_center_text(bg, a.headline, hfont, y, (255, 255, 255, 255))
if a.subline:
sfont = load_font(int(W * 0.036), a.subline)
y = draw_center_text(bg, a.subline, sfont, y + int(H * 0.006),
(255, 255, 255, 210))
# device
shot = Image.open(a.input).convert("RGB")
device = build_device(shot, int(W * a.device_scale))
dx = (W - device.width) // 2
dy = y + int(H * 0.03)
# keep device within canvas
if dy + device.height > H - int(H * 0.03):
dy = H - int(H * 0.03) - device.height
# shadow
shadow = Image.new("RGBA", (W, H), (0, 0, 0, 0))
sh = Image.new("RGBA", device.size, (0, 0, 0, 150))
sh.putalpha(device.split()[3].point(lambda p: int(p * 0.55)))
shadow.alpha_composite(sh, (dx, dy + int(W * 0.02)))
shadow = shadow.filter(ImageFilter.GaussianBlur(int(W * 0.03)))
bg.alpha_composite(shadow)
bg.alpha_composite(device, (dx, dy))
out = bg.convert("RGB")
Path(a.output).parent.mkdir(parents=True, exist_ok=True)
out.save(a.output, "PNG")
assert out.size == (W, H), f"size {out.size} != {(W, H)}"
print(f"OK {a.output} {out.size}")
if __name__ == "__main__":
main()