diff --git a/Scripts/aso/frame_panorama.py b/Scripts/aso/frame_panorama.py index 543f0e4..62e552b 100644 --- a/Scripts/aso/frame_panorama.py +++ b/Scripts/aso/frame_panorama.py @@ -92,7 +92,8 @@ def with_shadow(rgba: Image.Image, blur: int, alpha: float, off=(0, 40)) -> Imag 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)) + # cool light glow (not a dark drop-shadow) so the device lifts off the dark bg + dark = Image.new("RGBA", rgba.size, (78, 110, 180, 255)) dark.putalpha(sh.split()[3]) canvas.alpha_composite(dark, (pad + off[0], pad + off[1])) canvas = canvas.filter(ImageFilter.GaussianBlur(blur)) @@ -107,9 +108,9 @@ def _blob(W, H, cx, cy, r, a): 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) + """Dark premium background: near-black vertical wash (slate-900 -> near black) + + large soft brand-color glow blobs for subtle depth. `n` = number of panels.""" + top, bottom = (15, 23, 42), (2, 6, 23) # #0F172A -> #020617 # fast vertical gradient: build a 1px-wide column then stretch to full width col = Image.new("RGB", (1, H)) cp = col.load() @@ -120,11 +121,12 @@ def make_bg(W, H, accent, accent2, n=1): for i in range(n): col = accent if i % 2 == 0 else accent2 cx = pw * (i + 0.5) + # low-alpha glows so the dark stays premium, not garish 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))) + _blob(W, H, cx, H * 0.16, pw * 0.5, 60).point(lambda p: int(p * 0.26))) 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))) + _blob(W, H, cx, H * 0.90, pw * 0.55, 45).point(lambda p: int(p * 0.22))) return base @@ -146,16 +148,23 @@ def draw_headline(canvas, text, cx, top_y, panel_w, color, max_lines=2): `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) + # keep the block well inside the panel so nothing spills into the neighbour + maxw = int(panel_w * 0.78) + size = int(panel_w * 0.078) fnt = font(size, text) lines = wrap(d, text, fnt, maxw) - while size > int(panel_w * 0.042): + floor = int(panel_w * 0.034) + while size > floor: 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 + # hard safety: shrink until every line truly fits, even below the floor + while size > 14 and max(d.textlength(ln, font=fnt) for ln in lines) > maxw: + size -= 2 + fnt = font(size, text) + lines = wrap(d, text, fnt, maxw) asc, desc = fnt.getmetrics() lh = int((asc + desc) * 1.06) for i, ln in enumerate(lines): @@ -189,8 +198,8 @@ def main(): 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) + text_color = (248, 250, 252) # near-white on dark + sub_color = (148, 163, 184) # slate-400 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] @@ -205,13 +214,15 @@ def main(): 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) + # positive/stat sublines (▲ ↑ +) pop in green; the rest stay muted + sub_fill = accent2 if sub.strip()[:1] in "▲↑+" else sub_color 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) + d.text((panel_cx - w / 2, sy + j * lh), ln, font=sf, fill=sub_fill) 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)) + dev = with_shadow(dev, blur=int(W * 0.05), alpha=0.30, off=(0, 18)) ang = angles[i % len(angles)] dev = dev.rotate(ang, expand=True, resample=Image.BICUBIC) # nudge horizontally so adjacent devices overlap across the panel border diff --git a/Scripts/aso/frame_panorama_codex.py b/Scripts/aso/frame_panorama_codex.py new file mode 100644 index 0000000..9cd9823 --- /dev/null +++ b/Scripts/aso/frame_panorama_codex.py @@ -0,0 +1,522 @@ +#!/usr/bin/env python3 +"""Render the high-impact Portfolio Journal App Store panorama. + +The output is one continuous 5 x 1320 scene sliced into App Store panels. +Run with Scripts/aso/.venv/bin/python. +""" +from __future__ import annotations + +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + + +ROOT = Path(__file__).resolve().parents[2] +RAW = ROOT / "build/screenshots/src_raw" + +PANEL_W = 1320 +HEIGHT = 2868 +PANELS = 5 +WIDTH = PANEL_W * PANELS + +INK = (244, 248, 255) +MUTED = (155, 169, 191) +GREEN = (16, 185, 129) +BLUE = (48, 132, 255) +NAVY = (3, 9, 22) + +DISPLAY_FONT = "/System/Library/Fonts/Supplemental/Impact.ttf" +TEXT_FONT = "/System/Library/Fonts/Avenir Next.ttc" +CONDENSED_FONT = "/System/Library/Fonts/Avenir Next Condensed.ttc" +JAPANESE_FONT_CANDIDATES = ( + "/System/Library/Fonts/ヒラギノ角ゴシック W8.ttc", + "/System/Library/Fonts/ヒラギノ角ゴシック W9.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", +) + +LOCALIZED_SPECS = { + "es-ES": [ + { + "eyebrow": "TU PATRIMONIO", + "headline": ("CONOCE TU", "PATRIMONIO."), + "body": "Todo lo que tienes, de un vistazo.", + }, + { + "eyebrow": "CRECIMIENTO REAL", + "headline": ("MÍRALO", "AUMENTAR."), + "stat": "+22.7%", + "body": "Desde el inicio. Y sigue subiendo.", + }, + { + "eyebrow": "TODA TU CARTERA", + "headline": ("TODO LO", "QUE TIENES."), + "body": "Acciones, inmuebles, cripto y efectivo.", + }, + { + "eyebrow": "DECIDE CON DATOS", + "headline": ("DESCUBRE TUS", "GANADORES."), + "body": "Rentabilidad real. Sin suposiciones.", + }, + { + "eyebrow": "PRIVADO DE SERIE", + "headline": ("SIN CLAVES", "BANCARIAS."), + "body": "Tus datos nunca salen de tu iPhone.", + }, + ], + "de": [ + { + "eyebrow": "DEIN VERMÖGEN", + "headline": ("KENN DEIN", "VERMÖGEN."), + "body": "Alles, was dir gehört — auf einen Blick.", + }, + { + "eyebrow": "ECHTES VERMÖGEN. ECHTER SCHWUNG.", + "headline": ("SIEH ES", "WACHSEN."), + "stat": "+22.7%", + "body": "Seit Beginn — und weiter aufwärts.", + }, + { + "eyebrow": "DAS GANZE PORTFOLIO", + "headline": ("ALLES, WAS", "DIR GEHÖRT."), + "body": "Aktien · Immobilien · Krypto · Cash", + }, + { + "eyebrow": "KLARHEIT SCHAFFT ÜBERZEUGUNG", + "headline": ("FINDE DEINE", "GEWINNER."), + "body": "Echter CAGR. Kein Rätselraten.", + }, + { + "eyebrow": "PRIVAT. VON GRUND AUF.", + "headline": ("KEINE BANK-", "LOGINS. NIE."), + "body": "Deine Zahlen bleiben auf deinem iPhone.", + }, + ], + "fr": [ + { + "eyebrow": "VOTRE PATRIMOINE", + "headline": ("CONNAIS TON", "PATRIMOINE."), + "body": "Tout ce que tu possèdes, d’un coup d’œil.", + }, + { + "eyebrow": "VRAIE RICHESSE. VRAI ÉLAN.", + "headline": ("REGARDE-LE", "FRUCTIFIER."), + "stat": "+22.7%", + "body": "Depuis le début — et ça continue.", + }, + { + "eyebrow": "TOUT LE PORTEFEUILLE", + "headline": ("TOUT CE QUE", "TU POSSÈDES."), + "body": "Actions · immobilier · crypto · liquidités", + }, + { + "eyebrow": "LA CLARTÉ CRÉE LA CONVICTION", + "headline": ("REPÈRE TES", "GAGNANTS."), + "body": "CAGR réel. Zéro approximation.", + }, + { + "eyebrow": "CONFIDENTIEL PAR CONCEPTION", + "headline": ("AUCUN ACCÈS", "BANCAIRE. JAMAIS."), + "body": "Tes chiffres ne quittent jamais ton iPhone.", + }, + ], + "it": [ + { + "eyebrow": "IL TUO PATRIMONIO", + "headline": ("CONOSCI IL TUO", "PATRIMONIO."), + "body": "Tutto ciò che hai, in un colpo d’occhio.", + }, + { + "eyebrow": "PATRIMONIO REALE. SLANCIO REALE.", + "headline": ("GUARDALO", "CRESCERE."), + "stat": "+22.7%", + "body": "Dall’inizio — e continua a salire.", + }, + { + "eyebrow": "TUTTO IL PORTAFOGLIO", + "headline": ("TUTTO CIÒ", "CHE POSSIEDI."), + "body": "Azioni · immobili · crypto · liquidità", + }, + { + "eyebrow": "LA CHIAREZZA CREA CONVINZIONE", + "headline": ("SCOPRI I TUOI", "VINCENTI."), + "body": "CAGR reale. Zero ipotesi.", + }, + { + "eyebrow": "PRIVATO PER NATURA", + "headline": ("NESSUN LOGIN", "BANCARIO. MAI."), + "body": "I tuoi numeri restano sul tuo iPhone.", + }, + ], + "ja": [ + { + "eyebrow": "資産管理", + "headline": ("純資産を", "ひと目で。"), + "body": "持っている資産のすべてを。", + }, + { + "eyebrow": "着実な成長", + "headline": ("資産が", "増えていく。"), + "stat": "+22.7%", + "body": "運用開始から、今も上昇中。", + }, + { + "eyebrow": "ポートフォリオのすべて", + "headline": ("所有資産を", "すべて。"), + "body": "株式・不動産・暗号資産・現金", + }, + { + "eyebrow": "明確さが確信を生む", + "headline": ("勝ち筋を", "見極める。"), + "body": "本当のCAGR。勘に頼らない。", + }, + { + "eyebrow": "プライバシーを最優先", + "headline": ("銀行ログインは", "一切不要。"), + "body": "あなたの数字はiPhoneの外に出ません。", + }, + ], + "pt-BR": [ + { + "eyebrow": "SEU PATRIMÔNIO", + "headline": ("CONHEÇA SEU", "PATRIMÔNIO."), + "body": "Tudo o que é seu, num relance.", + }, + { + "eyebrow": "PATRIMÔNIO REAL. IMPULSO REAL.", + "headline": ("VEJA-O", "CRESCER."), + "stat": "+22.7%", + "body": "Desde o início — e ainda subindo.", + }, + { + "eyebrow": "A CARTEIRA COMPLETA", + "headline": ("TUDO QUE", "É SEU."), + "body": "Ações · imóveis · cripto · dinheiro", + }, + { + "eyebrow": "CLAREZA GERA CONVICÇÃO", + "headline": ("ACHE SEUS", "VENCEDORES."), + "body": "CAGR real. Zero achismo.", + }, + { + "eyebrow": "PRIVADO POR NATUREZA", + "headline": ("SEM LOGIN", "BANCÁRIO. NUNCA."), + "body": "Seus números nunca saem do seu iPhone.", + }, + ], +} + + +def font(path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(path, size=size, index=index) + + +def contains_cjk(text: str) -> bool: + return any( + "\u3040" <= char <= "\u30ff" or "\u3400" <= char <= "\u9fff" + for char in text + ) + + +def japanese_font_path() -> str: + for path in JAPANESE_FONT_CANDIDATES: + try: + font(path, 20) + return path + except OSError: + continue + raise RuntimeError("No supported heavy Japanese font could be loaded") + + +def face_for(text: str, latin_path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont: + if contains_cjk(text): + return font(japanese_font_path(), size) + return font(latin_path, size, index) + + +def gradient_background() -> Image.Image: + """Near-black navy base with a continuous blue/green atmospheric wash.""" + column = Image.new("RGB", (1, HEIGHT)) + px = column.load() + for y in range(HEIGHT): + t = y / (HEIGHT - 1) + px[0, y] = ( + int(10 - 7 * t), + int(21 - 12 * t), + int(42 - 20 * t), + ) + bg = column.resize((WIDTH, HEIGHT)).convert("RGBA") + + glows = [ + (460, 610, 920, BLUE, 58), + (1670, 1150, 870, GREEN, 42), + (2890, 520, 760, BLUE, 40), + (4130, 1240, 950, GREEN, 36), + (5720, 600, 940, BLUE, 44), + (6280, 2350, 980, GREEN, 28), + ] + for cx, cy, radius, color, alpha in glows: + mask = Image.new("L", (WIDTH, HEIGHT), 0) + md = ImageDraw.Draw(mask) + md.ellipse((cx - radius, cy - radius, cx + radius, cy + radius), fill=alpha) + mask = mask.filter(ImageFilter.GaussianBlur(radius // 2)) + wash = Image.new("RGBA", bg.size, color + (255,)) + bg = Image.composite(wash, bg, mask) + + # Subtle technical grid keeps the background dimensional without looking busy. + grid = Image.new("RGBA", bg.size, (0, 0, 0, 0)) + gd = ImageDraw.Draw(grid) + for x in range(0, WIDTH, 132): + gd.line((x, 0, x, HEIGHT), fill=(93, 132, 181, 11), width=1) + for y in range(0, HEIGHT, 132): + gd.line((0, y, WIDTH, y), fill=(93, 132, 181, 9), width=1) + bg.alpha_composite(grid) + return bg + + +def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image: + mask = Image.new("L", size, 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255 + ) + return mask + + +def build_iphone(shot: Image.Image, target_w: int) -> Image.Image: + """Realistic titanium iPhone frame with bezel, screen and Dynamic Island.""" + outer = max(18, int(target_w * 0.032)) + inner = max(6, int(target_w * 0.009)) + screen_w = target_w - 2 * (outer + inner) + screen_h = round(screen_w * shot.height / shot.width) + total_h = screen_h + 2 * (outer + inner) + corner = int(target_w * 0.126) + + phone = Image.new("RGBA", (target_w, total_h), (0, 0, 0, 0)) + d = ImageDraw.Draw(phone) + d.rounded_rectangle( + (0, 0, target_w - 1, total_h - 1), + radius=corner + outer, + fill=(26, 29, 35, 255), + outline=(147, 154, 166, 255), + width=max(4, target_w // 180), + ) + d.rounded_rectangle( + (outer, outer, target_w - outer - 1, total_h - outer - 1), + radius=corner, + fill=(2, 3, 5, 255), + outline=(79, 84, 94, 255), + width=max(3, target_w // 240), + ) + + screen = shot.convert("RGB").resize((screen_w, screen_h), Image.Resampling.LANCZOS) + screen_radius = int(screen_w * 0.105) + inset = outer + inner + phone.paste(screen, (inset, inset), rounded_mask(screen.size, screen_radius)) + + # The source shots already contain a Dynamic Island when captured on-device. + # Repainting it ensures the one older settings capture matches the same hardware. + island_w = int(screen_w * 0.315) + island_h = int(screen_w * 0.084) + island_x = inset + (screen_w - island_w) // 2 + island_y = inset + int(screen_w * 0.022) + d.rounded_rectangle( + (island_x, island_y, island_x + island_w, island_y + island_h), + radius=island_h // 2, + fill=(0, 0, 0, 255), + ) + + # Hardware controls sell the silhouette as a physical iPhone. + button = (70, 74, 82, 255) + d.rounded_rectangle((-4, total_h * 0.22, 7, total_h * 0.31), radius=5, fill=button) + d.rounded_rectangle((-4, total_h * 0.34, 7, total_h * 0.43), radius=5, fill=button) + d.rounded_rectangle( + (target_w - 7, total_h * 0.29, target_w + 4, total_h * 0.43), + radius=5, + fill=button, + ) + return phone + + +def elevated(phone: Image.Image, angle: float, glow: tuple[int, int, int]) -> Image.Image: + rotated = phone.rotate( + angle, expand=True, resample=Image.Resampling.BICUBIC + ) + pad = 160 + stage = Image.new( + "RGBA", (rotated.width + pad * 2, rotated.height + pad * 2), (0, 0, 0, 0) + ) + alpha = rotated.getchannel("A") + + shadow = Image.new("RGBA", rotated.size, (0, 0, 0, 0)) + shadow.putalpha(alpha.point(lambda a: int(a * 0.68))) + black = Image.new("RGBA", rotated.size, (0, 0, 0, 255)) + black.putalpha(shadow.getchannel("A")) + stage.alpha_composite(black, (pad + 18, pad + 74)) + stage = stage.filter(ImageFilter.GaussianBlur(70)) + + halo = Image.new("RGBA", rotated.size, glow + (255,)) + halo.putalpha(alpha.point(lambda a: int(a * 0.24))) + stage.alpha_composite(halo, (pad - 8, pad + 16)) + stage.alpha_composite(rotated, (pad, pad)) + return stage + + +def text_width(draw: ImageDraw.ImageDraw, text: str, face: ImageFont.FreeTypeFont) -> float: + return draw.textlength(text, font=face) + + +def draw_tracking_text( + draw: ImageDraw.ImageDraw, + xy: tuple[int, int], + text: str, + face: ImageFont.FreeTypeFont, + fill: tuple[int, int, int], + tracking: int, +) -> None: + x, y = xy + for char in text: + draw.text((x, y), char, font=face, fill=fill) + x += text_width(draw, char, face) + tracking + + +def fit_display(draw: ImageDraw.ImageDraw, lines: tuple[str, ...], max_width: int) -> ImageFont.FreeTypeFont: + size = 206 + display_path = japanese_font_path() if any(contains_cjk(line) for line in lines) else DISPLAY_FONT + while size > 110: + face = font(display_path, size) + if max(text_width(draw, line, face) for line in lines) <= max_width: + return face + size -= 4 + return font(display_path, size) + + +def fit_body(draw: ImageDraw.ImageDraw, text: str, max_width: int) -> ImageFont.FreeTypeFont: + size = 39 + while size > 29: + face = face_for(text, TEXT_FONT, size) + if text_width(draw, text, face) <= max_width: + return face + size -= 1 + return face_for(text, TEXT_FONT, size) + + +def draw_campaign_copy(canvas: Image.Image, panel: int, spec: dict) -> None: + d = ImageDraw.Draw(canvas) + left = panel * PANEL_W + 92 + right = (panel + 1) * PANEL_W - 92 + top = 78 + + eyebrow = face_for(spec["eyebrow"], CONDENSED_FONT, 33, 0) + draw_tracking_text(d, (left, top), spec["eyebrow"], eyebrow, GREEN, 5) + + index = font(CONDENSED_FONT, 31, 0) + index_text = f"0{panel + 1} / 05" + d.text( + (right - text_width(d, index_text, index), top), + index_text, + font=index, + fill=(100, 118, 145), + ) + d.rounded_rectangle((left, 139, right, 147), radius=4, fill=(38, 57, 82)) + d.rounded_rectangle((left, 139, left + 120, 147), radius=4, fill=GREEN) + + lines = tuple(spec["headline"]) + display = fit_display(d, lines, PANEL_W - 184) + line_h = int(display.size * (1.02 if any(contains_cjk(line) for line in lines) else 0.88)) + y = 190 + for line in lines: + # A restrained blue offset gives the letterforms extra physical punch. + d.text((left + 6, y + 9), line, font=display, fill=(23, 68, 127)) + d.text((left, y), line, font=display, fill=INK) + y += line_h + y += 34 + + if spec.get("stat"): + stat = font(DISPLAY_FONT, 110) + d.text((left, y + 8), spec["stat"], font=stat, fill=GREEN) + y += 139 + + body = fit_body(d, spec["body"], PANEL_W - 184) + d.text((left, y + 18), spec["body"], font=body, fill=MUTED) + + +def draw_growth_thread(canvas: Image.Image) -> None: + """Continuous electric growth curve tying all five gallery panels together.""" + overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + points = [ + (0, 1370), + (600, 1330), + (1210, 1380), + (1770, 1160), + (2380, 1215), + (2970, 1030), + (3590, 1080), + (4210, 890), + (4820, 960), + (5420, 750), + (6040, 820), + (6600, 650), + ] + glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + gd = ImageDraw.Draw(glow) + gd.line(points, fill=BLUE + (80,), width=38, joint="curve") + glow = glow.filter(ImageFilter.GaussianBlur(30)) + overlay.alpha_composite(glow) + od = ImageDraw.Draw(overlay) + od.line(points, fill=BLUE + (135,), width=5, joint="curve") + for x, y in points[1:-1]: + od.ellipse((x - 9, y - 9, x + 9, y + 9), fill=GREEN + (190,)) + canvas.alpha_composite(overlay) + + +def render(specs: list[dict], output_dir: Path) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + canvas = gradient_background() + draw_growth_thread(canvas) + + for i, spec in enumerate(specs): + draw_campaign_copy(canvas, i, spec) + + # One FULL iPhone per panel — sized + centered so nothing is clipped at the + # panel borders even after the tilt + halo padding. Panel centers are at + # 660, 1980, 3300, 4620, 5940 (PANEL_W = 1320). Widths kept small enough that + # rotated_width + halo stays inside the panel; top_y leaves the device fully + # visible (only a small, tasteful bottom bleed). + placements = [ + # source, width, angle, center x, top y, halo color + ("01_home.png", 860, -4.5, 660, 900, BLUE), + ("02_evolution.png", 860, 4.5, 1980, 960, GREEN), + ("03_allocation.png", 860, -3.6, 3300, 910, BLUE), + ("04_performance.png", 860, 4.0, 4620, 950, GREEN), + ("05_settings.png", 860, -4.0, 5940, 895, BLUE), + ] + for source, width, angle, center_x, top_y, halo in placements: + shot = Image.open(RAW / source) + phone = elevated(build_iphone(shot, width), angle, halo) + canvas.alpha_composite(phone, (center_x - phone.width // 2, top_y)) + + rgb = canvas.convert("RGB") + outputs: list[Path] = [] + for i in range(PANELS): + path = output_dir / f"portfolio_journal_{i + 1:02d}.png" + panel = rgb.crop((i * PANEL_W, 0, (i + 1) * PANEL_W, HEIGHT)) + panel.save(path, "PNG", optimize=True) + outputs.append(path) + + preview = output_dir / "_preview.png" + preview_w = 1980 + preview_h = round(HEIGHT * preview_w / WIDTH) + rgb.resize((preview_w, preview_h), Image.Resampling.LANCZOS).save( + preview, "PNG", optimize=True + ) + outputs.append(preview) + return outputs + + +if __name__ == "__main__": + for locale, localized_specs in LOCALIZED_SPECS.items(): + output_dir = ROOT / "build/screenshots" / f"pano_codex_{locale}" + rendered = render(localized_specs, output_dir) + for output in rendered: + with Image.open(output) as image: + print(f"{output} {image.width}x{image.height}") diff --git a/Scripts/aso/frame_panorama_codex_ipad.py b/Scripts/aso/frame_panorama_codex_ipad.py new file mode 100644 index 0000000..c843b74 --- /dev/null +++ b/Scripts/aso/frame_panorama_codex_ipad.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +"""Render the approved Portfolio Journal panorama for 12.9-inch iPad. + +Each App Store panel is exactly 2732 x 2048 (APP_IPAD_PRO_3GEN_129). +Run with Scripts/aso/.venv/bin/python. +""" +from __future__ import annotations + +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter, ImageFont + +import frame_panorama_codex as iphone_panorama + + +ROOT = Path(__file__).resolve().parents[2] +RAW = ROOT / "build/screenshots/src_raw_ipad" + +PANEL_W = 2732 +HEIGHT = 2048 +PANELS = 5 +WIDTH = PANEL_W * PANELS + +INK = iphone_panorama.INK +MUTED = iphone_panorama.MUTED +GREEN = iphone_panorama.GREEN +BLUE = iphone_panorama.BLUE +NAVY = iphone_panorama.NAVY + +DISPLAY_FONT = iphone_panorama.DISPLAY_FONT +TEXT_FONT = iphone_panorama.TEXT_FONT +CONDENSED_FONT = iphone_panorama.CONDENSED_FONT +LOCALIZED_SPECS = iphone_panorama.LOCALIZED_SPECS + +# The final iPhone module currently exposes the localized specs but not its +# English list as a named constant. These are the matching source strings from +# which that localization set was written. If the shared renderer later exports +# its English specs, this script automatically uses them instead. +_ENGLISH_FALLBACK = [ + { + "eyebrow": "YOUR NET WORTH", + "headline": ("KNOW YOUR", "NET WORTH."), + "body": "Everything you own, at a glance.", + }, + { + "eyebrow": "REAL WEALTH. REAL MOMENTUM.", + "headline": ("WATCH IT", "GROW."), + "stat": "+22.7%", + "body": "Since day one — and still climbing.", + }, + { + "eyebrow": "THE WHOLE PORTFOLIO", + "headline": ("EVERYTHING", "YOU OWN."), + "body": "Stocks · property · crypto · cash", + }, + { + "eyebrow": "CLARITY BUILDS CONVICTION", + "headline": ("FIND YOUR", "WINNERS."), + "body": "Real CAGR. Zero guesswork.", + }, + { + "eyebrow": "PRIVATE BY DESIGN", + "headline": ("NO BANK", "LOGINS. EVER."), + "body": "Your numbers never leave your iPhone.", + }, +] +DEFAULT_ENGLISH_SPECS = getattr( + iphone_panorama, + "DEFAULT_ENGLISH_SPECS", + getattr(iphone_panorama, "ENGLISH_SPECS", _ENGLISH_FALLBACK), +) + + +def font(path: str, size: int, index: int = 0) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(path, size=size, index=index) + + +def contains_cjk(text: str) -> bool: + return iphone_panorama.contains_cjk(text) + + +def japanese_font_path() -> str: + return iphone_panorama.japanese_font_path() + + +def face_for( + text: str, latin_path: str, size: int, index: int = 0 +) -> ImageFont.FreeTypeFont: + if contains_cjk(text): + return font(japanese_font_path(), size) + return font(latin_path, size, index) + + +def rounded_mask(size: tuple[int, int], radius: int) -> Image.Image: + mask = Image.new("L", size, 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255 + ) + return mask + + +def gradient_background() -> Image.Image: + """Near-black navy canvas with continuous atmospheric blue/green light.""" + column = Image.new("RGB", (1, HEIGHT)) + pixels = column.load() + for y in range(HEIGHT): + t = y / (HEIGHT - 1) + pixels[0, y] = ( + round(10 - 7 * t), + round(21 - 12 * t), + round(42 - 20 * t), + ) + background = column.resize((WIDTH, HEIGHT)).convert("RGBA") + + glow_layer = Image.new("RGBA", background.size, (0, 0, 0, 0)) + glow_draw = ImageDraw.Draw(glow_layer) + glows = [ + (680, 570, 940, BLUE, 56), + (2950, 1390, 930, GREEN, 38), + (5350, 510, 880, BLUE, 42), + (7770, 1350, 980, GREEN, 34), + (10350, 500, 920, BLUE, 43), + (12900, 1370, 980, GREEN, 32), + ] + for cx, cy, radius, color, alpha in glows: + glow_draw.ellipse( + (cx - radius, cy - radius, cx + radius, cy + radius), + fill=color + (alpha,), + ) + glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(330)) + background.alpha_composite(glow_layer) + + grid = Image.new("RGBA", background.size, (0, 0, 0, 0)) + grid_draw = ImageDraw.Draw(grid) + for x in range(0, WIDTH, 160): + grid_draw.line((x, 0, x, HEIGHT), fill=(93, 132, 181, 11), width=1) + for y in range(0, HEIGHT, 160): + grid_draw.line((0, y, WIDTH, y), fill=(93, 132, 181, 9), width=1) + background.alpha_composite(grid) + return background + + +def draw_growth_thread(canvas: Image.Image) -> None: + """Run one electric rising line continuously through all five panels.""" + points: list[tuple[int, int]] = [] + anchors = [ + 1460, + 1420, + 1470, + 1310, + 1360, + 1180, + 1240, + 1030, + 1090, + 880, + 940, + 750, + 820, + 650, + 710, + 560, + ] + for i, y in enumerate(anchors): + points.append((round(i * WIDTH / (len(anchors) - 1)), y)) + + glow = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + gd = ImageDraw.Draw(glow) + gd.line(points, fill=BLUE + (78,), width=42, joint="curve") + glow = glow.filter(ImageFilter.GaussianBlur(34)) + canvas.alpha_composite(glow) + + line = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + ld = ImageDraw.Draw(line) + ld.line(points, fill=BLUE + (138,), width=6, joint="curve") + for x, y in points[1:-1]: + ld.ellipse((x - 9, y - 9, x + 9, y + 9), fill=GREEN + (188,)) + canvas.alpha_composite(line) + + +def text_width( + draw: ImageDraw.ImageDraw, text: str, face: ImageFont.FreeTypeFont +) -> float: + return draw.textlength(text, font=face) + + +def draw_tracking_text( + draw: ImageDraw.ImageDraw, + xy: tuple[int, int], + text: str, + face: ImageFont.FreeTypeFont, + fill: tuple[int, int, int], + tracking: int, +) -> None: + x, y = xy + for char in text: + draw.text((x, y), char, font=face, fill=fill) + x += text_width(draw, char, face) + tracking + + +def fit_display( + draw: ImageDraw.ImageDraw, lines: tuple[str, ...], max_width: int +) -> ImageFont.FreeTypeFont: + is_cjk = any(contains_cjk(line) for line in lines) + display_path = japanese_font_path() if is_cjk else DISPLAY_FONT + size = 166 if is_cjk else 184 + while size > 126: + face = font(display_path, size) + if max(text_width(draw, line, face) for line in lines) <= max_width: + return face + size -= 4 + return font(display_path, size) + + +def fit_body( + draw: ImageDraw.ImageDraw, text: str, max_width: int +) -> ImageFont.FreeTypeFont: + size = 40 + while size > 30: + face = face_for(text, TEXT_FONT, size) + if text_width(draw, text, face) <= max_width: + return face + size -= 1 + return face_for(text, TEXT_FONT, size) + + +def draw_campaign_copy(canvas: Image.Image, panel: int, spec: dict) -> None: + draw = ImageDraw.Draw(canvas) + left = panel * PANEL_W + 150 + right = (panel + 1) * PANEL_W - 150 + top = 76 + + eyebrow = face_for(spec["eyebrow"], CONDENSED_FONT, 34) + draw_tracking_text(draw, (left, top), spec["eyebrow"], eyebrow, GREEN, 5) + + index = font(CONDENSED_FONT, 32) + index_text = f"0{panel + 1} / 05" + draw.text( + (right - text_width(draw, index_text, index), top), + index_text, + font=index, + fill=(100, 118, 145), + ) + draw.rounded_rectangle((left, 139, right, 147), radius=4, fill=(38, 57, 82)) + draw.rounded_rectangle((left, 139, left + 165, 147), radius=4, fill=GREEN) + + lines = tuple(spec["headline"]) + display = fit_display(draw, lines, 1640) + line_h = round( + display.size * (1.04 if any(contains_cjk(line) for line in lines) else 0.86) + ) + y = 171 + headline_bottom = y + for line in lines: + draw.text((left + 6, y + 9), line, font=display, fill=(23, 68, 127)) + draw.text((left, y), line, font=display, fill=INK) + headline_bottom = max( + headline_bottom, + draw.textbbox((left, y), line, font=display)[3], + ) + y += line_h + + body = fit_body(draw, spec["body"], 1060) + body_y = headline_bottom + 18 + draw.text((left, body_y), spec["body"], font=body, fill=MUTED) + + if spec.get("stat"): + stat = font(DISPLAY_FONT, 112) + stat_x = right - text_width(draw, spec["stat"], stat) + draw.text((stat_x, 457), spec["stat"], font=stat, fill=GREEN) + + +def build_ipad(shot: Image.Image, target_w: int) -> Image.Image: + """Build a slim, uniform titanium landscape iPad frame.""" + outer = max(22, round(target_w * 0.016)) + bezel = max(20, round(target_w * 0.014)) + screen_w = target_w - 2 * (outer + bezel) + screen_h = round(screen_w * shot.height / shot.width) + total_h = screen_h + 2 * (outer + bezel) + outer_radius = round(target_w * 0.036) + + ipad = Image.new("RGBA", (target_w, total_h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(ipad) + draw.rounded_rectangle( + (0, 0, target_w - 1, total_h - 1), + radius=outer_radius, + fill=(43, 46, 52, 255), + outline=(173, 179, 189, 255), + width=max(3, target_w // 420), + ) + draw.rounded_rectangle( + (outer, outer, target_w - outer - 1, total_h - outer - 1), + radius=outer_radius - outer // 3, + fill=(3, 4, 7, 255), + outline=(79, 84, 94, 255), + width=max(2, target_w // 600), + ) + + screen = shot.convert("RGB").resize((screen_w, screen_h), Image.Resampling.LANCZOS) + inset = outer + bezel + screen_radius = round(target_w * 0.020) + ipad.paste(screen, (inset, inset), rounded_mask(screen.size, screen_radius)) + + # A landscape iPad has a small camera on the center of the long top edge, + # never an iPhone-style Dynamic Island. + camera_r = max(5, round(target_w * 0.0042)) + camera_x = target_w // 2 + camera_y = outer + bezel // 2 + draw.ellipse( + ( + camera_x - camera_r, + camera_y - camera_r, + camera_x + camera_r, + camera_y + camera_r, + ), + fill=(5, 8, 13, 255), + outline=(32, 52, 75, 255), + width=2, + ) + draw.ellipse( + ( + camera_x - camera_r // 3, + camera_y - camera_r // 3, + camera_x + camera_r // 3, + camera_y + camera_r // 3, + ), + fill=(31, 60, 83, 255), + ) + return ipad + + +def elevated( + ipad: Image.Image, angle: float, glow_color: tuple[int, int, int] +) -> Image.Image: + rotated = ipad.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC) + pad = 68 + stage = Image.new( + "RGBA", + (rotated.width + pad * 2, rotated.height + pad * 2), + (0, 0, 0, 0), + ) + alpha = rotated.getchannel("A") + + shadow = Image.new("RGBA", rotated.size, (0, 0, 0, 255)) + shadow.putalpha(alpha.point(lambda value: round(value * 0.66))) + stage.alpha_composite(shadow, (pad + 12, pad + 36)) + stage = stage.filter(ImageFilter.GaussianBlur(34)) + + halo = Image.new("RGBA", rotated.size, glow_color + (255,)) + halo.putalpha(alpha.point(lambda value: round(value * 0.20))) + stage.alpha_composite(halo, (pad - 4, pad + 8)) + stage.alpha_composite(rotated, (pad, pad)) + return stage + + +PLACEMENTS = [ + ("01_home.png", 1690, -2.2, BLUE), + ("02_evolution.png", 1690, 2.2, GREEN), + ("03_allocation.png", 1690, -1.8, BLUE), + ("04_performance.png", 1690, 2.0, GREEN), + ("05_settings.png", 1690, -2.0, BLUE), +] + + +def render( + specs: list[dict], output_dir: Path, base_scene: Image.Image +) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + canvas = base_scene.copy() + + for panel, spec in enumerate(specs): + draw_campaign_copy(canvas, panel, spec) + + for panel, (source, target_w, angle, halo) in enumerate(PLACEMENTS): + with Image.open(RAW / source) as shot: + staged_ipad = elevated(build_ipad(shot, target_w), angle, halo) + panel_left = panel * PANEL_W + x = panel_left + (PANEL_W - staged_ipad.width) // 2 + y = HEIGHT - staged_ipad.height - 14 + if x < panel_left or x + staged_ipad.width > panel_left + PANEL_W or y < 0: + raise RuntimeError(f"iPad placement escapes panel {panel + 1}") + canvas.alpha_composite(staged_ipad, (x, y)) + + rgb = canvas.convert("RGB") + outputs: list[Path] = [] + for panel in range(PANELS): + path = output_dir / f"portfolio_journal_{panel + 1:02d}.png" + image = rgb.crop( + (panel * PANEL_W, 0, (panel + 1) * PANEL_W, HEIGHT) + ) + image.save(path, "PNG", optimize=True) + outputs.append(path) + + preview = output_dir / "_preview.png" + preview_w = PANEL_W + preview_h = round(HEIGHT * preview_w / WIDTH) + rgb.resize((preview_w, preview_h), Image.Resampling.LANCZOS).save( + preview, "PNG", optimize=True + ) + outputs.append(preview) + return outputs + + +def verify(outputs: list[Path]) -> None: + for path in outputs: + with Image.open(path) as image: + image.load() + expected = ( + (PANEL_W, HEIGHT) + if path.name != "_preview.png" + else (PANEL_W, round(HEIGHT * PANEL_W / WIDTH)) + ) + if image.size != expected: + raise RuntimeError(f"{path}: expected {expected}, got {image.size}") + print(f"{path} {image.width}x{image.height}") + + +def main() -> None: + base_scene = gradient_background() + draw_growth_thread(base_scene) + + locale_specs = [("en", DEFAULT_ENGLISH_SPECS), *LOCALIZED_SPECS.items()] + for locale, specs in locale_specs: + suffix = "" if locale == "en" else f"_{locale}" + output_dir = ROOT / "build/screenshots" / f"pano_codex_ipad{suffix}" + verify(render(specs, output_dir, base_scene)) + + +if __name__ == "__main__": + main() diff --git a/Scripts/aso/frame_screenshot.py b/Scripts/aso/frame_screenshot.py index 54b14bc..55a1f1d 100644 --- a/Scripts/aso/frame_screenshot.py +++ b/Scripts/aso/frame_screenshot.py @@ -23,6 +23,13 @@ DEFAULT_GRAD_TOP = "#0B5FFF" DEFAULT_GRAD_BOTTOM = "#00C2A8" HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b") +# --- Dark premium / fintech theme ----------------------------------------- +DARK_GRAD_TOP = "#0F172A" # slate-900 +DARK_GRAD_BOTTOM = "#020617" # near black +HEADLINE_COLOR = (248, 250, 252) # #F8FAFC near-white +ACCENT_COLOR = (52, 211, 153) # #34D399 vibrant green +MUTED_COLOR = (148, 163, 184) # #94A3B8 muted slate + FONT_CANDIDATES = ( # CJK-capable macOS fonts first. @@ -38,6 +45,24 @@ FONT_CANDIDATES = ( "/System/Library/Fonts/SFNS.ttf", ) +# Bold-weight candidates for headline / stat callout. +BOLD_FONT_CANDIDATES = ( + "/System/Library/Fonts/ヒラギノ角ゴシック W7.ttc", + "/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc", + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/SFNS.ttf", + "/System/Library/Fonts/Supplemental/Arial.ttf", +) + +# Regular-weight candidates for secondary / substat text. +REGULAR_FONT_CANDIDATES = ( + "/System/Library/Fonts/ヒラギノ角ゴシック W4.ttc", + "/System/Library/Fonts/ヒラギノ角ゴシック W5.ttc", + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/System/Library/Fonts/SFNS.ttf", + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", +) + def parse_hex_color(value: str) -> tuple[int, int, int]: """Return an RGB tuple from #RRGGBB.""" @@ -140,8 +165,11 @@ def make_vertical_gradient(size: tuple[int, int], top: str, bottom: str) -> Imag return gradient.convert("RGBA") -def load_font(size: int) -> ImageFont.ImageFont: - for font_path in FONT_CANDIDATES: +def load_font( + size: int, + candidates: Sequence[str] = FONT_CANDIDATES, +) -> ImageFont.ImageFont: + for font_path in candidates: path = Path(font_path) if not path.exists(): continue @@ -158,6 +186,14 @@ def load_font(size: int) -> ImageFont.ImageFont: return ImageFont.load_default() +def load_bold_font(size: int) -> ImageFont.ImageFont: + return load_font(size, BOLD_FONT_CANDIDATES) + + +def load_regular_font(size: int) -> ImageFont.ImageFont: + return load_font(size, REGULAR_FONT_CANDIDATES) + + def text_bbox(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]: if not text: return 0, 0 @@ -244,19 +280,21 @@ def fit_headline( max_width: int, max_height: int, draw: ImageDraw.ImageDraw, + max_scale: float = 0.052, + font_loader=load_font, ) -> tuple[str, ImageFont.ImageFont, int, int]: _, height = canvas_size - max_size = max(34, round(height * 0.052)) + max_size = max(34, round(height * max_scale)) min_size = 28 for size in range(max_size, min_size - 1, -2): - font = load_font(size) + font = font_loader(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) + font = font_loader(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 @@ -276,12 +314,32 @@ def paste_with_shadow( screenshot: Image.Image, position: tuple[int, int], radius: int, + glow: bool = False, ) -> 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 + if glow: + # Soft light halo so the device lifts off the dark background. + halo_blur = max(28, round(canvas.width * 0.05)) + halo_pad = max(24, round(canvas.width * 0.03)) + halo_size = (screenshot.width + halo_pad * 2, screenshot.height + halo_pad * 2) + halo = Image.new("RGBA", halo_size, (0, 0, 0, 0)) + halo_mask = Image.new("L", halo_size, 0) + ImageDraw.Draw(halo_mask).rounded_rectangle( + (halo_pad, halo_pad, halo_pad + screenshot.width, halo_pad + screenshot.height), + radius=radius, + fill=70, + ) + halo.putalpha(halo_mask) + # Tint the halo toward a cool light so it reads as premium elevation. + halo_rgb = Image.new("RGBA", halo_size, (148, 197, 255, 0)) + halo_rgb.putalpha(halo.getchannel("A")) + halo_rgb = halo_rgb.filter(ImageFilter.GaussianBlur(halo_blur)) + canvas.alpha_composite(halo_rgb, (x - halo_pad, y - halo_pad)) + 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) @@ -292,9 +350,17 @@ def paste_with_shadow( 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) +def resize_screenshot( + image: Image.Image, + canvas_width: int, + canvas_height: int, + width_frac: float = 0.78, + max_height_px: int | None = None, +) -> Image.Image: + target_width = round(canvas_width * width_frac) target_height = round(canvas_height * 0.72) + if max_height_px is not None: + target_height = min(target_height, max_height_px) 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: @@ -311,8 +377,18 @@ def frame_screenshot( grad_top: str, grad_bottom: str, device: str | None = None, + theme: str = "light", + stat: str | None = None, + substat: str | None = None, ) -> None: del device # Reserved for future per-device tuning. + if theme == "dark": + frame_screenshot_dark( + input_path, output_path, width, height, + headline, grad_top, grad_bottom, stat, substat, + ) + return + canvas = make_vertical_gradient((width, height), grad_top, grad_bottom) draw = ImageDraw.Draw(canvas) @@ -353,6 +429,133 @@ def frame_screenshot( canvas.convert("RGB").save(output_path, "PNG") +def frame_screenshot_dark( + input_path: Path, + output_path: Path, + width: int, + height: int, + headline: str, + grad_top: str, + grad_bottom: str, + stat: str | None, + substat: str | None, +) -> None: + """Dark premium / fintech framing: near-black gradient, near-white bold + headline, optional big green stat callout, and a device shot lifted off the + background with a subtle light glow.""" + 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.055) + max_text_width = width - horizontal_margin * 2 + line_spacing = max(8, round(height * 0.006)) + + # --- Headline: bold, near-white, slightly tighter/smaller than the light + # theme so it reads premium rather than shouty. + headline_area_height = round(height * 0.11) + wrapped, headline_font, _, headline_h = fit_headline( + headline, + (width, height), + max_text_width, + headline_area_height, + draw, + max_scale=0.044, + font_loader=load_bold_font, + ) + + cursor_y = top_margin + draw.multiline_text( + (width // 2, cursor_y), + wrapped, + font=headline_font, + fill=HEADLINE_COLOR + (255,), + anchor="ma", + align="center", + spacing=line_spacing, + ) + cursor_y += headline_h + + # --- Optional stat callout: big vibrant-green number + muted secondary. + if stat: + cursor_y += round(height * 0.022) + stat_size = max(48, round(height * 0.062)) + stat_font = load_bold_font(stat_size) + # Shrink to fit width if the number is long. + while stat_size > 40: + if text_bbox(draw, stat, stat_font)[0] <= max_text_width: + break + stat_size -= 2 + stat_font = load_bold_font(stat_size) + _, stat_h = text_bbox(draw, stat, stat_font) + draw.text( + (width // 2, cursor_y), + stat, + font=stat_font, + fill=ACCENT_COLOR + (255,), + anchor="ma", + ) + cursor_y += stat_h + + if substat: + cursor_y += round(height * 0.010) + sub_size = max(26, round(height * 0.026)) + sub_font = load_regular_font(sub_size) + sub_wrapped = wrap_headline(substat, sub_font, max_text_width, draw) + _, sub_h = text_bbox(draw, sub_wrapped, sub_font) + # A leading "▲" (or up-arrow) is a positive signal -> green. + sub_color = ACCENT_COLOR if substat.strip().startswith(("▲", "↑", "+")) else MUTED_COLOR + draw.multiline_text( + (width // 2, cursor_y), + sub_wrapped, + font=sub_font, + fill=sub_color + (255,), + anchor="ma", + align="center", + spacing=line_spacing, + ) + cursor_y += sub_h + elif substat: + # substat without a big stat -> supporting line. Positive-signal + # prefixes (▲ ↑ +) get the green accent; otherwise muted slate. + cursor_y += round(height * 0.014) + sub_size = max(26, round(height * 0.030)) + sub_font = load_bold_font(sub_size) + sub_wrapped = wrap_headline(substat, sub_font, max_text_width, draw) + _, sub_h = text_bbox(draw, sub_wrapped, sub_font) + sub_color = ACCENT_COLOR if substat.strip().startswith(("▲", "↑", "+")) else MUTED_COLOR + draw.multiline_text( + (width // 2, cursor_y), + sub_wrapped, + font=sub_font, + fill=sub_color + (255,), + anchor="ma", + align="center", + spacing=line_spacing, + ) + cursor_y += sub_h + + # --- Device shot below the text block, with glow + shadow. + # The device top is pinned just under the text block; it is scaled down so + # it always fits the remaining space and never overlaps the copy. + screenshot_y = cursor_y + round(height * 0.05) + available_bottom = height - round(height * 0.045) + max_device_height = available_bottom - screenshot_y + screenshot = resize_screenshot( + Image.open(input_path), width, height, max_height_px=max_device_height + ) + screenshot_x = (width - screenshot.width) // 2 + # Center the device within the space left below the text for balance. + slack = max(0, (available_bottom - screenshot_y) - screenshot.height) + screenshot_y += slack // 2 + + radius = max(24, round(width * 0.03)) + paste_with_shadow(canvas, screenshot, (screenshot_x, screenshot_y), radius, glow=True) + + 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: @@ -378,8 +581,28 @@ def build_parser() -> argparse.ArgumentParser: 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( + "--grad-top", + help="Top gradient color as #RRGGBB (defaults per theme).", + ) + parser.add_argument( + "--grad-bottom", + help="Bottom gradient color as #RRGGBB (defaults per theme).", + ) + parser.add_argument( + "--theme", + choices=("light", "dark"), + default="light", + help="Framing theme. 'dark' = dark premium / fintech look.", + ) + parser.add_argument( + "--stat", + help="Optional big accent stat callout, e.g. '€717,382' (dark theme).", + ) + parser.add_argument( + "--substat", + help="Optional smaller secondary line, e.g. '▲ +9.4% since last check-in'.", + ) parser.add_argument("--device", choices=("iphone", "ipad"), help="Optional device family hint.") return parser @@ -388,8 +611,15 @@ 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) + if args.theme == "dark": + grad_top = args.grad_top or DARK_GRAD_TOP + grad_bottom = args.grad_bottom or DARK_GRAD_BOTTOM + else: + grad_top = args.grad_top or DEFAULT_GRAD_TOP + grad_bottom = args.grad_bottom or DEFAULT_GRAD_BOTTOM + + parse_hex_color(grad_top) + parse_hex_color(grad_bottom) print_brand_colors(find_repo_root(Path(__file__).resolve())) frame_screenshot( @@ -398,9 +628,12 @@ def main(argv: Sequence[str] | None = None) -> int: width=args.width, height=args.height, headline=args.headline, - grad_top=args.grad_top, - grad_bottom=args.grad_bottom, + grad_top=grad_top, + grad_bottom=grad_bottom, device=args.device, + theme=args.theme, + stat=args.stat, + substat=args.substat, ) print(f"Wrote {args.output} ({args.width}x{args.height})") return 0