summaryrefslogtreecommitdiff
path: root/tools/store_assets.py
diff options
context:
space:
mode:
Diffstat (limited to 'tools/store_assets.py')
-rw-r--r--tools/store_assets.py177
1 files changed, 177 insertions, 0 deletions
diff --git a/tools/store_assets.py b/tools/store_assets.py
new file mode 100644
index 0000000..be7ae9a
--- /dev/null
+++ b/tools/store_assets.py
@@ -0,0 +1,177 @@
+#!/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()