[object Object]

← 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 &amp; 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 &amp; 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">&times;</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 →