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
211 lines
7.3 KiB
Python
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()
|