[object Object]

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