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