[object Object]

← back to Dw Marketing Reels

Reel publish path: durable jewelry-counter HOLD gate (TK-12012)

bfae37cc12d048d36ef9392719debda0088e6d93 · 2026-09-22 11:09:53 -0700 · Steve Abrams

Steve directive 2026-09-22: stop posting any Instagram with jewelry counters
(scope = jewelry ONLY; room-setting reels stay). publish-social.mjs publishes
the finished mp4, so this is the last line before a reel goes public.

- jewelry-gate.mjs: sample frames from the actual mp4 (ffmpeg) and classify each
  (ollama qwen2.5vl free / Gemini fallback), mp4-keyed cache. HOLD if any frame
  is jewelry:true OR undecidable (fail-closed); room frames never hold.
- publish-social.mjs: jewelry-hold block before publish -> status
  held-jewelry-review on IG + TikTok, posts nothing.
- jewelry-sweep-pending.mjs: quarantine any staged jewelry reel in the queue.

Posting stays DISARMED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CRkDtx31oCvHHaMfJf9CzT

Files touched

Diff

commit bfae37cc12d048d36ef9392719debda0088e6d93
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 22 11:09:53 2026 -0700

    Reel publish path: durable jewelry-counter HOLD gate (TK-12012)
    
    Steve directive 2026-09-22: stop posting any Instagram with jewelry counters
    (scope = jewelry ONLY; room-setting reels stay). publish-social.mjs publishes
    the finished mp4, so this is the last line before a reel goes public.
    
    - jewelry-gate.mjs: sample frames from the actual mp4 (ffmpeg) and classify each
      (ollama qwen2.5vl free / Gemini fallback), mp4-keyed cache. HOLD if any frame
      is jewelry:true OR undecidable (fail-closed); room frames never hold.
    - publish-social.mjs: jewelry-hold block before publish -> status
      held-jewelry-review on IG + TikTok, posts nothing.
    - jewelry-sweep-pending.mjs: quarantine any staged jewelry reel in the queue.
    
    Posting stays DISARMED. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01CRkDtx31oCvHHaMfJf9CzT
---
 .gitignore                        |   3 +
 scripts/jewelry-gate.mjs          | 178 ++++++++++++++++++++++++++++++++++++++
 scripts/jewelry-sweep-pending.mjs |  70 +++++++++++++++
 scripts/publish-social.mjs        |  25 ++++++
 4 files changed, 276 insertions(+)

diff --git a/.gitignore b/.gitignore
index f7c151e..38b8e87 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,6 @@ spotlight-work/
 reels/
 flipbooks/
 videos/
+
+# runtime jewelry-gate frame-classify cache (regenerated on demand)
+data/jewelry-reel-cache.json
diff --git a/scripts/jewelry-gate.mjs b/scripts/jewelry-gate.mjs
new file mode 100644
index 0000000..8f977ba
--- /dev/null
+++ b/scripts/jewelry-gate.mjs
@@ -0,0 +1,178 @@
+#!/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)}`);
+  }
+}
diff --git a/scripts/jewelry-sweep-pending.mjs b/scripts/jewelry-sweep-pending.mjs
new file mode 100644
index 0000000..8bfa530
--- /dev/null
+++ b/scripts/jewelry-sweep-pending.mjs
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+/**
+ * jewelry-sweep-pending.mjs — sweep the STAGED reel queue for jewelry-counter
+ * content so nothing already-queued slips through when posting is re-armed.
+ * TK-12012 / Steve 2026-09-22 (jewelry ONLY).
+ *
+ * For every reel in data/reels.json that is NOT already posted and whose mp4
+ * exists locally, run the same frame-classify jewelry gate publish-social.mjs
+ * uses. Any reel with a jewelry-counter frame is QUARANTINED in place —
+ * publish.instagram/tiktok stamped {status:'held-jewelry-review'} — so it will
+ * never auto-post. Room-setting reels are untouched (scope = jewelry only).
+ *
+ * Reversible: writes data/reels.json.jewelry-sweep-bak-<ts> before any change,
+ * and only writes the manifest if something was actually quarantined.
+ *   node jewelry-sweep-pending.mjs          # sweep + quarantine
+ *   node jewelry-sweep-pending.mjs --dry     # report only, no writes
+ */
+import fs from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { gateReel } from './jewelry-gate.mjs';
+
+const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const MAN = join(ROOT, 'data', 'reels.json');
+const DRY = process.argv.includes('--dry');
+const TERMINAL = new Set(['posted', 'already-posted', 'posted-unverified']);
+const now = () => new Date().toISOString();
+
+const reels = JSON.parse(fs.readFileSync(MAN, 'utf8'));
+let scanned = 0, quarantined = 0, clean = 0, undecided = 0, skipped = 0;
+const changes = [];
+
+for (const reel of reels) {
+  const st = reel.publish?.instagram?.status;
+  if (TERMINAL.has(st)) { skipped++; continue; }
+  if (st === 'held-jewelry-review') { quarantined++; continue; } // already held
+  const local = join(ROOT, 'reels', reel.file);
+  if (!fs.existsSync(local)) { skipped++; continue; }
+  scanned++;
+  const gate = await gateReel(local);
+  if (gate.reason === 'jewelry') {
+    quarantined++;
+    const held = { status: 'held-jewelry-review', reason: 'jewelry',
+      note: `swept: jewelry-counter frame (${gate.jewelryFrames}/${gate.sampled} sampled) — quarantined`, at: now() };
+    reel.publish = reel.publish || {};
+    reel.publish.instagram = { ...held };
+    reel.publish.tiktok = { ...held };
+    changes.push({ file: reel.file, jewelryFrames: gate.jewelryFrames, sampled: gate.sampled });
+    console.log(`  QUARANTINE  ${reel.file}  (${gate.jewelryFrames}/${gate.sampled} jewelry frames)`);
+  } else if (gate.reason === 'clean') {
+    clean++;
+    console.log(`  clean       ${reel.file}`);
+  } else {
+    undecided++;
+    console.log(`  undecided   ${reel.file}  (${gate.note || gate.reason}) — will be held at publish (fail-closed)`);
+  }
+}
+
+console.log(`\nswept ${scanned} staged reel(s): ${changes.length} newly quarantined, ${clean} clean, ${undecided} undecided(held-at-publish); ${quarantined} total held; ${skipped} skipped(posted/missing).`);
+
+if (changes.length && !DRY) {
+  const bak = `${MAN}.jewelry-sweep-bak-${Date.now()}`;
+  fs.copyFileSync(MAN, bak);
+  fs.writeFileSync(MAN, JSON.stringify(reels, null, 2));
+  console.log(`wrote quarantine marks to reels.json (backup: ${bak})`);
+} else if (changes.length) {
+  console.log('--dry: no writes.');
+} else {
+  console.log('no jewelry reels found in the staged queue — nothing to quarantine.');
+}
diff --git a/scripts/publish-social.mjs b/scripts/publish-social.mjs
index b8c0857..9056836 100644
--- a/scripts/publish-social.mjs
+++ b/scripts/publish-social.mjs
@@ -16,6 +16,7 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
 import { fileURLToPath } from 'node:url';
 import { dirname, join } from 'node:path';
 import { publishReelToTikTok, hasTikTokCreds } from './tiktok-post.mjs';
+import { gateReel } from './jewelry-gate.mjs';
 
 const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
 const MAN = join(ROOT, 'data', 'reels.json');
@@ -155,6 +156,30 @@ async function main() {
 
   reel.publish = reel.publish || {};
 
+  // JEWELRY-COUNTER HOLD (Steve, 2026-09-22): a reel is built from product photos and some
+  // lines render literal jewelry-display-case frames. Sample the actual mp4 and refuse to
+  // publish if ANY frame shows a jewelry counter — or if a frame can't be classified
+  // (fail-closed). Room-setting frames are never a reason to hold. Set JEWELRY_GATE=0 only
+  // for a deliberate bypass. This is the last line before the reel goes public.
+  {
+    const localReel = join(ROOT, 'reels', reel.file);
+    let gate;
+    try { gate = await gateReel(localReel); }
+    catch (e) { gate = { ok: false, held: true, reason: 'undecided', note: `jewelry gate error: ${e.message}` }; } // fail-closed
+    if (gate.held) {
+      const held = { status: 'held-jewelry-review', reason: gate.reason,
+        note: gate.reason === 'jewelry'
+          ? `reel contains a jewelry-counter frame (${gate.jewelryFrames}/${gate.sampled} sampled${gate.scenes ? ': ' + gate.scenes.join('; ') : ''}) — not posting`
+          : `jewelry gate could not clear this reel (${gate.note || gate.reason}) — fail-closed, not posting`,
+        at: now() };
+      if (CHANNELS.includes('instagram')) reel.publish.instagram = { ...held };
+      if (CHANNELS.includes('tiktok'))    reel.publish.tiktok    = { ...held };
+      writeFileSync(MAN, JSON.stringify(reels, null, 2));
+      console.log(`HELD ${reel.file}: ${held.note}`);
+      return;
+    }
+  }
+
   // Content-claims HOLD (Fix 1): never auto-post a reel with an unsubstantiated factual claim.
   if (claimsHold(reel)) {
     const held = { status: 'held-claims-review', note: 'unsubstantiated advertising claim (FTC §5) — set CLAIMS_SUBSTANTIATED=1 after Steve confirms the claim is true', at: now() };

← b26c6e3 auto-data-snapshot: 2026-09-22T09:54:34 (2 data files) — dat  ·  back to Dw Marketing Reels  ·  auto-data-snapshot: 2026-09-23T07:25:20 (3 data files) — dat ee925af →