← back to Wallco Ai
scripts/bleed_guard.js
288 lines
#!/usr/bin/env node
/**
* bleed_guard.js — kills MOTIF BLEED / ghost-layer artifacts forever.
*
* Steve directive 2026-05-20: "fix this issue forever with an agent."
*
* "Bleed" here is NOT banana-leaves-where-they-shouldn't-be (that's the
* legal settlement gate). It is the AESTHETIC bleed:
* • Ghost / echo / duplicate of the motif visible behind the main motif
* • Halftone / ben-day dots / dot screen behind motif
* • Atmospheric haze / watercolor wash / airbrush behind sharp motif
* • Sepia gradient / soft-focus background layer
* • Crosshatching or interior shading visible inside the motif silhouette
* • Layered-bleed double-exposure look
*
* Visual signature: motif appears doubled up — one crisp layer in front,
* a faded/blurred duplicate offset behind it, as if a screen-print
* registration is off. SDXL+Replicate trips this constantly.
*
* Usage:
* node scripts/bleed_guard.js # tick mode — last 5 min
* node scripts/bleed_guard.js --since="1 hour"
* node scripts/bleed_guard.js --last-n=50
* node scripts/bleed_guard.js --id=10386
* node scripts/bleed_guard.js --recheck # ignore prior verdict, recheck
*
* Scheduled via cron every 5 min — see install-bleed-guard-cron.sh.
*/
'use strict';
require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const ROOT = path.join(__dirname, '..');
const KEY = process.env.GEMINI_API_KEY;
if (!KEY) { console.error('[bleed_guard] GEMINI_API_KEY missing — exit'); process.exit(1); }
const PSQL = (process.platform === 'linux')
? 'sudo -n -u postgres psql dw_unified -At -q'
: 'psql dw_unified -At -q';
function psql(sql) {
return execSync(PSQL, { input: sql, encoding: 'utf8' }).trim();
}
const LOG_FILE = path.join(ROOT, 'data', 'logs', 'bleed_guard.log');
const DO_NOT_WANT = path.join(ROOT, 'data', 'do-not-want.jsonl');
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
function log(event) {
const line = JSON.stringify({ ts: new Date().toISOString(), ...event });
fs.appendFileSync(LOG_FILE, line + '\n');
console.log(line);
}
// ── CLI ──────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
function arg(flag) {
const hit = argv.find(a => a.startsWith(`${flag}=`));
return hit ? hit.slice(flag.length + 1) : null;
}
const ID_ONE = arg('--id');
const LAST_N = parseInt(arg('--last-n') || '0', 10);
const SINCE = arg('--since') || '5 minutes';
const CATEGORY = arg('--category'); // substring match on category, e.g. drunk|monkey|stoned
const RECHECK = argv.includes('--recheck');
const INCLUDE_REMOVED = argv.includes('--include-removed');
const REVIEW_ONLY = argv.includes('--review-only'); // don't write verdicts, just report
// ── candidates ──────────────────────────────────────────────────────────
function candidates() {
const skipPrior = RECHECK
? ''
: `AND (settlement_verdict IS NULL OR settlement_verdict NOT IN ('BLEED_GHOST_BLOCK','BLEED_GHOST_OK'))`;
if (ID_ONE) {
return parseRows(psql(`
SELECT id, category, COALESCE(dig_number,''), local_path, image_url
FROM spoon_all_designs WHERE id=${parseInt(ID_ONE, 10)} LIMIT 1;
`));
}
if (LAST_N > 0) {
return parseRows(psql(`
SELECT id, category, COALESCE(dig_number,''), local_path, image_url
FROM spoon_all_designs
WHERE 1=1 ${INCLUDE_REMOVED ? '' : 'AND is_published = TRUE AND user_removed = FALSE'}
${CATEGORY ? `AND category ~* '${CATEGORY.replace(/'/g, "''")}'` : ''}
AND (generator IS NULL OR generator NOT LIKE 'pil-%')
${skipPrior}
ORDER BY id DESC LIMIT ${LAST_N};
`));
}
// 2026-05-26 exemption — PIL-derived rows (pil-mid-heal, pil-fix-design,
// pil-fix, pil-opacity-snap) are already vetted by a deterministic
// pipeline and DON'T look like bleed/ghost in the original sense — the
// band-blur smoothing strip can trip Gemini's "halftone_layer" detector
// and re-block a perfectly clean heal. Skip them at candidate time.
return parseRows(psql(`
SELECT id, category, COALESCE(dig_number,''), local_path, image_url
FROM spoon_all_designs
WHERE is_published = TRUE AND user_removed = FALSE
AND created_at > NOW() - INTERVAL '${SINCE.replace(/'/g, "''")}'
AND (generator IS NULL OR generator NOT LIKE 'pil-%')
${skipPrior}
ORDER BY id DESC LIMIT 60;
`));
}
function parseRows(raw) {
return raw.split('\n').filter(Boolean).map(line => {
const [id, category, dig, local_path, image_url] = line.split('|');
return { id: parseInt(id, 10), category, dig, local_path, image_url };
});
}
// ── Gemini vision: ghost-layer / motif-bleed detector ───────────────────
async function visionCheck(imgPath, category, dig) {
const ext = imgPath.toLowerCase().split('.').pop();
const mime = ext === 'png' ? 'image/png' : (ext === 'webp' ? 'image/webp' : 'image/jpeg');
const b64 = fs.readFileSync(imgPath).toString('base64');
const prompt = `You are reviewing a wallpaper pattern for a quality defect called MOTIF BLEED.
What MOTIF BLEED looks like:
- The main motif is repeated as a faded ghost / duplicate / echo offset behind itself, like a screen-print with the registration off.
- A halftone, ben-day-dot, dot-screen, or blurred haze layer sits behind the sharp motif.
- Atmospheric haze, watercolor wash, airbrush, or sepia gradient blurs the background.
- Crosshatching or interior shading shows up INSIDE the motif silhouette that should be flat.
- A "double exposure" look — you can see two layers of the same motif at once.
What is NOT bleed (these are FINE — do not flag):
- A different motif or filler ornament in the background (e.g. small flowers behind a damask).
- A solid colored background.
- Texture that is consistent across the WHOLE image, not specifically a duplicate of the foreground motif.
- Color palette of 2-4 inks (totally fine).
Category: "${category}"
Identifier: "${dig || '(none)'}"
Return STRICT JSON only:
{
"has_motif_ghost": <true|false — is there a faded duplicate / echo of the main motif behind itself?>,
"has_halftone_layer": <true|false — halftone, ben-day, dot screen, or blur layer behind motif?>,
"has_atmospheric_haze": <true|false — soft haze / wash / airbrush / sepia gradient behind motif?>,
"has_interior_shading_bleed": <true|false — crosshatching or shading inside the silhouette that should be flat?>,
"bleed_severity": <"none" | "subtle" | "obvious">,
"confidence": <0.0 to 1.0>,
"one_line_reason": "<short single sentence>"
}`;
const body = {
contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: mime, data: b64 } }] }],
generationConfig: { temperature: 0, response_mime_type: 'application/json' },
};
const r = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/${process.env.GEMINI_VISION_MODEL || 'gemini-2.5-flash'}:generateContent?key=${KEY}`,
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }
);
if (!r.ok) {
const txt = await r.text();
throw new Error(`gemini ${r.status}: ${txt.slice(0, 200)}`);
}
const j = await r.json();
const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';
return JSON.parse(text);
}
// ── decision ─────────────────────────────────────────────────────────────
function decide(v) {
const flags = [];
if (v.has_motif_ghost) flags.push('motif_ghost');
if (v.has_halftone_layer) flags.push('halftone_layer');
if (v.has_atmospheric_haze) flags.push('atmospheric_haze');
if (v.has_interior_shading_bleed) flags.push('interior_shading_bleed');
// BLOCK if severity is obvious AND at least one flag tripped,
// OR motif_ghost is true at any subtle/obvious severity (this is the
// exact bleed Steve called out on /design/10386 and /design/10425).
if (v.has_motif_ghost && v.bleed_severity !== 'none') {
return { block: true, reason: `motif ghost layer (${v.bleed_severity})`, flags };
}
if (v.bleed_severity === 'obvious' && flags.length > 0) {
return { block: true, reason: `obvious bleed: ${flags.join(', ')}`, flags };
}
return { block: false, reason: 'clean', flags };
}
// Steve directive 2026-05-20: drunk-monkey / drunk-zoo / drunk-animals /
// stoned-animals concepts are LOVED — don't hard-delete on bleed. Instead
// unpublish + mark for regen so the prompt-system can rebuild them with
// anti-ghost language. All other categories: hard-delete as before.
const KEEP_FOR_REGEN_CATEGORIES = [
'drunk-animals', 'drunk-animals-designer', 'drunk-zoo-36', 'stoned-animals',
];
function isKeepForRegen(category) {
if (KEEP_FOR_REGEN_CATEGORIES.includes(category)) return true;
const lc = String(category || '').toLowerCase();
return lc.includes('monkey') || lc.includes('drunk') || lc.includes('stoned');
}
// ── unpublish + stamp (delete vs regen branch) ──────────────────────────
function hardDelete(designId, category, reason, verdict) {
const keep = isKeepForRegen(category);
const tag = keep ? 'BLEED_GHOST_REGEN' : 'BLEED_GHOST_BLOCK';
const safe = String(reason).replace(/'/g, "''").slice(0, 380);
if (keep) {
// Unpublish + flag for regen — keep the row + image around as reference.
psql(`
UPDATE spoon_all_designs
SET is_published = FALSE,
settlement_verdict = '${tag}',
notes = COALESCE(notes, '') || ' | BLEED_GHOST_REGEN: ${safe}'
WHERE id = ${designId};
`);
} else {
// Hard delete — unpublish + user_removed + do-not-want log.
psql(`
UPDATE spoon_all_designs
SET is_published = FALSE,
settlement_verdict = '${tag}',
user_removed = TRUE,
notes = COALESCE(notes, '') || ' | BLEED_GHOST: ${safe}'
WHERE id = ${designId};
`);
fs.appendFileSync(DO_NOT_WANT, JSON.stringify({
ts: new Date().toISOString(),
design_id: designId,
category,
reason,
verdict,
source: 'bleed_guard.ghost-layer',
}) + '\n');
}
}
function stampOk(designId) {
psql(`UPDATE spoon_all_designs SET settlement_verdict='BLEED_GHOST_OK' WHERE id=${designId};`);
}
function resolveImg(row) {
if (row.local_path && fs.existsSync(row.local_path)) return row.local_path;
if (row.image_url && row.image_url.startsWith('/designs/img/')) {
const f = path.join(ROOT, 'data', 'generated', row.image_url.replace('/designs/img/', ''));
if (fs.existsSync(f)) return f;
}
return null;
}
// ── main ────────────────────────────────────────────────────────────────
(async () => {
const rows = candidates();
if (!rows.length) { log({ event: 'tick_done', checked: 0, blocked: 0, note: 'no candidates' }); return; }
log({ event: 'tick_start', candidates: rows.length, mode: ID_ONE ? 'one' : (LAST_N ? `last-${LAST_N}` : `since-${SINCE}`), recheck: RECHECK });
let checked = 0, blocked = 0, errored = 0, skipped = 0;
for (const row of rows) {
const img = resolveImg(row);
if (!img) { skipped++; log({ event: 'skip', id: row.id, reason: 'image not found on disk' }); continue; }
try {
const verdict = await visionCheck(img, row.category, row.dig);
const decision = decide(verdict);
checked++;
if (decision.block) {
if (!REVIEW_ONLY) hardDelete(row.id, row.category, decision.reason, verdict);
blocked++;
const keep = isKeepForRegen(row.category);
log({ event: REVIEW_ONLY ? 'would_block' : (keep ? 'regen_marked' : 'block'), id: row.id, category: row.category, reason: decision.reason, flags: decision.flags, severity: verdict.bleed_severity, conf: verdict.confidence });
} else {
if (!REVIEW_ONLY) stampOk(row.id);
log({ event: 'ok', id: row.id, category: row.category, severity: verdict.bleed_severity });
}
} catch (e) {
errored++;
log({ event: 'error', id: row.id, err: e.message });
}
await new Promise(r => setTimeout(r, 250));
}
log({ event: 'tick_done', checked, blocked, errored, skipped });
if (blocked > 0) {
try {
execSync(`python3 ${path.join(ROOT, 'scripts/refresh_designs_snapshot.py')}`, { cwd: ROOT });
log({ event: 'designs_json_refreshed' });
} catch (e) {
log({ event: 'refresh_failed', err: e.message });
}
}
})().catch(e => {
log({ event: 'fatal', err: e.message });
process.exit(2);
});