[object Object]

← back to Wallco Ai

/design/:id?cw=<N> server-renders colorway-flavored title/canonical/og:image; bake-on-first-hit JPG cache at data/og-cw/cw_<id>.jpg (PIL recolor + 1200x630 OG-default crop).

5abd25bb4271ec1a4efe19e5e9654345fdccaf43 · 2026-05-29 15:07:37 -0700 · Steve

Files touched

Diff

commit 5abd25bb4271ec1a4efe19e5e9654345fdccaf43
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 29 15:07:37 2026 -0700

    /design/:id?cw=<N> server-renders colorway-flavored title/canonical/og:image; bake-on-first-hit JPG cache at data/og-cw/cw_<id>.jpg (PIL recolor + 1200x630 OG-default crop).
---
 scripts/bake-og-cw.py | 115 ++++++++++++++++++++++++++++++++++++++++++++++++++
 server.js             | 101 ++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 212 insertions(+), 4 deletions(-)

diff --git a/scripts/bake-og-cw.py b/scripts/bake-og-cw.py
new file mode 100755
index 0000000..2300b9b
--- /dev/null
+++ b/scripts/bake-og-cw.py
@@ -0,0 +1,115 @@
+#!/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())
diff --git a/server.js b/server.js
index 87a1018..aab4860 100644
--- a/server.js
+++ b/server.js
@@ -183,6 +183,17 @@ app.use('/designs/room', express.static(path.join(__dirname, 'data', 'rooms'), {
   setHeaders: (res) => res.setHeader('Cache-Control', 'public, max-age=604800')
 }));
 
+// ── Serve baked colorway og:image JPGs (one per saved colorway id).
+// Files live at data/og-cw/cw_<cwid>.jpg, baked on-demand by the
+// /design/:id?cw=<N> route via scripts/bake-og-cw.py. Long-lived cache —
+// rows in wallco_user_colorways are immutable today; if a re-bake is
+// ever needed, /design/:id?cw=<N> re-checks mtime vs row created_at and
+// re-bakes when stale.
+app.use('/designs/og-cw', express.static(path.join(__dirname, 'data', 'og-cw'), {
+  maxAge: '30d',
+  setHeaders: (res) => res.setHeader('Cache-Control', 'public, max-age=2592000, immutable')
+}));
+
 // ── robots.txt — allow all + reference the sitemap.
 // Last-Modified pinned to process start (robots policy rarely changes; if it
 // does, the deploy/restart updates the timestamp). Override the global
@@ -11610,13 +11621,95 @@ app.get('/design/:id', (req, res) => {
   });
   const schema = `${productSchema}</script>\n<script type="application/ld+json">${breadcrumbSchema}`;
 
+  // ── ?cw=<N> server-render meta override ──────────────────────────────────
+  // When the URL carries a saved-colorway id and the row exists, override
+  // title / canonical / og:image so social shares + SEO reflect THIS
+  // colorway instead of the parent design. Newer colorways already promote
+  // to a real /design/<new_id> page (src/colorways.js promoteToSpoonRow); the
+  // ?cw= fallback path is for older or non-promoted saves.
+  //
+  // og:image is BAKED on first hit via scripts/bake-og-cw.py (PIL recolor),
+  // cached to disk at data/og-cw/cw_<cwid>.jpg, served by express.static at
+  // /designs/og-cw/... Re-bake when cache file is missing or older than the
+  // wallco_user_colorways row's created_at (defensive — rows don't mutate
+  // today but might tomorrow). Bake failures fall through to parent og:image
+  // so a broken bake never breaks the page or the share preview.
+  let _cwMeta = null;  // { title, canonical, ogImage, ogImageAlt } when active
+  const _cwIdRaw = parseInt(req.query.cw, 10);
+  if (Number.isFinite(_cwIdRaw) && _cwIdRaw > 0) {
+    try {
+      const _cwRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, design_id,
+        ink_map, name, created_at, new_design_id FROM wallco_user_colorways
+        WHERE id=${_cwIdRaw} AND design_id=${id}) t;`);
+      if (_cwRaw && _cwRaw.trim()) {
+        const _cw = JSON.parse(_cwRaw);
+        // Resolve version (1-based, per-user-per-design, by created_at asc)
+        // matching how src/colorways.js GET /api/colorways numbers them.
+        let _ver = 1;
+        try {
+          const _vRaw = psqlQuery(`SELECT count(*) FROM wallco_user_colorways
+            WHERE design_id=${id}
+              AND user_id=(SELECT user_id FROM wallco_user_colorways WHERE id=${_cwIdRaw})
+              AND created_at <= (SELECT created_at FROM wallco_user_colorways WHERE id=${_cwIdRaw});`).trim();
+          _ver = parseInt(_vRaw, 10) || 1;
+        } catch {}
+        // Bake — on-demand, cached, mtime-invalidated against cw.created_at.
+        const _ogCwDir = path.join(__dirname, 'data', 'og-cw');
+        const _ogJpgPath = path.join(_ogCwDir, `cw_${_cw.id}.jpg`);
+        const _ogJpgUrl  = `https://wallco.ai/designs/og-cw/cw_${_cw.id}.jpg`;
+        let _ogReady = false;
+        try {
+          const _cwMs = _cw.created_at ? Date.parse(_cw.created_at) : 0;
+          const _stat = fs.statSync(_ogJpgPath);  // throws if missing
+          if (_stat.mtimeMs >= _cwMs) _ogReady = true;
+        } catch {}
+        if (!_ogReady) {
+          try {
+            try { fs.mkdirSync(_ogCwDir, { recursive: true }); } catch {}
+            // Resolve src PNG path — prefer in-memory design.filename, fall
+            // back to PG local_path for unpublished/lazy-loaded rows.
+            let _srcPng = design.filename ? path.join(IMG_DIR, design.filename) : null;
+            if (!_srcPng || !fs.existsSync(_srcPng)) {
+              try {
+                const _lp = psqlQuery(`SELECT COALESCE(local_path,'') FROM spoon_all_designs WHERE id=${id};`).trim();
+                if (_lp && fs.existsSync(_lp)) _srcPng = _lp;
+                else if (_lp) {
+                  const _bn = _lp.split('/').pop();
+                  const _alt = path.join(IMG_DIR, _bn);
+                  if (fs.existsSync(_alt)) _srcPng = _alt;
+                }
+              } catch {}
+            }
+            if (_srcPng && fs.existsSync(_srcPng)) {
+              const _spawn = require('child_process').spawnSync;
+              const _inkJson = JSON.stringify(_cw.ink_map || []);
+              const _bake = _spawn('python3', [
+                path.join(__dirname, 'scripts', 'bake-og-cw.py'),
+                _srcPng, _ogJpgPath, _inkJson
+              ], { timeout: 20000, encoding: 'utf8' });
+              if (_bake.status === 0 && fs.existsSync(_ogJpgPath)) _ogReady = true;
+              else console.warn('[design/:id?cw] bake failed', _cw.id, _bake.status, (_bake.stderr || '').slice(0, 160));
+            }
+          } catch (e) { console.warn('[design/:id?cw] bake error', _cw.id, e.message.slice(0, 160)); }
+        }
+        const _cwLabel = _cw.name ? String(_cw.name).slice(0, 60) : `Custom Colorway v${_ver}`;
+        _cwMeta = {
+          title: `${design.title} — ${_cwLabel}`,
+          canonical: `https://wallco.ai/design/${design.id}?cw=${_cw.id}`,
+          ogImage: _ogReady ? _ogJpgUrl : `https://wallco.ai${design.image_url}`,
+          ogImageAlt: `${design.title} in custom colorway v${_ver}${_cw.name ? ' (' + String(_cw.name).slice(0, 40) + ')' : ''} — recolored wallcovering pattern`
+        };
+      }
+    } catch (e) { /* PG hiccup — silently fall through to parent meta */ }
+  }
+
   res.type('html').send(`${htmlHead({
-    title: `${design.title} — wallco.ai`,
+    title: _cwMeta ? _cwMeta.title : `${design.title} — wallco.ai`,
     description: `wallco.ai original ${categoryDisplay(design.category).toLowerCase()} wallpaper. Pattern No.${design.id} — hand-curated, unique, never repeated. Free sample available.`,
-    canonical: `https://wallco.ai/design/${design.id}`,
-    ogImage: `https://wallco.ai${design.image_url}`,
+    canonical: _cwMeta ? _cwMeta.canonical : `https://wallco.ai/design/${design.id}`,
+    ogImage: _cwMeta ? _cwMeta.ogImage : `https://wallco.ai${design.image_url}`,
     ogType: 'product',
-    ogImageAlt: `${design.title} — ${_hueName} ${categoryDisplay(design.category).toLowerCase()} wallpaper pattern${_motifList ? ' featuring ' + _motifList : ''}`,
+    ogImageAlt: _cwMeta ? _cwMeta.ogImageAlt : `${design.title} — ${_hueName} ${categoryDisplay(design.category).toLowerCase()} wallpaper pattern${_motifList ? ' featuring ' + _motifList : ''}`,
     productOG: {
       availability: 'in stock',
       retailer_item_id: design.handle || `wallco-${String(design.id).padStart(4,'0')}`,

← 463100b color-dots: double-click on colorway chip launches its produ  ·  back to Wallco Ai  ·  publish-gate: raise execSync 90s→240s and urllib 120s→210s f c12ba4f →