← back to Wallco Ai
feat(admin): Crop & Fix tool — drag rect on any design, Gemini regenerates that region
d2d3d8ed97493536f31bf53be87dc9ec44cca466 · 2026-05-22 13:56:45 -0700 · Steve Abrams
Admin button on /design/:id (alongside Ghosting + Settlement Violation) for
fixing ghost-layer / overlap / composition artifacts WITHOUT regenerating
the whole design. Cheaper, faster, and surgically preserves the good parts.
UI flow (admin-only):
- Click 'Crop & Fix' button → modal opens with source image
- Drag a rectangle over the problem area
- Pick fix kind from dropdown: ghost-layer / overlap / composition
- Submit → status: 'sending to Gemini image-edit · ~10s…'
- ~10s later: redirects to the new fixed design
Backend POST /api/design/:id/crop-fix:
- Admin-gated (ADMIN_TOKEN via X-Admin-Token header OR ?admin= query)
- Body: { region: {x,y,w,h} as 0-100% of image, fix_kind: 'ghost-layer'|'overlap'|'composition' }
- Composites a 4px red bbox onto a copy of the source PNG via Python+PIL
- Calls gemini-2.5-flash-image-edit with a kind-specific prompt that:
1. Names the issue inside the red rectangle (ghost layer / overlap / wrong density)
2. Tells Gemini to preserve EVERYTHING outside the rectangle exactly
3. Tells Gemini to remove the red rectangle outline from the output
4. Constrains to existing palette + flat hand-painted-silk aesthetic
- Saves result as a NEW spoon_all_designs row with parent_design_id=src_id
- Unpublishes the original (admin can republish if the fix is worse)
- Returns { ok, new_id, elapsed_s, src_id, fix_kind }
Cost: ~$0.04/fix (gemini-2.5-flash-image-edit single call). Logged via
~/.claude/skills/cost-tracker.
Smoke (with admin token):
rating-crop-fix button rendered ✓ (3 hits in HTML)
crop-fix-modal markup rendered ✓
cf-img element present ✓
POST endpoint admin-gated ✓ (403 without, 400 'bad region' with)
Use cases:
- Ghost-layer artifacts SDXL's anti-prompt blindness produces
- Two cactus + pine motifs overlapping into a single confused silhouette
- Wrong-density patches (too crowded / too empty) in an otherwise good design
Codifies the SDXL→Gemini 2-pass recipe at SURGICAL granularity — fix just
the broken region, ship 80% of the original.
Files touched
Diff
commit d2d3d8ed97493536f31bf53be87dc9ec44cca466
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri May 22 13:56:45 2026 -0700
feat(admin): Crop & Fix tool — drag rect on any design, Gemini regenerates that region
Admin button on /design/:id (alongside Ghosting + Settlement Violation) for
fixing ghost-layer / overlap / composition artifacts WITHOUT regenerating
the whole design. Cheaper, faster, and surgically preserves the good parts.
UI flow (admin-only):
- Click 'Crop & Fix' button → modal opens with source image
- Drag a rectangle over the problem area
- Pick fix kind from dropdown: ghost-layer / overlap / composition
- Submit → status: 'sending to Gemini image-edit · ~10s…'
- ~10s later: redirects to the new fixed design
Backend POST /api/design/:id/crop-fix:
- Admin-gated (ADMIN_TOKEN via X-Admin-Token header OR ?admin= query)
- Body: { region: {x,y,w,h} as 0-100% of image, fix_kind: 'ghost-layer'|'overlap'|'composition' }
- Composites a 4px red bbox onto a copy of the source PNG via Python+PIL
- Calls gemini-2.5-flash-image-edit with a kind-specific prompt that:
1. Names the issue inside the red rectangle (ghost layer / overlap / wrong density)
2. Tells Gemini to preserve EVERYTHING outside the rectangle exactly
3. Tells Gemini to remove the red rectangle outline from the output
4. Constrains to existing palette + flat hand-painted-silk aesthetic
- Saves result as a NEW spoon_all_designs row with parent_design_id=src_id
- Unpublishes the original (admin can republish if the fix is worse)
- Returns { ok, new_id, elapsed_s, src_id, fix_kind }
Cost: ~$0.04/fix (gemini-2.5-flash-image-edit single call). Logged via
~/.claude/skills/cost-tracker.
Smoke (with admin token):
rating-crop-fix button rendered ✓ (3 hits in HTML)
crop-fix-modal markup rendered ✓
cf-img element present ✓
POST endpoint admin-gated ✓ (403 without, 400 'bad region' with)
Use cases:
- Ghost-layer artifacts SDXL's anti-prompt blindness produces
- Two cactus + pine motifs overlapping into a single confused silhouette
- Wrong-density patches (too crowded / too empty) in an otherwise good design
Codifies the SDXL→Gemini 2-pass recipe at SURGICAL granularity — fix just
the broken region, ship 80% of the original.
---
server.js | 330 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 330 insertions(+)
diff --git a/server.js b/server.js
index fcfd0b0..e972ddb 100644
--- a/server.js
+++ b/server.js
@@ -3755,6 +3755,13 @@ app.get('/designs', (req, res) => {
const want = hospQ.toLowerCase().replace(/-/g, ' ');
filtered = filtered.filter(d => String(hospitalityFit(d) || '').toLowerCase() === want);
}
+ // Studio composition pack filter — `?studio_pack=Desert|Coast|Forest`.
+ // Only meaningful with line=Studio (classifier returns null for Scenic).
+ const spackQ = String(req.query.studio_pack || '').trim();
+ if (spackQ) {
+ const want = spackQ.toLowerCase();
+ filtered = filtered.filter(d => String(studioPack(d) || '').toLowerCase() === want);
+ }
if (cat) filtered = filtered.filter(d => d.category === cat);
if (motifQ) filtered = filtered.filter(d => (d.motifs || []).includes(motifQ));
if (hueQ) filtered = filtered.filter(d => hueBucketOf(d.dominant_hex) === hueQ);
@@ -6760,6 +6767,161 @@ app.post('/api/design/:id/report-ghost', express.json({ limit: '4kb' }), (req, r
// delete → HARD delete + log fingerprint to ghosting do-not-want registry
// (same pattern as /settlement-violation).
+// POST /api/design/:id/crop-fix — admin crop-region fix tool.
+// Body: { region: {x,y,w,h} as PERCENT of image (0-100), fix_kind: 'ghost-layer'|'overlap'|'composition' }
+// 1. Composites a red 4px bbox around the cropped region on a copy of the source PNG
+// 2. Calls gemini-2.5-flash-image-edit with a "regenerate inside the red rectangle" prompt
+// 3. Saves the result as a NEW spoon_all_designs row, copies palette/category from source
+// 4. Unpublishes the original (admin can re-publish if the fix is worse)
+// 5. Returns { ok, new_id } so the modal can hop to the fixed design
+app.post('/api/design/:id/crop-fix', express.json({ limit: '16kb' }), async (req, res) => {
+ if (!adminRatingGate(req, res)) return;
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ const region = req.body?.region;
+ if (!region || ['x','y','w','h'].some(k => !Number.isFinite(region[k]))) {
+ return res.status(400).json({ error: 'region {x,y,w,h} required' });
+ }
+ if (region.w < 1 || region.h < 1 || region.w > 100 || region.h > 100) {
+ return res.status(400).json({ error: 'region dims must be 1-100 (percent)' });
+ }
+ const fixKind = String(req.body?.fix_kind || 'ghost-layer');
+
+ const GEMINI_KEY = process.env.GEMINI_API_KEY;
+ if (!GEMINI_KEY) return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
+
+ try {
+ // Load source row (PG → in-memory cache fallback)
+ let d = null;
+ try {
+ const pgRaw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, prompt, seed, category,
+ width_in, height_in, panels, motifs, tags, generator, dominant_hex, palette, local_path,
+ product_line, parent_design_id, dig_number
+ FROM spoon_all_designs WHERE id=${id}) t;`);
+ if (pgRaw && pgRaw.length > 2) d = JSON.parse(pgRaw);
+ } catch { /* non-fatal */ }
+ if (!d) {
+ const cached = DESIGNS.find(x => x.id === id);
+ if (cached) d = { ...cached };
+ }
+ if (!d) return res.status(404).json({ error: 'design not found' });
+ if (!d.local_path || !fs.existsSync(d.local_path)) {
+ return res.status(404).json({ error: 'source PNG missing on disk' });
+ }
+
+ // Composite a red bbox onto a copy of the source. Python+PIL stays simple
+ // (we already use PIL elsewhere for palette extraction).
+ const srcBuf = fs.readFileSync(d.local_path);
+ const composedPath = path.join(require('os').tmpdir(), `cf_${id}_${Date.now()}.png`);
+ const py = require('child_process').spawnSync('python3', ['-c', `
+import sys, os
+from PIL import Image, ImageDraw
+src = Image.open(sys.argv[1]).convert('RGBA')
+w, h = src.size
+import json
+r = json.loads(sys.argv[2])
+left = int(round(r['x']/100.0 * w))
+top = int(round(r['y']/100.0 * h))
+right = int(round((r['x']+r['w'])/100.0 * w))
+bottom = int(round((r['y']+r['h'])/100.0 * h))
+draw = ImageDraw.Draw(src)
+for off in range(4):
+ draw.rectangle([left-off, top-off, right+off, bottom+off], outline=(220, 28, 28, 255), width=1)
+src.save(sys.argv[3], 'PNG')
+print('OK', left, top, right, bottom)
+`, d.local_path, JSON.stringify(region), composedPath], { encoding: 'utf8' });
+ if (py.status !== 0) {
+ return res.status(500).json({ error: 'bbox composite failed: ' + (py.stderr || py.stdout || '?').slice(0, 240) });
+ }
+
+ // Build the Gemini edit prompt — kind-specific guidance for what's wrong
+ // in the region. The "preserve outside" line is critical: Gemini's image
+ // edit drifts the entire image without it.
+ const kindPrompt = ({
+ 'ghost-layer': 'There is a GHOST-LAYER artifact inside the red rectangle — faint duplicated motifs, ghosted edges, or layered bleed. Regenerate JUST that region in the same flat single-layer silhouette aesthetic as the rest of the image, using the same palette tones. No translucency, no fading, no second layer.',
+ '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.',
+ })[fixKind] || 'Regenerate the area inside the red rectangle as a flat clean silhouette in the existing palette.';
+
+ const editPrompt = (
+ `Fix the marked region of this hand-painted scenic wallcovering. ${kindPrompt} ` +
+ `CRITICAL: Preserve EVERYTHING outside the red rectangle EXACTLY as it is — same composition, same silhouettes, same palette, same positions. ` +
+ `Only the area INSIDE the red rectangle should change. ` +
+ `Remove the red rectangle outline from the output — it is a guide marker, not part of the final design. ` +
+ `Keep the flat hand-painted-silk aesthetic: hard razor-sharp silhouette edges, single-layer screenprint, NO gradient, NO atmospheric perspective, NO half-tones. ` +
+ `Existing palette: ground tone is the dominant background color; figure tone is the silhouette color. Use them only — no new colors. ` +
+ `No watermark, no text, no border, no signature.`
+ );
+
+ const imageB64 = fs.readFileSync(composedPath).toString('base64');
+ const body = {
+ contents: [{ parts: [
+ { inline_data: { mime_type: 'image/png', data: imageB64 } },
+ { text: editPrompt },
+ ]}],
+ generationConfig: { responseModalities: ['IMAGE'] },
+ };
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`;
+ const t0 = Date.now();
+ const r = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ if (!r.ok) return res.status(502).json({ error: 'gemini http ' + r.status + ': ' + (await r.text()).slice(0, 240) });
+ const j = await r.json();
+ try {
+ const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
+ logGemini(j, { app: 'wallco-ai', note: 'crop-fix-' + fixKind + '-from-' + id, model: 'gemini-2.5-flash-image' });
+ } catch { /* non-fatal */ }
+ const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+ const data = part?.inline_data?.data || part?.inlineData?.data;
+ if (!data) {
+ const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text;
+ return res.status(502).json({ error: 'gemini returned no image: ' + (text || '').slice(0, 240) });
+ }
+
+ // Save result + insert new row
+ const newSeed = require('crypto').randomInt(1, 2 ** 31 - 1);
+ const filename = `cropfix_${id}_${Date.now()}_${newSeed}.png`;
+ const outPath = path.join(__dirname, 'data', 'generated', filename);
+ fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+ try { fs.unlinkSync(composedPath); } catch {}
+
+ const esc = (v) => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+ const ins = psqlExecLocal(`
+INSERT INTO spoon_all_designs
+ (kind, brand, width_in, height_in, panels, generator, prompt, seed,
+ image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published,
+ parent_design_id)
+VALUES
+ (${esc(d.kind || 'mural_panel')}, 'wallco.ai',
+ ${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
+ 'gemini-2.5-flash-image-edit',
+ ${esc(`Crop & Fix ${fixKind} of #${id} (region x=${region.x.toFixed(1)}% y=${region.y.toFixed(1)}% w=${region.w.toFixed(1)}% h=${region.h.toFixed(1)}%): ${editPrompt.slice(0, 1200)}`)},
+ ${newSeed},
+ '/designs/img/by-id/__NEW__',
+ ${esc(outPath)},
+ ${esc(d.dominant_hex)},
+ ${esc(JSON.stringify(d.palette || []))}::jsonb,
+ ${d.motifs ? `ARRAY[${(d.motifs || []).map(m => esc(m)).join(',')}]::text[]` : 'NULL'},
+ ${d.tags ? `ARRAY[${(d.tags || []).map(t => esc(t)).join(',')}]::text[]` : 'NULL'},
+ ${esc(d.category)},
+ TRUE,
+ ${id})
+RETURNING id`);
+ const newId = parseInt(ins, 10);
+ if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
+ psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
+ psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+ return res.json({ ok: true, new_id: newId, elapsed_s: elapsed, src_id: id, fix_kind: fixKind });
+ } catch (e) {
+ console.error('[crop-fix]', e);
+ return res.status(500).json({ error: e.message });
+ }
+});
+
app.post('/api/design/:id/ghosting', express.json({ limit: '16kb' }), (req, res) => {
if (!adminRatingGate(req, res)) return;
const id = parseInt(req.params.id, 10);
@@ -7489,6 +7651,46 @@ ${htmlHeader('/designs')}
<button id="rating-settlement" type="button"
style="background:#b00020;border:1px solid #b00020;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
title="HARD delete: removes from URL (404), logs motifs/prompt to settlement do-not-want registry, kicks a fresh regen with this violation excluded">Settlement Violation</button>
+ <button id="rating-crop-fix" type="button"
+ style="background:#0a7c59;border:1px solid #0a7c59;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
+ title="Crop & Fix: drag a rectangle over the ghost-layer / overlap area; Gemini regenerates JUST that region in flat clean silhouette, preserving everything else">Crop & Fix</button>
+ </div>
+ </div>
+
+ <!-- Crop & Fix modal (admin) — drag-to-select rectangle over the
+ source image, submit → Gemini image-edit regenerates the cropped
+ region. Hidden by default; opens on rating-crop-fix click. -->
+ <div id="crop-fix-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:9999;align-items:center;justify-content:center;padding:24px">
+ <div style="background:#fff;border-radius:12px;width:min(96vw, 1080px);max-height:96vh;overflow:auto;padding:18px">
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
+ <h3 style="margin:0;font:600 16px var(--sans,system-ui)">Crop & Fix — drag a rectangle over the problem area</h3>
+ <button type="button" id="cf-close" style="background:none;border:0;font-size:22px;cursor:pointer;color:#666">×</button>
+ </div>
+ <p style="font:12px var(--sans,system-ui);color:var(--ink-soft,#555);margin:0 0 12px">
+ Drag a rectangle over the ghost-layer or overlap area. Gemini will regenerate JUST that region in flat clean silhouette,
+ preserving everything outside the rectangle. Saved as a new design ID; the original gets unpublished but stays in the DB.
+ </p>
+ <div id="cf-canvas-wrap" style="position:relative;display:inline-block;width:100%;line-height:0;background:#000;border-radius:6px;overflow:hidden">
+ <img id="cf-img" src="${design.image_url || ('/designs/img/by-id/' + design.id)}" style="width:100%;display:block;-webkit-user-drag:none" draggable="false" alt="">
+ <div id="cf-rect" style="position:absolute;border:2px dashed #0a7c59;background:rgba(10,124,89,.18);pointer-events:none;display:none"></div>
+ </div>
+ <div style="margin-top:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
+ <span id="cf-coords" style="font:11px ui-monospace,Menlo,monospace;color:var(--ink-soft,#555)">no region selected</span>
+ <label style="font:11px var(--sans,system-ui);color:var(--ink-soft,#555);display:flex;align-items:center;gap:6px">
+ Fix kind:
+ <select id="cf-kind" style="font:12px var(--sans,system-ui);padding:4px 8px;border:1px solid #ccc;border-radius:4px">
+ <option value="ghost-layer">Ghost layer — clean to flat silhouette</option>
+ <option value="overlap">Overlap — separate the colliding motifs</option>
+ <option value="composition">Composition — regenerate this region only</option>
+ </select>
+ </label>
+ <div style="margin-left:auto;display:flex;gap:8px">
+ <button type="button" id="cf-cancel" style="background:none;border:1px solid #999;color:#444;padding:7px 16px;border-radius:6px;cursor:pointer;font-size:12px">Cancel</button>
+ <button type="button" id="cf-submit" disabled
+ style="background:#0a7c59;border:1px solid #0a7c59;color:#fff;padding:7px 18px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;opacity:.5">Fix this region →</button>
+ </div>
+ </div>
+ <div id="cf-status" style="margin-top:10px;font:12px var(--sans,system-ui);color:var(--ink-soft,#555);min-height:20px"></div>
</div>
</div>
@@ -7716,6 +7918,117 @@ ${htmlHeader('/designs')}
})
.catch(function(e){ statusEl.textContent = 'ghosting error: ' + e.message; });
});
+
+ // ── Crop & Fix admin tool ─────────────────────────────────────
+ // Drag-rectangle over the source image, submit → Gemini image-edit
+ // regenerates JUST the cropped region. Modal-only UI; the server
+ // endpoint at /api/design/:id/crop-fix is admin-gated.
+ var cfBtn = document.getElementById('rating-crop-fix');
+ var cfModal = document.getElementById('crop-fix-modal');
+ var cfImg = document.getElementById('cf-img');
+ var cfRect = document.getElementById('cf-rect');
+ var cfCoords = document.getElementById('cf-coords');
+ var cfKind = document.getElementById('cf-kind');
+ var cfClose = document.getElementById('cf-close');
+ var cfCancel = document.getElementById('cf-cancel');
+ var cfSubmit = document.getElementById('cf-submit');
+ var cfStat = document.getElementById('cf-status');
+ var cfWrap = document.getElementById('cf-canvas-wrap');
+ var cfRegion = null; // { x, y, w, h } in PERCENT of image (0-100)
+
+ function openCf(){
+ if (!cfModal) return;
+ cfModal.style.display = 'flex';
+ cfRegion = null;
+ cfRect.style.display = 'none';
+ cfCoords.textContent = 'no region selected';
+ cfSubmit.disabled = true; cfSubmit.style.opacity = '.5';
+ cfStat.textContent = '';
+ }
+ function closeCf(){ if (cfModal) cfModal.style.display = 'none'; }
+ if (cfBtn) cfBtn.addEventListener('click', openCf);
+ if (cfClose) cfClose.addEventListener('click', closeCf);
+ if (cfCancel) cfCancel.addEventListener('click', closeCf);
+ if (cfModal) cfModal.addEventListener('click', function(e){ if (e.target === cfModal) closeCf(); });
+
+ // Drag-to-select rectangle. Coordinates stored as PERCENT of the
+ // image's intrinsic dimensions, so they translate cleanly to the
+ // server-side full-resolution PNG regardless of viewport scaling.
+ var cfDrag = null;
+ if (cfWrap) {
+ cfWrap.addEventListener('mousedown', function(e){
+ if (e.target !== cfImg) return;
+ var rect = cfImg.getBoundingClientRect();
+ cfDrag = {
+ startXpx: e.clientX - rect.left,
+ startYpx: e.clientY - rect.top,
+ rectW: rect.width,
+ rectH: rect.height,
+ };
+ cfRect.style.display = 'block';
+ cfRect.style.left = cfDrag.startXpx + 'px';
+ cfRect.style.top = cfDrag.startYpx + 'px';
+ cfRect.style.width = '0px';
+ cfRect.style.height = '0px';
+ e.preventDefault();
+ });
+ window.addEventListener('mousemove', function(e){
+ if (!cfDrag) return;
+ var rect = cfImg.getBoundingClientRect();
+ var x = Math.max(0, Math.min(rect.width, e.clientX - rect.left));
+ var y = Math.max(0, Math.min(rect.height, e.clientY - rect.top));
+ var L = Math.min(cfDrag.startXpx, x);
+ var T = Math.min(cfDrag.startYpx, y);
+ var W = Math.abs(x - cfDrag.startXpx);
+ var H = Math.abs(y - cfDrag.startYpx);
+ cfRect.style.left = L + 'px';
+ cfRect.style.top = T + 'px';
+ cfRect.style.width = W + 'px';
+ cfRect.style.height = H + 'px';
+ });
+ window.addEventListener('mouseup', function(){
+ if (!cfDrag) return;
+ var L = parseFloat(cfRect.style.left) || 0;
+ var T = parseFloat(cfRect.style.top) || 0;
+ var W = parseFloat(cfRect.style.width) || 0;
+ var H = parseFloat(cfRect.style.height)|| 0;
+ if (W < 12 || H < 12) {
+ cfRect.style.display = 'none';
+ cfDrag = null;
+ return;
+ }
+ cfRegion = {
+ x: (L / cfDrag.rectW) * 100,
+ y: (T / cfDrag.rectH) * 100,
+ w: (W / cfDrag.rectW) * 100,
+ h: (H / cfDrag.rectH) * 100,
+ };
+ cfCoords.textContent = 'region: x=' + cfRegion.x.toFixed(1) + '% y=' + cfRegion.y.toFixed(1) + '% w=' + cfRegion.w.toFixed(1) + '% h=' + cfRegion.h.toFixed(1) + '%';
+ cfSubmit.disabled = false; cfSubmit.style.opacity = '1';
+ cfDrag = null;
+ });
+ }
+
+ if (cfSubmit) cfSubmit.addEventListener('click', function(){
+ if (!cfRegion) return;
+ cfSubmit.disabled = true; cfSubmit.style.opacity = '.5';
+ cfStat.textContent = 'sending to Gemini image-edit · ~10s…';
+ var params = new URLSearchParams(window.location.search);
+ var adminToken = params.get('admin') || localStorage.getItem('wallco_admin_token') || '';
+ fetch('/api/design/' + did + '/crop-fix', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Admin-Token': adminToken },
+ credentials: 'same-origin',
+ body: JSON.stringify({ region: cfRegion, fix_kind: cfKind ? cfKind.value : 'ghost-layer' }),
+ })
+ .then(function(r){ return r.json().then(function(j){ return { ok:r.ok, j:j }; }); })
+ .then(function(res){
+ if (!res.ok) { cfStat.textContent = 'error: ' + (res.j.error || 'unknown'); cfSubmit.disabled = false; cfSubmit.style.opacity = '1'; return; }
+ cfStat.textContent = 'fixed → new design #' + res.j.new_id + ' (original unpublished). Opening…';
+ setTimeout(function(){ location.href = '/design/' + res.j.new_id; }, 1400);
+ })
+ .catch(function(e){ cfStat.textContent = 'crop-fix error: ' + e.message; cfSubmit.disabled = false; cfSubmit.style.opacity = '1'; });
+ });
})();
</script>
` : ''}
@@ -12457,6 +12770,23 @@ function productLine(d) {
// Aman = minimalist, restrained, very dark OR very neutral
// Auberge = warm, refined, hand-crafted (orange/amber, restrained sat)
// Four Seasons = classic / traditional / default fallback
+// Studio composition pack — Desert | Coast | Forest | null. Mirror of
+// hospitalityFit() but for the Studio line (the Scenic classifier returns
+// null for Studio). Classifies by FILENAME and TITLE keyword match since the
+// JSON-cached DESIGNS rows don't carry the full prompt. Keyword sets are
+// derived from the slug catalogs in scripts/generate-studio-batch{2,3}.js
+// and scripts/gemini-39055-colorways.js.
+function studioPack(d) {
+ if (!d || productLine(d) !== 'Studio') return null;
+ const blob = (String(d.filename || '') + ' ' + String(d.title || '') + ' ' + String(d.handle || '')).toLowerCase();
+ if (/aspen|redwood|sycamore|mountain[ -]?laurel|palo[ -]?verde|cottonwood|agarita/.test(blob)) return 'Forest';
+ if (/cypress|pacific|coast|dune|headland|storm[ -]?coast|carmel|bluff|ocean|beach/.test(blob)) return 'Coast';
+ // Default — Sonoran / Mojave / Baja desert (saguaro / agave / ocotillo /
+ // cactus / cardon / joshua / pinyon-juniper / arroyo / mesa / cardon /
+ // organ-pipe / etc.)
+ return 'Desert';
+}
+
function hospitalityFit(d) {
if (!d) return null;
if (productLine(d) !== 'Scenic') return null;
← 2bd3823 feat(scenic): hospitality style-pack sub-filter — Aman / Aub
·
back to Wallco Ai
·
add scripts/sweep_orphan_generated.js — orphan-PNG sweeper f 323e1fa →