Files
InvestmentTrackerApp/Scripts/aso/frame_panorama_codex.py
T
alexandrev-tibco 20a73ea957 ASO: renderers dark-panorama (iPhone + iPad) para screenshots 1.5.0
frame_panorama_codex.py / _ipad.py: panorámica premium oscura (Impact, marco real,
+22.7% verde, línea continua) con copy localizado nativo en 7 idiomas. Usado para
las screenshots publicadas de la 1.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-25 11:47:52 +02:00

523 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, dun 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 docchio.",
},
{
"eyebrow": "PATRIMONIO REALE. SLANCIO REALE.",
"headline": ("GUARDALO", "CRESCERE."),
"stat": "+22.7%",
"body": "Dallinizio — 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}")