[object Object]

← back to Wallco Ai

Full-res TIF colorways + hi-res mural preview

709a34d34d21cbec6d81d46da8dd2f325af84652 · 2026-06-02 07:56:58 -0700 · Steve Abrams

Steve 2026-06-02: (1) at-scale Wall-Mural preview must use the full-res TIF;
(2) color changes must be applied to the TIF at full res — edges looked bad
on new colorways because color-dots.js recolored a downscaled browser canvas
(hard nearest-ink snap) and that low-res PNG became the colorway product image.

- scripts/recolor-tif.py: server-side, full-res, EDGE-PRESERVING recolor —
  adds the per-ink DELTA (to-from) of the nearest ink instead of replacing the
  pixel, so anti-aliased edges keep their gradient; LANCZOS-downscales a 2600px
  web preview. PIL+numpy, ~5-8s at 5400px.
- scripts/tif-to-web.py: TIF -> 2600px progressive JPEG for previews.
- server.js: GET /designs/hires/:id — serves a TIF-derived hi-res JPEG (or a
  true vector SVG when present), cached to data/hires/<id>.jpg, 302-fallback to
  the by-id PNG when no TIF (e.g. Mac2, TIFs are prod-only).
- src/colorways.js: promote now recolors the SOURCE design's full-size TIF
  server-side -> full-res recolored TIF (print/order asset, can pass the
  publish gate) + crisp 2600px product PNG. Falls back to the client canvas PNG
  when no TIF is reachable; can also promote on TIF-only (no browser PNG).
- v11-best.html: at-scale Wall-Mural preview sources /designs/hires/:id.
- deploy-kamatera.sh: ship the two new runtime scripts (scripts/ is excluded
  from bulk rsync); exclude data/hires/ from --delete so the cache survives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 709a34d34d21cbec6d81d46da8dd2f325af84652
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 2 07:56:58 2026 -0700

    Full-res TIF colorways + hi-res mural preview
    
    Steve 2026-06-02: (1) at-scale Wall-Mural preview must use the full-res TIF;
    (2) color changes must be applied to the TIF at full res — edges looked bad
    on new colorways because color-dots.js recolored a downscaled browser canvas
    (hard nearest-ink snap) and that low-res PNG became the colorway product image.
    
    - scripts/recolor-tif.py: server-side, full-res, EDGE-PRESERVING recolor —
      adds the per-ink DELTA (to-from) of the nearest ink instead of replacing the
      pixel, so anti-aliased edges keep their gradient; LANCZOS-downscales a 2600px
      web preview. PIL+numpy, ~5-8s at 5400px.
    - scripts/tif-to-web.py: TIF -> 2600px progressive JPEG for previews.
    - server.js: GET /designs/hires/:id — serves a TIF-derived hi-res JPEG (or a
      true vector SVG when present), cached to data/hires/<id>.jpg, 302-fallback to
      the by-id PNG when no TIF (e.g. Mac2, TIFs are prod-only).
    - src/colorways.js: promote now recolors the SOURCE design's full-size TIF
      server-side -> full-res recolored TIF (print/order asset, can pass the
      publish gate) + crisp 2600px product PNG. Falls back to the client canvas PNG
      when no TIF is reachable; can also promote on TIF-only (no browser PNG).
    - v11-best.html: at-scale Wall-Mural preview sources /designs/hires/:id.
    - deploy-kamatera.sh: ship the two new runtime scripts (scripts/ is excluded
      from bulk rsync); exclude data/hires/ from --delete so the cache survives.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 deploy-kamatera.sh                  |   3 +-
 public/theme-variants/v11-best.html |   9 +++
 scripts/recolor-tif.py              | 144 ++++++++++++++++++++++++++++++++++++
 scripts/tif-to-web.py               |  51 +++++++++++++
 server.js                           |  53 +++++++++++++
 src/colorways.js                    |  81 ++++++++++++++++++--
 6 files changed, 334 insertions(+), 7 deletions(-)

diff --git a/deploy-kamatera.sh b/deploy-kamatera.sh
index 594478e..0095d99 100755
--- a/deploy-kamatera.sh
+++ b/deploy-kamatera.sh
@@ -49,6 +49,7 @@ rsync -az --delete \
   --exclude='data/generated_cactus_quarantine/' \
   --exclude='data/elements' \
   --exclude='data/tif/' \
+  --exclude='data/hires/' \
   --exclude='data/rooms/' \
   --exclude='data/spoonflower-pulled/' \
   --exclude='data/pdp-theme.json' \
@@ -105,7 +106,7 @@ rsync -az "$LOCAL_DIR/scripts/refresh_designs_snapshot.py" \
 #     for those features to work. List grows narrowly as endpoints add deps;
 #     don't unbox scripts/ wholesale.
 echo "[2d/6] ship runtime-endpoint scripts..."
-for script in scripts/seam-defect-boxes.py scripts/mint-shopify-download-token.js scripts/generate_designs.js scripts/generator_tick.js scripts/seam-heal-feather.py scripts/quantize-no-ghost.py; do
+for script in scripts/seam-defect-boxes.py scripts/mint-shopify-download-token.js scripts/generate_designs.js scripts/generator_tick.js scripts/seam-heal-feather.py scripts/quantize-no-ghost.py scripts/recolor-tif.py scripts/tif-to-web.py; do
   if [ -f "$LOCAL_DIR/$script" ]; then
     rsync -az "$LOCAL_DIR/$script" "$REMOTE:$REMOTE_DIR/$script" && echo "  $script shipped"
   fi
diff --git a/public/theme-variants/v11-best.html b/public/theme-variants/v11-best.html
index 3a47b8b..40bf70a 100644
--- a/public/theme-variants/v11-best.html
+++ b/public/theme-variants/v11-best.html
@@ -320,6 +320,11 @@
   const palette = Array.isArray(design.palette) ? design.palette : [];
   const dominant = design.dominant_hex || (palette[0] && palette[0].hex) || '#888';
   const img = (/^(\/[^"'<>\s]*|https?:\/\/[^"'<>\s]*)$/i.test(String(design.image_url||'')) ? design.image_url : null) || ('/designs/img/by-id/' + id);
+  // High-res, TIF-derived preview for the Wall-Mural at-scale view ("one image
+  // across the entire wall" must be crisp, not the soft 1024px tile). The
+  // /designs/hires/:id route serves a 2600px JPEG from the full-size TIF (or a
+  // vector SVG), and falls back to the by-id PNG when no TIF exists.
+  const hiresImg = '/designs/hires/' + id;
   const w = Number(design.width_in) || 24, h = Number(design.height_in) || 24;
   const repeatLabel = w + '"' + (w !== h ? ' × ' + h + '"' : '');
   const rollOrPanel = soldByText(mode);
@@ -610,12 +615,16 @@
     const asBadge = document.getElementById('atscale-badge');
     if (ascale) {
       if (isM) {
+        // Mural: one continuous image across the wall, from the full-res TIF.
+        ascale.style.backgroundImage = "url('" + hiresImg + "')";
         ascale.style.backgroundRepeat = 'no-repeat';
         ascale.style.backgroundSize = 'cover';
         ascale.style.backgroundPosition = 'center';
         if (pill) pill.style.display = 'none';
         if (asBadge) asBadge.textContent = 'Wall mural · one image · 10 × 9 ft wall';
       } else {
+        // Wallpaper: tile the standard preview at the chosen scale.
+        ascale.style.backgroundImage = "url('" + img + "')";
         ascale.style.backgroundRepeat = 'repeat';
         ascale.style.backgroundSize = (scale ? scale.value : 220) + 'px ' + (scale ? scale.value : 220) + 'px';
         if (pill) pill.style.display = '';
diff --git a/scripts/recolor-tif.py b/scripts/recolor-tif.py
new file mode 100644
index 0000000..4a579f5
--- /dev/null
+++ b/scripts/recolor-tif.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""recolor-tif.py — apply a colorway ink_map to a design at FULL resolution.
+
+The customer color-dots wheel used to recolor a *downscaled canvas* in the
+browser (hard nearest-ink snap → `toDataURL`), and that low-res PNG became the
+promoted colorway's product image. Result: jagged, haloed edges on every new
+colorway. This script does the recolor SERVER-SIDE off the full-res TIF so the
+colorway's print asset + web preview keep crisp edges.
+
+Two things make the edges clean:
+  1. Full resolution in (the 5400px TIF, not a ~600px canvas), LANCZOS-
+     downscaled out for the web preview — so anti-aliasing is reconstructed
+     at display size instead of upscaled from a tiny snap.
+  2. Edge-preserving recolor: instead of REPLACING each pixel with its ink's
+     `to` color (which hard-snaps anti-aliased edge pixels and halos), we add
+     the per-ink DELTA (to-from) of the nearest ink. Region interiors shift to
+     the new color; the anti-aliased blend at an edge keeps its gradient, just
+     shifted — so the edge stays smooth.
+
+CLI:
+    recolor-tif.py --src <tif|png> --inkmap <json> \
+                   [--out-tif <path>] [--out-png <path>] \
+                   [--preview-maxedge 2600] [--falloff 0]
+
+  --inkmap   JSON array, same shape as wallco_user_colorways.ink_map:
+             [{"from":"#rrggbb","to":"#rrggbb"}, ...]
+  --out-tif  full-res recolored TIFF (the print/order asset). Optional.
+  --out-png  web preview PNG, downscaled to --preview-maxedge long edge.
+  --falloff  if >0, only pixels within this RGB distance of an ink are shifted
+             (keeps off-palette colors untouched). 0 = shift every pixel by its
+             nearest ink's delta (default; patterns are solid screen-print so
+             nearly every pixel belongs to an ink).
+
+Prints a one-line JSON result to stdout: {"ok":true,"w":..,"h":..,
+"out_tif":..,"out_png":..}. Exit non-zero on failure (caller falls back).
+
+PIL + numpy only — both present on Mac2 and Kamatera prod (PIL has libtiff).
+"""
+import sys, os, json, argparse, time
+
+
+def hex_to_rgb(h):
+    h = h.strip().lstrip('#')
+    if len(h) != 6:
+        raise ValueError("bad hex: %r" % h)
+    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--src', required=True)
+    ap.add_argument('--inkmap', required=True)
+    ap.add_argument('--out-tif', default='')
+    ap.add_argument('--out-png', default='')
+    ap.add_argument('--preview-maxedge', type=int, default=2600)
+    ap.add_argument('--falloff', type=float, default=0.0)
+    args = ap.parse_args()
+
+    if not os.path.exists(args.src):
+        print(json.dumps({"ok": False, "error": "src missing: %s" % args.src}))
+        return 3
+    try:
+        ink_map = json.loads(args.inkmap)
+        froms, tos = [], []
+        for m in ink_map:
+            froms.append(hex_to_rgb(m['from']))
+            tos.append(hex_to_rgb(m['to']))
+        if not froms:
+            print(json.dumps({"ok": False, "error": "ink_map empty"}))
+            return 4
+    except Exception as e:
+        print(json.dumps({"ok": False, "error": "ink_map parse: %s" % e}))
+        return 5
+
+    try:
+        import numpy as np
+        from PIL import Image
+    except Exception as e:
+        print(json.dumps({"ok": False, "error": "deps: %s" % e}))
+        return 6
+
+    Image.MAX_IMAGE_PIXELS = None  # full-size archives exceed PIL's bomb guard
+    t0 = time.time()
+    try:
+        im = Image.open(args.src).convert('RGB')
+        arr = np.asarray(im, dtype=np.int16)          # (H,W,3)
+        H, W = arr.shape[0], arr.shape[1]
+        froms = np.array(froms, dtype=np.int16)        # (K,3)
+        tos = np.array(tos, dtype=np.int16)            # (K,3)
+        deltas = (tos - froms).astype(np.int16)        # (K,3)
+
+        # nearest-ink index per pixel (squared RGB distance, vectorized over K)
+        flat = arr.reshape(-1, 3).astype(np.int32)     # (N,3)
+        K = froms.shape[0]
+        # accumulate distances ink-by-ink to keep peak memory at one (N,) array
+        best_d = np.full(flat.shape[0], np.iinfo(np.int64).max, dtype=np.int64)
+        best_k = np.zeros(flat.shape[0], dtype=np.int32)
+        for k in range(K):
+            diff = flat - froms[k].astype(np.int32)    # (N,3)
+            d = np.einsum('ij,ij->i', diff, diff)      # (N,) squared dist
+            upd = d < best_d
+            best_d[upd] = d[upd]
+            best_k[upd] = k
+
+        shift = deltas[best_k].astype(np.int16)        # (N,3)
+        if args.falloff > 0:
+            within = (best_d <= (args.falloff * args.falloff)).astype(np.int16)
+            shift = shift * within[:, None]            # zero out off-palette pixels
+
+        out = flat.astype(np.int16) + shift
+        np.clip(out, 0, 255, out=out)
+        out = out.astype(np.uint8).reshape(H, W, 3)
+        out_im = Image.fromarray(out, 'RGB')
+    except Exception as e:
+        print(json.dumps({"ok": False, "error": "recolor: %s" % e}))
+        return 7
+
+    result = {"ok": True, "w": W, "h": H, "src": args.src, "out_tif": "", "out_png": ""}
+    try:
+        if args.out_tif:
+            os.makedirs(os.path.dirname(args.out_tif), exist_ok=True)
+            out_im.save(args.out_tif, format='TIFF', compression='tiff_lzw')
+            result["out_tif"] = args.out_tif
+        if args.out_png:
+            os.makedirs(os.path.dirname(args.out_png), exist_ok=True)
+            prev = out_im
+            me = max(W, H)
+            if args.preview_maxedge and me > args.preview_maxedge:
+                scale = args.preview_maxedge / float(me)
+                prev = out_im.resize((max(1, int(W * scale)), max(1, int(H * scale))),
+                                     Image.LANCZOS)
+            prev.save(args.out_png, format='PNG', optimize=True)
+            result["out_png"] = args.out_png
+    except Exception as e:
+        print(json.dumps({"ok": False, "error": "write: %s" % e}))
+        return 8
+
+    result["ms"] = int((time.time() - t0) * 1000)
+    print(json.dumps(result))
+    return 0
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/scripts/tif-to-web.py b/scripts/tif-to-web.py
new file mode 100644
index 0000000..0065858
--- /dev/null
+++ b/scripts/tif-to-web.py
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+"""tif-to-web.py — convert a full-res design TIF to a browser-renderable,
+high-resolution JPEG for on-page previews (the at-scale Wall-Mural preview).
+
+TIFs are the full-size print archives (~5400px) and are NOT browser-renderable;
+the 1024px generated PNG that /designs/img/by-id/:id serves is too soft for a
+"one image across the entire wall" mural preview. This emits a sharp JPEG at a
+capped long edge (default 2600px) so the mural preview is crisp without shipping
+the multi-MB TIF to the browser.
+
+CLI:  tif-to-web.py <src_tif> <out_jpg> [maxedge=2600] [quality=88]
+Exit 0 on success (prints out path), non-zero on failure (caller falls back to
+the by-id PNG). PIL-only (libtiff present on Mac2 + prod).
+"""
+import sys, os
+
+
+def main():
+    if len(sys.argv) < 3:
+        print("usage: tif-to-web.py <src_tif> <out_jpg> [maxedge] [quality]", file=sys.stderr)
+        return 2
+    src, out = sys.argv[1], sys.argv[2]
+    maxedge = int(sys.argv[3]) if len(sys.argv) > 3 else 2600
+    quality = int(sys.argv[4]) if len(sys.argv) > 4 else 88
+    if not os.path.exists(src):
+        print("src missing: %s" % src, file=sys.stderr)
+        return 3
+    try:
+        from PIL import Image
+    except Exception as e:
+        print("PIL missing: %s" % e, file=sys.stderr)
+        return 6
+    Image.MAX_IMAGE_PIXELS = None
+    try:
+        im = Image.open(src).convert('RGB')
+        w, h = im.size
+        me = max(w, h)
+        if maxedge and me > maxedge:
+            s = maxedge / float(me)
+            im = im.resize((max(1, int(w * s)), max(1, int(h * s))), Image.LANCZOS)
+        os.makedirs(os.path.dirname(out), exist_ok=True)
+        im.save(out, format='JPEG', quality=quality, optimize=True, progressive=True)
+        print(out)
+        return 0
+    except Exception as e:
+        print("convert fail: %s" % e, file=sys.stderr)
+        return 7
+
+
+if __name__ == '__main__':
+    sys.exit(main())
diff --git a/server.js b/server.js
index 95321a0..c1f83ba 100644
--- a/server.js
+++ b/server.js
@@ -4101,6 +4101,59 @@ app.get('/designs/img/by-id/:id', (req, res) => {
   return res.status(404).end();
 });
 
+// Resolve a stored asset path (tif_path / svg_path) to a real local file.
+// PG may carry a Mac2-absolute path that doesn't exist on prod (and vice-
+// versa); match on the `data/<kind>/…` suffix and re-root under __dirname.
+function resolveAssetLocal(p, kind) {
+  if (!p || typeof p !== 'string') return null;
+  if (fs.existsSync(p)) return p;
+  const marker = 'data/' + kind + '/';
+  const i = p.indexOf(marker);
+  if (i >= 0) {
+    const local = path.join(__dirname, p.slice(i));
+    if (fs.existsSync(local)) return local;
+  }
+  const flat = path.join(__dirname, 'data', kind, path.basename(p));
+  if (fs.existsSync(flat)) return flat;
+  return null;
+}
+
+// ── /designs/hires/:id — high-resolution, browser-renderable preview derived
+// from the full-size TIF. The at-scale Wall-Mural preview uses this so "one
+// image across the entire wall" is crisp, instead of the soft 1024px by-id
+// PNG. Caches the JPEG to data/hires/<id>.jpg (TIFs are immutable archives).
+// Prefers a true vector SVG when one exists (infinite resolution). Falls back
+// to the by-id PNG when no TIF is present (e.g. on Mac2, where the full-size
+// TIFs are prod-only) — so the preview always renders something.
+const HIRES_DIR = path.join(__dirname, 'data', 'hires');
+app.get('/designs/hires/:id', (req, res) => {
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(404).end();
+  const byId = '/designs/img/by-id/' + id;
+  let tifPath = null, svgPath = null, svgVector = false;
+  try {
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT tif_path, svg_path, svg_is_vector FROM all_designs WHERE id=${id} LIMIT 1) t;`);
+    const line = String(raw || '').trim().split('\n').filter(Boolean)[0];
+    if (line) { const r = JSON.parse(line); tifPath = r.tif_path || null; svgPath = r.svg_path || null; svgVector = !!r.svg_is_vector; }
+  } catch { /* fall through to by-id */ }
+
+  if (svgVector && svgPath) {
+    const sv = resolveAssetLocal(svgPath, 'svg');
+    if (sv) { res.type('image/svg+xml'); return res.sendFile(sv); }
+  }
+  const tifLocal = resolveAssetLocal(tifPath, 'tif');
+  if (!tifLocal) return res.redirect(302, byId);
+  try {
+    const cached = path.join(HIRES_DIR, id + '.jpg');
+    if (fs.existsSync(cached) && fs.statSync(cached).mtimeMs >= fs.statSync(tifLocal).mtimeMs) {
+      return res.sendFile(cached);
+    }
+    const r = spawnSync('python3', [path.join(__dirname, 'scripts', 'tif-to-web.py'), tifLocal, cached, '2600', '88'], { encoding: 'utf8', timeout: 60000 });
+    if (r.status === 0 && fs.existsSync(cached)) return res.sendFile(cached);
+  } catch { /* fall through */ }
+  return res.redirect(302, byId);
+});
+
 app.post('/api/merge', mergeUpload.array('uploads', 3), async (req, res) => {
   try {
     const sourceIds = []
diff --git a/src/colorways.js b/src/colorways.js
index b438c5c..9429348 100644
--- a/src/colorways.js
+++ b/src/colorways.js
@@ -44,6 +44,29 @@ function _rawDesignsById() {
   return _rawDesignsCache;
 }
 
+// Resolve a source design's full-size TIF to a local file for server-side
+// recolor. Queries all_designs then spoon_all_designs (prod keeps them as
+// separate tables); re-roots a Mac2/prod-absolute path under this repo's
+// data/tif/ when the stored prefix doesn't exist on this box. Returns null
+// when no TIF is reachable (e.g. on Mac2, where the TIFs are prod-only).
+function resolveSrcTifLocal(srcId) {
+  let tif = null;
+  for (const tbl of ['all_designs', 'spoon_all_designs']) {
+    if (tif) break;
+    try {
+      const r = psql(`SELECT tif_path FROM ${tbl} WHERE id=${parseInt(srcId, 10)} AND tif_path IS NOT NULL LIMIT 1;`).trim();
+      if (r) tif = r.split('\n')[0].trim();
+    } catch (e) { /* table/col may not exist on this box */ }
+  }
+  if (!tif) return null;
+  if (fs.existsSync(tif)) return tif;
+  const i = tif.indexOf('data/tif/');
+  if (i >= 0) { const local = path.join(__dirname, '..', tif.slice(i)); if (fs.existsSync(local)) return local; }
+  const flat = path.join(__dirname, '..', 'data', 'tif', path.basename(tif));
+  if (fs.existsSync(flat)) return flat;
+  return null;
+}
+
 // Synthetic admin trade-user — lets logged-in admins (or loopback) save
 // colorways without having a real trade-user account. Created lazily on first
 // admin use. Shared across all admin sessions (Steve is typically the only
@@ -144,12 +167,14 @@ function mount(app, deps) {
       const version = parseInt(psql(`SELECT count(*) FROM wallco_user_colorways
         WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId};`).trim(), 10) || 1;
 
-      // Optional promote step — only if a usable PNG arrived. Failures here do
-      // NOT fail the save (ink_map-only save still succeeded above).
+      // Promote step — fires if a usable client PNG arrived OR the source has a
+      // full-size TIF we can recolor server-side (so a colorway promotes with a
+      // crisp full-res asset even when the browser couldn't supply a canvas
+      // PNG). Failures here do NOT fail the save (ink_map-only save succeeded).
       let newDesignId = null;
-      if (promoteBuf) {
+      if (promoteBuf || resolveSrcTifLocal(designId)) {
         try {
-          newDesignId = promoteToSpoonRow({ srcId: designId, userId: u.id, colorwayId, version, buf: promoteBuf, palette: inkMap.map(m => m.to) });
+          newDesignId = promoteToSpoonRow({ srcId: designId, userId: u.id, colorwayId, version, buf: promoteBuf, palette: inkMap.map(m => m.to), inkMap });
           if (newDesignId) {
             psql(`UPDATE wallco_user_colorways SET new_design_id=${newDesignId} WHERE id=${colorwayId};`);
           }
@@ -167,7 +192,7 @@ function mount(app, deps) {
   // mirroring the source design's identity. is_published=FALSE so it doesn't
   // hit the catalog grid; viewable directly via /design/<new_id> (per the
   // existing PDP fallback for unpublished). Returns new id, or null on failure.
-  function promoteToSpoonRow({ srcId, userId, colorwayId, version, buf, palette }) {
+  function promoteToSpoonRow({ srcId, userId, colorwayId, version, buf, palette, inkMap }) {
     // Prefer PG (full metadata) but fall back to the data/designs.json snapshot
     // when prod PG is sparse — see memory feedback_wallco_prod_pg_sparse_*.
     // If both miss, use sensible defaults so the colorway still promotes.
@@ -199,7 +224,39 @@ function mount(app, deps) {
     const filename = `colorway_${srcId}_cw${colorwayId}_${ts}.png`;
     const outPath = path.join(__dirname, '..', 'data', 'generated', filename);
     try { fs.mkdirSync(path.dirname(outPath), { recursive: true }); } catch (e) {}
-    fs.writeFileSync(outPath, buf);
+
+    // FULL-RES RECOLOR (Steve 2026-06-02): apply the colorway to the source
+    // design's full-size TIF server-side — NOT the browser's downscaled canvas
+    // (which hard-snapped anti-aliased edges → jagged colorways). Produces a
+    // full-res recolored TIF (the print/order asset, so the colorway can pass
+    // the publish gate) plus a crisp 2600px PNG (the product image). Falls back
+    // to the client-baked canvas PNG (buf) when no TIF is reachable on this box
+    // (Mac2 / TIF-less designs) so the save still promotes.
+    let recoloredTif = null, tifW = null, tifH = null, tifBytes = null, usedFullRes = false;
+    const srcTif = resolveSrcTifLocal(srcId);
+    if (srcTif && Array.isArray(inkMap) && inkMap.length) {
+      try {
+        const outTif = path.join(__dirname, '..', 'data', 'tif', 'colorways', `colorway_${srcId}_cw${colorwayId}_${ts}.tif`);
+        const { spawnSync } = require('child_process');
+        const r = spawnSync('python3', [
+          path.join(__dirname, '..', 'scripts', 'recolor-tif.py'),
+          '--src', srcTif, '--inkmap', JSON.stringify(inkMap),
+          '--out-tif', outTif, '--out-png', outPath, '--preview-maxedge', '2600'
+        ], { encoding: 'utf8', timeout: 120000, maxBuffer: 8 * 1024 * 1024 });
+        if (r.status === 0 && fs.existsSync(outPath) && fs.existsSync(outTif)) {
+          let info = {}; try { info = JSON.parse(String(r.stdout).trim().split('\n').filter(Boolean).pop() || '{}'); } catch (e) {}
+          recoloredTif = outTif; tifW = info.w || null; tifH = info.h || null;
+          try { tifBytes = fs.statSync(outTif).size; } catch (e) {}
+          usedFullRes = true;
+        } else {
+          console.error('[colorways/recolor-tif] failed status=%s err=%s', r.status, String(r.stderr || '').slice(0, 200));
+        }
+      } catch (e) { console.error('[colorways/recolor-tif]', e.message); }
+    }
+    if (!usedFullRes) {
+      if (!buf) return null;   // no TIF and no client PNG → nothing to promote
+      fs.writeFileSync(outPath, buf);
+    }
 
     const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
     const arrLit = (a) => (!Array.isArray(a) || !a.length) ? 'NULL' : `ARRAY[${a.map(v => esc(v)).join(',')}]::text[]`;
@@ -231,6 +288,18 @@ RETURNING id;`).trim();
     const idNum = parseInt(newId, 10);
     if (!idNum) return null;
     psql(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${idNum}' WHERE id=${idNum};`);
+    // Attach the full-res recolored TIF (guarded — tif_* columns may be absent
+    // on prod's sparse spoon_all_designs; a missing column must not fail the
+    // promote). This is what lets the colorway later pass the publish gate.
+    if (recoloredTif) {
+      try {
+        const sets = [`tif_path=${esc(recoloredTif)}`];
+        if (tifW) sets.push(`tif_w_px=${tifW}`);
+        if (tifH) sets.push(`tif_h_px=${tifH}`);
+        if (tifBytes) sets.push(`tif_bytes=${tifBytes}`);
+        psql(`UPDATE spoon_all_designs SET ${sets.join(', ')} WHERE id=${idNum};`);
+      } catch (e) { console.error('[colorways/tif-col]', e.message); }
+    }
     return idNum;
   }
 

← 0e62d01 la-toile: density gate 0.52->0.58 (stop over-rejecting class  ·  back to Wallco Ai  ·  curator 'Remove from all surfaces' now hard-deletes the TIF 3b80205 →