← 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
A scripts/bake-og-cw.pyM server.js
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 →