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:
@@ -28,3 +28,10 @@ fastlane/test_output/
|
|||||||
Justificante_*.pdf
|
Justificante_*.pdf
|
||||||
*.pkg
|
*.pkg
|
||||||
og-image.png
|
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()
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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()
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user