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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
+246
-13
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user