← back to Wallco Ai

scripts/bake-og-cw.py

116 lines

#!/usr/bin/env python3
"""bake-og-cw.py — bake a colorway-recolored og:image JPG for
/design/:id?cw=<N>.

Reads the source design PNG, applies the colorway's ink_map (nearest-color
substitution in RGB euclidean), resizes to 1200x630 (Open Graph default
aspect, ~50KB target), encodes JPEG q=85, writes to the path the server
passes us.

CLI (called via spawnSync from server.js /design/:id route):
    bake-og-cw.py <src_png> <out_jpg> <inkmap_json>

inkmap_json is the same shape stored in wallco_user_colorways.ink_map:
    [{"from": "#rrggbb", "to": "#rrggbb"}, ...]

Exit 0 on success, non-zero on any failure (server falls back to parent
og:image on non-zero). Prints brief status to stderr.

Pattern mirrors scripts/fix-seam.py (PIL-only, no extra deps). On Mac2 +
Kamatera prod both have Pillow installed system-wide. ~150ms cold-start +
~200ms recolor for a 1024x1024 input; cached to disk so only on first hit.
"""
import sys, os, json, time

def hex_to_rgb(h):
    h = h.strip().lstrip('#')
    if len(h) != 6:
        raise ValueError(f"bad hex: {h!r}")
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))

def main():
    if len(sys.argv) != 4:
        print("usage: bake-og-cw.py <src_png> <out_jpg> <inkmap_json>", file=sys.stderr)
        return 2
    src, out, ink_json = sys.argv[1], sys.argv[2], sys.argv[3]
    if not os.path.exists(src):
        print(f"src missing: {src}", file=sys.stderr)
        return 3
    try:
        ink_map = json.loads(ink_json)
        pairs = []
        for m in ink_map:
            f = hex_to_rgb(m['from']); t = hex_to_rgb(m['to'])
            pairs.append((f, t))
        if not pairs:
            print("ink_map empty", file=sys.stderr)
            return 4
    except Exception as e:
        print(f"ink_map parse fail: {e}", file=sys.stderr)
        return 5

    try:
        from PIL import Image
    except ImportError:
        print("Pillow not installed (pip3 install Pillow)", file=sys.stderr)
        return 6

    t0 = time.time()
    try:
        im = Image.open(src).convert('RGB')
    except Exception as e:
        print(f"open fail: {e}", file=sys.stderr)
        return 7

    # Resize to 1200x630 Open Graph default (preserve aspect, cover-crop).
    # Most src tiles are square (1024x1024) — cover-crop centers horizontally.
    OG_W, OG_H = 1200, 630
    sw, sh = im.size
    src_ar = sw / sh; og_ar = OG_W / OG_H
    if src_ar > og_ar:
        # source wider than og — crop width
        new_w = int(sh * og_ar); off = (sw - new_w) // 2
        im = im.crop((off, 0, off + new_w, sh))
    elif src_ar < og_ar:
        # source narrower than og — crop height
        new_h = int(sw / og_ar); off = (sh - new_h) // 2
        im = im.crop((0, off, sw, off + new_h))
    im = im.resize((OG_W, OG_H), Image.LANCZOS)

    # Per-pixel nearest-color recolor. For each input pixel, find the
    # nearest 'from' color in pairs by RGB-euclidean; if within tolerance,
    # remap to 'to'. Tolerance 48 mirrors the typical wallco palette
    # spacing (solid screen-print inks, ~80-100 between distinct colors).
    TOL_SQ = 48 * 48
    px = im.load()
    w, h = im.size
    fr = [p[0] for p in pairs]; to = [p[1] for p in pairs]
    for y in range(h):
        for x in range(w):
            r, g, b = px[x, y]
            best_i = -1; best_d = TOL_SQ + 1
            for i, (fr_r, fr_g, fr_b) in enumerate(fr):
                d = (r - fr_r) ** 2 + (g - fr_g) ** 2 + (b - fr_b) ** 2
                if d < best_d:
                    best_d = d; best_i = i
            if best_i >= 0 and best_d <= TOL_SQ:
                px[x, y] = to[best_i]

    os.makedirs(os.path.dirname(out), exist_ok=True)
    tmp = out + '.tmp'
    try:
        im.save(tmp, 'JPEG', quality=85, optimize=True, progressive=True)
        os.replace(tmp, out)
    except Exception as e:
        print(f"save fail: {e}", file=sys.stderr)
        try: os.unlink(tmp)
        except: pass
        return 8

    dt = time.time() - t0
    print(f"baked {out} in {dt*1000:.0f}ms", file=sys.stderr)
    return 0

if __name__ == '__main__':
    sys.exit(main())