[object Object]

← back to Wallco Ai

colorways: add preset-colorway endpoint (method 3) + LIMIT 1 lineage fix (INSERT-only dup-row trap nulled parent_design_id)

62ff6da725dbd1ea2e149dcd4ab42b7ccd0c3ef1 · 2026-06-12 07:47:55 -0700 · Steve Abrams

Files touched

Diff

commit 62ff6da725dbd1ea2e149dcd4ab42b7ccd0c3ef1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 12 07:47:55 2026 -0700

    colorways: add preset-colorway endpoint (method 3) + LIMIT 1 lineage fix (INSERT-only dup-row trap nulled parent_design_id)
---
 src/colorways.js | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 148 insertions(+), 2 deletions(-)

diff --git a/src/colorways.js b/src/colorways.js
index 8c92f7d..32eff2a 100644
--- a/src/colorways.js
+++ b/src/colorways.js
@@ -88,6 +88,38 @@ function resolveSrcTifLocal(srcId) {
   return null;
 }
 
+// Resolve a source design's web PNG to a local file (the on-disk product image
+// under data/generated/). designs.json carries NO local_path (PII rule), so we
+// derive the filename from image_url and root it under IMG_DIR. Used by the
+// preset-colorway path when no TIF is reachable on this box (e.g. Mac2) so the
+// preset still bakes a recolored asset to promote. Returns null when missing.
+const IMG_DIR = path.join(__dirname, '..', 'data', 'generated');
+function resolveSrcPngLocal(srcId) {
+  // 1) PG local_path (prod keeps the absolute path; re-root under IMG_DIR if the
+  //    stored prefix doesn't exist on this box).
+  for (const tbl of ['spoon_all_designs', 'all_designs']) {
+    try {
+      const r = psql(`SELECT local_path, image_url FROM ${tbl} WHERE id=${parseInt(srcId, 10)} LIMIT 1;`);
+      if (r && r.trim()) {
+        const parts = r.trim().split('\n')[0].split('|').map(s => s.trim());
+        const lp = parts[0], iu = parts[1] || '';
+        if (lp && lp !== '' && fs.existsSync(lp)) return lp;
+        if (lp && lp !== '') { const b = path.join(IMG_DIR, path.basename(lp)); if (fs.existsSync(b)) return b; }
+        if (iu && iu.startsWith('/designs/img/')) { const b = path.join(IMG_DIR, iu.replace('/designs/img/', '')); if (fs.existsSync(b)) return b; }
+      }
+    } catch (e) { /* table/col may differ on this box */ }
+  }
+  // 2) designs.json snapshot fallback (the catalog the server actually serves).
+  try {
+    const hit = _rawDesignsById().get(parseInt(srcId, 10));
+    if (hit) {
+      const fn = (hit.filename || (hit.image_url || '').replace('/designs/img/', '')).trim();
+      if (fn) { const b = path.join(IMG_DIR, fn); if (fs.existsSync(b)) return b; }
+    }
+  } catch (e) { /* non-fatal */ }
+  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
@@ -209,6 +241,115 @@ function mount(app, deps) {
     }
   });
 
+  // ── METHOD 3: PRESET COLORWAYS ──────────────────────────────────────────
+  // The preset list lives ONLY in scripts/colorway-variants.py (COLORWAY_PAIRS).
+  // We load it once via the --list-presets CLI so there is no duplicated palette
+  // in JS. Each preset is {slug, ground, figure}: ground/figure are the `to`
+  // colors; the design's own two dominant tones become the `from` colors. That
+  // makes a preset just a server-computed ink_map — so it flows through the SAME
+  // promoteToSpoonRow path methods 1+2 use (full-res recolor when a TIF exists,
+  // FALSE child row, parent lineage). One pattern, three entry points.
+  let _presetsCache = null;
+  function loadPresets() {
+    if (_presetsCache) return _presetsCache;
+    try {
+      const { spawnSync } = require('child_process');
+      const r = spawnSync('python3', [path.join(__dirname, '..', 'scripts', 'colorway-variants.py'), '--list-presets'],
+        { encoding: 'utf8', timeout: 15000 });
+      const j = JSON.parse(String(r.stdout || '').trim());
+      if (j && j.ok && Array.isArray(j.presets)) _presetsCache = j.presets;
+    } catch (e) { console.error('[colorways/presets] load failed:', e.message); }
+    return _presetsCache || [];
+  }
+
+  // Detect a source design's two dominant tones (ground=most-common,
+  // figure=less-common) as hex, from its on-disk PNG. Returns null if no source
+  // image is reachable on this box.
+  function detectSrcTones(srcId) {
+    const png = resolveSrcPngLocal(srcId);
+    if (!png) return null;
+    try {
+      const { spawnSync } = require('child_process');
+      const r = spawnSync('python3', [path.join(__dirname, '..', 'scripts', 'colorway-variants.py'), '--detect', png],
+        { encoding: 'utf8', timeout: 30000 });
+      const line = String(r.stdout || '').trim().split('\n').filter(Boolean).pop() || '';
+      const j = JSON.parse(line);
+      if (j && j.ok && /^#[0-9a-f]{6}$/i.test(j.ground_hex) && /^#[0-9a-f]{6}$/i.test(j.figure_hex)) {
+        return { png, ground: j.ground_hex.toLowerCase(), figure: j.figure_hex.toLowerCase() };
+      }
+    } catch (e) { console.error('[colorways/detect]', e.message); }
+    return null;
+  }
+
+  // GET /api/colorways/presets — the preset palette chips (admin renders these).
+  app.get('/api/colorways/presets', (req, res) => {
+    res.json({ ok: true, presets: loadPresets() });
+  });
+
+  // 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) => {
+    const u = getEffectiveUser(req);
+    if (!u) return res.status(401).json({ ok: false, error: 'Sign in to create a preset colorway.' });
+    const b = req.body || {};
+    const designId = parseInt(b.design_id, 10);
+    if (!Number.isFinite(designId) || designId <= 0) return res.status(400).json({ ok: false, error: 'design_id required.' });
+    const slug = String(b.slug || '').trim().toLowerCase();
+    const preset = loadPresets().find(p => p.slug === slug);
+    if (!preset) return res.status(400).json({ ok: false, error: 'unknown preset slug', valid: loadPresets().map(p => p.slug) });
+
+    // Build the ink_map from the design's own two dominant tones → preset colors.
+    const tones = detectSrcTones(designId);
+    if (!tones) return res.status(404).json({ ok: false, error: 'source image not reachable on this box — cannot build a preset ink_map.' });
+    const inkMap = [
+      { from: tones.ground, to: preset.ground.toLowerCase() },
+      { from: tones.figure, to: preset.figure.toLowerCase() },
+    ];
+
+    // Pre-bake a recolored PNG so the promote succeeds even when no TIF exists
+    // (Mac2). promoteToSpoonRow will prefer a full-res TIF recolor when one is
+    // reachable, and otherwise fall back to this buf — same as the client path.
+    let buf = null;
+    try {
+      const { spawnSync } = require('child_process');
+      const tmpOut = path.join(IMG_DIR, `_preset_tmp_${designId}_${slug}_${Date.now()}.png`);
+      const r = spawnSync('python3', [
+        path.join(__dirname, '..', 'scripts', 'recolor-tif.py'),
+        '--src', tones.png, '--inkmap', JSON.stringify(inkMap),
+        '--out-png', tmpOut, '--preview-maxedge', '2600',
+      ], { encoding: 'utf8', timeout: 120000, maxBuffer: 8 * 1024 * 1024 });
+      if (r.status === 0 && fs.existsSync(tmpOut)) {
+        buf = fs.readFileSync(tmpOut);
+        try { fs.unlinkSync(tmpOut); } catch (e) {}
+      } else {
+        console.error('[colorways/preset recolor]', r.status, String(r.stderr || '').slice(0, 200));
+      }
+    } catch (e) { console.error('[colorways/preset recolor]', e.message); }
+
+    try {
+      // Record the colorway (so it lists + gets publish/unpublish for free),
+      // then promote to a FALSE child SKU via the shared path.
+      const name = `Preset · ${slug}`;
+      const colorwayId = parseInt(psql(`INSERT INTO wallco_user_colorways (user_id, design_id, name, ink_map)
+        VALUES (${parseInt(u.id, 10)}, ${designId}, ${pgEsc(name)}, ${pgEsc(JSON.stringify(inkMap))}::jsonb)
+        RETURNING id;`).trim(), 10);
+      const version = parseInt(psql(`SELECT count(*) FROM wallco_user_colorways
+        WHERE user_id=${parseInt(u.id, 10)} AND design_id=${designId};`).trim(), 10) || 1;
+
+      const newDesignId = promoteToSpoonRow({
+        srcId: designId, userId: u.id, colorwayId, version,
+        buf, palette: inkMap.map(m => m.to), inkMap,
+      });
+      if (!newDesignId) return res.status(500).json({ ok: false, error: 'promote failed (no recolored asset produced).' });
+      psql(`UPDATE wallco_user_colorways SET new_design_id=${newDesignId} WHERE id=${colorwayId};`);
+
+      res.json({ ok: true, id: colorwayId, version, design_id: designId, slug, new_design_id: newDesignId });
+    } catch (e) {
+      console.error('[colorways/preset] failed:', e.message);
+      res.status(500).json({ ok: false, error: 'Preset colorway failed.' });
+    }
+  });
+
   // Promote helper: bake recolored PNG to disk, INSERT spoon_all_designs row
   // 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
@@ -219,10 +360,15 @@ function mount(app, deps) {
     // If both miss, use sensible defaults so the colorway still promotes.
     let src = null, srcInPg = false;
     try {
+      // LIMIT 1 is load-bearing: spoon_all_designs is INSERT-only with no PK, so
+      // a root id can have duplicate rows. Without LIMIT 1, row_to_json emits two
+      // JSON lines → JSON.parse throws → srcInPg stays false → the child loses
+      // its parent_design_id lineage (violates "parent_design_id=root every
+      // time"). Take the first row deterministically.
       const srcRaw = psql(`SELECT row_to_json(t) FROM (SELECT id, kind, brand, width_in, height_in,
         panels, category, dominant_hex, motifs, tags, product_line, generator
-        FROM spoon_all_designs WHERE id=${parseInt(srcId, 10)}) t;`);
-      if (srcRaw && srcRaw.trim()) { src = JSON.parse(srcRaw); srcInPg = true; }
+        FROM spoon_all_designs WHERE id=${parseInt(srcId, 10)} LIMIT 1) t;`);
+      if (srcRaw && srcRaw.trim()) { src = JSON.parse(srcRaw.trim().split('\n')[0]); srcInPg = true; }
     } catch (e) { /* fall through to disk */ }
     if (!src) {
       try {

← 231021e colorway-variants.py: add --detect (two dominant tones) + --  ·  back to Wallco Ai  ·  design-edit: surface preset colorways (method 3) on the reco 2d7a39e →