← back to Wallco Ai

lib/composition-detector.js

248 lines

// composition-detector — wallpaper-tile composition QA via Gemini Vision.
//
// Detects whether a generated wallpaper tile is:
//   (a) a true multi-motif REPEATING FIELD (what we want), or
//   (b) a single CENTERED HERO with wrap-half artefacts at the corners
//
// Both can be mathematically tileable. The composition-detector is the
// gate that distinguishes them so the smart-fix / generate pipelines can
// retry when Gemini slips into single-hero mode despite the multi-instance
// prompt language from lib/repeat-prompt.
//
// Sister module to lib/ghost-detector — same shape, different defect.
// Ghost-detector finds opacity defects. Composition-detector finds
// "wallpaper-or-portrait" defects.
//
// Cost: one gemini-2.0-flash vision call per check (~$0.0005). Sometimes
// callers will also want a stable variant — 3 votes + majority — modeled
// after analyzeGhostLayerStable. Provided as analyzeCompositionStable.

const fs = require('fs');
const path = require('path');

// 2026-06-01: gemini-2.0-flash retired by Google (404) — migrate to 2.5-flash
// (matches settlement-post-gen-vision fix b25dd485). Override via COMPOSITION_DETECTOR_MODEL.
const GEMINI_MODEL = process.env.COMPOSITION_DETECTOR_MODEL || 'gemini-2.5-flash';
const GEMINI_BASE  = 'https://generativelanguage.googleapis.com/v1beta/models';

// Scenic categories — these are MURAL designs (single panoramic scenes spanning
// multiple panels), NOT seamless tiles. By design they show one large scene, not
// multiple instances of a motif. Treating them as repeating-field defects produces
// false positives. The composition-detector returns a synthetic "scenic pass"
// for these categories without calling Gemini. Mirrors SCENIC_CATEGORIES in
// lib/ghost-detector.js.
const SCENIC_CATEGORIES = new Set([
  'cactus-pine-scenic', 'cactus-11ft-mural', 'tree-mural',
  'mural', 'scenic', 'panoramic', 'desert-flora', 'mural-animal',
  'mural-scenic', 'designer-scenic', 'monterey-mural',
  'theatrical-circus', 'saguaro-arms',
]);

function isScenicCategory(category) {
  if (!category) return false;
  // Strip "· colorway" suffix used in color-variant rows
  const base = category.split('·')[0].trim().toLowerCase();
  return SCENIC_CATEGORIES.has(base);
}

const PROMPT = `You are inspecting a WALLPAPER TILE image (a single tile of a repeating pattern).

Distinguish between two compositions:

WALLPAPER REPEAT (the intended kind):
  - Multiple instances of the same motif spread across the tile
  - Each instance roughly the same scale and orientation
  - The tile reads as an "all-over pattern" — your eye sees a field, not a focal point
  - Motifs typically continue across left/right and top/bottom edges (partial motifs near edges that wrap to the opposite edge)

CENTERED HERO (the defect):
  - ONE large motif occupying the centre of the tile
  - The corners show empty ground OR partial fragments of the same motif from a wrap-around shift
  - The tile reads as a "portrait" or "medallion" — one focal point, not an all-over pattern
  - If you mentally tile it 2×2, you would see one big motif per tile with cropped halves at the seams

Count the visible instances of the dominant motif. A partial/cropped motif near an edge counts as 1 if you can see it's the same motif. Don't count background florals or small filler — count the primary repeating motif only.

Also judge LARGE EMPTY AREA: TRUE if a big contiguous region (roughly a third or more of the tile) is flat empty background — a blank band, a void, or a large gap with no motif in it — so the tile reads as half-empty rather than a full all-over field. A textured/tone-on-tone background densely populated with motifs is NOT a large empty area. A single broad blank diagonal/horizontal band IS.

Return JSON ONLY (no markdown):
{"is_repeating_field": <bool>, "instance_count": <int>, "hero_centered": <bool>, "large_empty_area": <bool>, "confidence": <0..1>, "what_you_see": "<one short sentence>"}

Decision rule for hero_centered: TRUE if there's ONE clearly-dominant motif at or near the centre that's larger than the others (or all alone), with corners showing wrap-fragments or empty ground.

Decision rule for is_repeating_field: TRUE if instance_count >= 3 AND hero_centered === false. FALSE otherwise.`;

function readKey() {
  if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
  try {
    const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
    const m = env.match(/^GEMINI_API_KEY=(.+)$/m);
    if (m) return m[1].trim();
  } catch {}
  throw new Error('GEMINI_API_KEY missing');
}

// Accept either a path string or a base64 string. If it's neither, treat as buffer.
function toBase64(input) {
  if (Buffer.isBuffer(input)) return { b64: input.toString('base64'), mime: 'image/png' };
  if (typeof input !== 'string') throw new Error('composition-detector: input must be path|b64|buffer');
  // A real path is short and exists on disk. A base64 image is long — and a
  // JPEG base64 even starts with "/9j/", which naively looks like an abs path
  // (this broke Nano Banana 3.1 / gemini-3.x, which return C2PA-signed JPEG).
  // So only treat as a path when it's a plausible length AND actually exists.
  if (input.length < 4096 && fs.existsSync(input)) {
    const bytes = fs.readFileSync(input);
    const mime  = /\.png$/i.test(input) ? 'image/png' : 'image/jpeg';
    return { b64: bytes.toString('base64'), mime };
  }
  // Otherwise it's already base64 — detect JPEG vs PNG from the b64 magic.
  return { b64: input, mime: /^\/9j\//.test(input) ? 'image/jpeg' : 'image/png' };
}

async function analyzeComposition(input, opts = {}) {
  // Scenic murals bypass — single-scene panoramic designs are exempt from
  // the repeating-field requirement. Caller passes `category` (e.g.
  // 'monterey-mural', 'cactus-11ft-mural') to enable this exemption.
  if (opts.category && isScenicCategory(opts.category)) {
    return {
      is_repeating_field: true,
      instance_count:     1,
      hero_centered:      false,
      confidence:         1.0,
      what_you_see:       `Scenic-category exemption (${opts.category}) — single-scene mural, not a tile defect.`,
      cost_estimate:      0,
      scenic_exempt:      true,
    };
  }
  const KEY = readKey();
  const { b64, mime } = toBase64(input);
  const url = `${GEMINI_BASE}/${GEMINI_MODEL}:generateContent?key=${KEY}`;
  const body = {
    contents: [{ parts: [
      { text: PROMPT },
      { inline_data: { mime_type: mime, data: b64 } },
    ]}],
    generationConfig: {
      temperature: 0.0,
      topP: 1.0,
      topK: 1,
      candidateCount: 1,
      response_mime_type: 'application/json',
    },
  };
  const r = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
  if (!r.ok) {
    const txt = await r.text().catch(() => '');
    throw new Error(`gemini ${r.status}: ${txt.slice(0, 200)}`);
  }
  const j = await r.json();
  const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
  let parsed;
  try { parsed = JSON.parse(text); }
  catch { throw new Error(`gemini returned non-JSON: ${text.slice(0, 200)}`); }
  // Coerce field types; tolerate missing fields with sane defaults
  const instance_count = Number.isFinite(parsed.instance_count) ? Math.max(0, parseInt(parsed.instance_count, 10)) : 0;
  const hero_centered  = parsed.hero_centered === true;
  const large_empty_area = parsed.large_empty_area === true;
  // Recompute is_repeating_field locally — don't trust Gemini's boolean math
  const is_repeating_field = instance_count >= 3 && !hero_centered;
  return {
    is_repeating_field,
    instance_count,
    hero_centered,
    large_empty_area,
    confidence:   Number(parsed.confidence) || 0,
    what_you_see: String(parsed.what_you_see || '').slice(0, 250),
    cost_estimate: 0.0005,
  };
}

// Stable variant — 3 parallel votes, majority on the binary is_repeating_field flag.
// Returns the winner with median instance_count, mode hero_centered, mean confidence.
async function analyzeCompositionStable(input, opts = {}) {
  // Scenic exemption — same shortcut as the single-call variant
  if (opts.category && isScenicCategory(opts.category)) {
    return {
      is_repeating_field: true,
      instance_count:     1,
      hero_centered:      false,
      confidence:         1.0,
      consensus:          1.0,
      unanimous:          true,
      votes:              [],
      errored:            0,
      what_you_see:       `Scenic-category exemption (${opts.category}) — single-scene mural, not a tile defect.`,
      cost_estimate:      0,
      scenic_exempt:      true,
    };
  }
  const N = 3;
  const settled = await Promise.allSettled(
    Array.from({ length: N }, () => analyzeComposition(input, opts))
  );
  const votes = settled.filter(s => s.status === 'fulfilled').map(s => s.value);
  const errored = N - votes.length;
  if (votes.length === 0) {
    const e = settled.find(s => s.status === 'rejected');
    throw new Error(`all ${N} composition votes errored: ${e?.reason?.message || 'unknown'}`);
  }
  const trueVotes  = votes.filter(v => v.is_repeating_field).length;
  const falseVotes = votes.length - trueVotes;
  const is_repeating_field = trueVotes > falseVotes;
  const unanimous = trueVotes === votes.length || falseVotes === votes.length;
  // Median instance_count
  const counts = votes.map(v => v.instance_count).sort((a, b) => a - b);
  const median = counts[Math.floor(counts.length / 2)];
  // Mode hero_centered
  const heroTrue = votes.filter(v => v.hero_centered).length;
  const hero_centered = heroTrue > votes.length / 2;
  // Mode large_empty_area
  const voidTrue = votes.filter(v => v.large_empty_area).length;
  const large_empty_area = voidTrue > votes.length / 2;
  const avgConf = votes.reduce((s, v) => s + v.confidence, 0) / votes.length;
  // Reason = whichever winning-side vote has highest confidence
  const winners = votes.filter(v => v.is_repeating_field === is_repeating_field);
  const reasonVote = winners.sort((a, b) => b.confidence - a.confidence)[0];
  return {
    is_repeating_field,
    instance_count: median,
    hero_centered,
    large_empty_area,
    confidence: avgConf,
    consensus:  Math.max(trueVotes, falseVotes) / votes.length,  // 1.0 unanimous, 0.67 split
    unanimous,
    votes: votes.map(v => ({ rep: v.is_repeating_field, n: v.instance_count, hero: v.hero_centered })),
    errored,
    what_you_see: reasonVote.what_you_see,
    cost_estimate: 0.0005 * votes.length,
  };
}

// Gate variant — for the smart-fix / generate retry loop. Returns
// { ok, reason } — ok is false when the image is a centered-hero or has too
// few instances. Caller decides whether to retry or accept.
async function gateComposition(input, opts = {}) {
  const minInstances = opts.minInstances ?? 3;
  const stable = opts.stable === true;
  const r = stable ? await analyzeCompositionStable(input, opts) : await analyzeComposition(input, opts);
  // Scenic exemption always passes the gate
  if (r.scenic_exempt) return { ok: true, result: r, reason: 'scenic-exempt' };
  // Open-space guard is OPT-IN (opts.rejectOpenSpace) so the generate/smart-fix
  // retry loop keeps its original behavior; the triage decider passes it true.
  const voidReject = opts.rejectOpenSpace === true && r.large_empty_area === true;
  const ok = !r.hero_centered && r.instance_count >= minInstances && !voidReject;
  let reason = 'pass';
  if (!ok) {
    if (r.hero_centered) reason = `centered-hero (n=${r.instance_count})`;
    else if (r.instance_count < minInstances) reason = `too-few-instances (n=${r.instance_count}<${minInstances})`;
    else if (voidReject) reason = `large-empty-void`;
  }
  return { ok, result: r, reason };
}

module.exports = { analyzeComposition, analyzeCompositionStable, gateComposition, isScenicCategory, SCENIC_CATEGORIES, GEMINI_MODEL };