diff --git a/.gitignore b/.gitignore index e21dedd..d3faca2 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/PortfolioJournalUITests/PortfolioJournalUITests.swift b/PortfolioJournalUITests/PortfolioJournalUITests.swift index 6c0d191..15e8398 100644 --- a/PortfolioJournalUITests/PortfolioJournalUITests.swift +++ b/PortfolioJournalUITests/PortfolioJournalUITests.swift @@ -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)) + } + } + } } diff --git a/Scripts/aso/asc.py b/Scripts/aso/asc.py new file mode 100644 index 0000000..ef80c49 --- /dev/null +++ b/Scripts/aso/asc.py @@ -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 ... # 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") diff --git a/Scripts/aso/frame_layout.py b/Scripts/aso/frame_layout.py new file mode 100644 index 0000000..9ac9790 --- /dev/null +++ b/Scripts/aso/frame_layout.py @@ -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() diff --git a/Scripts/aso/frame_panorama.py b/Scripts/aso/frame_panorama.py new file mode 100644 index 0000000..543f0e4 --- /dev/null +++ b/Scripts/aso/frame_panorama.py @@ -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() diff --git a/Scripts/aso/frame_premium.py b/Scripts/aso/frame_premium.py new file mode 100644 index 0000000..e92cc0b --- /dev/null +++ b/Scripts/aso/frame_premium.py @@ -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() diff --git a/Scripts/aso/frame_screenshot.py b/Scripts/aso/frame_screenshot.py new file mode 100644 index 0000000..54b14bc --- /dev/null +++ b/Scripts/aso/frame_screenshot.py @@ -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()) diff --git a/Scripts/aso/frame_tilt.py b/Scripts/aso/frame_tilt.py new file mode 100644 index 0000000..7437539 --- /dev/null +++ b/Scripts/aso/frame_tilt.py @@ -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() diff --git a/Scripts/aso/layout_ipad.json b/Scripts/aso/layout_ipad.json new file mode 100644 index 0000000..e307f67 --- /dev/null +++ b/Scripts/aso/layout_ipad.json @@ -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"} + ] +} diff --git a/Scripts/aso/layout_iphone.json b/Scripts/aso/layout_iphone.json new file mode 100644 index 0000000..aa6c31a --- /dev/null +++ b/Scripts/aso/layout_iphone.json @@ -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"} + ] +}