Files
hash-wallet/tools/demo/storeshots_ipad.py
T
jwinterm 852f8d536d tools(demo): store-screenshot composers (App Store, 13" iPad, Play 2:1)
storeshots.py (website-dark theme), storeshots_ipad.py (2048x2732), and
storeshots_play.py (1290x2580, Play's 2:1 cap). Compose the funded demo captures
onto the Hash Bags brand background for the store listings.
2026-08-07 03:31:55 -04:00

147 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""
storeshots_ipad.py -- 13-inch iPad App Store screenshots (2048x2732).
App Store Connect requires at least one 13" iPad screenshot when the app
declares iPad support. This composes the same funded phone captures onto the
website-dark brand background at iPad dimensions, so the iPad set matches the
phone set (tools/demo/storeshots.py) visually.
2048x2732 is the classic 12.9"/13" iPad Pro portrait size; App Store Connect
accepts it for the "13-inch iPad" slot (2064x2752 is also accepted).
Usage:
python3 tools/demo/storeshots_ipad.py \
--srcdir ~/Build/hash-wallet/demo/screenshots-funded \
--outdir ~/Build/hash-wallet/demo/appstore-ipad
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
W, H = 2048, 2732
FONT_DIR = "/usr/share/fonts/opentype/inter"
F_HEAD = f"{FONT_DIR}/InterDisplay-Bold.otf"
F_SUB = f"{FONT_DIR}/Inter-SemiBold.otf"
CROP_FULLSCREEN = 66
CROP_MODAL = 128
# Same copy + accents as the phone set.
SHOTS = [
("01-dashboard.png",
[("Your bags.", False), ("Your keys.", True)],
"Wownero · Monero · Bitcoin & more", CROP_FULLSCREEN),
("02-receive.png",
[("Get paid", False), ("in a tap.", True)],
"Scan, share, done", CROP_MODAL),
("03-send.png",
[("Send anywhere,", False), ("ask no one.", True)],
"Non-custodial by design", CROP_MODAL),
("04-swap.png",
[("Swap coins,", False), ("skip the signup.", True)],
"No account required", CROP_MODAL),
]
FOOTER = "Open-source · Non-custodial"
WEB_BG = "#0a0b0c"
WEB_BG_EDGE = "#050708"
WEB_GLOW = "#1f7d4d"
WEB_INK = "#e8efe9"
WEB_MINT = "#86d3a6"
WEB_GREY = "#9aa3a0"
def rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
def gradient_bg() -> Image.Image:
bg = Image.new("RGB", (W, H), rgb(WEB_BG))
vig = Image.new("L", (W, H), 0)
ImageDraw.Draw(vig).ellipse([-W * 0.25, -H * 0.15, W * 1.25, H * 1.05], fill=255)
vig = vig.filter(ImageFilter.GaussianBlur(300))
bg = Image.composite(bg, Image.new("RGB", (W, H), rgb(WEB_BG_EDGE)), vig)
glow = Image.new("L", (W, H), 0)
cx, cy, r = int(W * 0.5), int(H * 0.55), int(W * 0.7)
ImageDraw.Draw(glow).ellipse([cx - r, cy - r, cx + r, cy + r], fill=44)
glow = glow.filter(ImageFilter.GaussianBlur(320))
return Image.composite(Image.new("RGB", (W, H), rgb(WEB_GLOW)), bg, glow)
def rounded(im: Image.Image, rad: int) -> Image.Image:
mask = Image.new("L", im.size, 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, im.size[0], im.size[1]], rad, fill=255)
out = Image.new("RGBA", im.size, (0, 0, 0, 0))
out.paste(im, (0, 0), mask)
return out
def build(srcdir: Path, src: str, headline, subhead: str, top_crop: int, out: Path) -> None:
bg = gradient_bg().convert("RGBA")
d = ImageDraw.Draw(bg)
fh = ImageFont.truetype(F_HEAD, 150)
y = 200
for text, accent in headline:
col = rgb(WEB_MINT) if accent else rgb(WEB_INK)
w = d.textlength(text, font=fh)
d.text(((W - w) / 2, y), text, font=fh, fill=col)
y += 170
fs = ImageFont.truetype(F_SUB, 62)
w = d.textlength(subhead, font=fs)
d.text(((W - w) / 2, y + 20), subhead, font=fs, fill=rgb(WEB_GREY))
im = Image.open(srcdir / src).convert("RGB")
im = im.crop((0, top_crop, im.width, im.height))
sw = 1000
sh = int(im.height * sw / im.width)
im = im.resize((sw, sh), Image.LANCZOS)
im = rounded(im, 60)
ImageDraw.Draw(im).rounded_rectangle([1, 1, sw - 2, sh - 2], 60,
outline=rgb(WEB_MINT) + (235,), width=3)
px_x = (W - sw) // 2
px_y = 760
shadow = Image.new("RGBA", (sw + 160, sh + 160), (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle([80, 80, sw + 80, sh + 80], 90, fill=(0, 0, 0, 150))
shadow = shadow.filter(ImageFilter.GaussianBlur(60))
sh_layer = Image.new("RGBA", bg.size, (0, 0, 0, 0))
sh_layer.paste(shadow, (px_x - 80, px_y - 56), shadow)
bg = Image.alpha_composite(bg, sh_layer)
bg.paste(im, (px_x, px_y), im)
d = ImageDraw.Draw(bg)
w = d.textlength(FOOTER, font=fs)
d.text(((W - w) / 2, H - 150), FOOTER, font=fs, fill=rgb(WEB_MINT))
out.parent.mkdir(parents=True, exist_ok=True)
bg.convert("RGB").save(out)
print(f"wrote {out} ({W}x{H})")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--srcdir", required=True)
ap.add_argument("--outdir", required=True)
args = ap.parse_args()
srcdir = Path(os.path.expanduser(args.srcdir))
outdir = Path(os.path.expanduser(args.outdir))
for i, (src, headline, subhead, top_crop) in enumerate(SHOTS, 1):
if not (srcdir / src).exists():
print(f"skip {src}: not found")
continue
build(srcdir, src, headline, subhead, top_crop, outdir / f"appstore-ipad-{i:02d}.png")
if __name__ == "__main__":
main()