diff --git a/tools/demo/storeshots.py b/tools/demo/storeshots.py index 410bdbb87..4d243ea23 100644 --- a/tools/demo/storeshots.py +++ b/tools/demo/storeshots.py @@ -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) diff --git a/tools/demo/storeshots_ipad.py b/tools/demo/storeshots_ipad.py new file mode 100644 index 000000000..d0e29f080 --- /dev/null +++ b/tools/demo/storeshots_ipad.py @@ -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() diff --git a/tools/demo/storeshots_play.py b/tools/demo/storeshots_play.py new file mode 100644 index 000000000..13965e500 --- /dev/null +++ b/tools/demo/storeshots_play.py @@ -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()