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:
alexandrev-tibco
2026-07-02 22:11:20 +02:00
parent 9d56d420aa
commit 19e80c912b
10 changed files with 1515 additions and 0 deletions
+7
View File
@@ -28,3 +28,10 @@ fastlane/test_output/
Justificante_*.pdf
*.pkg
og-image.png
# ASO tooling artifacts
Scripts/aso/.venv/
Scripts/aso/_*.png
Scripts/aso/pano_*/
Scripts/aso/originals/
__pycache__/
@@ -116,4 +116,47 @@ final class PortfolioJournalUITests: XCTestCase {
dashboardTab.tap()
}
}
// MARK: - App Store Screenshot Capture
/// Captures full-screen screenshots of the main tabs with demo data for App Store
/// marketing. Launches the app in `--screenshots` mode (onboarding/lock skipped,
/// SampleDataService seeded). Language can be driven via the SCREENSHOT_LANG env var
/// (e.g. "es", "de"). Screenshots are attached to the test result and extracted from
/// the .xcresult afterwards by the framing pipeline.
func testCaptureScreenshots() throws {
let capture = XCUIApplication()
capture.launchArguments = ["--screenshots"]
if let lang = ProcessInfo.processInfo.environment["SCREENSHOT_LANG"], !lang.isEmpty {
capture.launchArguments += ["-AppleLanguages", "(\(lang))", "-AppleLocale", lang]
}
capture.launch()
let tabBar = capture.tabBars.firstMatch
guard tabBar.waitForExistence(timeout: 20) else {
XCTFail("Tab bar not found in screenshot mode")
return
}
// Let demo data seed and charts render.
Thread.sleep(forTimeInterval: 3.0)
func snap(_ name: String) {
let shot = XCTAttachment(screenshot: capture.screenshot())
shot.name = name
shot.lifetime = .keepAlways
add(shot)
}
// Dashboard is already selected on launch.
snap("01_Dashboard")
for (index, tabName) in ["Sources", "Goals", "Journal", "Settings"].enumerated() {
let tab = tabBar.buttons[tabName]
if tab.waitForExistence(timeout: 5), tab.isHittable {
tab.tap()
Thread.sleep(forTimeInterval: 1.5)
snap(String(format: "%02d_%@", index + 2, tabName))
}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Minimal App Store Connect API helper for screenshots.
Auth pulls the key from `pass` (appstore/api-key-id, appstore/issuer-id,
appstore/api-key-p8). Read-only `status` command lists the editable iOS version
and the screenshot sets for a locale so we know what exists before uploading.
Usage:
python asc.py status [locale] # default en-US
python asc.py upload <locale> <display_type> <img1> <img2> ... # replaces that set
"""
import sys, time, json, hashlib, subprocess
import jwt, requests
BASE = "https://api.appstoreconnect.apple.com/v1"
BUNDLE = "com.alexandrevazquez.PortfolioJournal"
def _pass(k):
return subprocess.check_output(["pass", "show", k]).decode().strip()
def token():
kid = _pass("appstore/api-key-id")
iss = _pass("appstore/issuer-id")
key = _pass("appstore/api-key-p8")
payload = {"iss": iss, "iat": int(time.time()), "exp": int(time.time()) + 1100,
"aud": "appstoreconnect-v1"}
return jwt.encode(payload, key, algorithm="ES256", headers={"kid": kid, "typ": "JWT"})
def H():
return {"Authorization": f"Bearer {token()}", "Content-Type": "application/json"}
def get(path, **params):
r = requests.get(f"{BASE}{path}", headers=H(), params=params)
r.raise_for_status()
return r.json()
def app_id():
d = get("/apps", **{"filter[bundleId]": BUNDLE})
return d["data"][0]["id"]
def editable_version(aid):
d = get(f"/apps/{aid}/appStoreVersions", **{"filter[platform]": "IOS", "limit": 10})
for v in d["data"]:
st = v["attributes"]["appStoreState"]
if st in ("PREPARE_FOR_SUBMISSION", "DEVELOPER_REJECTED", "REJECTED",
"METADATA_REJECTED", "WAITING_FOR_REVIEW", "READY_FOR_REVIEW"):
return v
return d["data"][0] if d["data"] else None
def loc_id(version_id, locale):
d = get(f"/appStoreVersions/{version_id}/appStoreVersionLocalizations",
**{"limit": 50})
for l in d["data"]:
if l["attributes"]["locale"] == locale:
return l["id"]
return None
def sets_for_loc(lid):
return get(f"/appStoreVersionLocalizations/{lid}/appScreenshotSets", **{"limit": 50})["data"]
def status(locale="en-US"):
aid = app_id()
v = editable_version(aid)
print("app id:", aid)
print("version:", v["attributes"]["versionString"], "| state:", v["attributes"]["appStoreState"], "| id:", v["id"])
lid = loc_id(v["id"], locale)
print(f"locale {locale} localization id:", lid)
if not lid:
return
for s in sets_for_loc(lid):
dt = s["attributes"]["screenshotDisplayType"]
shots = get(f"/appScreenshotSets/{s['id']}/appScreenshots", **{"limit": 50})["data"]
print(f" set {dt}: {len(shots)} screenshots (set id {s['id']})")
if __name__ == "__main__":
cmd = sys.argv[1] if len(sys.argv) > 1 else "status"
if cmd == "status":
status(sys.argv[2] if len(sys.argv) > 2 else "en-US")
+300
View File
@@ -0,0 +1,300 @@
#!/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()
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Connected "panorama" App Store screenshots (modern style).
Composes a single wide canvas spanning N panels with a light modern background,
angled iPhone mockups that overlap and cross panel boundaries, and per-panel
headlines — then slices it into individual App Store slots so that, swiped in the
gallery, they read as one continuous scene (à la NetWorth Pro 2).
Requirements: pip install Pillow
Example:
python frame_panorama.py \
--inputs a.png,b.png,c.png \
--headlines "Watch your net worth grow||Log it once a month||Charts that reveal more" \
--panel-width 1320 --height 2868 \
--out-dir out --out-prefix iphone --preview out/_wide.png
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
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 _cjk(s):
return any(ord(c) > 0x2E00 for c in s)
def font(size, text):
for p in (CJK_FONTS if _cjk(text) else []) + HEADLINE_FONTS:
if Path(p).exists():
try:
return ImageFont.truetype(p, size)
except Exception:
pass
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:
"""iPhone mockup (RGBA): metal bezel + rounded screen + Dynamic Island."""
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=(20, 20, 22, 255))
d.rounded_rectangle([2, 2, W - 2, H - 2], radius=dev_r, outline=(70, 72, 78, 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: Image.Image, blur: int, alpha: float, off=(0, 40)) -> Image.Image:
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, (10, 20, 40, 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.6)))
def make_bg(W, H, accent, accent2, n=1):
"""Light, premium background: near-white vertical wash + large soft brand-color
blobs (blue + green) for depth. `n` = number of panels (for even blob spread)."""
top, bottom = (251, 252, 254), (238, 242, 248)
# fast vertical gradient: build a 1px-wide column then stretch to full width
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((W, H)).convert("RGBA")
pw = W / max(1, n)
for i in range(n):
col = accent if i % 2 == 0 else accent2
cx = pw * (i + 0.5)
base = Image.composite(Image.new("RGBA", (W, H), col + (255,)), base,
_blob(W, H, cx, H * 0.20, pw * 0.42, 60).point(lambda p: int(p * 0.55)))
col2 = accent2 if i % 2 == 0 else accent
base = Image.composite(Image.new("RGBA", (W, H), col2 + (255,)), base,
_blob(W, H, cx, H * 0.86, pw * 0.5, 45).point(lambda p: int(p * 0.5)))
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 draw_headline(canvas, text, cx, top_y, panel_w, color, max_lines=2):
"""Draw a centered headline, auto-shrinking the font until it fits in
`max_lines` lines within the panel margins (never clips). Returns the y
coordinate just below the headline block."""
d = ImageDraw.Draw(canvas)
maxw = int(panel_w * 0.82)
size = int(panel_w * 0.074)
fnt = font(size, text)
lines = wrap(d, text, fnt, maxw)
while size > int(panel_w * 0.042):
fnt = font(size, text)
lines = wrap(d, text, fnt, maxw)
if len(lines) <= max_lines and max(d.textlength(ln, font=fnt) for ln in lines) <= maxw:
break
size -= 3
asc, desc = fnt.getmetrics()
lh = int((asc + desc) * 1.06)
for i, ln in enumerate(lines):
w = d.textlength(ln, font=fnt)
d.text((cx - w / 2, top_y + i * lh), ln, font=fnt, fill=color)
return top_y + len(lines) * lh
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--inputs", required=True, help="comma-separated screenshot paths")
ap.add_argument("--headlines", required=True, help="|| separated, one per panel")
ap.add_argument("--sublines", default="", help="|| separated, one per panel")
ap.add_argument("--panel-width", type=int, required=True)
ap.add_argument("--height", type=int, required=True)
ap.add_argument("--out-dir", required=True)
ap.add_argument("--out-prefix", default="iphone")
ap.add_argument("--accent", default="#3B82F6")
ap.add_argument("--accent2", default="#10B981")
ap.add_argument("--device-scale", type=float, default=0.86)
ap.add_argument("--preview", default="")
a = ap.parse_args()
inputs = [s for s in a.inputs.split(",") if s]
headlines = a.headlines.split("||")
sublines = a.sublines.split("||") if a.sublines else [""] * len(inputs)
n = len(inputs)
assert len(headlines) == n, "headlines count must match inputs"
W, H = a.panel_width, a.height
accent, accent2 = hexrgb(a.accent), hexrgb(a.accent2)
canvas = make_bg(W * n, H, accent, accent2, n=n)
text_color = (13, 22, 38)
sub_color = (90, 102, 120)
dev_w = int(W * a.device_scale)
angles = [-6, 5, -5, 6, -5, 5]
y_off = [0.0, 0.05, 0.0, 0.05, 0.0, 0.05]
for i, (path, head) in enumerate(zip(inputs, headlines)):
panel_cx = int(W * i + W * 0.5)
hy = draw_headline(canvas, head, panel_cx, int(H * 0.052), W, text_color)
sub = sublines[i] if i < len(sublines) else ""
if sub.strip():
d = ImageDraw.Draw(canvas)
sf = font(int(W * 0.034), sub)
lines = wrap(d, sub, sf, int(W * 0.78))
asc, desc = sf.getmetrics(); lh = int((asc + desc) * 1.05)
sy = hy + int(H * 0.012)
for j, ln in enumerate(lines):
w = d.textlength(ln, font=sf)
d.text((panel_cx - w / 2, sy + j * lh), ln, font=sf, fill=sub_color)
shot = Image.open(path).convert("RGB")
dev = build_device(shot, dev_w)
dev = with_shadow(dev, blur=int(W * 0.04), alpha=0.34, off=(0, 46))
ang = angles[i % len(angles)]
dev = dev.rotate(ang, expand=True, resample=Image.BICUBIC)
# nudge horizontally so adjacent devices overlap across the panel border
nudge = int(W * (0.15 if i % 2 == 0 else -0.15))
dx = panel_cx + nudge - dev.width // 2
dy = int(H * (0.315 + y_off[i % len(y_off)]))
if dy + dev.height > int(H * 1.04):
dy = int(H * 1.04) - dev.height
canvas.alpha_composite(dev, (dx, dy))
out = canvas.convert("RGB")
outdir = Path(a.out_dir); outdir.mkdir(parents=True, exist_ok=True)
for i in range(n):
panel = out.crop((i * W, 0, (i + 1) * W, H))
panel.save(outdir / f"{a.out_prefix}_{i+1:02d}.png")
if a.preview:
Path(a.preview).parent.mkdir(parents=True, exist_ok=True)
scale = min(1.0, 1600 / out.width)
out.resize((int(out.width * scale), int(out.height * scale)), Image.LANCZOS).save(a.preview)
print(f"OK {n} panels -> {outdir} (each {W}x{H})")
if __name__ == "__main__":
main()
+210
View File
@@ -0,0 +1,210 @@
#!/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()
+410
View File
@@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""Frame raw app screenshots for App Store marketing artwork.
Requirements:
pip install Pillow
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import textwrap
import warnings
from pathlib import Path
from typing import Iterable, Sequence
from PIL import Image, ImageDraw, ImageFilter, ImageFont
DEFAULT_GRAD_TOP = "#0B5FFF"
DEFAULT_GRAD_BOTTOM = "#00C2A8"
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")
FONT_CANDIDATES = (
# CJK-capable macOS fonts first.
"/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc",
"/System/Library/Fonts/ヒラギノ角ゴシック W5.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/Library/Fonts/Arial Unicode.ttf",
# Strong Latin fallbacks common on macOS.
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/System/Library/Fonts/SFNS.ttf",
)
def parse_hex_color(value: str) -> tuple[int, int, int]:
"""Return an RGB tuple from #RRGGBB."""
if not HEX_RE.fullmatch(value):
raise argparse.ArgumentTypeError(f"Expected #RRGGBB hex color, got {value!r}")
value = value.lstrip("#")
return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4))
def find_repo_root(start: Path) -> Path:
for path in (start, *start.parents):
if (path / ".git").exists():
return path
return start
def find_brand_colors(repo_root: Path) -> tuple[str | None, str | None]:
"""Look for SwiftUI Color.appPrimary/appSecondary hex values, then assets."""
primary = secondary = None
for swift_file in repo_root.rglob("*.swift"):
try:
text = swift_file.read_text(encoding="utf-8")
except UnicodeDecodeError:
text = swift_file.read_text(errors="ignore")
primary_match = re.search(r"appPrimary\s*=\s*Color\(hex:\s*\"(#[0-9A-Fa-f]{6})\"", text)
secondary_match = re.search(r"appSecondary\s*=\s*Color\(hex:\s*\"(#[0-9A-Fa-f]{6})\"", text)
if primary_match:
primary = primary_match.group(1).upper()
if secondary_match:
secondary = secondary_match.group(1).upper()
if primary and secondary:
return primary, secondary
for contents_file in repo_root.glob("**/*.colorset/Contents.json"):
name = contents_file.parent.name.lower()
if "primary" not in name and "secondary" not in name:
continue
color = read_xcasset_hex(contents_file)
if not color:
continue
if "primary" in name and not primary:
primary = color
elif "secondary" in name and not secondary:
secondary = color
return primary, secondary
def read_xcasset_hex(path: Path) -> str | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
for color_entry in data.get("colors", []):
components = color_entry.get("color", {}).get("components", {})
red, green, blue = components.get("red"), components.get("green"), components.get("blue")
if red is None or green is None or blue is None:
continue
try:
values = [component_to_byte(channel) for channel in (red, green, blue)]
except ValueError:
continue
return "#{:02X}{:02X}{:02X}".format(*values)
return None
def component_to_byte(value: str | float | int) -> int:
if isinstance(value, str) and value.startswith("0x"):
return int(value, 16)
number = float(value)
if number <= 1:
number *= 255
return max(0, min(255, round(number)))
def print_brand_colors(repo_root: Path) -> tuple[str, str]:
primary, secondary = find_brand_colors(repo_root)
top = primary or DEFAULT_GRAD_TOP
bottom = secondary or DEFAULT_GRAD_BOTTOM
source = "found" if primary and secondary else "default"
print(f"Brand colors ({source}): appPrimary={top} appSecondary={bottom}")
return top, bottom
def make_vertical_gradient(size: tuple[int, int], top: str, bottom: str) -> Image.Image:
width, height = size
top_rgb = parse_hex_color(top)
bottom_rgb = parse_hex_color(bottom)
gradient = Image.new("RGB", size)
draw = ImageDraw.Draw(gradient)
for y in range(height):
ratio = y / max(1, height - 1)
color = tuple(round(top_rgb[i] + (bottom_rgb[i] - top_rgb[i]) * ratio) for i in range(3))
draw.line([(0, y), (width, y)], fill=color)
return gradient.convert("RGBA")
def load_font(size: int) -> ImageFont.ImageFont:
for font_path in FONT_CANDIDATES:
path = Path(font_path)
if not path.exists():
continue
try:
return ImageFont.truetype(str(path), size=size)
except OSError:
continue
warnings.warn(
"No CJK-capable macOS font found; falling back to PIL default. "
"Japanese or other non-Latin text may not render correctly.",
RuntimeWarning,
)
return ImageFont.load_default()
def text_bbox(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
if not text:
return 0, 0
left, top, right, bottom = draw.multiline_textbbox((0, 0), text, font=font, spacing=8, align="center")
return right - left, bottom - top
def wrap_headline(
text: str,
font: ImageFont.ImageFont,
max_width: int,
draw: ImageDraw.ImageDraw,
) -> str:
if contains_cjk(text):
return wrap_cjk_text(text, font, max_width, draw)
return wrap_word_text(text, font, max_width, draw)
def contains_cjk(text: str) -> bool:
return any(
"\u3040" <= char <= "\u30ff"
or "\u3400" <= char <= "\u4dbf"
or "\u4e00" <= char <= "\u9fff"
or "\uf900" <= char <= "\ufaff"
for char in text
)
def wrap_word_text(
text: str,
font: ImageFont.ImageFont,
max_width: int,
draw: ImageDraw.ImageDraw,
) -> str:
words = text.split()
if not words:
return text
lines: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if text_bbox(draw, candidate, font)[0] <= max_width:
current = candidate
else:
lines.append(current)
current = word
lines.append(current)
return "\n".join(lines)
def wrap_cjk_text(
text: str,
font: ImageFont.ImageFont,
max_width: int,
draw: ImageDraw.ImageDraw,
) -> str:
lines: list[str] = []
current = ""
for char in text:
if char.isspace():
if current:
lines.append(current)
current = ""
continue
candidate = f"{current}{char}"
if current and text_bbox(draw, candidate, font)[0] > max_width:
lines.append(current)
current = char
else:
current = candidate
if current:
lines.append(current)
return "\n".join(lines)
def fit_headline(
headline: str,
canvas_size: tuple[int, int],
max_width: int,
max_height: int,
draw: ImageDraw.ImageDraw,
) -> tuple[str, ImageFont.ImageFont, int, int]:
_, height = canvas_size
max_size = max(34, round(height * 0.052))
min_size = 28
for size in range(max_size, min_size - 1, -2):
font = load_font(size)
wrapped = wrap_headline(headline, font, max_width, draw)
text_width, text_height = text_bbox(draw, wrapped, font)
if text_width <= max_width and text_height <= max_height:
return wrapped, font, text_width, text_height
font = load_font(min_size)
wrapped = wrap_headline(headline, font, max_width, draw)
text_width, text_height = text_bbox(draw, wrapped, font)
return wrapped, font, text_width, text_height
def rounded_image(image: Image.Image, radius: int) -> Image.Image:
image = image.convert("RGBA")
mask = Image.new("L", image.size, 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, image.width, image.height), radius=radius, fill=255)
image.putalpha(mask)
return image
def paste_with_shadow(
canvas: Image.Image,
screenshot: Image.Image,
position: tuple[int, int],
radius: int,
) -> None:
x, y = position
shadow_offset = max(10, round(canvas.width * 0.014))
shadow_blur = max(18, round(canvas.width * 0.025))
shadow_opacity = 105
shadow = Image.new("RGBA", screenshot.size, (0, 0, 0, 0))
mask = Image.new("L", screenshot.size, 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, screenshot.width, screenshot.height), radius=radius, fill=shadow_opacity)
shadow.putalpha(mask)
shadow = shadow.filter(ImageFilter.GaussianBlur(shadow_blur))
canvas.alpha_composite(shadow, (x, y + shadow_offset))
canvas.alpha_composite(rounded_image(screenshot, radius), position)
def resize_screenshot(image: Image.Image, canvas_width: int, canvas_height: int) -> Image.Image:
target_width = round(canvas_width * 0.78)
target_height = round(canvas_height * 0.72)
scale = min(target_width / image.width, target_height / image.height, 1.0)
new_size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
if new_size == image.size:
return image.convert("RGBA")
return image.convert("RGBA").resize(new_size, Image.Resampling.LANCZOS)
def frame_screenshot(
input_path: Path,
output_path: Path,
width: int,
height: int,
headline: str,
grad_top: str,
grad_bottom: str,
device: str | None = None,
) -> None:
del device # Reserved for future per-device tuning.
canvas = make_vertical_gradient((width, height), grad_top, grad_bottom)
draw = ImageDraw.Draw(canvas)
horizontal_margin = round(width * 0.08)
top_margin = round(height * 0.052)
headline_area_height = round(height * 0.14)
max_text_width = width - horizontal_margin * 2
wrapped, font, _, text_height = fit_headline(
headline,
(width, height),
max_text_width,
headline_area_height,
draw,
)
headline_y = top_margin + max(0, (headline_area_height - text_height) // 2)
draw.multiline_text(
(width // 2, headline_y),
wrapped,
font=font,
fill=(255, 255, 255, 255),
anchor="ma",
align="center",
spacing=max(8, round(height * 0.006)),
)
screenshot = resize_screenshot(Image.open(input_path), width, height)
screenshot_x = (width - screenshot.width) // 2
min_screenshot_y = round(height * 0.22)
screenshot_y = max(top_margin + headline_area_height + round(height * 0.035), min_screenshot_y)
available_bottom = height - round(height * 0.045)
if screenshot_y + screenshot.height > available_bottom:
screenshot_y = max(round(height * 0.19), available_bottom - screenshot.height)
radius = max(24, round(width * 0.03))
paste_with_shadow(canvas, screenshot, (screenshot_x, screenshot_y), radius)
output_path.parent.mkdir(parents=True, exist_ok=True)
canvas.convert("RGB").save(output_path, "PNG")
def positive_int(value: str) -> int:
number = int(value)
if number <= 0:
raise argparse.ArgumentTypeError("Value must be greater than zero")
return number
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Create gradient App Store marketing screenshots from raw PNG captures.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent(
"""\
Example:
python Scripts/aso/frame_screenshot.py \\
--input raw.png --output framed.png --width 1320 --height 2868 \\
--headline "Watch your wealth grow" --grad-top "#3B82F6" --grad-bottom "#10B981"
"""
),
)
parser.add_argument("--input", required=True, type=Path, help="Raw app screenshot PNG.")
parser.add_argument("--output", required=True, type=Path, help="Framed output PNG.")
parser.add_argument("--width", required=True, type=positive_int, help="Final canvas width.")
parser.add_argument("--height", required=True, type=positive_int, help="Final canvas height.")
parser.add_argument("--headline", required=True, help="Localized headline text.")
parser.add_argument("--grad-top", required=True, help="Top gradient color as #RRGGBB.")
parser.add_argument("--grad-bottom", required=True, help="Bottom gradient color as #RRGGBB.")
parser.add_argument("--device", choices=("iphone", "ipad"), help="Optional device family hint.")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
parse_hex_color(args.grad_top)
parse_hex_color(args.grad_bottom)
print_brand_colors(find_repo_root(Path(__file__).resolve()))
frame_screenshot(
input_path=args.input,
output_path=args.output,
width=args.width,
height=args.height,
headline=args.headline,
grad_top=args.grad_top,
grad_bottom=args.grad_bottom,
device=args.device,
)
print(f"Wrote {args.output} ({args.width}x{args.height})")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+184
View File
@@ -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()
+16
View File
@@ -0,0 +1,16 @@
{
"panel_width": 2732, "height": 2048, "n_panels": 3, "hscale": 0.046,
"accent": "#2563EB", "accent2": "#10B981",
"out_dir": "Scripts/aso/pano_ipad", "out_prefix": "ipad",
"preview": "Scripts/aso/_pano_ipad_preview.png",
"devices": [
{"image": "/Users/alexandrev/Desktop/portfoliojournal-raw-images/Simulator Screenshot - iPad Air 13-inch (M3) - 2026-01-18 at 16.12.19.png", "cx_panels": 0.58, "top": 0.19, "scale": 1.02, "yaw": 0.24, "pitch": 0.05, "roll": -12},
{"image": "/Users/alexandrev/Desktop/portfoliojournal-raw-images/Simulator Screenshot - iPad Air 13-inch (M3) - 2026-01-18 at 16.35.08.png", "cx_panels": 1.43, "top": 0.13, "scale": 1.02, "yaw": -0.22, "pitch": 0.05, "roll": 13},
{"image": "/Users/alexandrev/Desktop/portfoliojournal-raw-images/Simulator Screenshot - iPad Air 13-inch (M3) - 2026-01-18 at 16.35.16.png", "cx_panels": 2.55, "top": 0.24, "scale": 1.00, "yaw": 0.22, "pitch": 0.06, "roll": -8}
],
"texts": [
{"cx_panels": 0.5, "top": 0.045, "headline": "Net worth, no bank login"},
{"cx_panels": 1.5, "top": 0.045, "headline": "Watch your wealth grow"},
{"cx_panels": 2.5, "top": 0.045, "headline": "Rebalance with confidence", "badge": "NEW"}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"panel_width": 1320, "height": 2868, "n_panels": 5,
"accent": "#2563EB", "accent2": "#10B981",
"out_dir": "Scripts/aso/pano_iphone", "out_prefix": "iphone",
"preview": "Scripts/aso/_pano_iphone_preview.png",
"devices": [
{"image": "/Users/alexandrev/Desktop/portfoliojournal-raw-images/Simulator Screenshot - iPhone 17 - 2026-02-12 at 14.06.02.png", "cx_panels": 1.0, "cy": 0.73, "scale": 0.95, "angle": -24},
{"image": "/Users/alexandrev/Desktop/portfoliojournal-raw-images/Simulator Screenshot - iPhone Air - 2026-01-17 at 16.15.26.png", "cx_panels": 2.66, "cy": 0.62, "scale": 0.80, "angle": -8},
{"image": "/Users/alexandrev/Desktop/portfoliojournal-raw-images/Simulator Screenshot - iPhone 17 - 2026-02-12 at 14.21.35.png", "cx_panels": 3.36, "cy": 0.62, "scale": 0.80, "angle": 8},
{"image": "/Users/alexandrev/Desktop/portfoliojournal-raw-images/Simulator Screenshot - iPhone 17 - 2026-02-12 at 14.21.46.png", "cx_panels": 4.5, "cy": 0.60, "scale": 0.82, "angle": 0}
],
"texts": [
{"cx_panels": 0.5, "top": 0.05, "headline": "Net worth, no bank login"},
{"cx_panels": 1.5, "top": 0.05, "headline": "Know where you'll be in a year", "badge": "NEW"},
{"cx_panels": 2.5, "top": 0.05, "headline": "Stocks to real estate, together"},
{"cx_panels": 3.5, "top": 0.05, "headline": "Rebalance with confidence", "badge": "NEW"},
{"cx_panels": 4.5, "top": 0.05, "headline": "See your true CAGR"}
]
}