← 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 →