[object Object]

← back to Wallco Ai

ghost-detector v2 + Matisse v4 generation prompt

3b8b3c15861a45cdff2b81dc51a7c6e57a3e1d2b · 2026-05-23 11:41:08 -0700 · Steve Abrams

- lib/ghost-detector.js: split into PROMPT_TILE (strict, repeat-pattern)
  vs PROMPT_SCENIC (lenient, atmospheric perspective is intended) so
  scenic mural categories don't false-positive on depth haze. Caller
  passes opts.category; scenic categories list is set-defined.
- scripts/scan-ghost-layer.js: category-aware scan; passes through to
  ghost-detector with the design's stored category.
- scripts/generate_designs.js: replicate prompt reframed as Matisse
  cut-paper collage / vinyl die-cut sticker aesthetic, two-tone
  silhouette-on-background only. Eliminates outline-only / ghost-layer
  failure mode that the v3 "embossed relief / luxe material finish"
  language was directly producing.
- scripts/generate-studio-batch3.js: new — Studio-line batch 3 (319 LOC).
- data/do-not-want.jsonl: append latest bleed_guard rejections from
  cactus-pine-scenic class to keep the exclusion set fresh.
- data/generated/*.png: remove 5 orphan PNGs swept by the orphan
  sweeper (323e1fa).

Files touched

Diff

commit 3b8b3c15861a45cdff2b81dc51a7c6e57a3e1d2b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 11:41:08 2026 -0700

    ghost-detector v2 + Matisse v4 generation prompt
    
    - lib/ghost-detector.js: split into PROMPT_TILE (strict, repeat-pattern)
      vs PROMPT_SCENIC (lenient, atmospheric perspective is intended) so
      scenic mural categories don't false-positive on depth haze. Caller
      passes opts.category; scenic categories list is set-defined.
    - scripts/scan-ghost-layer.js: category-aware scan; passes through to
      ghost-detector with the design's stored category.
    - scripts/generate_designs.js: replicate prompt reframed as Matisse
      cut-paper collage / vinyl die-cut sticker aesthetic, two-tone
      silhouette-on-background only. Eliminates outline-only / ghost-layer
      failure mode that the v3 "embossed relief / luxe material finish"
      language was directly producing.
    - scripts/generate-studio-batch3.js: new — Studio-line batch 3 (319 LOC).
    - data/do-not-want.jsonl: append latest bleed_guard rejections from
      cactus-pine-scenic class to keep the exclusion set fresh.
    - data/generated/*.png: remove 5 orphan PNGs swept by the orphan
      sweeper (323e1fa).
---
 data/do-not-want.jsonl            |   1 +
 lib/ghost-detector.js             |  72 +++++++--
 scripts/generate-studio-batch3.js | 319 ++++++++++++++++++++++++++++++++++++++
 scripts/generate_designs.js       |  73 ++++++++-
 scripts/scan-ghost-layer.js       |  94 ++++++++++-
 5 files changed, 533 insertions(+), 26 deletions(-)

diff --git a/data/do-not-want.jsonl b/data/do-not-want.jsonl
index f2995c6..7b95bcc 100644
--- a/data/do-not-want.jsonl
+++ b/data/do-not-want.jsonl
@@ -2291,3 +2291,4 @@
 {"ts":"2026-05-22T02:45:18.937Z","design_id":39158,"category":"designer-flat-lips","reason":"obvious bleed: interior_shading_bleed","verdict":{"has_motif_ghost":false,"has_halftone_layer":false,"has_atmospheric_haze":false,"has_interior_shading_bleed":true,"bleed_severity":"obvious","confidence":1,"one_line_reason":"There is interior shading inside the motif silhouette that should be flat."},"source":"bleed_guard.ghost-layer"}
 {"ts":"2026-05-22T02:45:31.360Z","design_id":39140,"category":"cactus-pine-scenic","reason":"motif ghost layer (obvious)","verdict":{"has_motif_ghost":true,"has_halftone_layer":true,"has_atmospheric_haze":true,"has_interior_shading_bleed":true,"bleed_severity":"obvious","confidence":0.95,"one_line_reason":"The cactus motif has a faded duplicate behind it, a halftone layer, atmospheric haze, and interior shading."},"source":"bleed_guard.ghost-layer"}
 {"ts":"2026-05-22T02:45:41.949Z","design_id":39131,"category":"cactus-pine-scenic","reason":"motif ghost layer (obvious)","verdict":{"has_motif_ghost":true,"has_halftone_layer":true,"has_atmospheric_haze":true,"has_interior_shading_bleed":false,"bleed_severity":"obvious","confidence":0.95,"one_line_reason":"The cactus motifs have a very obvious faded ghost behind them, and there is a halftone layer and atmospheric haze."},"source":"bleed_guard.ghost-layer"}
+{"ts":"2026-05-22T20:45:09.092Z","design_id":39261,"category":"cactus-pine-scenic","reason":"obvious bleed: atmospheric_haze, interior_shading_bleed","verdict":{"has_motif_ghost":false,"has_halftone_layer":false,"has_atmospheric_haze":true,"has_interior_shading_bleed":true,"bleed_severity":"obvious","confidence":0.95,"one_line_reason":"The image has atmospheric haze and interior shading bleed within the landscape elements."},"source":"bleed_guard.ghost-layer"}
diff --git a/lib/ghost-detector.js b/lib/ghost-detector.js
index 3b017ea..94734f0 100644
--- a/lib/ghost-detector.js
+++ b/lib/ghost-detector.js
@@ -16,26 +16,63 @@ const path = require('path');
 const GEMINI_MODEL = process.env.GHOST_DETECTOR_MODEL || 'gemini-2.0-flash';
 const GEMINI_BASE  = 'https://generativelanguage.googleapis.com/v1beta/models';
 
-const PROMPT = `You are inspecting a single wallpaper / surface-design image for a manufacturing defect called a "ghost layer."
+const PROMPT_TILE = `You are inspecting a TILED WALLPAPER REPEAT-PATTERN image for a manufacturing defect called a "ghost layer."
 
-A ghost layer means the design has MULTIPLE OPACITY TIERS in the SAME illustration — e.g. solid sharp-edged foreground motifs PLUS faded / translucent / watercolor-washed copies of the same motifs sitting behind them at lower opacity. The intended aesthetic for these designs is FLAT, SINGLE-INK, single-opacity hand-blocked silhouette. Any secondary motifs at <100% opacity = defect.
+A ghost layer = the SAME MOTIF appearing TWICE in the same pattern at different opacity levels (one sharp solid, one faded/translucent behind). Intended aesthetic = ONE flat ink on a background. Any duplicate copies at lower opacity = defect.
 
-Decide:
-  hasGhostLayer: true  if you see ANY of:
-    - lighter / faded copies of motifs sitting behind solid ones
-    - semi-transparent secondary layer (the same motifs at ~30-70% opacity)
-    - atmospheric perspective / depth haze
-    - watercolor-wash secondary layer
-    - "halo" pale silhouettes behind sharp ones
-  hasGhostLayer: false if the design is uniformly flat single-ink-on-background with no secondary opacity tier.
+hasGhostLayer: TRUE if you see ANY of these in the SAME pattern:
+  - Identical motifs (same cactus, same flower, same beetle, etc.) rendered TWICE — one sharp + one faded behind
+  - Semi-transparent secondary copies of the foreground motifs
+  - "Halo" pale silhouettes echoing the sharp ones
+  - Multi-opacity copies of repeat elements
 
-  confidence: 0.0-1.0  how confident you are
+hasGhostLayer: FALSE if:
+  - The design is uniformly flat single-ink-on-background
+  - All motifs are at the SAME opacity (even if multiple colors)
+  - It's a scenic landscape with intentional atmospheric perspective (distant elements fainter than near ones — that is NOT a ghost layer, that's depth)
 
-  reason: ONE short sentence (max 25 words) describing what you saw
+DO NOT flag atmospheric perspective in landscapes. DO NOT flag intentional shading inside a motif. ONLY flag when the SAME motif clearly appears at two opacity levels in a repeating tile context.
 
-Reply with ONLY a JSON object exactly in this shape:
-{"hasGhostLayer": true|false, "confidence": <number>, "reason": "<short sentence>"}
-No other text. No markdown. No prose.`;
+confidence: 0.0-1.0
+reason: ONE short sentence (≤25 words) describing what you saw
+
+Reply with ONLY a JSON object in this exact shape (no markdown, no prose):
+{"hasGhostLayer": true|false, "confidence": <number>, "reason": "<short sentence>"}`;
+
+const PROMPT_SCENIC = `You are inspecting a SCENIC / PANORAMIC MURAL image (a single landscape scene, not a repeat tile).
+
+For scenic murals, atmospheric perspective (distant mountains lighter, distant foliage fainter, depth-haze) is the INTENDED aesthetic — not a defect.
+
+A "ghost layer" in a scenic mural is something different: a duplicated foreground motif appearing twice at different opacities, OR a watermark-like translucent overlay of the WHOLE scene repeated.
+
+hasGhostLayer: TRUE only if you see:
+  - The same foreground tree / cactus / animal duplicated at sharp + faded versions in the same view
+  - A full-image translucent ghost overlay (the scene appearing twice stacked)
+
+hasGhostLayer: FALSE if:
+  - Distant elements are simply lighter than near elements (= atmospheric perspective, intended)
+  - The scene is a single coherent landscape with depth
+  - There are multiple distinct planes (foreground, midground, background) at different brightness — that's correct scenic composition
+
+DO NOT confuse atmospheric depth with ghost layers.
+
+confidence: 0.0-1.0
+reason: ONE short sentence (≤25 words)
+
+Reply with ONLY a JSON object (no markdown, no prose):
+{"hasGhostLayer": true|false, "confidence": <number>, "reason": "<short sentence>"}`;
+
+// scenic categories — atmospheric perspective is intended, use the lenient prompt
+const SCENIC_CATEGORIES = new Set([
+  'cactus-pine-scenic', 'cactus-11ft-mural', 'tree-mural',
+  'mural', 'scenic', 'panoramic', 'desert-flora', 'mural-animal',
+  'mural-scenic', 'designer-scenic',
+]);
+
+function pickPrompt(category) {
+  if (category && SCENIC_CATEGORIES.has(category.toLowerCase())) return PROMPT_SCENIC;
+  return PROMPT_TILE;
+}
 
 function readKey() {
   if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
@@ -53,11 +90,14 @@ async function analyzeGhostLayer(imagePath, opts = {}) {
   const bytes = fs.readFileSync(imagePath);
   const mime  = imagePath.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
   const url   = `${GEMINI_BASE}/${GEMINI_MODEL}:generateContent?key=${KEY}`;
+  // Pick the prompt based on design category — scenic murals get the
+  // "atmospheric perspective is OK" prompt; tile patterns get the strict one.
+  const prompt = pickPrompt(opts.category);
 
   const body = {
     contents: [{
       parts: [
-        { text: PROMPT },
+        { text: prompt },
         { inline_data: { mime_type: mime, data: bytes.toString('base64') } },
       ],
     }],
diff --git a/scripts/generate-studio-batch3.js b/scripts/generate-studio-batch3.js
new file mode 100644
index 0000000..8c6b849
--- /dev/null
+++ b/scripts/generate-studio-batch3.js
@@ -0,0 +1,319 @@
+#!/usr/bin/env node
+/**
+ * Studio batch 3 — 12 NEW compositions, distinct from batch 2. Pushes the
+ * Studio line from 24 → 36 published designs. Same SDXL→Gemini 2-pass
+ * pipeline as batches 2 (gemini-39055-colorways) and 2b (generate-studio-
+ * batch2). Distinct compositional set: night scenes, river-canyon, beach +
+ * dune subjects, mesa silhouettes, dramatic single-tree heroes, etc. — to
+ * stretch the Studio catalog beyond Sonoran-desert defaults.
+ *
+ * Originally bootstrapped from batch 2; composition spec rewritten below.
+ *
+ * Pipeline (the SDXL→Gemini 2-pass codified in
+ * feedback_wallco_gemini_edit_recovers_sdxl_aesthetic.md):
+ *   1. SDXL Pass-2 cactus-first prompt at 1536×640 (gets the SUBJECTS right,
+ *      lands photoreal)
+ *   2. gemini-2.5-flash-image-edit to push toward flat 2-tone silk + pick
+ *      a matched colorway from the Tucson palette
+ *
+ * Each design is a different COMPOSITION (varies subjects + layout); each
+ * is matched to a different colorway from the 10-color Tucson palette.
+ *
+ * Cost: 12 × ($0.003 SDXL + $0.04 Gemini) ≈ $0.52. Wall ~10-12 min.
+ *
+ * Per Steve directive 2026-05-21 ("Scenic and Studio, go with 12 more
+ * Studio") — this lands in category=cactus-pine-scenic for now; the
+ * pending sub-brand-rename plan in docs/SUB-BRAND-PROPOSAL-SCENIC-VS-
+ * STUDIO.md will retag the whole set once Steve approves the migration.
+ */
+
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { execSync, spawnSync } = require('child_process');
+
+const KEY = process.env.GEMINI_API_KEY;
+if (!KEY) { console.error('GEMINI_API_KEY missing'); process.exit(1); }
+const OUT_DIR = path.join(__dirname, '..', 'data', 'generated');
+const ROOT = path.join(__dirname, '..');
+
+const DB = 'dw_unified';
+function psql(sql) { return execSync(`psql ${DB} -At -q`, { input: sql, encoding: 'utf8' }).trim(); }
+function esc(v) { if (v == null) return 'NULL'; return "'" + String(v).replace(/'/g, "''") + "'"; }
+
+// 12 distinct Studio compositions × matched colorway
+const COMPOSITIONS = [
+  {
+    slug: 'saguaro-night-moonlit',
+    cw: { slug: 'twilight-indigo',  ground: '#1E3550', ground_name: 'deep moonlit indigo blue',           figure: '#F2EADB', figure_name: 'tortilla-bone cream' },
+    sdxl_prompt:
+      'A continuous 60-foot wide moonlit Sonoran night scenic. CACTUS DOMINATES the canvas: 10 saguaros silhouetted against ' +
+      'the indigo sky, each with arms reaching upward. A pale crescent moon sits in the upper-center. Scattered ocotillo plants ' +
+      'with whip stems, prickly-pear paddle clusters at the base, mexican-fence-post pillars. NO pine. ' +
+      'Distant low Catalina mountains as a flat silhouette band. Solid indigo sky tone, NO gradient, NO stars.',
+  },
+  {
+    slug: 'desert-river-canyon-clay',
+    cw: { slug: 'adobe-sundown',    ground: '#A8623A', ground_name: 'Spanish Colonial adobe terracotta',  figure: '#F2EADB', figure_name: 'tortilla-bone cream' },
+    sdxl_prompt:
+      'A continuous 60-foot wide desert river-canyon scenic. A meandering river cuts through the lower third of the canvas. ' +
+      'On the canyon walls: 14 saguaros at varying heights, scattered ocotillo, agave americana with bloom stalks. ' +
+      'Foreground: river-bank cottonwoods rendered as cream silhouettes. NO pine. ' +
+      'Layered mesa walls receding into the distance as flat silhouette bands. Solid terracotta sky tone, NO gradient.',
+  },
+  {
+    slug: 'beach-dune-sea-oats-bone',
+    cw: { slug: 'mesquite-on-bone', ground: '#E8DCC4', ground_name: 'warm chalky bone-cream paper',       figure: '#2A2520', figure_name: 'deep mesquite-charcoal ink' },
+    sdxl_prompt:
+      'A continuous 60-foot wide Pacific-beach dune scenic. ROLLING DUNES DOMINATE the foreground with sea-oat clusters ' +
+      '(tall grass tufts with seed-heads) scattered across them. A line of low coastal scrub at the back of the dunes. ' +
+      'NO cactus, NO Monterey pine — pure beach-dune subject. The ocean opens behind in 3 flat horizontal bands. ' +
+      'Distant low headland silhouette at horizon. Solid bone-cream sky tone, NO gradient.',
+  },
+  {
+    slug: 'mesa-silhouette-celadon',
+    cw: { slug: 'celadon',          ground: '#A8B8A8', ground_name: 'pale celadon green-grey jade',       figure: '#F2EADB', figure_name: 'tortilla-bone cream' },
+    sdxl_prompt:
+      'A continuous 60-foot wide Southwest mesa-country scenic. LAYERED MESAS DOMINATE the upper two-thirds — 5 flat-topped ' +
+      'mesa silhouettes in receding planes, the foreground mesa darkest, distant mesas progressively lighter (still as flat ' +
+      'silhouette bands, NOT atmospheric perspective). Foreground: scattered saguaros and yucca silhouettes. NO pine. ' +
+      'Solid celadon sky tone, NO gradient.',
+  },
+  {
+    slug: 'lone-cypress-bluff-mist',
+    cw: { slug: 'winter-fog',       ground: '#9CA09B', ground_name: 'Pacific-mist pewter grey',           figure: '#3A2A1C', figure_name: 'dark cypress charcoal' },
+    sdxl_prompt:
+      'A continuous 60-foot wide Pacific-coastal scenic. ONE MAJESTIC LONE MONTEREY CYPRESS dominates the center-right of the ' +
+      'canvas, silhouetted on a granite headland, its canopy windswept asymmetric leaning inland dramatically. ' +
+      'Foreground: rocky bluff with succulent silhouettes. The Pacific opens behind in flat layered bands of pewter ground tone. ' +
+      'A few smaller secondary cypress silhouettes on the far-left horizon. NO cactus. ' +
+      'Solid Pacific-mist sky tone, NO gradient.',
+  },
+  {
+    slug: 'cottonwood-arroyo-eucalyptus',
+    cw: { slug: 'eucalyptus-sage',  ground: '#7A8B7E', ground_name: 'sage-eucalyptus green-grey',         figure: '#F2EADB', figure_name: 'tortilla-bone cream' },
+    sdxl_prompt:
+      'A continuous 60-foot wide riparian-desert scenic. A row of 8 mature cottonwood trees (Populus fremontii) follows a hidden ' +
+      'arroyo across the canvas — tall trunks with broad rounded canopies rendered as flat cream silhouettes. ' +
+      'Scattered saguaros and prickly pear on the canyon walls above. NO Monterey pine. ' +
+      'Distant low mountains as a flat silhouette band at horizon. Solid sage-eucalyptus sky tone, NO gradient.',
+  },
+  {
+    slug: 'agave-bloom-stalks-coral',
+    cw: { slug: 'sun-baked-clay',   ground: '#B85440', ground_name: 'sun-baked-clay desert coral',        figure: '#F2EADB', figure_name: 'tortilla-bone cream' },
+    sdxl_prompt:
+      'A continuous 60-foot wide Sonoran scenic. AGAVE BLOOM STALKS DOMINATE: 12 monumental agave americana with tall flowering ' +
+      'stalks reaching the canvas top, each stalk branched with secondary flower spikes. Below the stalks: the wide-spiky ' +
+      'rosettes from which they emerge. Scattered saguaros and ocotillo between. NO pine. ' +
+      'Distant low mountain band at horizon. Solid desert-coral sky tone, NO gradient.',
+  },
+  {
+    slug: 'sycamore-fall-saddle',
+    cw: { slug: 'autumn-saddle',    ground: '#7A4A28', ground_name: 'burnt-umber autumn saddle',          figure: '#F2EADB', figure_name: 'tortilla-bone cream' },
+    sdxl_prompt:
+      'A continuous 60-foot wide California-Sycamore autumn scenic. 7 mature California sycamore trees (Platanus racemosa) ' +
+      'with broad gnarled trunks and wide spreading canopies dominate across the canvas. The leafy canopies as flat cream ' +
+      'silhouette masses with stripped winter branches showing through at the edges. Below: scattered live oaks and yucca. ' +
+      'NO cactus, NO Monterey pine. Distant low foothill band as flat silhouette. Solid burnt-umber sky tone, NO gradient.',
+  },
+  {
+    slug: 'ocean-cypress-storm-pewter',
+    cw: { slug: 'winter-fog',       ground: '#7A8085', ground_name: 'storm-coast slate grey',             figure: '#E8E0D2', figure_name: 'rain-cream ivory' },
+    sdxl_prompt:
+      'A continuous 60-foot wide Pacific-coastal storm scenic. 5 windswept Monterey cypress trees on a granite headland in ' +
+      'a heavy storm — canopies bent hard to the left by visible wind. Foreground: rocky tide-pool bluff with cresting wave ' +
+      'silhouettes. Beyond: layered bands of stormy ocean and an obvious horizontal squall line as a flat band across the upper ' +
+      'third. NO cactus. Solid slate-grey sky tone, NO gradient.',
+  },
+  {
+    slug: 'agarita-grove-eucalyptus',
+    cw: { slug: 'eucalyptus-sage',  ground: '#7A8B7E', ground_name: 'sage-eucalyptus green-grey',         figure: '#3A2A1C', figure_name: 'cypress charcoal' },
+    sdxl_prompt:
+      'A continuous 60-foot wide Texas-Hill-Country / high-desert scenic. SCATTERED AGARITA SHRUBS AND MOUNTAIN-LAUREL trees ' +
+      'dominate: 14 round mountain laurel trees (Sophora secundiflora) with dense rounded canopies scattered across rolling hills. ' +
+      'Foreground: agarita shrub silhouettes. Distant low limestone-mesa band at horizon. NO cactus, NO Monterey pine. ' +
+      'Solid sage-eucalyptus sky tone, NO gradient.',
+  },
+  {
+    slug: 'palo-verde-grove-bone',
+    cw: { slug: 'mesquite-on-bone', ground: '#E8DCC4', ground_name: 'warm chalky bone-cream paper',       figure: '#3A2A1C', figure_name: 'cypress charcoal' },
+    sdxl_prompt:
+      'A continuous 60-foot wide Sonoran palo-verde scenic. 10 PALO VERDE TREES (Parkinsonia florida) dominate with their ' +
+      'distinctive zig-zag green-trunk geometry and feathery canopies. Each tree silhouetted in cypress-charcoal. Scattered ' +
+      'saguaros between, prickly pear at the base. The palo verde trunks have a graphic angular bone-structure quality unique ' +
+      'to the species. NO Monterey pine. Distant low mountain band at horizon. Solid bone-cream sky tone, NO gradient.',
+  },
+  {
+    slug: 'redwood-cathedral-bone',
+    cw: { slug: 'mesquite-on-bone', ground: '#D8CDB4', ground_name: 'forest-floor warm cream',            figure: '#3A2A1C', figure_name: 'cypress charcoal' },
+    sdxl_prompt:
+      'A continuous 60-foot wide coast-redwood cathedral scenic. 12 TOWERING REDWOOD TRUNKS rising as vertical columns across ' +
+      'the canvas, with substantial scale variation in trunk diameter (foreground trunks 3x the diameter of distant trunks). ' +
+      'Foreground forest floor with sword-fern silhouettes. The leafy canopy band at top — flat solid cream ground tone, no ' +
+      'gradient suggesting depth. NO cactus, NO Monterey pine — pure redwood. Strong vertical rhythm. Solid cream sky tone, NO gradient.',
+  },
+];
+
+const NEGATIVE_SDXL =
+  'gradient, sunset gradient, sky gradient, dawn gradient, dusk gradient, atmospheric depth, atmospheric perspective, ' +
+  'haze, photoreal, photorealism, photograph, painterly oil, oil painting, watercolor, halftone, crosshatching, ' +
+  'palm, banana, monstera, grapes, birds, butterflies, room mockup, perspective, people, furniture, watermark, ' +
+  'signature, text, logos, border, frame, depth-of-field, bokeh, glow, lens flare.';
+
+const STUDIO_GEMINI_EDIT_PROMPT_TEMPLATE = (cw) => (
+  'Transform this Sonoran/coastal/forest scenic mural into a flat HAND-PAINTED-SILK SCENIC WALLCOVERING in the ' +
+  'style of panoramic-scenic & Cie, hand-painted-silk, and a luxury hand-painted scenic house — single-layer hand-blocked silkscreen aesthetic. ' +
+  '\n\nStyle requirements: ' +
+  'Flat solid color regions only — NO gradient, NO atmospheric perspective, NO half-tones, NO crosshatching. ' +
+  'Hard razor-sharp silhouette edges. Every figure region is one flat color tone. ' +
+  'Sky area is ONE completely flat solid color from edge to edge — no clouds, no gradient, no atmospheric haze. ' +
+  'Distant mountains as a single flat silhouette band (no fading). ' +
+  'Each plant / tree / cactus is one flat silhouette in the figure color — no internal shading, no highlights. ' +
+  '\n\nPalette (strict 2-tone): ' +
+  `Ground (sky, background, ground plane): ${cw.ground_name} (hex ${cw.ground}). ` +
+  `Figure (all silhouettes): ${cw.figure_name} (hex ${cw.figure}). ` +
+  '\n\nKeep the composition, layout, and subjects exactly as they are. Only transform the visual register. ' +
+  'No watermark, no text, no border, no signature.'
+);
+
+const PRINT_W_IN  = 720;
+const PRINT_H_IN  = 132;
+const SDXL_W      = 1536;
+const SDXL_H      = 640;
+
+const sdxlEnv = {
+  ...process.env,
+  COMFY_WIDTH:     String(SDXL_W),
+  COMFY_HEIGHT:    String(SDXL_H),
+  REPLICATE_WIDTH: String(SDXL_W),
+  REPLICATE_HEIGHT:String(SDXL_H),
+  GEN_BACKEND:     'replicate',
+};
+
+async function geminiEdit(localPng, cw, slug) {
+  const imageB64 = fs.readFileSync(localPng).toString('base64');
+  const editPrompt = STUDIO_GEMINI_EDIT_PROMPT_TEMPLATE(cw);
+  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=${KEY}`;
+  const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
+  if (!r.ok) throw new Error(`Gemini HTTP ${r.status}: ${await r.text()}`);
+  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: 'studio-batch2-' + slug, model: 'gemini-2.5-flash-image' });
+  } catch {}
+  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) throw new Error(`no image (${j.candidates?.[0]?.finishReason || 'unknown'})`);
+
+  const seed = crypto.randomInt(1, 2 ** 31 - 1);
+  const filename = `studio_${slug}_${Date.now()}_${seed}.png`;
+  const outPath = path.join(OUT_DIR, filename);
+  fs.writeFileSync(outPath, Buffer.from(data, 'base64'));
+  return { outPath, seed };
+}
+
+function paletteOf(p) {
+  const py = spawnSync('python3', ['-c', `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB')
+img.thumbnail((300, 300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+pixels = list(pal.getdata())
+cnt = Counter(pixels)
+total = sum(cnt.values())
+palette = [{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb), 'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]
+print(json.dumps(palette))
+`, p], { encoding: 'utf8' });
+  try { return JSON.parse(py.stdout.trim()); } catch { return []; }
+}
+
+(async function main() {
+  console.log(`Studio batch 2 — ${COMPOSITIONS.length} new scenic compositions × Gemini flat-aesthetic pass\n`);
+  const newIds = [];
+  const startedAt = Date.now();
+
+  for (let i = 0; i < COMPOSITIONS.length; i++) {
+    const c = COMPOSITIONS[i];
+    process.stdout.write(`[${(i+1).toString().padStart(2,' ')}/${COMPOSITIONS.length}] ${c.slug} → SDXL...`);
+
+    // ── Step 1: SDXL Pass-2 cactus-first render ──
+    const sdxlR = spawnSync('node', [
+      path.join(__dirname, 'generate_designs.js'),
+      '--n', '1', '--kind', 'mural_panel',
+      '--width',  String(PRINT_W_IN),
+      '--height', String(PRINT_H_IN),
+      '--panels', '20',
+      '--category', 'cactus-pine-scenic',
+      '--prompts', c.sdxl_prompt + ' ' + NEGATIVE_SDXL,
+    ], { cwd: ROOT, encoding: 'utf8', env: sdxlEnv });
+
+    const m = (sdxlR.stdout || '').match(/#(\d+)\s*·/);
+    if (!m) {
+      const reason = ((sdxlR.stdout || '') + '\n' + (sdxlR.stderr || ''))
+        .split('\n').filter((l) => /✗|Error|FAILED|timed out|undefined/.test(l)).slice(-1).join(' | ')
+        || (sdxlR.stderr || sdxlR.stdout || '').slice(-160);
+      console.log(` SDXL FAIL: ${reason}`);
+      continue;
+    }
+    const sdxlId = parseInt(m[1], 10);
+    process.stdout.write(` #${sdxlId} → Gemini...`);
+
+    // Find the SDXL output's local path
+    const srcPath = psql(`SELECT local_path FROM spoon_all_designs WHERE id = ${sdxlId}`);
+    if (!srcPath || !fs.existsSync(srcPath)) {
+      console.log(` SDXL output not found at ${srcPath}`);
+      continue;
+    }
+
+    // Unpublish the raw SDXL row (we want the Gemini-edited version published, not the photoreal source)
+    psql(`UPDATE spoon_all_designs SET is_published = FALSE WHERE id = ${sdxlId}`);
+
+    // ── Step 2: Gemini image-edit to flat hand-painted-silk ──
+    try {
+      const t0 = Date.now();
+      const edited = await geminiEdit(srcPath, c.cw, c.slug);
+      const palette = paletteOf(edited.outPath);
+      const dominant = palette[0]?.hex || c.cw.ground;
+
+      const ins = psql(`
+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)
+VALUES
+  ('mural_panel', 'wallco.ai', ${PRINT_W_IN}, ${PRINT_H_IN}, 20,
+   'gemini-2.5-flash-image-edit',
+   ${esc('Studio batch 2 ' + c.slug + ' (' + c.cw.slug + ') from SDXL #' + sdxlId + ': ' + STUDIO_GEMINI_EDIT_PROMPT_TEMPLATE(c.cw).slice(0, 1200))},
+   ${edited.seed},
+   '/designs/img/by-id/__NEW__',
+   ${esc(edited.outPath)},
+   ${esc(dominant)},
+   ${esc(JSON.stringify(palette))}::jsonb,
+   NULL,
+   NULL,
+   'cactus-pine-scenic',
+   TRUE)
+RETURNING id`);
+      const newId = parseInt(ins, 10);
+      psql(`UPDATE spoon_all_designs SET image_url = '/designs/img/by-id/${newId}', is_published = TRUE WHERE id = ${newId}`);
+      newIds.push(newId);
+      console.log(` #${newId} (${((Date.now() - t0)/1000).toFixed(1)}s)`);
+    } catch (err) {
+      console.log(` Gemini FAIL: ${err.message}`);
+    }
+  }
+
+  const elapsed = ((Date.now() - startedAt)/1000).toFixed(1);
+  console.log(`\nDone in ${elapsed}s — ${newIds.length}/${COMPOSITIONS.length} ok`);
+  console.log('New IDs: ' + JSON.stringify(newIds));
+})();
diff --git a/scripts/generate_designs.js b/scripts/generate_designs.js
index 0a758f9..dd6cc9b 100644
--- a/scripts/generate_designs.js
+++ b/scripts/generate_designs.js
@@ -171,8 +171,16 @@ function genReplicate(prompt, seed, outPath) {
   const WIDTH  = parseInt(process.env.REPLICATE_WIDTH  || '1024', 10);
   const HEIGHT = parseInt(process.env.REPLICATE_HEIGHT || '1024', 10);
 
-  const positive = `${prompt}, seamless tile, repeating pattern, no edges, fabric pattern, wallpaper design, archival quality, high detail, screen-printed wallpaper quality, solid opaque richly-saturated ink, every motif fully filled with solid color, crisp clean color separation, dimensional embossed relief, three-dimensional rendered depth welcome, tactile luxe material finishes — suede, woven trim, raffia, grasscloth texture`;
-  const negative = `low quality, blurry, edges, seam, border, frame, signature, watermark, text, hands, fingers, deformed, transparent, see-through, translucent, unfilled shapes, hollow outlines, outline-only motifs, ghosted layers, washed out, faded, low opacity, gaps in the ink, missing fill, gradient bleed, halftone, halftone dots, ben-day dots, dot-screen shading, half-tone gradient texture, ${NEGATIVE_PROMPT_ADDON}`;
+  // 2026-05-22 — match the comfy backend's v3 paper-cut-stencil aesthetic.
+  // The old prompt asked for "dimensional embossed relief" + "tactile luxe material
+  // finishes" which directly produced the ghost-layer / outline-fill failures Steve
+  // rejected. Replaced with the same anchor as genComfy().
+  // 2026-05-22 v4 — Steve directive "it's like we are literally pasting silhouettes."
+  // Reframe the entire generation as Matisse-cut-paper collage / sticker-on-paper.
+  // The figure-ground relationship is the WHOLE design — no depth, no atmosphere,
+  // no secondary motifs behind primary, no shaded interiors.
+  const positive = `${prompt}. Matisse cut-paper collage aesthetic — every motif is a flat solid-color silhouette physically cut from construction paper with scissors and pasted onto a single solid background, like stickers peeled from a sheet. SILHOUETTE ON BACKGROUND ONLY. No depth, no atmosphere, no secondary motifs behind primary ones, no shading inside shapes. Every shape one opaque ink color, hard razor-sharp paper-cut edges. Two ink tones maximum (figure + ground). Vinyl die-cut sticker aesthetic, seamless tile, generous negative space, sparse minimal composition, archival quality designer wallcovering`;
+  const negative = `outline only, line drawing, linework, hollow shape, hollow interior, unfilled motif, see-through, white interior, transparent fill, translucent fill, outline drawing of a flower, outline drawing of a leaf, ink outline without fill, line-art rendered motif, hand-drawn ink lines, etching, engraving, gradient fill, drop shadow, ambient occlusion, soft shadow, semi-transparent, alpha overlay, multiply blend, ghost background, ghost layer, ghosted, layered bleed, bleed between inks, overlapping translucent layers, faded background, multi-tone interior shading, dimensional, embossed, relief, 3D, three-dimensional, depth, atmospheric perspective, fur texture, anatomical detail inside silhouette, crosshatching, halftone, ben-day dots, dot screen, watercolor wash, airbrush, soft edges, blurry, low quality, seam visible, border, frame, signature, watermark, text, hands, fingers, busy, cluttered, edge-to-edge, tessellated densely, more than 4 colors, ${NEGATIVE_PROMPT_ADDON}`;
 
   const body = {
     version: MODEL_VERSION,
@@ -326,12 +334,73 @@ function genComfy(prompt, seed, outPath) {
   return outPath;
 }
 
+// FLUX-schnell on Replicate — much better prompt adherence than SDXL for
+// flat-color / stencil aesthetics. SDXL ghosts at ~100% with our prompts
+// (see feedback_sdxl_antiprompts_unreliable.md); FLUX renders the prompt
+// more literally so the "solid filled silhouette" anchor actually holds.
+// Cost ~$0.003/image (vs $0.014 for SDXL).
+function genReplicateFlux(prompt, seed, outPath) {
+  const TOKEN = loadReplicateToken();
+  if (!TOKEN) throw new Error('REPLICATE_API_TOKEN not found');
+  // 2026-05-22 — Critical insight: every prompt that mentions "seamless tile /
+  // wallpaper / repeating pattern" triggers BOTH SDXL and FLUX to render
+  // multi-opacity satellite copies of the primary motif (the ghost-layer
+  // failure mode). The tile-merging is handled post-generation by
+  // make_seamless.py. So the PROMPT now describes ONE figure on a flat ground.
+  const positive = `${prompt}. Single flat-color silhouette of the figure on a solid uniform background. Matisse paper-cut style — figure is one solid opaque ink, background is one different solid color. NO repeating pattern, NO secondary copies of the figure, NO depth, NO shading inside the figure, NO atmospheric haze. Just one figure on one background. Hard razor-sharp edges. Two colors total.`;
+  const body = {
+    // black-forest-labs/flux-schnell — versionless model endpoint
+    input: { prompt: positive, num_outputs: 1, aspect_ratio: '1:1', output_format: 'png', seed },
+  };
+  const submit = execSync(
+    `curl -sf -m 60 -H 'Authorization: Bearer ${TOKEN}' -H 'Content-Type: application/json' -H 'Prefer: wait' -X POST 'https://api.replicate.com/v1/models/black-forest-labs/flux-schnell/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.status === 'failed') throw new Error(`FLUX failed: ${JSON.stringify(pred.error || '').slice(0, 200)}`);
+  // FLUX-schnell with Prefer:wait typically returns 'succeeded' immediately, but support polling fallback
+  if (pred.status !== 'succeeded') {
+    const t0 = Date.now();
+    while (Date.now() - t0 < 60_000) {
+      execSync('sleep 2');
+      const poll = execSync(`curl -sf -m 10 -H 'Authorization: Bearer ${TOKEN}' '${pred.urls.get}'`, { encoding: 'utf8' });
+      pred = JSON.parse(poll);
+      if (pred.status === 'succeeded') break;
+      if (pred.status === 'failed') throw new Error(`FLUX failed mid-poll: ${JSON.stringify(pred.error || '').slice(0, 200)}`);
+    }
+    if (pred.status !== 'succeeded') throw new Error('FLUX poll timeout');
+  }
+  const imageUrl = Array.isArray(pred.output) ? pred.output[0] : pred.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 (!fs.existsSync(outPath) || fs.statSync(outPath).size < 1000) throw new Error(`FLUX image download failed (${imageUrl})`);
+  return outPath;
+}
+
 function generate(prompt, seed, outPath, meta) {
   switch (BACKEND) {
     case 'replicate': genReplicate(prompt, seed, outPath); break;
+    case 'flux':      genReplicateFlux(prompt, seed, outPath); break;
     case 'comfy':     genComfy(prompt, seed, outPath); break;
     default:          genStub(prompt, seed, outPath); break;
   }
+  // 2026-05-22 — Ghost-layer post-process. SDXL produces multi-opacity ghost
+  // copies of repeat motifs ~100% of the time even with strong anti-prompts
+  // (see memory feedback_sdxl_antiprompts_unreliable.md). quantize-no-ghost.py
+  // is the deterministic fix: median-cut to N colors, then collapse any pair
+  // of adjacent-luma colors whose Δluma < threshold (the ghost signature). Set
+  // QUANT_COLORS=4 / GHOST_LUMA_GAP=35 by default in the script. Opt out via
+  // env SKIP_QUANTIZE_NO_GHOST=1 if a category needs >4 tones.
+  if (process.env.SKIP_QUANTIZE_NO_GHOST !== '1') {
+    const quantScript = path.join(__dirname, 'quantize-no-ghost.py');
+    if (fs.existsSync(quantScript) && fs.existsSync(outPath)) {
+      const r = spawnSync('python3', [quantScript, outPath], { encoding: 'utf8' });
+      if (r.status !== 0) {
+        console.error('[quantize-no-ghost] FAILED — image may have ghost layers:', (r.stderr || r.stdout || '').slice(-200));
+      }
+    }
+  }
   // Seamless post-process — collapse edge-pixel Δ via offset-and-blend so the
   // tile repeats cleanly (raw-download / Spoonflower print files need this;
   // the room renderer hides seams on a composite but a print does not).
diff --git a/scripts/scan-ghost-layer.js b/scripts/scan-ghost-layer.js
index 209a5f3..cbe525d 100644
--- a/scripts/scan-ghost-layer.js
+++ b/scripts/scan-ghost-layer.js
@@ -59,8 +59,14 @@ function loadScanned() {
   return ids;
 }
 
-// ── fetch target ids from PG ────────────────────────────────────────────
-function fetchTargets() {
+// ── fetch target ids ────────────────────────────────────────────────────
+// SOURCE='pg'  → spoon_all_designs (canonical, has local_path)
+// SOURCE='json' → designs.json cache (24k+ older imports, no local_path
+//                 for many — falls back to fetching image_url over HTTP)
+// SOURCE='both' (default) → PG first, then JSON-only (de-duped)
+const SOURCE = (arg('source', 'both') || 'both').toLowerCase();
+
+function fetchPgTargets() {
   const where = [
     'is_published = TRUE',
     'user_removed IS NOT TRUE',
@@ -70,35 +76,107 @@ function fetchTargets() {
   const sql = `SELECT id, category, local_path
                FROM spoon_all_designs
                WHERE ${where.join(' AND ')}
-               ORDER BY id DESC
-               ${LIMIT > 0 ? `LIMIT ${LIMIT}` : ''}`;
+               ORDER BY id DESC`;
   const raw = psql(sql);
   const rows = [];
   for (const line of raw.split('\n')) {
     if (!line.trim()) continue;
     const [id, category, local_path] = line.split('|');
-    rows.push({ id: parseInt(id, 10), category, local_path });
+    rows.push({ id: parseInt(id, 10), category, local_path, source: 'pg' });
+  }
+  return rows;
+}
+
+// Vendor-imported categories — Steve's rule: scan only wallco-AI generations.
+// Real designer products from Shopify/etc. keep their (intentional) opacity choices.
+const VENDOR_CATS = new Set([
+  'thibaut','dolce-gabbana','koroseal','graduate-collection','traditional-whimsy',
+  'phillipe-romano','coordonn-','ralph-lauren','mind-the-gap','designer-wallcoverings',
+  'malibu-wallpaper','maya-romanoff','roberto-cavalli-wallpaper','china-seas',
+  'daisy-bennett','glitter-walls','dw-shopify',
+]);
+
+const GENERATED_DIR = path.join(ROOT, 'data', 'generated');
+
+function fetchJsonTargets(excludeIds) {
+  const f = path.join(ROOT, 'data', 'designs.json');
+  if (!fs.existsSync(f)) return [];
+  const arr = JSON.parse(fs.readFileSync(f, 'utf8'));
+  const rows = [];
+  for (const d of arr) {
+    if (excludeIds.has(d.id)) continue;
+    if (!d.id) continue;
+    if (CATEGORY && d.category !== CATEGORY) continue;
+    // Skip vendor-imported — Steve's rule
+    if (VENDOR_CATS.has((d.category || '').toLowerCase())) continue;
+    // Derive disk path from image_url like /designs/img/X.png → data/generated/X.png
+    let diskPath = null;
+    if (d.image_url && d.image_url.startsWith('/designs/img/')) {
+      const fn = d.image_url.replace(/^\/designs\/img\//, '');
+      const candidate = path.join(GENERATED_DIR, fn);
+      if (fs.existsSync(candidate)) diskPath = candidate;
+    }
+    if (!diskPath) continue;  // can't scan without a readable file
+    rows.push({
+      id: d.id,
+      category: d.category,
+      local_path: diskPath,
+      image_url: d.image_url,
+      source: 'json',
+    });
   }
   return rows;
 }
 
+function fetchTargets() {
+  let rows = [];
+  if (SOURCE === 'pg' || SOURCE === 'both') {
+    rows = rows.concat(fetchPgTargets());
+  }
+  if (SOURCE === 'json' || SOURCE === 'both') {
+    const have = new Set(rows.map(r => r.id));
+    rows = rows.concat(fetchJsonTargets(have));
+  }
+  if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+  return rows;
+}
+
+// Stream-download an image to a temp file when local_path is missing.
+const TMP_IMG_DIR = '/tmp/ghost-scan-imgs';
+fs.mkdirSync(TMP_IMG_DIR, { recursive: true });
+async function resolveImagePath(row) {
+  if (row.local_path && fs.existsSync(row.local_path)) return row.local_path;
+  // Fetch via prod URL — by-id endpoint resolves regardless of source
+  const url = `http://127.0.0.1:9905/designs/img/by-id/${row.id}`;
+  const dest = path.join(TMP_IMG_DIR, `${row.id}.png`);
+  if (fs.existsSync(dest)) return dest;
+  const r = await fetch(url);
+  if (!r.ok) throw new Error(`fetch ${r.status}`);
+  const buf = Buffer.from(await r.arrayBuffer());
+  if (buf.length < 200) throw new Error(`tiny body ${buf.length}b`);
+  fs.writeFileSync(dest, buf);
+  return dest;
+}
+
 // ── worker pool ─────────────────────────────────────────────────────────
 async function processOne(row) {
+  // Read directly from disk — no HTTP fetch, no wallco-ai load.
   if (!row.local_path || !fs.existsSync(row.local_path)) {
-    return { id: row.id, ts: new Date().toISOString(), error: 'file_missing', local_path: row.local_path };
+    return { id: row.id, ts: new Date().toISOString(), error: 'file_missing', category: row.category, source: row.source };
   }
   try {
-    const r = await analyzeGhostLayer(row.local_path);
+    const r = await analyzeGhostLayer(row.local_path, { category: row.category });
     return {
       id: row.id, ts: new Date().toISOString(),
       hasGhostLayer: r.hasGhostLayer,
       confidence:    r.confidence,
       reason:        r.reason,
       category:      row.category,
+      source:        row.source,
       image_url:     `/designs/img/by-id/${row.id}`,
     };
   } catch (e) {
-    return { id: row.id, ts: new Date().toISOString(), error: e.message.slice(0, 200), category: row.category };
+    return { id: row.id, ts: new Date().toISOString(), error: e.message.slice(0, 200), category: row.category, source: row.source };
   }
 }
 

← 75da858 fix /api/designs PII leak: drop local_path from designs.json  ·  back to Wallco Ai  ·  cleanup: delete .bak files, orphan entrypoints, unused @dw/i 53c740e →