20a73ea957
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
432 lines
13 KiB
Python
432 lines
13 KiB
Python
#!/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()
|