master
John Lauer hero v6: bridge-family layout, 242px title, crawl value prop (Hero Studio stamped); 1.3.3 59be9e9 1mo ago
#!/usr/bin/env python3
"""Compose the chip-fetcher hero in the Adom bridge-family style (Pup / KiCad / Fusion).

Same geometry as kicad-bridge's compose_hero.py (itself measured off Pup's
published asset): 2000x1250, Familjen Grotesk Bold title at 242px (cap height
155px, cap top y=263, ink left x=83), Satoshi subtitle at 52px, the ADOM
wordmark and the BRIDGE pill cropped verbatim from Pup's hero, the real app in
a 1030x1025 rounded vitrine at (920,150). The only family deviation: the title
is two lines ("Chip" / "Fetcher") because one word at 240px does not fit.

Usage:
    python3 compose_hero.py <dashboard-capture.png> <out.png>

The capture should be the REAL dashboard at 1030 wide. A 1030x910 in-container
capture gets Pup's Chrome titlebar strip stacked on top to make 1030x1025; a
1030x1025 real-window capture is used as is.
"""
import sys
from pathlib import Path

import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont

KB = Path("/home/adom/project/adom-desktop-kicad-bridge/skills")
PUP = KB / "kicad-bridge-hero" / "reference-pup-hero.png"
FONTS = KB / "kicad-tour" / "hero" / "fonts"
TITLEBAR = Path("/home/adom/project/pup-bridge/dev-skills/pup-hero-image/assets/win_slot3.png")

W, H = 2000, 1250
TITLE_LINES = ["Chip", "Fetcher"]
SUB_1A, SUB_1B = "Crawls the web for your chips, ", ""
SUB_2A, SUB_2B = "brings back ", "all the chip data."
TEAL = (0, 230, 220)

LOGO_CROP, LOGO_AT = (60, 48, 335, 130), (60, 48)
PILL_CROP, PILL_AT = (1796, 36, 1964, 108), (1796, 36)
INK_LEFT = 91   # the studio stamps the standard wordmark with its ink at x=91; title + subtitle align to it
CAP_TOP = 263
LINE_H = 245
SUB_Y1, SUB_Y2 = CAP_TOP + 2 * LINE_H + 0, CAP_TOP + 2 * LINE_H + 64   # below the second title line
VITRINE_AT, VITRINE_W, VITRINE_H = (920, 150), 1030, 1025
RADIUS, SHADOW_BLUR, SHADOW_DY = 24, 28, 12


def left_ink(im, y0, y1):
    a = np.array(im).astype(int)
    diff = np.abs(a - a[700, 400]).sum(axis=2) > 45
    for x in range(40, 900):
        if diff[y0:y1, x].sum() > 2:
            return x
    return None


def draw_pill(d, pup):
    """Same pill as the bridge family (position, size, fill sampled from Pup's
    asset), reading APP: Chip Fetcher is an app, not a bridge."""
    fill = (0, 184, 177)      # the Adom teal, #00b8b1 (Pup's pill measures (1,182,172))
    text = (5, 34, 31)        # --on-accent, #05221f
    x0, y0, x1, y1 = PILL_CROP
    w = 128
    x0 = x1 - w
    d.rounded_rectangle((x0, y0, x1 - 1, y1 - 1), radius=(y1 - y0) // 2, fill=fill)
    pf = ImageFont.truetype(str(FONTS / "FamiljenGrotesk-Bold.ttf"), 34)
    label = "APP"
    tw = d.textlength(label, font=pf)
    bb = pf.getbbox(label)
    d.text((x0 + (w - tw) / 2, y0 + ((y1 - y0) - (bb[3] - bb[1])) / 2 - bb[1]), label, font=pf, fill=text)


def draw_title(d, tf, dx):
    for i, line in enumerate(TITLE_LINES):
        y = CAP_TOP + i * LINE_H - tf.getbbox(line[0])[1]
        x = dx
        for ch in line:
            d.text((x, y), ch, font=tf, fill=(230, 237, 243))
            x += d.textlength(ch, font=tf) - 5


def compose(title_dx, sub_dx, pup, base, tf, sf):
    hero = base.copy()
    # wordmark: NOT drawn here; Hero Studio stamps the standard mark (overlay.logo=true, hero-studio#37)
    d = ImageDraw.Draw(hero)
    draw_pill(d, pup)
    draw_title(d, tf, title_dx)
    scol = (232, 238, 244)
    w = d.textlength(SUB_1A, font=sf)
    d.text((sub_dx, SUB_Y1), SUB_1A, font=sf, fill=scol)
    d.text((sub_dx + w, SUB_Y1), SUB_1B, font=sf, fill=scol)
    w = d.textlength(SUB_2A, font=sf)
    d.text((sub_dx, SUB_Y2), SUB_2A, font=sf, fill=scol)
    d.text((sub_dx + w, SUB_Y2), SUB_2B, font=sf, fill=TEAL)
    return hero


def main(capture_path, out_path):
    pup = Image.open(PUP).convert("RGB")
    pa = np.array(pup)
    tl, tr = pa[6, 6].astype(float), pa[6, W - 7].astype(float)
    bl, br = pa[H - 7, 6].astype(float), pa[H - 7, W - 7].astype(float)
    xs = np.linspace(0, 1, W)[None, :, None]
    ys = np.linspace(0, 1, H)[:, None, None]
    base = Image.fromarray((tl * (1 - xs) * (1 - ys) + tr * xs * (1 - ys)
                            + bl * (1 - xs) * ys + br * xs * ys).astype(np.uint8))
    tf = ImageFont.truetype(str(FONTS / "FamiljenGrotesk-Bold.ttf"), 242)
    sf = ImageFont.truetype(str(FONTS / "Satoshi-Regular.ttf"), 52)

    t_dx, s_dx = 98, 84
    im = compose(t_dx, s_dx, pup, base, tf, sf)
    t_dx += INK_LEFT - left_ink(im, CAP_TOP, CAP_TOP + 2 * LINE_H - 40)
    s_dx += INK_LEFT - left_ink(im, SUB_Y1 - 6, SUB_Y2 + 70)
    hero = compose(t_dx, s_dx, pup, base, tf, sf)
    ti = left_ink(hero, CAP_TOP, CAP_TOP + 2 * LINE_H - 40)
    su = left_ink(hero, SUB_Y1 - 6, SUB_Y2 + 70)
    assert (ti, su) == (INK_LEFT, INK_LEFT), f"alignment drifted: {(ti, su)}"

    win = Image.open(capture_path).convert("RGB")
    if win.size[0] != VITRINE_W:
        win = win.resize((VITRINE_W, round(win.size[1] * VITRINE_W / win.size[0])), Image.LANCZOS)
    if win.size[1] < VITRINE_H:
        strip_h = VITRINE_H - win.size[1]
        strip = Image.open(TITLEBAR).convert("RGB")
        strip = strip.crop((12, 0, strip.size[0] - 12, 125)).resize((VITRINE_W, strip_h), Image.LANCZOS)
        stacked = Image.new("RGB", (VITRINE_W, VITRINE_H))
        stacked.paste(strip, (0, 0))
        stacked.paste(win, (0, strip_h))
        win = stacked
    elif win.size[1] > VITRINE_H:
        win = win.crop((0, 0, VITRINE_W, VITRINE_H))

    mask = Image.new("L", (VITRINE_W, VITRINE_H), 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, VITRINE_W - 1, VITRINE_H - 1), radius=RADIUS, fill=255)
    sh = Image.new("L", (W, H), 0)
    vx, vy = VITRINE_AT
    ImageDraw.Draw(sh).rounded_rectangle((vx, vy + SHADOW_DY, vx + VITRINE_W, vy + SHADOW_DY + VITRINE_H),
                                         radius=RADIUS, fill=110)
    sh = sh.filter(ImageFilter.GaussianBlur(SHADOW_BLUR))
    hero = Image.composite(Image.new("RGB", (W, H), (0, 0, 0)), hero, sh)
    hero.paste(win, VITRINE_AT, mask)
    hero.save(out_path)
    print(f"saved {out_path} (title/sub ink left = {INK_LEFT}, title 242px Familjen Bold, two lines)")


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit(__doc__)
    main(sys.argv[1], sys.argv[2])