← back to Wallco Ai
designs: sort buttons (was dropdown) + /merge upload-and-fuse feature
d7d74bc9b408a707eab0d54d100e10209c67ea3c · 2026-05-13 11:01:15 -0700 · SteveStudio2
/designs:
- Sort <select> dropdown replaced with prominent button-pill row:
Newest · Title A→Z · Category · Light→Dark · Dark→Light · Color Wheel
- Active sort pill highlighted (dark fill); ARIA role=radiogroup
- Density slider styled inline with same baseline
- Both visible without needing to open a control panel
/merge (NEW):
- Drop in 1-3 source designs via either: paste a catalog ID OR upload
a PNG/JPG/WebP (multer, 20mb cap each, max 3 files)
- Optional extra direction textarea
- 'Merge & Generate' button → POST /api/merge
* Pulls each catalog source's prompt + dominant_hex + motifs[] from PG
* For uploads: notes them as 'user reference N' textual cue
* Synthesizes a unified SDXL prompt: 'A new wallcovering that visually
fuses elements from N references: [source #1: ...] [source #2: ...]
... Tie the palette around these dominants: #abc, #def. Direction: <hint>.'
* Fires generate_designs.js --category=merged --kind=seamless_tile
* Returns {ok, design_id, seed, prompt, image_url, source_count}
- Helper: GET /designs/img/by-id/:id streams the design PNG by ID
(powers the slot preview when a catalog ID is pasted)
Smoke-tested: /merge page renders, multer installed, route compiles.
Files touched
Diff
commit d7d74bc9b408a707eab0d54d100e10209c67ea3c
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 11:01:15 2026 -0700
designs: sort buttons (was dropdown) + /merge upload-and-fuse feature
/designs:
- Sort <select> dropdown replaced with prominent button-pill row:
Newest · Title A→Z · Category · Light→Dark · Dark→Light · Color Wheel
- Active sort pill highlighted (dark fill); ARIA role=radiogroup
- Density slider styled inline with same baseline
- Both visible without needing to open a control panel
/merge (NEW):
- Drop in 1-3 source designs via either: paste a catalog ID OR upload
a PNG/JPG/WebP (multer, 20mb cap each, max 3 files)
- Optional extra direction textarea
- 'Merge & Generate' button → POST /api/merge
* Pulls each catalog source's prompt + dominant_hex + motifs[] from PG
* For uploads: notes them as 'user reference N' textual cue
* Synthesizes a unified SDXL prompt: 'A new wallcovering that visually
fuses elements from N references: [source #1: ...] [source #2: ...]
... Tie the palette around these dominants: #abc, #def. Direction: <hint>.'
* Fires generate_designs.js --category=merged --kind=seamless_tile
* Returns {ok, design_id, seed, prompt, image_url, source_count}
- Helper: GET /designs/img/by-id/:id streams the design PNG by ID
(powers the slot preview when a catalog ID is pasted)
Smoke-tested: /merge page renders, multer installed, route compiles.
---
server.js | 248 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 236 insertions(+), 12 deletions(-)
diff --git a/server.js b/server.js
index 11435f3..2214d5a 100644
--- a/server.js
+++ b/server.js
@@ -730,6 +730,224 @@ app.post('/admin/reload-designs', (req, res) => {
res.json({ ok: true, before, after: DESIGNS.length, catalog_last_modified: CATALOG_LAST_MODIFIED, by_color_cache_ver: _byColorCacheVer });
});
+// ── Merge ────────────────────────────────────────────────────────────────
+// Take 1-3 designs (catalog IDs and/or uploaded files), pull element
+// descriptions from each, fuse into a single SDXL prompt, generate a new
+// design that visually pulls from all sources. Saved with category='merged'.
+const mergeUpload = require('multer')({
+ dest: path.join(__dirname, 'data', 'merge-uploads'),
+ limits: { fileSize: 20 * 1024 * 1024, files: 3 },
+});
+
+app.get('/merge', (_req, res) => {
+ res.type('html').send(`<!doctype html>
+<html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Merge designs — wallco.ai</title>
+<style>
+:root { --ink:#2a1f10; --line:#d8d0c0; --gold:#c9a14b; --accent:#1a1a1a; --bg:#fff; --card:#f8f4ec; }
+body { margin:0; background:var(--bg); color:var(--ink); font:14px -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif }
+header { padding:20px 32px; border-bottom:1px solid var(--line); display:flex; gap:16px; align-items:baseline }
+header h1 { font-family:'Playfair Display','Didot',serif; font-weight:400; font-size:26px; margin:0 }
+header .meta { color:#888; font-size:13px }
+header a { margin-left:auto; color:var(--ink); text-decoration:none; font-size:13px; padding:6px 14px; border:1px solid var(--line); border-radius:14px }
+main { max-width:1080px; margin:0 auto; padding:32px 24px }
+.slots { display:grid; grid-template-columns:repeat(3,1fr); gap:18px; margin-bottom:22px }
+@media (max-width:760px) { .slots { grid-template-columns:1fr } }
+.slot { background:var(--card); border:1px solid var(--line); border-radius:8px; padding:14px; min-height:240px; display:flex; flex-direction:column; gap:8px }
+.slot h3 { margin:0; font-size:12px; letter-spacing:.16em; text-transform:uppercase; color:#7a6e5a }
+.slot .preview { flex:1; min-height:160px; background-size:cover; background-position:center; background-color:#fff; border:1px dashed var(--line); border-radius:6px; display:flex; align-items:center; justify-content:center; color:#bba; font-size:12px }
+.slot input[type=text], .slot input[type=file] { width:100%; padding:6px 8px; border:1px solid var(--line); border-radius:4px; font:12px var(--sans); background:#fff }
+.controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:14px }
+.controls textarea { flex:1; min-width:280px; padding:8px 10px; border:1px solid var(--line); border-radius:4px; font:13px monospace; min-height:54px }
+button.go { padding:12px 28px; background:var(--accent); color:#f4ece0; border:0; border-radius:6px; font:13px var(--sans); letter-spacing:.06em; text-transform:uppercase; cursor:pointer }
+button.go[disabled] { opacity:.55; cursor:wait }
+.status { font-size:12px; color:#888 }
+.result { margin-top:24px; padding:16px; border:1px solid var(--line); border-radius:8px; background:var(--card); display:none }
+.result.show { display:block }
+.result img { max-width:100%; border:1px solid var(--line); border-radius:6px; display:block; margin:10px 0 }
+</style>
+</head><body>
+<header>
+ <h1>Merge designs</h1>
+ <span class="meta">Pull elements from 1-3 designs into one new generation</span>
+ <a href="/designs">← Grid</a>
+</header>
+<main>
+ <p style="color:#666;line-height:1.6">Drop in up to 3 source images (uploads) or paste catalog design IDs. The merger pulls each design's motifs / color story / style and fuses them into a single new SDXL generation. Saved to the catalog as <code>category='merged'</code>.</p>
+
+ <div class="slots">
+ ${[1,2,3].map(n => `
+ <div class="slot" data-slot="${n}">
+ <h3>Source ${n}</h3>
+ <div class="preview" id="prev-${n}">drop image or paste design ID</div>
+ <label style="font-size:11px;color:#888">Catalog ID
+ <input type="text" id="src-id-${n}" placeholder="e.g. 1537" inputmode="numeric">
+ </label>
+ <label style="font-size:11px;color:#888">— or upload —
+ <input type="file" id="src-file-${n}" accept="image/png,image/jpeg,image/webp">
+ </label>
+ </div>
+ `).join('')}
+ </div>
+
+ <div class="controls">
+ <textarea id="extra-hint" placeholder="Optional extra direction — e.g. 'in a Persian miniature manner, dusty palette, mural-scale'"></textarea>
+ <button class="go" id="btn-merge" type="button">Merge & Generate</button>
+ <span class="status" id="status"></span>
+ </div>
+
+ <div class="result" id="result">
+ <strong>Merged design</strong>
+ <p id="result-meta" style="margin:6px 0;font-size:12px;color:#888"></p>
+ <img id="result-img" alt="merged">
+ <p><a id="result-link" href="#" style="color:var(--accent);font-weight:600">Open on the design page →</a></p>
+ </div>
+</main>
+<script>
+// Preview helpers — show image when ID is entered or file is picked
+[1,2,3].forEach(function(n){
+ var idIn = document.getElementById('src-id-'+n);
+ var fileIn = document.getElementById('src-file-'+n);
+ var prev = document.getElementById('prev-'+n);
+ idIn.addEventListener('input', function(){
+ var v = idIn.value.replace(/\\D/g,'');
+ if (v && parseInt(v,10) > 0) {
+ prev.style.backgroundImage = "url('/designs/img/by-id/" + v + "')";
+ // Fallback: just show the URL — server will respond if it exists
+ var img = new Image();
+ img.onload = function(){ prev.style.backgroundImage = "url('"+img.src+"')"; prev.textContent=''; };
+ img.onerror = function(){ prev.style.backgroundImage = ''; prev.textContent = '#'+v+' not found'; };
+ img.src = '/designs/img/by-id/' + v;
+ fileIn.value = '';
+ } else { prev.style.backgroundImage = ''; prev.textContent = 'drop image or paste design ID'; }
+ });
+ fileIn.addEventListener('change', function(){
+ var f = fileIn.files && fileIn.files[0]; if (!f) return;
+ var reader = new FileReader();
+ reader.onload = function(){ prev.style.backgroundImage = "url('"+reader.result+"')"; prev.textContent=''; };
+ reader.readAsDataURL(f);
+ idIn.value = '';
+ });
+});
+
+document.getElementById('btn-merge').addEventListener('click', function(){
+ var btn = this, status = document.getElementById('status');
+ var fd = new FormData();
+ var hint = document.getElementById('extra-hint').value.trim();
+ if (hint) fd.append('hint', hint);
+ var n = 0;
+ [1,2,3].forEach(function(i){
+ var id = (document.getElementById('src-id-'+i).value||'').replace(/\\D/g,'');
+ var f = document.getElementById('src-file-'+i).files[0];
+ if (id) { fd.append('source_ids', id); n++; }
+ else if (f) { fd.append('uploads', f); n++; }
+ });
+ if (n === 0) { status.textContent = 'Add at least one source'; return; }
+ status.textContent = 'Merging ~30-60s…'; btn.disabled = true;
+ fetch('/api/merge', { method:'POST', body: fd })
+ .then(function(r){ return r.json(); })
+ .then(function(j){
+ btn.disabled = false;
+ if (!j.ok) { status.textContent = 'Error: ' + (j.error||'unknown'); return; }
+ status.textContent = 'Merged → design #' + j.design_id;
+ var res = document.getElementById('result');
+ document.getElementById('result-meta').textContent =
+ 'Sources: ' + (j.source_count||n) + ' · seed ' + j.seed + ' · ' + j.prompt.slice(0,80) + '…';
+ document.getElementById('result-img').src = j.image_url + '?t=' + Date.now();
+ document.getElementById('result-link').href = '/design/' + j.design_id;
+ res.classList.add('show');
+ res.scrollIntoView({ behavior:'smooth' });
+ })
+ .catch(function(e){ btn.disabled = false; status.textContent = 'Error: ' + e.message; });
+});
+</script>
+</body></html>`);
+});
+
+// Helper: serve a design image by id (used by /merge previews)
+app.get('/designs/img/by-id/:id', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(404).end();
+ try {
+ const lp = psqlExecLocal(`SELECT local_path FROM spoon_all_designs WHERE id=${id}`);
+ if (!lp || !fs.existsSync(lp)) return res.status(404).end();
+ res.sendFile(path.resolve(lp));
+ } catch { res.status(500).end(); }
+});
+
+app.post('/api/merge', mergeUpload.array('uploads', 3), async (req, res) => {
+ try {
+ const sourceIds = []
+ .concat(req.body.source_ids || [])
+ .map(s => parseInt(s, 10))
+ .filter(n => Number.isFinite(n) && n > 0);
+ const uploads = (req.files || []);
+ const totalSources = sourceIds.length + uploads.length;
+ if (totalSources === 0) return res.status(400).json({ ok: false, error: 'no sources' });
+ if (totalSources > 3) return res.status(400).json({ ok: false, error: 'max 3 sources' });
+ const hint = String(req.body.hint || '').slice(0, 400);
+
+ // Pull a short element-description from each source.
+ // - Catalog IDs: read prompt + dominant_hex + motifs straight from PG (free).
+ // - Uploads: skip llava for now; just note "user-uploaded reference N" and
+ // include the file in the prompt as an inspiration (textual cue only —
+ // SDXL won't ingest the image without img2img; this is a future bump).
+ const fragments = [];
+ const palettes = [];
+ for (const sid of sourceIds) {
+ const row = psqlExecLocal(
+ `SELECT json_build_object('prompt', prompt, 'dominant_hex', dominant_hex, 'motifs', motifs, 'category', category)::text
+ FROM spoon_all_designs WHERE id=${sid}`
+ );
+ if (!row) continue;
+ let r; try { r = JSON.parse(row); } catch { continue; }
+ // Truncate the original prompt aggressively — keep first ~120 chars
+ const seed = (r.prompt || '').slice(0, 160).replace(/,?\s*NO\s.*$/i, '').trim();
+ fragments.push(`[source #${sid}: ${seed}${(r.motifs || []).length ? ' · motifs: ' + r.motifs.slice(0,4).join(', ') : ''}]`);
+ if (r.dominant_hex) palettes.push(r.dominant_hex);
+ }
+ for (let i = 0; i < uploads.length; i++) {
+ fragments.push(`[user reference #${i+1}: blend its overall feel and palette]`);
+ }
+ const mergedPrompt = [
+ `A new wallcovering design that visually fuses elements from ${totalSources} reference${totalSources>1?'s':''}:`,
+ fragments.join(' '),
+ palettes.length ? `Tie the palette around these dominants: ${palettes.join(', ')}.` : '',
+ hint ? `Direction: ${hint}.` : '',
+ 'Refined hand-painted execution, seamless wallpaper repeat, archival quality, no text, no watermark.',
+ ].filter(Boolean).join(' ');
+
+ // Fire generate_designs.js with the merged prompt. category='merged'.
+ const { spawnSync } = require('child_process');
+ const r = spawnSync('node', [
+ path.join(__dirname, 'scripts', 'generate_designs.js'),
+ '--n', '1',
+ '--kind', 'seamless_tile',
+ '--category', 'merged',
+ '--prompts', mergedPrompt,
+ ], { encoding: 'utf8', cwd: __dirname, timeout: 180_000 });
+ if (r.status !== 0) {
+ return res.status(500).json({ ok: false, error: 'generator failed: ' + (r.stderr || r.stdout || '').slice(-200) });
+ }
+ // Pull the newest 'merged' row — that's the one we just made.
+ const idRaw = psqlExecLocal(`SELECT id FROM spoon_all_designs WHERE category='merged' ORDER BY id DESC LIMIT 1`);
+ const newId = parseInt(idRaw, 10);
+ const seedRaw = psqlExecLocal(`SELECT json_build_object('seed', seed, 'local_path', local_path)::text FROM spoon_all_designs WHERE id=${newId}`);
+ let meta = {}; try { meta = JSON.parse(seedRaw); } catch {}
+ const filename = (meta.local_path || '').split('/').pop();
+ res.json({
+ ok: true, design_id: newId,
+ seed: meta.seed,
+ prompt: mergedPrompt,
+ image_url: '/designs/img/' + filename,
+ source_count: totalSources,
+ });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
// ── License + download flow ────────────────────────────────────────────
// One-time $950 infinite-use license per mural-scenic panel. Modeled on
// Hannah's archive licensing — pay once, perpetual unlimited use, the buyer
@@ -3030,18 +3248,24 @@ ${cat === 'mural-scenic' ? `
<option value="">All Categories</option>
${cats.map(c => `<option value="${c}" ${c===cat?'selected':''}>${c.charAt(0).toUpperCase()+c.slice(1)}</option>`).join('')}
</select>
- <label class="sort-label">Sort
- <select id="sort-select" class="filter-select" onchange="document.getElementById('sort-hidden').value=this.value;this.form.submit()">
- <option value="newest" ${sort==='newest'?'selected':''}>Newest</option>
- <option value="title" ${sort==='title'?'selected':''}>Title A→Z</option>
- <option value="category" ${sort==='category'?'selected':''}>Category</option>
- <option value="light-dark" ${sort==='light-dark'?'selected':''}>Light → Dark</option>
- <option value="dark-light" ${sort==='dark-light'?'selected':''}>Dark → Light</option>
- <option value="color-wheel" ${sort==='color-wheel'?'selected':''}>Color Wheel</option>
- </select>
- </label>
- <label class="density-label">Density
- <input type="range" id="density-slider" min="1" max="8" step="1" value="3" class="density-range">
+ <div class="sort-btn-row" style="display:flex;gap:4px;flex-wrap:wrap;align-items:center" role="radiogroup" aria-label="Sort designs">
+ <span style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--ink-faint);margin-right:4px">Sort</span>
+ ${[
+ ['newest', 'Newest'],
+ ['title', 'Title A→Z'],
+ ['category', 'Category'],
+ ['light-dark', 'Light → Dark'],
+ ['dark-light', 'Dark → Light'],
+ ['color-wheel', 'Color Wheel'],
+ ].map(([k, label]) => {
+ const on = sort === k;
+ return `<button type="button" class="sort-btn${on?' is-on':''}" data-sort="${k}" role="radio" aria-checked="${on}"
+ onclick="document.getElementById('sort-hidden').value='${k}';this.form.submit()"
+ style="padding:5px 11px;border:1px solid ${on?'var(--accent,#1a1a1a)':'var(--line,#d8d0c0)'};background:${on?'var(--accent,#1a1a1a)':'transparent'};color:${on?'var(--bg,#fff)':'var(--ink,#3a2818)'};border-radius:999px;font:11px var(--sans,system-ui);letter-spacing:.04em;cursor:pointer;white-space:nowrap">${label}</button>`;
+ }).join('')}
+ </div>
+ <label class="density-label" style="display:inline-flex;align-items:center;gap:8px;font:11px var(--sans);text-transform:uppercase;letter-spacing:.1em;color:var(--ink-faint)">Density
+ <input type="range" id="density-slider" min="1" max="8" step="1" value="3" class="density-range" style="flex:0 0 110px">
</label>
<button type="button" id="surprise-btn" class="filter-select"
style="background:var(--gold,#c9a14b);color:var(--accent,#0d0d0d);border:0;font-weight:500;padding:8px 14px;cursor:pointer;border-radius:6px;letter-spacing:.04em"
← 753227d designs: studio adjust panel (6 photoshop sliders) + per-car
·
back to Wallco Ai
·
/generator: fix broken 'Run tick NOW' + DW palette slider gr 10447d1 →