← back to Dw Marketing Reels
scripts/jewelry-gate.mjs
179 lines
#!/usr/bin/env node
/**
* jewelry-gate.mjs — DURABLE jewelry-counter exclusion gate for the REEL path.
*
* Steve, 2026-09-22: "stop creating any instagrams with jewelry counters."
* A reel is a video built from product photos — some product lines (Majilite via
* batch-jewelry.mjs) render literal jewelry-display-case frames. publish-social.mjs
* publishes the finished mp4, so THIS gate is the last line before it goes public:
* it samples frames from the actual mp4 and asks the same binary jewelry question.
*
* Verdict (FAIL-CLOSED — an un-measured frame is never "safe to post"):
* - PASS only if EVERY sampled frame was decided AND none is jewelry:true
* - HELD if any frame is jewelry:true (reason 'jewelry')
* - HELD if any frame could not be decided (reason 'undecided') <- fail-closed
* Scope = JEWELRY ONLY: room:true frames are NEVER a reason to hold.
*
* Results cache to data/jewelry-reel-cache.json keyed by mp4 path+mtime+size, so
* a reel's frames are classified once, not on every nightly run. Only decided
* verdicts (pass / jewelry-hold) are cached; a transient undecided-hold is not,
* so a later run re-measures it.
*
* Backend (local/free first): ollama qwen2.5vl on OLLAMA_HOSTS, Gemini fallback.
*/
import fs from 'node:fs';
import os from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const CACHE_FILE = join(ROOT, 'data', 'jewelry-reel-cache.json');
const OLLAMA_HOSTS = (process.env.OLLAMA_HOSTS || '127.0.0.1,192.168.1.133').split(',').map((s) => s.trim()).filter(Boolean);
const VL_MODEL = process.env.VL_MODEL || 'qwen2.5vl:7b';
const CALL_TIMEOUT = Number(process.env.JEWELRY_CALL_TIMEOUT_MS || 60000);
const FRAMES = Number(process.env.JEWELRY_REEL_FRAMES || 8);
const PROMPT = `Classify this wallpaper/wallcovering brand Instagram frame on two independent yes/no questions.
ROOM = the wallcovering is shown INSTALLED in a decorated room or styled vignette, with furniture, decor, lamps, plants, rugs, or architectural context. A flat swatch, rolled bolt, folded fabric drape, close-up texture, pattern tile, or product-on-white-background is NOT a room -> room:false.
JEWELRY = the frame shows a JEWELRY DISPLAY CASE, jewelry counter, or jewelry-store glass display presenting the product (rings, necklaces on busts, earrings on stands in a glass/lit case).
Reply with ONLY a compact JSON object, no prose: {"room":true|false,"jewelry":true|false,"scene":"3-6 word description"}`;
function loadCache() { try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; } }
function saveCache(c) {
try { fs.mkdirSync(dirname(CACHE_FILE), { recursive: true }); fs.writeFileSync(CACHE_FILE, JSON.stringify(c, null, 2)); }
catch { /* best-effort; a cache-write failure must never publish an unclassified reel */ }
}
async function classifyOllama(host, buf) {
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), CALL_TIMEOUT);
try {
const res = await fetch(`http://${host}:11434/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({ model: VL_MODEL, prompt: PROMPT, images: [buf.toString('base64')], stream: false, format: 'json', options: { temperature: 0, num_predict: 80 } }),
});
clearTimeout(to);
if (!res.ok) return null;
const j = await res.json();
const m = String(j.response || '').match(/\{[\s\S]*\}/);
if (!m) return null;
const p = JSON.parse(m[0]);
return { room: !!p.room, jewelry: !!p.jewelry, scene: String(p.scene || '').slice(0, 60), backend: `ollama:${host}` };
} catch { clearTimeout(to); return null; }
}
let GEMINI_KEY = null;
function geminiKey() {
if (GEMINI_KEY !== null) return GEMINI_KEY;
try {
const env = fs.readFileSync(join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8');
GEMINI_KEY = (env.match(/^GEMINI_API_KEY=(.+)$/m) || [])[1]?.trim().replace(/"/g, '') || '';
} catch { GEMINI_KEY = ''; }
return GEMINI_KEY;
}
async function classifyGemini(buf) {
const key = geminiKey();
if (!key) return null;
const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
const ep = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
const ac = new AbortController();
const to = setTimeout(() => ac.abort(), CALL_TIMEOUT);
try {
const res = await fetch(ep, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
body: JSON.stringify({ contents: [{ parts: [{ text: PROMPT }, { inline_data: { mime_type: 'image/jpeg', data: buf.toString('base64') } }] }], generationConfig: { temperature: 0, maxOutputTokens: 200 } }),
});
clearTimeout(to);
if (!res.ok) return null;
const j = await res.json();
const m = String(j?.candidates?.[0]?.content?.parts?.[0]?.text || '').match(/\{[\s\S]*\}/);
if (!m) return null;
const p = JSON.parse(m[0]);
return { room: !!p.room, jewelry: !!p.jewelry, scene: String(p.scene || '').slice(0, 60), backend: `gemini:${model}` };
} catch { clearTimeout(to); return null; }
}
async function classifyBuffer(buf) {
for (const host of OLLAMA_HOSTS) { const v = await classifyOllama(host, buf); if (v) return v; }
return await classifyGemini(buf);
}
/** Extract up to N evenly-spaced frames from the mp4 into a temp dir; return their paths. */
function extractFrames(mp4Path, n) {
const dur = (() => {
try {
const out = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', mp4Path], { encoding: 'utf8' });
return Math.max(1, parseFloat(out.trim()) || 1);
} catch { return null; }
})();
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'jgate-frames-'));
try {
if (dur) {
// one frame at each of n evenly-spaced timestamps (avoid the very edges)
for (let i = 0; i < n; i++) {
const t = (dur * (i + 0.5)) / n;
execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-ss', t.toFixed(2), '-i', mp4Path, '-frames:v', '1', '-q:v', '3', join(tmp, `f${i}.jpg`)], { stdio: 'ignore' });
}
} else {
// duration unknown -> sample by fps
execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', mp4Path, '-vf', `fps=1`, '-frames:v', String(n), '-q:v', '3', join(tmp, 'f%d.jpg')], { stdio: 'ignore' });
}
} catch { /* partial extraction still usable; falls through */ }
const frames = fs.readdirSync(tmp).filter((f) => f.endsWith('.jpg')).map((f) => join(tmp, f));
return { dir: tmp, frames };
}
/**
* Gate a reel mp4. Returns { ok, held, reason, jewelryFrames[], sampled, decided, backend }.
* ok===true means safe to publish; held===true means DO NOT publish.
*/
export async function gateReel(mp4Path) {
if (process.env.JEWELRY_GATE === '0') return { ok: true, held: false, reason: 'bypass' };
let st;
try { st = fs.statSync(mp4Path); } catch {
return { ok: false, held: true, reason: 'undecided', note: `reel file missing: ${mp4Path}`, sampled: 0, decided: 0 };
}
const key = `${mp4Path}|${st.mtimeMs}|${st.size}`;
const cache = loadCache();
if (cache[key] && cache[key].decidedVerdict) return cache[key].result;
const { dir, frames } = extractFrames(mp4Path, FRAMES);
try {
if (!frames.length) return { ok: false, held: true, reason: 'undecided', note: 'no frames extracted (ffmpeg?)', sampled: 0, decided: 0 };
const results = [];
for (const f of frames) {
const v = await classifyBuffer(fs.readFileSync(f));
results.push(v);
}
const jewelry = results.filter((v) => v && v.jewelry === true);
const anyUndecided = results.some((v) => !v);
const decided = results.filter(Boolean).length;
let result, decidedVerdict;
if (jewelry.length) {
result = { ok: false, held: true, reason: 'jewelry', jewelryFrames: jewelry.length, sampled: frames.length, decided, backend: (results.find(Boolean) || {}).backend, scenes: jewelry.map((v) => v.scene) };
decidedVerdict = true; // a jewelry hit is a firm verdict — cache it
} else if (anyUndecided) {
result = { ok: false, held: true, reason: 'undecided', jewelryFrames: 0, sampled: frames.length, decided };
decidedVerdict = false; // transient — do NOT cache, re-measure next run
} else {
result = { ok: true, held: false, reason: 'clean', jewelryFrames: 0, sampled: frames.length, decided, backend: (results.find(Boolean) || {}).backend };
decidedVerdict = true;
}
if (decidedVerdict) { cache[key] = { decidedVerdict, result, at: new Date().toISOString() }; saveCache(cache); }
return result;
} finally {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
// CLI: `node jewelry-gate.mjs <reel.mp4> [...]`
if (import.meta.url === `file://${process.argv[1]}`) {
const args = process.argv.slice(2);
if (!args.length) { console.error('usage: node jewelry-gate.mjs <reel.mp4> [...]'); process.exit(1); }
for (const a of args) {
const r = await gateReel(a);
console.log(`${r.held ? 'HELD(' + r.reason + ')' : 'PASS'} ${a} -> ${JSON.stringify(r)}`);
}
}