Files
InvestmentTrackerApp/Scripts/aso/frame_panorama.py
T
alexandrev-tibco 19e80c912b ASO tooling: compositores de marketing + captura UITest
- testCaptureScreenshots en PortfolioJournalUITests: captura tabs con datos demo,
  idioma vía SCREENSHOT_LANG (usa el modo --screenshots del commit siguiente)
- Scripts/aso: compositores de screenshots de App Store (frame_layout con
  perspectiva 3D + panorama multi-panel, variantes premium/tilt), layouts JSON
  iPhone/iPad, y asc.py (helper API App Store Connect para estado/screenshots)
- Fix test roto pre-existente: OnboardingQuickStartView.appSettingsURL restaurado

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015qUZrBusG82T37R7PeokqJ
2026-07-02 22:12:31 +02:00

239 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""Connected "panorama" App Store screenshots (modern style).
Composes a single wide canvas spanning N panels with a light modern background,
angled iPhone mockups that overlap and cross panel boundaries, and per-panel
headlines — then slices it into individual App Store slots so that, swiped in the
gallery, they read as one continuous scene (à la NetWorth Pro 2).
Requirements: pip install Pillow
Example:
python frame_panorama.py \
--inputs a.png,b.png,c.png \
--headlines "Watch your net worth grow||Log it once a month||Charts that reveal more" \
--panel-width 1320 --height 2868 \
--out-dir out --out-prefix iphone --preview out/_wide.png
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HEADLINE_FONTS = [
"/System/Library/Fonts/SFNSDisplay.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/System/Library/Fonts/Supplemental/HelveticaNeue.ttc",
"/Library/Fonts/Arial Unicode.ttf",
]
CJK_FONTS = [
"/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/PingFang.ttc",
]
def _cjk(s):
return any(ord(c) > 0x2E00 for c in s)
def font(size, text):
for p in (CJK_FONTS if _cjk(text) else []) + HEADLINE_FONTS:
if Path(p).exists():
try:
return ImageFont.truetype(p, size)
except Exception:
pass
return ImageFont.load_default()
def hexrgb(h):
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def lerp(a, b, t):
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
def rounded_mask(size, radius):
m = Image.new("L", size, 0)
ImageDraw.Draw(m).rounded_rectangle([0, 0, size[0], size[1]], radius=radius, fill=255)
return m
def build_device(shot: Image.Image, target_w: int) -> Image.Image:
"""iPhone mockup (RGBA): metal bezel + rounded screen + Dynamic Island."""
bezel = max(10, int(target_w * 0.030))
sw = target_w - bezel * 2
sh = int(sw * shot.height / shot.width)
screen = shot.resize((sw, sh), Image.LANCZOS).convert("RGB")
scr_r = int(target_w * 0.12)
dev_r = scr_r + bezel
W, H = target_w, sh + bezel * 2
dev = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(dev)
d.rounded_rectangle([0, 0, W, H], radius=dev_r, fill=(20, 20, 22, 255))
d.rounded_rectangle([2, 2, W - 2, H - 2], radius=dev_r, outline=(70, 72, 78, 255), width=3)
dev.paste(screen, (bezel, bezel), rounded_mask((sw, sh), scr_r))
di_w, di_h = int(sw * 0.30), int(target_w * 0.034)
dix = bezel + (sw - di_w) // 2
diy = bezel + int(target_w * 0.024)
ImageDraw.Draw(dev).rounded_rectangle([dix, diy, dix + di_w, diy + di_h],
radius=di_h // 2, fill=(0, 0, 0, 255))
return dev
def with_shadow(rgba: Image.Image, blur: int, alpha: float, off=(0, 40)) -> Image.Image:
pad = blur * 3
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))
dark.putalpha(sh.split()[3])
canvas.alpha_composite(dark, (pad + off[0], pad + off[1]))
canvas = canvas.filter(ImageFilter.GaussianBlur(blur))
canvas.alpha_composite(rgba, (pad, pad))
return canvas
def _blob(W, H, cx, cy, r, a):
m = Image.new("L", (W, H), 0)
ImageDraw.Draw(m).ellipse([cx - r, cy - r, cx + r, cy + r], fill=a)
return m.filter(ImageFilter.GaussianBlur(int(r * 0.6)))
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)
# fast vertical gradient: build a 1px-wide column then stretch to full width
col = Image.new("RGB", (1, H))
cp = col.load()
for y in range(H):
cp[0, y] = lerp(top, bottom, y / max(1, H - 1))
base = col.resize((W, H)).convert("RGBA")
pw = W / max(1, n)
for i in range(n):
col = accent if i % 2 == 0 else accent2
cx = pw * (i + 0.5)
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)))
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)))
return base
def wrap(draw, text, fnt, maxw):
words, lines, cur = text.split(), [], ""
for w in words:
t = (cur + " " + w).strip()
if draw.textlength(t, font=fnt) <= maxw or not cur:
cur = t
else:
lines.append(cur); cur = w
if cur:
lines.append(cur)
return lines
def draw_headline(canvas, text, cx, top_y, panel_w, color, max_lines=2):
"""Draw a centered headline, auto-shrinking the font until it fits in
`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)
fnt = font(size, text)
lines = wrap(d, text, fnt, maxw)
while size > int(panel_w * 0.042):
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
asc, desc = fnt.getmetrics()
lh = int((asc + desc) * 1.06)
for i, ln in enumerate(lines):
w = d.textlength(ln, font=fnt)
d.text((cx - w / 2, top_y + i * lh), ln, font=fnt, fill=color)
return top_y + len(lines) * lh
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--inputs", required=True, help="comma-separated screenshot paths")
ap.add_argument("--headlines", required=True, help="|| separated, one per panel")
ap.add_argument("--sublines", default="", help="|| separated, one per panel")
ap.add_argument("--panel-width", type=int, required=True)
ap.add_argument("--height", type=int, required=True)
ap.add_argument("--out-dir", required=True)
ap.add_argument("--out-prefix", default="iphone")
ap.add_argument("--accent", default="#3B82F6")
ap.add_argument("--accent2", default="#10B981")
ap.add_argument("--device-scale", type=float, default=0.86)
ap.add_argument("--preview", default="")
a = ap.parse_args()
inputs = [s for s in a.inputs.split(",") if s]
headlines = a.headlines.split("||")
sublines = a.sublines.split("||") if a.sublines else [""] * len(inputs)
n = len(inputs)
assert len(headlines) == n, "headlines count must match inputs"
W, H = a.panel_width, a.height
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)
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]
for i, (path, head) in enumerate(zip(inputs, headlines)):
panel_cx = int(W * i + W * 0.5)
hy = draw_headline(canvas, head, panel_cx, int(H * 0.052), W, text_color)
sub = sublines[i] if i < len(sublines) else ""
if sub.strip():
d = ImageDraw.Draw(canvas)
sf = font(int(W * 0.034), sub)
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)
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)
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))
ang = angles[i % len(angles)]
dev = dev.rotate(ang, expand=True, resample=Image.BICUBIC)
# nudge horizontally so adjacent devices overlap across the panel border
nudge = int(W * (0.15 if i % 2 == 0 else -0.15))
dx = panel_cx + nudge - dev.width // 2
dy = int(H * (0.315 + y_off[i % len(y_off)]))
if dy + dev.height > int(H * 1.04):
dy = int(H * 1.04) - dev.height
canvas.alpha_composite(dev, (dx, dy))
out = canvas.convert("RGB")
outdir = Path(a.out_dir); outdir.mkdir(parents=True, exist_ok=True)
for i in range(n):
panel = out.crop((i * W, 0, (i + 1) * W, H))
panel.save(outdir / f"{a.out_prefix}_{i+1:02d}.png")
if a.preview:
Path(a.preview).parent.mkdir(parents=True, exist_ok=True)
scale = min(1.0, 1600 / out.width)
out.resize((int(out.width * scale), int(out.height * scale)), Image.LANCZOS).save(a.preview)
print(f"OK {n} panels -> {outdir} (each {W}x{H})")
if __name__ == "__main__":
main()