← 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 };