← back to Wallco Ai
generator: user-chosen batch size renders server-side in background (survives tab-close)
71b98af814f5e66ea3ee88b82e80f2e274953cc7 · 2026-05-31 16:36:32 -0700 · Steve Abrams
Files touched
Diff
commit 71b98af814f5e66ea3ee88b82e80f2e274953cc7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 31 16:36:32 2026 -0700
generator: user-chosen batch size renders server-side in background (survives tab-close)
---
server.js | 134 +++++++++++++++++++++++++++++++++++++++-----------------------
1 file changed, 84 insertions(+), 50 deletions(-)
diff --git a/server.js b/server.js
index 19538ac..a861235 100644
--- a/server.js
+++ b/server.js
@@ -20618,10 +20618,22 @@ app.delete('/api/generator/recipes/:id', (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
-// One-step batch generate: colors + elements + styles + base patterns → SDXL
+// One-step batch generate: colors + elements + styles + base patterns → SDXL.
+// 2026-05-31: the caller now chooses the batch size (`n`, 1–50) and the whole
+// batch renders SERVER-SIDE in a single background child process. Previously
+// the browser fired N separate requests in a client-side loop, so closing the
+// tab mid-batch abandoned the remaining renders. Now one POST kicks the whole
+// batch; the child lives under the server process (NOT the browser), so it
+// runs to completion regardless of the tab. Progress shows up in the Recent
+// Runs list, which the page already polls every 30s.
app.post('/api/generator/batch', (req, res) => {
const { colors = '', elements = [], styles = [], patterns = [], extra_text = '' } = req.body || {};
- // Build the prompt
+ // Clamp the requested count to a sane 1–50 so a fat-fingered number can't
+ // spawn a runaway render job.
+ const n = Math.max(1, Math.min(50, parseInt(req.body && req.body.n, 10) || 1));
+
+ // Build the prompt (one prompt drives the whole batch; each render gets its
+ // own random seed so the N outputs are siblings, not duplicates).
const artistLine = patterns.map(p => p.artist).filter(Boolean).slice(0, 3);
const patternTitles = patterns.map(p => p.title).filter(Boolean).slice(0, 3);
const patternHex = patterns.map(p => p.dominant_hex).filter(Boolean);
@@ -20639,38 +20651,56 @@ app.post('/api/generator/batch', (req, res) => {
const prompt = promptParts.join(', ').replace(/\s+/g, ' ').trim();
try {
- const runId = psqlQuery(`INSERT INTO wallco_generator_runs(recipe_id, prompt, palette_seed_ids, status)
- VALUES(NULL, '${prompt.replace(/'/g,"''")}', '{}'::bigint[], 'running') RETURNING id;`);
+ const runId = parseInt(psqlQuery(`INSERT INTO wallco_generator_runs(recipe_id, prompt, palette_seed_ids, status)
+ VALUES(NULL, '${(`[batch ×${n}] ${prompt}`).replace(/'/g,"''")}', '{}'::bigint[], 'running') RETURNING id;`), 10);
const category = (styles[0] || 'mixed').toLowerCase();
- const r = spawnSync('node', [
+ // generate_designs.js splits --prompts on '|' and indexes prompts[i] for
+ // i<n, so repeat the prompt n times to drive all N renders from it.
+ const promptsArg = Array(n).fill(prompt).join('|');
+ const logFile = path.join(__dirname, 'data', 'logs', `batch-${runId}-${Date.now()}.log`);
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
+ const out = fs.openSync(logFile, 'a');
+
+ // Non-detached spawn: the child is owned by the server process, not the
+ // HTTP request, so returning the response now does NOT kill it and the
+ // browser tab closing is irrelevant. Generous timeout: ~2 min/render.
+ const child = spawn('node', [
path.join(__dirname, 'scripts', 'generate_designs.js'),
- '--n', '1', '--kind', 'seamless_tile',
+ '--n', String(n), '--kind', 'seamless_tile',
'--category', category,
- '--prompts', prompt,
+ '--prompts', promptsArg,
], { cwd: __dirname, env: { ...process.env, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
- encoding: 'utf8', timeout: 120000 });
+ stdio: ['ignore', out, out], timeout: Math.max(180000, n * 150000) });
- let designId = null, filename = null, domHex = null;
- try {
- const row = psqlQuery(`SELECT id::text || '|' || COALESCE(local_path,'') || '|' || COALESCE(dominant_hex,'')
- FROM spoon_all_designs ORDER BY id DESC LIMIT 1;`);
- const [idStr, lp, dh] = row.split('|');
- designId = parseInt(idStr, 10);
- filename = lp ? path.basename(lp) : null;
- domHex = dh || null;
- } catch {}
-
- const ok = r.status === 0;
- psqlQuery(`UPDATE wallco_generator_runs SET
- status='${ok ? 'done' : 'error'}',
- design_id=${designId || 'NULL'},
- error=${ok ? 'NULL' : "'"+((r.stderr||r.stdout||'').slice(-300).replace(/'/g,"''"))+"'"},
- finished_at=now() WHERE id=${runId};`);
+ child.on('exit', (code) => {
+ // Reconcile: the N freshest spoon_all_designs rows are this batch's
+ // output. Record the newest design_id on the run row and mark done/error.
+ let designId = null;
+ try {
+ const row = psqlQuery(`SELECT id FROM spoon_all_designs ORDER BY id DESC LIMIT 1;`);
+ designId = parseInt(row, 10) || null;
+ } catch {}
+ const ok = code === 0;
+ try {
+ psqlQuery(`UPDATE wallco_generator_runs SET
+ status='${ok ? 'done' : 'error'}',
+ design_id=${designId || 'NULL'},
+ error=${ok ? 'NULL' : `'batch exited code ${code}'`},
+ finished_at=now() WHERE id=${runId};`);
+ } catch {}
+ });
+ child.on('error', (err) => {
+ try {
+ psqlQuery(`UPDATE wallco_generator_runs SET status='error',
+ error='${String(err.message||'spawn failed').replace(/'/g,"''")}',
+ finished_at=now() WHERE id=${runId};`);
+ } catch {}
+ });
- res.json({ ok, design_id: designId, run_id: parseInt(runId,10),
- prompt, filename, dominant_hex: domHex,
- error: ok ? null : (r.stderr || r.stdout || '').slice(-400) });
+ // Respond immediately — the batch renders in the background.
+ res.json({ ok: true, run_id: runId, count: n, status: 'running', prompt,
+ note: `Rendering ${n} design${n>1?'s':''} in the background — watch the Recent Runs list. Safe to close this tab.` });
} catch (e) { res.status(500).json({ ok:false, error: e.message }); }
});
@@ -21092,33 +21122,37 @@ document.getElementById('btn-batch-go').addEventListener('click', async function
this.disabled = true;
var s = document.getElementById('batch-status');
document.getElementById('batch-results').innerHTML = '';
- for (var i = 1; i <= n; i++) {
- s.textContent = 'Generating ' + i + ' / ' + n + ' …';
- try {
- var j = await fetch('/api/generator/batch', {
- method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify({ colors, elements, styles,
- patterns: patterns.map(function(p){return {source:p.source, id:p.id, title:p.title, artist:p.artist, tags:p.tags, dominant_hex:p.dominant_hex};})
- })
- }).then(r=>r.json());
- if (j.ok && j.design_id) {
- var imgUrl = '/designs/img/'+(j.filename || '');
- var html = '<a href="/design/'+j.design_id+'" target="_blank" style="text-decoration:none;color:inherit" rel="noopener noreferrer">'+
- '<div style="border:1px solid #444;border-radius:4px;overflow:hidden;background:#0f0e0c">'+
- (j.filename ? '<img src="'+imgUrl+'" style="width:100%;aspect-ratio:1;object-fit:cover">' : '<div style="aspect-ratio:1;background:'+(j.dominant_hex||'#222')+'"></div>')+
- '<div style="padding:4px 6px;font-size:10px;color:#eee">#'+j.design_id+' '+(j.dominant_hex||'')+'</div>'+
- '</div></a>';
- document.getElementById('batch-results').insertAdjacentHTML('beforeend', html);
- } else {
- document.getElementById('batch-results').insertAdjacentHTML('beforeend', '<div style="border:1px solid #c00;border-radius:4px;padding:6px;font-size:10px;color:#faa">err: '+((j.error||'?').slice(0,80))+'</div>');
- }
- } catch (e) {
- document.getElementById('batch-results').insertAdjacentHTML('beforeend', '<div style="border:1px solid #c00;border-radius:4px;padding:6px;font-size:10px;color:#faa">err: '+e.message+'</div>');
+ // 2026-05-31: one request kicks the whole batch SERVER-SIDE. The render runs
+ // in a background process owned by the server, so it finishes even if you
+ // close this tab. Progress shows up in the Recent Runs list below.
+ try {
+ var j = await fetch('/api/generator/batch', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ n: n, colors: colors, elements: elements, styles: styles,
+ patterns: patterns.map(function(p){return {source:p.source, id:p.id, title:p.title, artist:p.artist, tags:p.tags, dominant_hex:p.dominant_hex};})
+ })
+ }).then(r=>r.json());
+ if (j.ok) {
+ s.textContent = (j.note || ('Batch of ' + n + ' started.'));
+ document.getElementById('batch-results').innerHTML =
+ '<div style="grid-column:1/-1;padding:10px;border:1px dashed #d2b15c;border-radius:6px;color:#d2b15c;font-size:12px">'+
+ '⏳ Rendering ' + n + ' design' + (n>1?'s':'') + ' in the background (run #' + (j.run_id||'?') + '). '+
+ 'They appear in <b>Recent Runs</b> below as they finish — safe to close this tab.</div>';
+ } else {
+ s.textContent = 'Failed to start batch.';
+ document.getElementById('batch-results').innerHTML =
+ '<div style="grid-column:1/-1;border:1px solid #c00;border-radius:4px;padding:6px;font-size:11px;color:#faa">err: '+((j.error||'?')+'').slice(0,160)+'</div>';
}
+ } catch (e) {
+ s.textContent = 'Failed to start batch.';
+ document.getElementById('batch-results').innerHTML =
+ '<div style="grid-column:1/-1;border:1px solid #c00;border-radius:4px;padding:6px;font-size:11px;color:#faa">err: '+e.message+'</div>';
}
- s.textContent = 'Done. ' + n + ' generated.';
this.disabled = false;
loadRuns();
+ // Poll the runs list a few times so the batch's completion shows without a
+ // manual refresh (the 30s interval still covers the long tail).
+ var polls = 0, pid = setInterval(function(){ polls++; loadRuns(); if (polls >= 6) clearInterval(pid); }, 15000);
});
loadBatchPickers();
← afa8a3e Add per-site favicon (kills /favicon.ico 404)
·
back to Wallco Ai
·
Interior-designer naming: motif-aware pattern names (parse h a7c3834 →