mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
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:
+29
-19
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user