#!/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}")