#!/usr/bin/env python3 # Author: cirakg & claude ai # Summary: Builds the Connect IQ Store listing images (round screens, cover, hero banner) into docs/store/. """Store listing assets from the fr70 golden screenshots and the launcher icon. Run from anywhere: python3 tools/store_assets.py. Needs Pillow only. The launcher icon is 70 px at most, so the lock motif is redrawn here. """ from __future__ import annotations from pathlib import Path from PIL import Image, ImageDraw, ImageFont from PIL.PngImagePlugin import PngInfo ROOT = Path(__file__).resolve().parent.parent GOLDEN = ROOT / "test" / "golden" / "fr70" OUT = ROOT / "docs" / "store" TEAL = (0, 160, 160) ICON_TEAL = (20, 130, 140) WHITE = (255, 255, 255) SS = 4 # supersampling factor SCREENS = [ ("1-list", "list"), ("2-otp", "otp"), ("3-qr", "code_qr"), ("4-glance", "glance_code"), ("5-pin", "pin"), ("6-pdf417", "code_pdf417"), ("7-import", "import_results"), ] FONT_BOLD = [ "/usr/share/fonts/TTF/Roboto-Bold.ttf", "/usr/share/fonts/roboto/Roboto-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", ] FONT_REG = [ "/usr/share/fonts/TTF/Roboto-Regular.ttf", "/usr/share/fonts/roboto/Roboto-Regular.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf", "/usr/share/fonts/dejavu/DejaVuSans.ttf", ] def font(paths: list[str], size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: for p in paths: if Path(p).exists(): return ImageFont.truetype(p, size) return ImageFont.load_default(size) def round_mask(size: int) -> Image.Image: m = Image.new("L", (size * SS, size * SS), 0) ImageDraw.Draw(m).ellipse((0, 0, size * SS - 1, size * SS - 1), fill=255) return m.resize((size, size), Image.Resampling.LANCZOS) def round_screen(name: str) -> Image.Image: im = Image.open(GOLDEN / f"{name}.png").convert("RGBA") im.putalpha(round_mask(im.width)) return im def lock_icon(size: int) -> Image.Image: """The launcher motif (teal disc, white padlock with three dots), redrawn.""" s = size * SS im = Image.new("RGBA", (s, s), (0, 0, 0, 0)) d = ImageDraw.Draw(im) u = s / 70.0 d.ellipse((0, 0, s - 1, s - 1), fill=ICON_TEAL) # shackle d.rounded_rectangle((24 * u, 12 * u, 46 * u, 40 * u), radius=11 * u, fill=WHITE) d.rounded_rectangle((29 * u, 18 * u, 41 * u, 44 * u), radius=6 * u, fill=ICON_TEAL) # body d.rounded_rectangle((17 * u, 31 * u, 53 * u, 57 * u), radius=4 * u, fill=WHITE) for cx in (26, 35, 44): d.ellipse((cx * u - 2.4 * u, 44 * u - 2.4 * u, cx * u + 2.4 * u, 44 * u + 2.4 * u), fill=ICON_TEAL) return im.resize((size, size), Image.Resampling.LANCZOS) Font = ImageFont.FreeTypeFont | ImageFont.ImageFont def centered_text(d: ImageDraw.ImageDraw, at: tuple[int, int], text: str, f: Font, fill: tuple[int, int, int]) -> None: cx, y = at w = d.textlength(text, font=f) d.text((cx - w / 2, y), text, font=f, fill=fill) def cover() -> Image.Image: im = Image.new("RGB", (500, 500), (0, 0, 0)) d = ImageDraw.Draw(im) icon = lock_icon(300) im.paste(icon, (100, 60), icon) centered_text(d, (250, 392), "OTP Wallet", font(FONT_BOLD, 56), WHITE) d.rounded_rectangle((210, 466, 290, 471), radius=2, fill=TEAL) return im def bezel_watch(name: str, size: int) -> Image.Image: ring = 14 total = size + 2 * ring s = total * SS out = Image.new("RGBA", (s, s), (0, 0, 0, 0)) d = ImageDraw.Draw(out) d.ellipse((0, 0, s - 1, s - 1), fill=(58, 62, 68)) d.ellipse((4 * SS, 4 * SS, s - 4 * SS - 1, s - 4 * SS - 1), fill=(22, 24, 28)) out = out.resize((total, total), Image.Resampling.LANCZOS) scr = round_screen(name).resize((size, size), Image.Resampling.LANCZOS) out.paste(scr, (ring, ring), scr) return out def hero() -> Image.Image: w, h = 1440, 720 # Diagonal gradient, slightly teal toward the bottom right. grad = Image.new("RGB", (w, h)) px = grad.load() assert px is not None for y in range(h): for x in range(w): t = x / w * 0.6 + y / h * 0.4 px[x, y] = (int(8 + 4 * t), int(12 + 30 * t), int(16 + 34 * t)) im = grad.convert("RGBA") d = ImageDraw.Draw(im) # text, left x0 = 100 d.text((x0, 150), "OTP Wallet", font=font(FONT_BOLD, 96), fill=WHITE) d.rounded_rectangle((x0, 268, x0 + 120, 274), radius=3, fill=TEAL) d.text((x0, 300), "One-time codes and barcodes", font=font(FONT_REG, 38), fill=(220, 230, 232)) d.text((x0, 346), "on your wrist", font=font(FONT_REG, 38), fill=(220, 230, 232)) bf = font(FONT_REG, 32) for i, line in enumerate( ["TOTP, HOTP and Steam Guard", "QR, PDF417, Aztec and more barcodes", "Encrypted on the watch. No cloud."] ): y = 440 + i * 60 d.ellipse((x0, y + 12, x0 + 16, y + 28), fill=TEAL) d.text((x0 + 36, y), line, font=bf, fill=WHITE) # watches, right: back-left list, back-right qr, front otp for name, size, cx, cy in (("list", 290, 930, 400), ("code_qr", 290, 1215, 330), ("otp", 360, 1075, 440)): wt = bezel_watch(name, size) shadow = Image.new("RGBA", wt.size, (0, 0, 0, 0)) ImageDraw.Draw(shadow).ellipse((0, 0, wt.width - 1, wt.height - 1), fill=(0, 0, 0, 110)) im.alpha_composite(shadow, (cx - wt.width // 2 + 8, cy - wt.height // 2 + 12)) im.alpha_composite(wt, (cx - wt.width // 2, cy - wt.height // 2)) return im.convert("RGB") def save_png(img: Image.Image, path: Path, description: str) -> None: # Every PNG in the repo carries Author and Description chunks (lint HDR001). info = PngInfo() info.add_text("Author", "cirakg & claude ai") info.add_text("Description", description) img.save(path, format="PNG", optimize=True, pnginfo=info) def main() -> None: OUT.mkdir(parents=True, exist_ok=True) for tag, name in SCREENS: description = f"Connect IQ Store screen image: {name} on the Forerunner 70." save_png(round_screen(name), OUT / f"screen-{tag}.png", description) save_png(cover(), OUT / "cover-500.png", "Connect IQ Store cover image, 500 x 500.") save_png(hero(), OUT / "hero-1440x720.png", "Connect IQ Store hero image for mobile, 1440 x 720.") for p in sorted(OUT.glob("*.png")): with Image.open(p) as im: print(f"{p.name}: {im.size[0]}x{im.size[1]} {p.stat().st_size} bytes") if __name__ == "__main__": main()