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.
This commit is contained in:
jwinterm
2026-08-07 03:31:55 -04:00
parent 4818cdf017
commit 852f8d536d
3 changed files with 316 additions and 19 deletions
+29 -19
View File
@@ -71,24 +71,34 @@ def rgb(h: str) -> tuple[int, int, int]:
return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
# Website (hash.boats) theme: near-black background, mint-green accents, a soft
# green glow. Overrides the app pack's dark-green so all marketing (icon,
# feature graphic, screenshots) reads as one system with the site.
WEB_BG = "#0a0b0c" # near-black
WEB_BG_EDGE = "#050708" # darker vignette edge
WEB_GLOW = "#1f7d4d" # green glow
WEB_INK = "#e8efe9" # headline line 1
WEB_MINT = "#86d3a6" # headline accent line (matches site wordmark)
WEB_GREY = "#9aa3a0" # subhead
WEB_FOOT = "#6f7a75" # footer
def gradient_bg() -> Image.Image:
"""Vertical brand gradient + a soft accent glow top-right (matches the
Play feature graphic)."""
top, bot = rgb(PAL["bg"]), rgb(PAL["bg_hi"])
bg = Image.new("RGB", (W, H))
px = bg.load()
for y in range(H):
t = y / (H - 1)
row = tuple(int(top[i] + (bot[i] - top[i]) * t) for i in range(3))
for x in range(W):
px[x, y] = row
"""Near-black website background with a soft vignette and a green glow
behind where the phone sits."""
bg = Image.new("RGB", (W, H), rgb(WEB_BG))
# vignette: darken toward the edges
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(240))
bg = Image.composite(bg, Image.new("RGB", (W, H), rgb(WEB_BG_EDGE)), vig)
# soft green glow centered where the framed phone will sit
glow = Image.new("L", (W, H), 0)
gd = ImageDraw.Draw(glow)
cx, cy, r = int(W * 0.80), int(H * 0.15), int(W * 0.55)
gd.ellipse([cx - r, cy - r, cx + r, cy + r], fill=70)
glow = glow.filter(ImageFilter.GaussianBlur(190))
acc = Image.new("RGB", (W, H), rgb(PAL["accent"]))
return Image.composite(acc, bg, glow)
cx, cy, r = int(W * 0.5), int(H * 0.52), int(W * 0.62)
ImageDraw.Draw(glow).ellipse([cx - r, cy - r, cx + r, cy + r], fill=48)
glow = glow.filter(ImageFilter.GaussianBlur(260))
return Image.composite(Image.new("RGB", (W, H), rgb(WEB_GLOW)), bg, glow)
def rounded(im: Image.Image, rad: int) -> Image.Image:
@@ -109,7 +119,7 @@ def build(srcdir: Path, src: str, headline, subhead: str, top_crop: int,
fh = ImageFont.truetype(F_HEAD, 112)
y = 170
for text, accent in headline:
col = rgb(PAL["accent_hi"]) if accent else rgb(PAL["ink"])
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 += 128
@@ -117,7 +127,7 @@ def build(srcdir: Path, src: str, headline, subhead: str, top_crop: int,
# ---- subhead ----
fs = ImageFont.truetype(F_SUB, 46)
w = d.textlength(subhead, font=fs)
d.text(((W - w) / 2, y + 22), subhead, font=fs, fill=rgb(PAL["muted"]))
d.text(((W - w) / 2, y + 22), subhead, font=fs, fill=rgb(WEB_GREY))
# ---- framed app screenshot ----
im = Image.open(srcdir / src).convert("RGB")
@@ -146,7 +156,7 @@ def build(srcdir: Path, src: str, headline, subhead: str, top_crop: int,
# ---- footer ----
d = ImageDraw.Draw(bg)
w = d.textlength(FOOTER, font=fs)
d.text(((W - w) / 2, H - 130), FOOTER, font=fs, fill=rgb(PAL["accent_hi"]))
d.text(((W - w) / 2, H - 130), FOOTER, font=fs, fill=rgb(WEB_MINT))
out.parent.mkdir(parents=True, exist_ok=True)
bg.convert("RGB").save(out)
+146
View File
@@ -0,0 +1,146 @@
#!/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()
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
storeshots_play.py -- Google Play phone screenshots (1290x2580, exactly 2:1).
Play caps phone screenshots at a 2:1 aspect ratio; the App Store set (1290x2796,
~2.17:1) is too tall. This composes the same funded captures onto the website-dark
brand background at a Play-legal 2:1 canvas, matching storeshots.py visually.
Usage:
python3 tools/demo/storeshots_play.py \
--srcdir ~/Build/hash-wallet/demo/screenshots-funded \
--outdir ~/Build/hash-wallet/demo/play-phone
"""
from __future__ import annotations
import argparse
import os
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageFilter
W, H = 1290, 2580 # exactly 2:1
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
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(230))
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.52), int(W * 0.62)
ImageDraw.Draw(glow).ellipse([cx - r, cy - r, cx + r, cy + r], fill=48)
glow = glow.filter(ImageFilter.GaussianBlur(250))
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, 108)
y = 150
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 += 124
fs = ImageFont.truetype(F_SUB, 44)
w = d.textlength(subhead, font=fs)
d.text(((W - w) / 2, y + 18), 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 = 980
sh = int(im.height * sw / im.width)
im = im.resize((sw, sh), Image.LANCZOS)
im = rounded(im, 52)
ImageDraw.Draw(im).rounded_rectangle([1, 1, sw - 2, sh - 2], 52,
outline=rgb(WEB_MINT) + (235,), width=3)
px_x = (W - sw) // 2
px_y = 600
shadow = Image.new("RGBA", (sw + 120, sh + 120), (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle([60, 60, sw + 60, sh + 60], 66, fill=(0, 0, 0, 150))
shadow = shadow.filter(ImageFilter.GaussianBlur(46))
sh_layer = Image.new("RGBA", bg.size, (0, 0, 0, 0))
sh_layer.paste(shadow, (px_x - 60, px_y - 40), 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 - 120), 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"play-{i:02d}.png")
if __name__ == "__main__":
main()