[object Object]

← back to Wallco Ai

joint-fix: surgical seam repair (95%-preserve, edges only)

1a05f4b075ec35d779394bbfa3cea75778d74a41 · 2026-05-24 22:27:50 -0700 · Steve Abrams

Different fix tool from smart-fix-v2. Smart-fix-v2 throws away the source
design and regenerates from a motif description — produces a NEW design,
beautiful but different from the original. Wrong tool when the source has
95% of a good design and only the joints are broken.

Joint-fix preserves the central 87.5% of the source EXACTLY and repaints
only the outer 6.25% edge band so the tile wraps. Uses
gemini-2.5-flash-image-edit via the existing crop-fix endpoint with:
  • region = entire image
  • keep_regions = central 87.5% square (drawn as green box on input)
  • fix_kind = 'tile-edge' (new prompt added to server.js)

server.js — new 'tile-edge' fix_kind in crop-fix kindPrompt dict. Prompt
explicitly: "keep central green-marked region EXACTLY... in outer band,
redraw so left edge becomes pixel-perfect continuation of right edge,
top edge becomes continuation of bottom edge... same palette, no new
motif types."

scripts/joint-fix-batch.js — batch runner. Reads
data/composition-scan-flagged.jsonl (same skip filters as
auto-fix-composition: scenic categories, recolor, bgswap). Calls crop-fix
with the tile-edge mode. Logs to data/joint-fix-log.jsonl.

Smoke-tested on 3 known-broken sources (35072 skull, 35965 ikat, 36448
green damask) — 3/3 succeeded in 7-8s each, central composition preserved,
edges redrawn to tile. ~3x faster than v2 smart-fix because no detect step,
no opacity verify, no composition retry loop — just one Gemini image-edit call.

Files touched

Diff

commit 1a05f4b075ec35d779394bbfa3cea75778d74a41
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 22:27:50 2026 -0700

    joint-fix: surgical seam repair (95%-preserve, edges only)
    
    Different fix tool from smart-fix-v2. Smart-fix-v2 throws away the source
    design and regenerates from a motif description — produces a NEW design,
    beautiful but different from the original. Wrong tool when the source has
    95% of a good design and only the joints are broken.
    
    Joint-fix preserves the central 87.5% of the source EXACTLY and repaints
    only the outer 6.25% edge band so the tile wraps. Uses
    gemini-2.5-flash-image-edit via the existing crop-fix endpoint with:
      • region = entire image
      • keep_regions = central 87.5% square (drawn as green box on input)
      • fix_kind = 'tile-edge' (new prompt added to server.js)
    
    server.js — new 'tile-edge' fix_kind in crop-fix kindPrompt dict. Prompt
    explicitly: "keep central green-marked region EXACTLY... in outer band,
    redraw so left edge becomes pixel-perfect continuation of right edge,
    top edge becomes continuation of bottom edge... same palette, no new
    motif types."
    
    scripts/joint-fix-batch.js — batch runner. Reads
    data/composition-scan-flagged.jsonl (same skip filters as
    auto-fix-composition: scenic categories, recolor, bgswap). Calls crop-fix
    with the tile-edge mode. Logs to data/joint-fix-log.jsonl.
    
    Smoke-tested on 3 known-broken sources (35072 skull, 35965 ikat, 36448
    green damask) — 3/3 succeeded in 7-8s each, central composition preserved,
    edges redrawn to tile. ~3x faster than v2 smart-fix because no detect step,
    no opacity verify, no composition retry loop — just one Gemini image-edit call.
---
 scripts/joint-fix-batch.js | 208 +++++++++++++++++++++++++++++++++++++++++++++
 server.js                  |   1 +
 2 files changed, 209 insertions(+)

diff --git a/scripts/joint-fix-batch.js b/scripts/joint-fix-batch.js
new file mode 100644
index 0000000..3ad88d7
--- /dev/null
+++ b/scripts/joint-fix-batch.js
@@ -0,0 +1,208 @@
+#!/usr/bin/env node
+// joint-fix-batch — surgical seam repair on tile designs whose interior
+// is good but whose edges don't tile seamlessly.
+//
+// Why this exists: smart-fix-v2 (the auto-fix-composition.js path) throws
+// away the source design entirely and regenerates from a motif description.
+// That works for "the existing design is unsalvageable" but produces a
+// different design — losing 95% of what was right.
+//
+// Joint-fix instead:
+//   1. Keeps the central 87.5% of the source UNCHANGED (a green keep_region)
+//   2. Repaints ONLY the outer 6.25% edge band so the tile actually wraps
+//   3. Uses gemini-2.5-flash-image-edit via the existing crop-fix endpoint
+//      with fix_kind='tile-edge' (added 2026-05-25)
+//
+// This is the right tool when the source has a coherent design that just
+// has broken joints — e.g. one big centered motif with corner-fragments
+// that don't continue across edges.
+//
+// Usage:
+//   node scripts/joint-fix-batch.js --from composition-flagged  # default
+//   node scripts/joint-fix-batch.js --limit 5                   # smoke test
+//   node scripts/joint-fix-batch.js --concurrency 2             # default 2
+//   node scripts/joint-fix-batch.js --inner 87.5                # keep-box size (%)
+//   node scripts/joint-fix-batch.js --ids 35965,36448,35072     # explicit ids
+//   node scripts/joint-fix-batch.js --port 9877                 # server port
+//   node scripts/joint-fix-batch.js --dry-run                   # list, no curl
+//
+// Output:
+//   data/joint-fix-log.jsonl — one row per attempt:
+//     { src_id, new_id, ok, elapsed_s, ts, src_category, inner_pct, error? }
+
+const fs = require('fs');
+const path = require('path');
+const { SCENIC_CATEGORIES } = require('../lib/composition-detector');
+
+const ARGS = process.argv.slice(2);
+const arg = (n, d) => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i + 1] : d; };
+const FROM        = arg('from', 'composition-flagged');
+const LIMIT       = parseInt(arg('limit', '0'), 10);
+const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '2'), 10));
+const INNER_PCT   = parseFloat(arg('inner', '87.5'));
+const ID_LIST     = arg('ids', null);
+const PORT        = parseInt(arg('port', '9877'), 10);
+const DRY_RUN     = ARGS.includes('--dry-run');
+
+const ROOT = path.join(__dirname, '..');
+const FLAGGED_F = path.join(ROOT, 'data', 'composition-scan-flagged.jsonl');
+const LOG_F     = path.join(ROOT, 'data', 'joint-fix-log.jsonl');
+
+const SKIP_CATEGORIES = new Set([
+  'recolor', 'bgswap', 'bg-swap',
+]);
+
+function isSkipCategory(category) {
+  if (!category) return true;
+  const base = category.split('·')[0].trim().toLowerCase();
+  if (SCENIC_CATEGORIES.has(base)) return true;
+  if (SKIP_CATEGORIES.has(base)) return true;
+  return false;
+}
+
+function loadFlagged() {
+  if (!fs.existsSync(FLAGGED_F)) return [];
+  const lines = fs.readFileSync(FLAGGED_F, 'utf8').trim().split('\n');
+  const items = [];
+  const seen = new Set();
+  for (const line of lines) {
+    if (!line.trim()) continue;
+    try {
+      const r = JSON.parse(line);
+      if (!r.id || seen.has(r.id)) continue;
+      seen.add(r.id);
+      items.push(r);
+    } catch {}
+  }
+  return items;
+}
+
+function loadDone() {
+  if (!fs.existsSync(LOG_F)) return new Set();
+  const ids = new Set();
+  for (const line of fs.readFileSync(LOG_F, 'utf8').split('\n')) {
+    if (!line.trim()) continue;
+    try {
+      const r = JSON.parse(line);
+      if (r.ok && r.new_id) ids.add(r.src_id);
+    } catch {}
+  }
+  return ids;
+}
+
+async function fireJointFix(id, category) {
+  // Build the crop-fix payload. region = whole image. keep_regions = the
+  // central INNER_PCT square (preserves the interior). fix_kind = 'tile-edge'
+  // — the new prompt added to server.js for seam repair (2026-05-25).
+  const off = (100 - INNER_PCT) / 2;
+  const body = {
+    region: { x: 0, y: 0, w: 100, h: 100 },
+    keep_regions: [{ x: off, y: off, w: INNER_PCT, h: INNER_PCT }],
+    fix_kind: 'tile-edge',
+  };
+  const t0 = Date.now();
+  try {
+    const r = await fetch(`http://127.0.0.1:${PORT}/api/design/${id}/crop-fix`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(body),
+    });
+    const text = await r.text();
+    let j; try { j = JSON.parse(text); } catch { j = { raw: text.slice(0, 240) }; }
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    return { ...j, elapsed_local_s: elapsed, http_status: r.status };
+  } catch (e) {
+    const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+    return { error: e.message, elapsed_local_s: elapsed };
+  }
+}
+
+async function runPool(items, workers, onResult) {
+  let cursor = 0;
+  const next = () => (cursor < items.length ? items[cursor++] : null);
+  async function workerLoop() {
+    while (true) {
+      const r = next();
+      if (!r) return;
+      await onResult(r);
+      await new Promise(res => setTimeout(res, 300));
+    }
+  }
+  await Promise.all(Array.from({ length: workers }, workerLoop));
+}
+
+function pgLookup(ids) {
+  // Pull category + local_path for ad-hoc ids (when --ids passed)
+  const { spawnSync } = require('child_process');
+  const r = spawnSync('psql', ['dw_unified', '-At', '-F|', '-c',
+    `SELECT id, category, local_path FROM spoon_all_designs WHERE id = ANY(ARRAY[${ids.join(',')}]::int[])`],
+    { encoding: 'utf8' });
+  const rows = [];
+  for (const line of (r.stdout || '').split('\n')) {
+    if (!line.trim()) continue;
+    const [id, cat, lp] = line.split('|');
+    rows.push({ id: parseInt(id, 10), category: cat, local_path: lp });
+  }
+  return rows;
+}
+
+(async () => {
+  console.log(`[joint-fix] starting · from=${FROM} limit=${LIMIT || 'all'} concurrency=${CONCURRENCY} inner=${INNER_PCT}% port=${PORT} dry_run=${DRY_RUN}`);
+  let flagged;
+  if (ID_LIST) {
+    const ids = ID_LIST.split(',').map(s => parseInt(s, 10)).filter(Number.isFinite);
+    flagged = pgLookup(ids);
+    console.log(`[joint-fix] explicit ids: ${flagged.length}`);
+  } else {
+    flagged = loadFlagged();
+    console.log(`[joint-fix] flagged total: ${flagged.length}`);
+  }
+  const done = loadDone();
+  console.log(`[joint-fix] already done: ${done.size}`);
+
+  const candidates = [];
+  let skipCat = 0, skipDone = 0;
+  for (const f of flagged) {
+    if (done.has(f.id)) { skipDone++; continue; }
+    if (isSkipCategory(f.category)) { skipCat++; continue; }
+    candidates.push(f);
+  }
+  const todo = LIMIT > 0 ? candidates.slice(0, LIMIT) : candidates;
+  console.log(`[joint-fix] skipped: ${skipDone} done + ${skipCat} skip-cat · todo: ${todo.length}`);
+
+  if (DRY_RUN) {
+    console.log('\n[dry-run] would joint-fix:');
+    for (const c of todo.slice(0, 20)) {
+      console.log(`  ${c.id.toString().padStart(5)} · ${c.category}`);
+    }
+    if (todo.length > 20) console.log(`  ... + ${todo.length - 20} more`);
+    return;
+  }
+
+  let n = 0, ok = 0, errored = 0;
+  const t0 = Date.now();
+  await runPool(todo, CONCURRENCY, async (c) => {
+    n++;
+    const r = await fireJointFix(c.id, c.category);
+    const row = {
+      ts: new Date().toISOString(),
+      src_id: c.id,
+      src_category: c.category,
+      new_id: r.new_id || null,
+      ok: !!r.ok && !!r.new_id,
+      inner_pct: INNER_PCT,
+      elapsed_s: r.elapsed_s || r.elapsed_local_s,
+      http_status: r.http_status,
+      error: r.error || null,
+    };
+    fs.appendFileSync(LOG_F, JSON.stringify(row) + '\n');
+    if (row.ok) ok++; else errored++;
+    const rate = n / ((Date.now() - t0) / 1000);
+    const eta = ((todo.length - n) / Math.max(0.001, rate)).toFixed(0);
+    const flag = row.ok ? '✓' : '✗';
+    const err = row.error ? ` ERR: ${row.error.slice(0, 80)}` : '';
+    console.log(`[joint-fix] ${flag} ${n}/${todo.length} src=${c.id} → new=${row.new_id || '-'} (${row.elapsed_s}s)${err} · rate=${rate.toFixed(2)}/s ETA=${eta}s`);
+  });
+  console.log(`\n[joint-fix] done · processed=${n} ok=${ok} errored=${errored}`);
+  console.log(`[joint-fix] log: ${LOG_F}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/server.js b/server.js
index 61bd95a..e91837d 100644
--- a/server.js
+++ b/server.js
@@ -7154,6 +7154,7 @@ print('OK', left, top, right, bottom, 'keeps=', len(keeps))
       'overlap':     'There is an OVERLAP / collision inside the red rectangle — two motifs are intersecting in a way that reads as a single confused mass. Separate the colliding shapes so each motif has its own clear silhouette boundary. Preserve the count of distinct subjects, just clean up the edges.',
       'composition': 'The composition inside the red rectangle is off — too dense / too empty / wrong scale relative to the rest of the canvas. Regenerate that region only, matching the density and scale of the surrounding areas.',
       'remove-fill': 'ERASE everything inside the red rectangle and fill it back in with the BACKGROUND pattern / negative space that surrounds it. Use the same palette and the same flat single-layer aesthetic. Do NOT add any new motifs in the erased region — only the surrounding background, ground tone, and continuation of any pattern (e.g. continue stripes, continue plain field, continue ground texture). The result should look like the defective content was never there.',
+      'tile-edge':   'This wallpaper tile fails to repeat seamlessly — the joints where the left edge meets the right edge, and the top edge meets the bottom edge, do not continue cleanly. Repaint ONLY the band between the red rectangle (the outer edge of the canvas) and the green rectangle (the preserved interior). Keep the central green-marked region EXACTLY as it is — same motifs, same colors, same composition, same positions. In the outer band, redraw the edges so the LEFT edge becomes a pixel-perfect continuation of the RIGHT edge (a motif partly clipped on the right edge continues on the left edge of the same tile), and the TOP edge becomes a pixel-perfect continuation of the BOTTOM edge. Use the same palette and motif vocabulary as the green interior — do NOT add new motif types. The result should look identical to the input from inside the green box, but tile seamlessly when placed adjacent to copies of itself.',
     })[fixKind] || 'Regenerate the area inside the red rectangle as a flat clean silhouette in the existing palette.';
 
     const keepPromptPart = keepRegions.length ? (

← a2e6fe6 generate: wire frame-overlay gate + strengthen anti-frame pr  ·  back to Wallco Ai  ·  fix(learn-and-purge): PG-side vendor-cat safety net 78cd6df →