[object Object]

← back to Wallco Ai

colorways: add POST /api/design/:id/recolor-preview — live crisp recolor off full-res TIF (recolor-tif.py, --preview-maxedge 1400, ink-map-hash cache, no_tif fallback) + cache serve route

66f6a713e9287672e39b396b048c10f567279bbf · 2026-06-12 10:52:04 -0700 · Steve Abrams

Server half of the click->TIF crisp-preview feature. Endpoint reuses resolveSrcTifLocal + the edge-preserving recolor-tif.py (the canonical fix for the 600px-canvas blocky+halo edges). Validated on Mac2: no_tif fallback, empty-map + no-op-map rejection. Crisp result verifiable on prod (designs with a TIF). Client wiring (swap img.src on click) still TODO.

Files touched

Diff

commit 66f6a713e9287672e39b396b048c10f567279bbf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 12 10:52:04 2026 -0700

    colorways: add POST /api/design/:id/recolor-preview — live crisp recolor off full-res TIF (recolor-tif.py, --preview-maxedge 1400, ink-map-hash cache, no_tif fallback) + cache serve route
    
    Server half of the click->TIF crisp-preview feature. Endpoint reuses resolveSrcTifLocal + the edge-preserving recolor-tif.py (the canonical fix for the 600px-canvas blocky+halo edges). Validated on Mac2: no_tif fallback, empty-map + no-op-map rejection. Crisp result verifiable on prod (designs with a TIF). Client wiring (swap img.src on click) still TODO.
---
 src/colorways.js | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 102 insertions(+)

diff --git a/src/colorways.js b/src/colorways.js
index 32eff2a..128fdec 100644
--- a/src/colorways.js
+++ b/src/colorways.js
@@ -286,6 +286,108 @@ function mount(app, deps) {
     res.json({ ok: true, presets: loadPresets() });
   });
 
+  // ── Live crisp-preview cache ─────────────────────────────────────────────
+  // POST /api/design/:id/recolor-preview  { ink_map:[{from,to}] }
+  //
+  // The customer color-dots wheel applies an INSTANT but rough preview by hard-
+  // snapping pixels on a ~600px browser canvas (downscale→upscale = blocky;
+  // anti-aliased edge pixels stranded = halo fringe). This endpoint produces the
+  // CRISP equivalent: it runs the same edge-preserving recolor-tif.py used by the
+  // colorway promote path against the source's FULL-RES TIF, but in fast preview-
+  // only mode (--out-png, no --out-tif) at a smaller --preview-maxedge so it's
+  // snappy for live feedback. The client swaps img.src to this once it lands.
+  //
+  // Returns:
+  //   { ok:true, url:'/designs/img/_recolor_preview_cache/<file>.png', w, h }
+  //   { ok:false, no_tif:true }   — no reachable TIF (Mac2 / TIF-less design);
+  //                                 client keeps its canvas preview, no error.
+  //
+  // PUBLIC by design — same trust level as the customer color-dots wheel that
+  // calls it. It produces an EPHEMERAL, non-promoted asset (never inserts a
+  // spoon_all_designs row, never publishes), so there is nothing to gate.
+  const PREVIEW_CACHE_DIR = path.join(IMG_DIR, '_recolor_preview_cache');
+  let _previewCachePruned = 0;
+  function prunePreviewCache() {
+    // Cheap TTL sweep so the preview cache can't grow unbounded. Runs at most
+    // once a minute; deletes preview PNGs older than 30 min.
+    const now = Date.now();
+    if (now - _previewCachePruned < 60 * 1000) return;
+    _previewCachePruned = now;
+    try {
+      if (!fs.existsSync(PREVIEW_CACHE_DIR)) return;
+      for (const f of fs.readdirSync(PREVIEW_CACHE_DIR)) {
+        if (!f.endsWith('.png')) continue;
+        const fp = path.join(PREVIEW_CACHE_DIR, f);
+        try { if (now - fs.statSync(fp).mtimeMs > 30 * 60 * 1000) fs.unlinkSync(fp); } catch (e) {}
+      }
+    } catch (e) { /* non-fatal */ }
+  }
+
+  app.post('/api/design/:id/recolor-preview', expressJson, (req, res) => {
+    const designId = parseInt(req.params.id, 10);
+    if (!Number.isFinite(designId) || designId <= 0) return res.status(400).json({ ok: false, error: 'bad id' });
+    let inkMap = (req.body && req.body.ink_map) || [];
+    if (!Array.isArray(inkMap) || !inkMap.length) return res.status(400).json({ ok: false, error: 'ink_map required.' });
+    // sanitize: keep only {from,to} hex pairs, drop no-op (from===to) entries
+    inkMap = inkMap
+      .filter(m => m && /^#[0-9a-fA-F]{6}$/.test(m.from) && /^#[0-9a-fA-F]{6}$/.test(m.to))
+      .map(m => ({ from: m.from.toLowerCase(), to: m.to.toLowerCase() }))
+      .filter(m => m.from !== m.to);
+    if (!inkMap.length) return res.status(400).json({ ok: false, error: 'ink_map had no valid hex pairs.' });
+
+    // Resolve the source TIF. None reachable (Mac2 / TIF-less) → no_tif so the
+    // client gracefully keeps its instant canvas preview.
+    const srcTif = resolveSrcTifLocal(designId);
+    if (!srcTif) return res.json({ ok: false, no_tif: true });
+
+    prunePreviewCache();
+    try { fs.mkdirSync(PREVIEW_CACHE_DIR, { recursive: true }); } catch (e) {}
+
+    // Cache key: design + the ink_map. Identical requests reuse the file so a
+    // re-drag of the same colors is instant.
+    const crypto = require('crypto');
+    const key = crypto.createHash('sha1').update(designId + '|' + JSON.stringify(inkMap)).digest('hex').slice(0, 16);
+    const outName = `cw_${designId}_${key}.png`;
+    const outPath = path.join(PREVIEW_CACHE_DIR, outName);
+    const url = `/designs/img/_recolor_preview_cache/${outName}`;
+
+    if (fs.existsSync(outPath)) {
+      try { fs.utimesSync(outPath, new Date(), new Date()); } catch (e) {}  // refresh TTL
+      return res.json({ ok: true, url, cached: true });
+    }
+
+    try {
+      const { spawnSync } = require('child_process');
+      const r = spawnSync('python3', [
+        path.join(__dirname, '..', 'scripts', 'recolor-tif.py'),
+        '--src', srcTif, '--inkmap', JSON.stringify(inkMap),
+        '--out-png', outPath, '--preview-maxedge', '1400',
+      ], { encoding: 'utf8', timeout: 60000, maxBuffer: 8 * 1024 * 1024 });
+      if (r.status === 0 && fs.existsSync(outPath)) {
+        let info = {}; try { info = JSON.parse(String(r.stdout).trim().split('\n').filter(Boolean).pop() || '{}'); } catch (e) {}
+        return res.json({ ok: true, url, w: info.w || null, h: info.h || null });
+      }
+      console.error('[colorways/recolor-preview] failed status=%s err=%s', r.status, String(r.stderr || '').slice(0, 200));
+      return res.status(500).json({ ok: false, error: 'recolor failed' });
+    } catch (e) {
+      console.error('[colorways/recolor-preview]', e.message);
+      return res.status(500).json({ ok: false, error: 'recolor errored' });
+    }
+  });
+
+  // Serve the ephemeral recolor-preview cache. NOTE: `/designs/img/:filename`
+  // in server.js is a single-segment route, NOT a static mount — so this nested
+  // path needs its own handler or it 404s. Basename + strict-name guarded.
+  app.get('/designs/img/_recolor_preview_cache/:name', (req, res) => {
+    const name = path.basename(String(req.params.name || ''));
+    if (!/^cw_\d+_[0-9a-f]{16}\.png$/.test(name)) return res.status(404).end();
+    const fp = path.join(PREVIEW_CACHE_DIR, name);
+    if (!fp.startsWith(PREVIEW_CACHE_DIR) || !fs.existsSync(fp)) return res.status(404).end();
+    res.type('png');
+    res.setHeader('Cache-Control', 'public, max-age=600');
+    fs.createReadStream(fp).pipe(res);
+  });
+
   // POST /api/colorways/preset { design_id, slug } — apply a named preset to a
   // design, producing exactly ONE is_published=FALSE child SKU (a colorway).
   app.post('/api/colorways/preset', expressJson, (req, res) => {

← 393fbb9 Add edge-seam mural gate batch (DTD-B: edge-PASS keeps tile,  ·  back to Wallco Ai  ·  Fix hero click landing on wrong design: add pointer-events:n 8f57319 →