[object Object]

← back to Wallco Ai

refiner-compare: 3 more render columns (Gemini 2.5 Flash Image, Flux 1.1 Pro, gpt-image-1)

a109d4fd237794e70395138eef9a895570051e2e · 2026-05-19 15:22:11 -0700 · SteveStudio2

- scripts/compare_progress.js: shared 6-field progress.json writer derived from
  disk so 4 concurrent cloud backends + 2 ComfyUI shards never clobber each other
- scripts/gemini_compare.js: Gemini 2.5 Flash Image (~$0.04/img, 25 renders)
- scripts/flux_compare.js:  Replicate black-forest-labs/flux-1.1-pro ($0.04, 25)
- scripts/dalle_compare.js: OpenAI gpt-image-1 ($0.04, 25) — sk-proj key
  doesn't expose dall-e-3 and the response_format/style params are rejected;
  set OPENAI_IMAGE_MODEL=dall-e-3 to override if available on another key
- refiner_compare.js + replicate_compare.js: use shared save() so they don't
  drop the 3 new cloud columns when racing the new backends
- compare.html: .trio (3-col) → .hex (6-col, wraps 3-3 at <1500px, 2-2-2 at <900px);
  COST + costPill teach about gemini/flux/dalle; status line and total spend
  show all 4 cloud counts; cache-buster keys on every column's count

Verified: Gemini 23/25, Flux 25/25, gpt-image-1 in flight. Routed OPENAI_API_KEY
through secrets-manager (added wallco-ai/.env destination under services.OPENAI_API_KEY).

Files touched

Diff

commit a109d4fd237794e70395138eef9a895570051e2e
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 19 15:22:11 2026 -0700

    refiner-compare: 3 more render columns (Gemini 2.5 Flash Image, Flux 1.1 Pro, gpt-image-1)
    
    - scripts/compare_progress.js: shared 6-field progress.json writer derived from
      disk so 4 concurrent cloud backends + 2 ComfyUI shards never clobber each other
    - scripts/gemini_compare.js: Gemini 2.5 Flash Image (~$0.04/img, 25 renders)
    - scripts/flux_compare.js:  Replicate black-forest-labs/flux-1.1-pro ($0.04, 25)
    - scripts/dalle_compare.js: OpenAI gpt-image-1 ($0.04, 25) — sk-proj key
      doesn't expose dall-e-3 and the response_format/style params are rejected;
      set OPENAI_IMAGE_MODEL=dall-e-3 to override if available on another key
    - refiner_compare.js + replicate_compare.js: use shared save() so they don't
      drop the 3 new cloud columns when racing the new backends
    - compare.html: .trio (3-col) → .hex (6-col, wraps 3-3 at <1500px, 2-2-2 at <900px);
      COST + costPill teach about gemini/flux/dalle; status line and total spend
      show all 4 cloud counts; cache-buster keys on every column's count
    
    Verified: Gemini 23/25, Flux 25/25, gpt-image-1 in flight. Routed OPENAI_API_KEY
    through secrets-manager (added wallco-ai/.env destination under services.OPENAI_API_KEY).
---
 scripts/compare_progress.js  |  54 ++++++++++++++++++++++
 scripts/dalle_compare.js     |  95 +++++++++++++++++++++++++++++++++++++++
 scripts/flux_compare.js      | 105 +++++++++++++++++++++++++++++++++++++++++++
 scripts/gemini_compare.js    |  92 +++++++++++++++++++++++++++++++++++++
 scripts/refiner_compare.js   |  19 ++------
 scripts/replicate_compare.js |  17 ++-----
 6 files changed, 353 insertions(+), 29 deletions(-)

diff --git a/scripts/compare_progress.js b/scripts/compare_progress.js
new file mode 100644
index 0000000..c98f139
--- /dev/null
+++ b/scripts/compare_progress.js
@@ -0,0 +1,54 @@
+'use strict';
+/**
+ * Shared progress.json writer for the refiner-compare 6-column grid.
+ *
+ * Every backend (refiner_compare, replicate_compare, gemini_compare,
+ * flux_compare, dalle_compare) derives the row state fresh from disk on
+ * every write, so concurrent writers never clobber each other — the last
+ * writer is always correct, and the file always reflects the union of all
+ * 6 columns currently on disk.
+ */
+const fs = require('fs');
+const path = require('path');
+const { FASHION_PALETTES } = require('./fashion_palettes');
+
+const STYLES = [
+  'damask', 'arabesque trellis', 'geometric art-deco', 'botanical floral', 'ogee medallion',
+  'stripe and sprig', 'block-print paisley', 'scrollwork acanthus', 'chinoiserie scenic', 'herringbone tonal',
+  'guilloché lattice', 'stylized fan', 'marbled swirl', 'star tessellation', 'vine and berry',
+  'diamond harlequin', 'rosette grid', 'wave and shell', 'feathered plume', 'interlocking fretwork key',
+  'arts-and-crafts leaf', 'moroccan zellige', 'pinstripe ticking', 'sunburst medallion', 'quatrefoil net',
+];
+
+const OUT = path.join(__dirname, '..', 'public', 'refiner-compare');
+const haveImg = p => { try { return fs.statSync(p).size > 1000; } catch { return false; } };
+
+function buildRows() {
+  return STYLES.map((style, i) => ({
+    i, style,
+    brand: FASHION_PALETTES[i % FASHION_PALETTES.length].brand,
+    seed: 770000 + i,
+    base:      haveImg(path.join(OUT, `${i}_base.png`))      ? `${i}_base.png`      : null,
+    refiner:   haveImg(path.join(OUT, `${i}_refiner.png`))   ? `${i}_refiner.png`   : null,
+    replicate: haveImg(path.join(OUT, `${i}_replicate.png`)) ? `${i}_replicate.png` : null,
+    gemini:    haveImg(path.join(OUT, `${i}_gemini.png`))    ? `${i}_gemini.png`    : null,
+    flux:      haveImg(path.join(OUT, `${i}_flux.png`))      ? `${i}_flux.png`      : null,
+    dalle:     haveImg(path.join(OUT, `${i}_dalle.png`))     ? `${i}_dalle.png`     : null,
+  }));
+}
+
+function save() {
+  const rows = buildRows();
+  // `done` still tracks the local-ComfyUI base+refiner count (the original
+  // progress bar semantics). Cloud columns are surfaced as separate counts
+  // in the status line on compare.html, derived directly from `rows`.
+  const done = rows.reduce((n, r) => n + (r.base ? 1 : 0) + (r.refiner ? 1 : 0), 0);
+  const total = STYLES.length * 2;
+  fs.writeFileSync(
+    path.join(OUT, 'progress.json'),
+    JSON.stringify({ done, total, rows }, null, 1),
+  );
+  return { done, total, rows };
+}
+
+module.exports = { STYLES, OUT, haveImg, buildRows, save };
diff --git a/scripts/dalle_compare.js b/scripts/dalle_compare.js
new file mode 100644
index 0000000..5484124
--- /dev/null
+++ b/scripts/dalle_compare.js
@@ -0,0 +1,95 @@
+'use strict';
+/**
+ * Column 6 of the refiner comparison — OpenAI image generation, 1024×1024.
+ *
+ * Defaults to `gpt-image-1` (OpenAI's current image model, ~$0.04/image at
+ * high quality 1024×1024). The sk-proj key we route doesn't expose dall-e-3,
+ * and gpt-image-1 is the state-of-the-art replacement anyway.
+ *
+ * Same 25 prompts as refiner_compare.js. gpt-image-1 doesn't accept a seed,
+ * and the prompt-rewriter behavior differs by model. We pass the explicit
+ * "I NEED to test … AS-IS" preamble to discourage automatic rewriting.
+ *
+ *   node scripts/dalle_compare.js
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+const { FASHION_PALETTES, paletteString } = require('./fashion_palettes');
+const { STYLES, OUT, haveImg, save } = require('./compare_progress');
+
+const TAG = '[dalle]';
+// gpt-image-1 = OpenAI's current image model (DALL-E 3's replacement).
+// Set OPENAI_IMAGE_MODEL=dall-e-3 to override if your key has dall-e-3 access.
+const MODEL = process.env.OPENAI_IMAGE_MODEL || 'gpt-image-1';
+
+function loadKey() { return process.env.OPENAI_API_KEY || null; }
+
+function buildPrompt(style, pal) {
+  // "I NEED to test" is the OpenAI-documented preamble that suppresses
+  // DALL-E 3's automatic prompt rewriting — we want the exact prompt to
+  // hit the model so column-vs-column is a fair comparison.
+  return 'I NEED to test how the model tokenizes this exact request. ' +
+    'DO NOT add any detail, just use it AS-IS: ' +
+    `A seamless wallpaper pattern, ${style} motif, ${paletteString(pal)}, ` +
+    'seamless tile, repeating pattern, no edges, fabric pattern, wallpaper design, archival quality, high detail, ' +
+    'screen-printed wallpaper, solid opaque flat ink, every motif fully filled with solid color, ' +
+    'no halftone, no dot screens, no gradients, no text, no watermark, no border.';
+}
+
+function genDalle(prompt, outPath) {
+  const KEY = loadKey();
+  if (!KEY) throw new Error('OPENAI_API_KEY not set in wallco-ai/.env');
+
+  const sh = (cmd, opts) => {
+    try { return execSync(cmd, opts); }
+    catch (e) { throw new Error(String((e && e.message) || e).split(KEY).join('sk-***REDACTED')); }
+  };
+
+  // gpt-image-1 always returns b64 (no response_format needed) and has a
+  // 4-tier quality enum (low/medium/high/auto). For dall-e-3, override:
+  //   OPENAI_IMAGE_MODEL=dall-e-3   (uses standard/hd quality)
+  const isGpt = MODEL.startsWith('gpt-image');
+  const body = {
+    model: MODEL,
+    prompt,
+    n: 1,
+    size: '1024x1024',
+    quality: isGpt ? 'high' : 'hd',
+    ...(isGpt ? {} : { response_format: 'b64_json' }),
+  };
+
+  const raw = sh(
+    `curl -sf -m 120 -H 'Authorization: Bearer ${KEY}' -H 'Content-Type: application/json' -X POST 'https://api.openai.com/v1/images/generations' -d @-`,
+    { input: JSON.stringify(body), encoding: 'utf8', maxBuffer: 40 * 1024 * 1024 },
+  );
+
+  let res;
+  try { res = JSON.parse(raw); }
+  catch { throw new Error(`DALL-E non-JSON response: ${raw.slice(0, 300)}`); }
+  if (res.error) throw new Error(`DALL-E error: ${res.error.message || JSON.stringify(res.error)}`);
+
+  const b64 = res.data && res.data[0] && res.data[0].b64_json;
+  if (!b64) throw new Error(`DALL-E returned no image: ${JSON.stringify(res).slice(0, 300)}`);
+  fs.writeFileSync(outPath, Buffer.from(b64, 'base64'));
+  if (!haveImg(outPath)) throw new Error('DALL-E wrote a sub-1KB file (likely empty)');
+  return outPath;
+}
+
+(async () => {
+  const t0 = Date.now();
+  let ok = 0, fail = 0, skip = 0;
+  console.log(`${TAG} 25 renders via OpenAI ${MODEL} (HD)`);
+  for (let i = 0; i < STYLES.length; i++) {
+    const style = STYLES[i], pal = FASHION_PALETTES[i % FASHION_PALETTES.length];
+    const outPath = path.join(OUT, `${i}_dalle.png`);
+    if (haveImg(outPath)) { skip++; save(); console.log(`  ${i} dalle — skip`); continue; }
+    try { genDalle(buildPrompt(style, pal), outPath); ok++; }
+    catch (e) { fail++; console.log(`  ${i} dalle ERR ${e.message.slice(0, 200)}`); }
+    save();
+    console.log(`${TAG} [${i + 1}/25] ${style} · ${pal.brand} — ${haveImg(outPath) ? 'ok' : 'FAIL'} · ${((Date.now() - t0) / 60000).toFixed(1)}min`);
+  }
+  save();
+  console.log(`${TAG} DONE — ${ok} rendered, ${skip} skipped, ${fail} failed · ${((Date.now() - t0) / 60000).toFixed(1)}min`);
+})().catch(e => { console.error(`${TAG} FATAL`, e.message); process.exit(1); });
diff --git a/scripts/flux_compare.js b/scripts/flux_compare.js
new file mode 100644
index 0000000..ab676f3
--- /dev/null
+++ b/scripts/flux_compare.js
@@ -0,0 +1,105 @@
+'use strict';
+/**
+ * Column 5 of the refiner comparison — Flux 1.1 Pro via Replicate.
+ *
+ * Same 25 prompts + seeds as refiner_compare.js, rendered through Replicate's
+ * `black-forest-labs/flux-1.1-pro` model. Flux 1.1 Pro accepts a seed input
+ * natively, so columns 1/2/3/5 share identical seeds.
+ *
+ * Cost ≈ $0.040 per image × 25 = $1.00 total.
+ *
+ *   node scripts/flux_compare.js
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+const { FASHION_PALETTES, paletteString } = require('./fashion_palettes');
+const { STYLES, OUT, haveImg, save } = require('./compare_progress');
+
+const TAG = '[flux]';
+const MODEL = 'black-forest-labs/flux-1.1-pro';
+
+function loadToken() { return process.env.REPLICATE_API_TOKEN || null; }
+
+function buildPrompt(style, pal) {
+  // Flux 1.1 Pro doesn't have a separate negative_prompt; we bake the
+  // "screen-printed, fully filled, no halftone" cues into the positive
+  // prompt directly, mirroring the SDXL prompt without the negative chunk.
+  return `A seamless wallpaper pattern, ${style} motif, ${paletteString(pal)}, ` +
+    'seamless tile, repeating pattern, no edges, fabric pattern, wallpaper design, archival quality, high detail, ' +
+    'screen-printed wallpaper, solid opaque flat ink, every motif fully filled with solid color, crisp clean color separation, ' +
+    'no halftone, no dot screens, no gradients, no text, no watermark, no border';
+}
+
+function genFlux(prompt, seed, outPath) {
+  const TOKEN = loadToken();
+  if (!TOKEN) throw new Error('REPLICATE_API_TOKEN not set in wallco-ai/.env');
+
+  const sh = (cmd, opts) => {
+    try { return execSync(cmd, opts); }
+    catch (e) { throw new Error(String((e && e.message) || e).split(TOKEN).join('r8_***REDACTED')); }
+  };
+
+  // Replicate "official model" predictions endpoint — no version pin needed
+  // because Flux 1.1 Pro publishes as a tracked-latest official model.
+  const body = {
+    input: {
+      prompt,
+      aspect_ratio: '1:1',
+      output_format: 'png',
+      output_quality: 90,
+      safety_tolerance: 5,    // looser tolerance for design patterns (default 2)
+      prompt_upsampling: false, // we already crafted the prompt — keep it stable
+      seed,
+    },
+  };
+
+  const submit = sh(
+    `curl -sf -m 30 -H 'Authorization: Bearer ${TOKEN}' -H 'Content-Type: application/json' -X POST 'https://api.replicate.com/v1/models/${MODEL}/predictions' -d @-`,
+    { input: JSON.stringify(body), encoding: 'utf8' },
+  );
+  let pred;
+  try { pred = JSON.parse(submit); } catch { throw new Error('Flux submit non-JSON: ' + submit.slice(0, 200)); }
+  if (!pred.id) throw new Error('Flux: no prediction id — ' + JSON.stringify(pred).slice(0, 300));
+
+  const start = Date.now();
+  let final;
+  while (Date.now() - start < 3 * 60_000) {
+    execSync('sleep 3');
+    const poll = sh(
+      `curl -sf -m 10 -H 'Authorization: Bearer ${TOKEN}' 'https://api.replicate.com/v1/predictions/${pred.id}'`,
+      { encoding: 'utf8' },
+    );
+    const p = JSON.parse(poll);
+    if (p.status === 'succeeded') { final = p; break; }
+    if (p.status === 'failed' || p.status === 'canceled') {
+      throw new Error(`Flux ${p.status}: ${JSON.stringify(p.error || p.logs || '').slice(0, 300)}`);
+    }
+  }
+  if (!final) throw new Error(`Flux timed out after 3 min (id=${pred.id})`);
+
+  const imageUrl = Array.isArray(final.output) ? final.output[0] : final.output;
+  if (!imageUrl) throw new Error('Flux returned no image URL');
+  execSync(`curl -sf -m 60 -L -o ${JSON.stringify(outPath)} ${JSON.stringify(imageUrl)}`);
+  if (!haveImg(outPath)) throw new Error(`Flux image download failed (${imageUrl})`);
+  return outPath;
+}
+
+(async () => {
+  const t0 = Date.now();
+  let ok = 0, fail = 0, skip = 0;
+  console.log(`${TAG} 25 renders via Replicate ${MODEL}`);
+  for (let i = 0; i < STYLES.length; i++) {
+    const style = STYLES[i], pal = FASHION_PALETTES[i % FASHION_PALETTES.length];
+    const seed = 770000 + i;
+    const outPath = path.join(OUT, `${i}_flux.png`);
+    if (haveImg(outPath)) { skip++; save(); console.log(`  ${i} flux — skip`); continue; }
+    try { genFlux(buildPrompt(style, pal), seed, outPath); ok++; }
+    catch (e) { fail++; console.log(`  ${i} flux ERR ${e.message.slice(0, 200)}`); }
+    save();
+    console.log(`${TAG} [${i + 1}/25] ${style} · ${pal.brand} — ${haveImg(outPath) ? 'ok' : 'FAIL'} · ${((Date.now() - t0) / 60000).toFixed(1)}min`);
+  }
+  save();
+  console.log(`${TAG} DONE — ${ok} rendered, ${skip} skipped, ${fail} failed · ${((Date.now() - t0) / 60000).toFixed(1)}min`);
+})().catch(e => { console.error(`${TAG} FATAL`, e.message); process.exit(1); });
diff --git a/scripts/gemini_compare.js b/scripts/gemini_compare.js
new file mode 100644
index 0000000..5750d31
--- /dev/null
+++ b/scripts/gemini_compare.js
@@ -0,0 +1,92 @@
+'use strict';
+/**
+ * Column 4 of the refiner comparison — Gemini 2.5 Flash Image.
+ *
+ * Same 25 prompts + seeds as refiner_compare.js, rendered through Google's
+ * generative-language REST API. Gemini 2.5 Flash Image is a hard-prompt
+ * model (no native seed input), so we append the seed integer to the prompt
+ * to bias toward reproducibility — same as the practice in cloud SDXL.
+ *
+ * Cost ≈ $30 per 1M output tokens × ~1290 tokens/image = ~$0.0387/image
+ * × 25 = ~$0.97 total.
+ *
+ *   node scripts/gemini_compare.js
+ */
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+const { FASHION_PALETTES, paletteString } = require('./fashion_palettes');
+const { STYLES, OUT, haveImg, save } = require('./compare_progress');
+
+const TAG = '[gemini]';
+const MODEL = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
+
+function loadKey() {
+  return process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || null;
+}
+
+function buildPrompt(style, pal, seed) {
+  // mirrors refiner_compare.js buildPos() with the same screen-print suffix
+  return `A seamless wallpaper pattern, ${style} motif, ${paletteString(pal)}, ` +
+    'seamless tile, repeating pattern, no edges, fabric pattern, wallpaper design, high detail, archival quality, ' +
+    'screen-printed wallpaper, solid opaque flat ink, flat color blocks, every shape fully filled with solid color, ' +
+    'even saturated pigment coverage, crisp screen-print layers. ' +
+    // pseudo-seed nudge — Gemini doesn't take seeds natively but a stable
+    // integer in the prompt biases regeneration toward consistency.
+    `Pattern seed reference: ${seed}.`;
+}
+
+function genGemini(prompt, outPath) {
+  const KEY = loadKey();
+  if (!KEY) throw new Error('GEMINI_API_KEY not set in wallco-ai/.env');
+
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+  const body = {
+    contents: [{ parts: [{ text: prompt }] }],
+    generationConfig: { responseModalities: ['IMAGE'] },
+  };
+
+  const sh = (cmd, opts) => {
+    try { return execSync(cmd, opts); }
+    catch (e) { throw new Error(String((e && e.message) || e).split(KEY).join('AIza***REDACTED')); }
+  };
+
+  const raw = sh(
+    `curl -sf -m 90 -H 'Content-Type: application/json' -X POST ${JSON.stringify(url)} -d @-`,
+    { input: JSON.stringify(body), encoding: 'utf8', maxBuffer: 40 * 1024 * 1024 },
+  );
+
+  let res;
+  try { res = JSON.parse(raw); }
+  catch { throw new Error(`Gemini non-JSON response: ${raw.slice(0, 300)}`); }
+
+  const cands = res.candidates || [];
+  const parts = (cands[0] && cands[0].content && cands[0].content.parts) || [];
+  const imgPart = parts.find(p => p.inlineData && p.inlineData.data);
+  if (!imgPart) {
+    const txt = parts.find(p => p.text)?.text || '';
+    throw new Error(`Gemini returned no image (text: ${txt.slice(0, 200) || '<none>'})`);
+  }
+  fs.writeFileSync(outPath, Buffer.from(imgPart.inlineData.data, 'base64'));
+  if (!haveImg(outPath)) throw new Error('Gemini wrote a sub-1KB file (likely empty)');
+  return outPath;
+}
+
+(async () => {
+  const t0 = Date.now();
+  let ok = 0, fail = 0, skip = 0;
+  console.log(`${TAG} 25 renders via Gemini ${MODEL}`);
+  for (let i = 0; i < STYLES.length; i++) {
+    const style = STYLES[i], pal = FASHION_PALETTES[i % FASHION_PALETTES.length];
+    const seed = 770000 + i;
+    const outPath = path.join(OUT, `${i}_gemini.png`);
+    if (haveImg(outPath)) { skip++; save(); console.log(`  ${i} gemini — skip`); continue; }
+    try { genGemini(buildPrompt(style, pal, seed), outPath); ok++; }
+    catch (e) { fail++; console.log(`  ${i} gemini ERR ${e.message.slice(0, 200)}`); }
+    save();
+    console.log(`${TAG} [${i + 1}/25] ${style} · ${pal.brand} — ${haveImg(outPath) ? 'ok' : 'FAIL'} · ${((Date.now() - t0) / 60000).toFixed(1)}min`);
+  }
+  save();
+  console.log(`${TAG} DONE — ${ok} rendered, ${skip} skipped, ${fail} failed · ${((Date.now() - t0) / 60000).toFixed(1)}min`);
+})().catch(e => { console.error(`${TAG} FATAL`, e.message); process.exit(1); });
diff --git a/scripts/refiner_compare.js b/scripts/refiner_compare.js
index 67e2887..f8dcdfc 100644
--- a/scripts/refiner_compare.js
+++ b/scripts/refiner_compare.js
@@ -103,21 +103,10 @@ const TAG = SHARDS > 1 ? `[shard ${SHARD}/${SHARDS}] ` : '';
   const t0 = Date.now();
   const TOTAL = STYLES.length * 2;   // 50 renders total across all shards
   const haveImg = p => { try { return fs.statSync(p).size > 1000; } catch { return false; } };
-  // progress.json is derived fresh from disk on every write, so multiple shard
-  // processes can each write it without clobbering — the last writer is always
-  // correct. Resume-safety also keys off these same on-disk files.
-  const save = () => {
-    const rows = STYLES.map((style, i) => ({
-      i, style, brand: FASHION_PALETTES[i % FASHION_PALETTES.length].brand, seed: 770000 + i,
-      base:      haveImg(path.join(OUT, `${i}_base.png`))      ? `${i}_base.png`      : null,
-      refiner:   haveImg(path.join(OUT, `${i}_refiner.png`))   ? `${i}_refiner.png`   : null,
-      // 3rd column — wallco.ai's live app render via Replicate, filled in by
-      // replicate_compare.js. Counted separately, not in the ComfyUI `done`.
-      replicate: haveImg(path.join(OUT, `${i}_replicate.png`)) ? `${i}_replicate.png` : null,
-    }));
-    const done = rows.reduce((n, r) => n + (r.base ? 1 : 0) + (r.refiner ? 1 : 0), 0);
-    fs.writeFileSync(path.join(OUT, 'progress.json'), JSON.stringify({ done, total: TOTAL, rows }, null, 1));
-  };
+  // Shared 6-column progress writer — derives row state fresh from disk
+  // including the 4 cloud columns (replicate/gemini/flux/dalle) so this
+  // writer doesn't clobber them when it races the cloud-backend scripts.
+  const { save } = require('./compare_progress');
   console.log(`${TAG}COMFY=${COMFY}`);
   save();
 
diff --git a/scripts/replicate_compare.js b/scripts/replicate_compare.js
index 9f33388..ba3d949 100644
--- a/scripts/replicate_compare.js
+++ b/scripts/replicate_compare.js
@@ -92,20 +92,9 @@ function genReplicate(prompt, seed, outPath) {
   return outPath;
 }
 
-// ---- progress.json: disk-derived (same schema as refiner_compare.js) so this
-// process and the two shard processes can all write it without clobbering ---
-const TOTAL = STYLES.length * 2;   // 50 = the local ComfyUI render count
-const haveImg = p => { try { return fs.statSync(p).size > 1000; } catch { return false; } };
-const save = () => {
-  const rows = STYLES.map((style, i) => ({
-    i, style, brand: FASHION_PALETTES[i % FASHION_PALETTES.length].brand, seed: 770000 + i,
-    base:      haveImg(path.join(OUT, `${i}_base.png`))      ? `${i}_base.png`      : null,
-    refiner:   haveImg(path.join(OUT, `${i}_refiner.png`))   ? `${i}_refiner.png`   : null,
-    replicate: haveImg(path.join(OUT, `${i}_replicate.png`)) ? `${i}_replicate.png` : null,
-  }));
-  const done = rows.reduce((n, r) => n + (r.base ? 1 : 0) + (r.refiner ? 1 : 0), 0);
-  fs.writeFileSync(path.join(OUT, 'progress.json'), JSON.stringify({ done, total: TOTAL, rows }, null, 1));
-};
+// ---- progress.json: 6-column writer (shared helper, derives from disk so
+// all 4 backends + 2 ComfyUI shards can write concurrently without clobber).
+const { save, haveImg } = require('./compare_progress');
 
 (async () => {
   const t0 = Date.now();

← 187a96b /designs HTML: filter cards by designHasImage (mirror /api/d  ·  back to Wallco Ai  ·  report: dw-stale-urls trim-B HALTED (0 orphans, outside band fd7b0dc →