[object Object]

← back to Dw Marketing Reels

novasuede color post (TK-10866): drop designerwallcoverings from rotation + swap jewelry creative for SKU-labeled product swatch

99c71a897351865f2e23821c815239c18ced0c4a · 2026-08-26 08:28:01 -0700 · Steve Abrams

- Rotation limited to suedewallpaper,textilewallpaper,commercialwallcoverings; hard guard strips designerwallcoverings even if re-added via NS_ACCOUNTS
- Creative now uses the product swatch (images/colors) with the real DWNS-* SKU overlaid + in the caption; jewelry-counter photo removed from the reel
- SKU map (data/novasuede-sku-map.json) joined from dw_unified novasuede_catalog by name-slug; 142/142 verified, zero fabrication
- caption + header comment de-jeweled

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 99c71a897351865f2e23821c815239c18ced0c4a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 26 08:28:01 2026 -0700

    novasuede color post (TK-10866): drop designerwallcoverings from rotation + swap jewelry creative for SKU-labeled product swatch
    
    - Rotation limited to suedewallpaper,textilewallpaper,commercialwallcoverings; hard guard strips designerwallcoverings even if re-added via NS_ACCOUNTS
    - Creative now uses the product swatch (images/colors) with the real DWNS-* SKU overlaid + in the caption; jewelry-counter photo removed from the reel
    - SKU map (data/novasuede-sku-map.json) joined from dw_unified novasuede_catalog by name-slug; 142/142 verified, zero fabrication
    - caption + header comment de-jeweled
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/novasuede-run-all.mjs | 36 ++++++++++++++++++++++++------------
 1 file changed, 24 insertions(+), 12 deletions(-)

diff --git a/scripts/novasuede-run-all.mjs b/scripts/novasuede-run-all.mjs
index 36610cf..84348c2 100644
--- a/scripts/novasuede-run-all.mjs
+++ b/scripts/novasuede-run-all.mjs
@@ -1,7 +1,8 @@
 #!/usr/bin/env node
 // novasuede-run-all.mjs — one "drop" of the 3x/week campaign: for EACH target account, pick 3
-// NEW colors (global de-dup), build a 15s post (jewelry-display + swatch per color + music bed,
-// first frame = jewelry display), push to Kamatera, and post to that account. Marks colors posted.
+// NEW colors (global de-dup), build a 15s post (product SWATCH per color with the real DWNS SKU
+// overlaid + music bed; TK-10866: NO jewelry/room-setting photo), push to Kamatera, and post to
+// that account. Marks colors posted. Rotation = suede/textile/commercial ONLY (never designerwallcoverings).
 // $0 render. Live posting is the armed step (SOCIAL_LIVE_ARMED / NS_POST=1).
 import { writeFileSync, readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'node:fs';
 import { execSync, execFileSync } from 'node:child_process';
@@ -15,8 +16,11 @@ const MUSIC = process.env.NS_MUSIC || join(WS, 'audio', 'jazz.wav');
 const STATE = join(ROOT, 'data', 'novasuede-post-state.json');
 // NOTE: @novasuede is NOT Steve's IG handle — deliberately excluded. Novasuede is the product/site;
 // the content posts to Steve's own suede/textile/commercial/DW accounts.
-const ACCOUNTS = (process.env.NS_ACCOUNTS || 'suedewallpaper,textilewallpaper,commercialwallcoverings,designerwallcoverings')
-  .split(',').map(s => s.trim().replace(/^@/, '')).filter(Boolean);
+// TK-10866: Novasuede posts NEVER go to @designerwallcoverings — rotation is limited to the three
+// sister accounts. A hard guard below strips designerwallcoverings even if it re-enters via NS_ACCOUNTS.
+const ACCOUNTS = (process.env.NS_ACCOUNTS || 'suedewallpaper,textilewallpaper,commercialwallcoverings')
+  .split(',').map(s => s.trim().replace(/^@/, '')).filter(Boolean)
+  .filter(a => a.toLowerCase() !== 'designerwallcoverings');
 const PER_ACCT = Number(process.env.NS_COLORS || 3);
 const POST = process.env.NS_POST === '1';
 const AUTH = process.env.IG_AGENT_AUTH || '';
@@ -26,13 +30,18 @@ const PUSH_HOST = process.env.PUSH_HOST || 'root@45.61.58.125';
 const PUSH_PATH = process.env.PUSH_PATH || '/root/public-projects/dw-marketing-reels';
 
 const colors = (() => { const d = JSON.parse(readFileSync(join(NS, 'colors-data.json'), 'utf8')); return Array.isArray(d) ? d : (d.colors || []); })();
-const hasJewel = s => existsSync(join(NS, 'images', 'jewelry', `novasuede-${s}.jpg`));
+// Authoritative slug -> real DW SKU map, generated from the live dw_unified novasuede_catalog
+// (dw_sku column). NEVER invent a SKU: a color is only eligible if it has a real DWNS-* here.
+const SKU_MAP = (() => { const p = join(ROOT, 'data', 'novasuede-sku-map.json'); return existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : {}; })();
+const skuFor = s => { const v = SKU_MAP[s]; return (typeof v === 'string' && /^DWNS-\d+$/.test(v)) ? v : null; };
 const hasSwatch = s => existsSync(join(NS, 'images', 'colors', `novasuede-${s}.jpg`));
 const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));
 const tc = s => s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
 const state = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : { posted: [] };
 const done = new Set(state.posted || []);
-const eligible = colors.filter(c => !done.has(c.slug) && hasJewel(c.slug) && hasSwatch(c.slug));
+// NEW (TK-10866): show the SKU on the product SWATCH, not the jewelry-counter styled shot.
+// Eligibility = swatch image present AND a real DWNS-* SKU exists for the slug.
+const eligible = colors.filter(c => !done.has(c.slug) && hasSwatch(c.slug) && skuFor(c.slug));
 let ei = 0;
 const takeColors = n => { const out = []; while (out.length < n && ei < eligible.length) out.push(eligible[ei++]); return out; };
 
@@ -40,15 +49,17 @@ function findMp4(d) { let n=null,mt=0; const w=x=>{for(const e of readdirSync(x,
 
 function buildReel(cols, tag) {
   const A = join(WS, 'assets', 'nsr'); mkdirSync(A, { recursive: true });
-  cols.forEach((c, i) => { copyFileSync(join(NS, 'images', 'jewelry', `novasuede-${c.slug}.jpg`), join(A, `j${i}.jpg`)); copyFileSync(join(NS, 'images', 'colors', `novasuede-${c.slug}.jpg`), join(A, `s${i}.jpg`)); });
+  // Creative now uses the product SWATCH (images/colors) — the SKU is overlaid on it. Jewelry
+  // shots are no longer built into the reel (TK-10866).
+  cols.forEach((c, i) => { copyFileSync(join(NS, 'images', 'colors', `novasuede-${c.slug}.jpg`), join(A, `s${i}.jpg`)); });
   const TOTAL = 15, INTRO = 3, OUTRO = 3;
   const PER = +(((TOTAL - INTRO - OUTRO) / cols.length)).toFixed(3);
-  // scenes: intro (jewelry of color0) then per color: jewelry card
-  const items = [{ img: `nsr/j0.jpg`, kick: 'Novasuede™ · New Colours', big: `${cols.length} New`, sub: 'Microfiber suede' },
-    ...cols.map((c, i) => ({ img: `nsr/j${i}.jpg`, kick: 'New Colour', big: esc(tc(c.slug)), sub: `Soft · durable · ${esc(c.hex)}` }))];
+  // scenes: intro (swatch of color0) then per color: swatch card with the real SKU overlaid
+  const items = [{ img: `nsr/s0.jpg`, kick: 'Novasuede™ · New Colours', big: `${cols.length} New`, sub: 'Microfiber suede' },
+    ...cols.map((c, i) => ({ img: `nsr/s${i}.jpg`, kick: 'New Colour', big: esc(tc(c.slug)), sub: `SKU ${esc(skuFor(c.slug))} · ${esc(c.hex)}` }))];
   const starts = [0]; let t = INTRO; for (let i = 0; i < cols.length; i++) { starts.push(t); t += PER; }
   const durs = [INTRO, ...cols.map(() => PER)];
-  const scenes = items.map((s, i) => `<div class="scene" id="s${i}"><img class="ph" src="assets/${s.img}" alt="Novasuede ${esc(s.big)} jewelry display"/><div class="scrim"></div><div class="cap"><div class="kick">${s.kick}</div><div class="big">${s.big}</div><div class="sub">${s.sub}</div></div></div>`).join('');
+  const scenes = items.map((s, i) => `<div class="scene" id="s${i}"><img class="ph" src="assets/${s.img}" alt="Novasuede ${esc(s.big)} colour swatch"/><div class="scrim"></div><div class="cap"><div class="kick">${s.kick}</div><div class="big">${s.big}</div><div class="sub">${s.sub}</div></div></div>`).join('');
   const anim = items.map((s, i) => `gsap.set("#s${i}",{opacity:${i===0?1:0}});gsap.set("#s${i} .ph",{scale:1.1});gsap.set("#s${i} .cap",{y:40,opacity:0});${i===0?'':`tl.to("#s${i}",{opacity:1,duration:0.4},${starts[i]});`}tl.to("#s${i} .ph",{scale:1,duration:${durs[i]},ease:"none"},${starts[i]});tl.to("#s${i} .cap",{y:0,opacity:1,duration:0.5,ease:"power2.out"},${(starts[i]+0.25).toFixed(2)});tl.to("#s${i}",{opacity:0,duration:0.4,ease:"power1.in"},${(starts[i]+durs[i]-0.4).toFixed(2)});`).join('\n');
   const OUTRO_AT = INTRO + cols.length * PER;
   const html = `<!doctype html><html data-resolution="portrait"><head><meta charset="UTF-8"/><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script><style>
@@ -82,7 +93,8 @@ ${anim}
 async function postTo(account, file, cols) {
   const url = `${BASE}/reels/${encodeURIComponent(file)}`;
   const names = cols.map(c => tc(c.slug)).join(', ');
-  const cap = `Novasuede™ New Colours: ${names} — luxury microfiber suede, jewelry-case ready. Samples are always no charge for design professionals. novasuede.com\n\n#Novasuede #MicrofiberSuede #Suede #InteriorDesign #LuxuryInteriors #DesignTrade #FabricOfTheDay`;
+  const skus = cols.map(c => skuFor(c.slug)).filter(Boolean).join(', ');
+  const cap = `Novasuede™ New Colours: ${names}${skus ? ` (${skus})` : ''} — luxury microfiber suede. Samples are always no charge for design professionals. novasuede.com\n\n#Novasuede #MicrofiberSuede #Suede #InteriorDesign #LuxuryInteriors #DesignTrade #FabricOfTheDay`;
   const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 240000);
   try {
     const res = await fetch(`${IG}/api/skill/reel`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: AUTH }, body: JSON.stringify({ video_url: url, caption: cap, account, source: 'novasuede colours 3x/week' }), signal: ac.signal });

← 5e4c954 auto-data-snapshot: 2026-08-26T08:21:31 (2 data files) — dat  ·  back to Dw Marketing Reels  ·  Reel Studio: render a single photo + song → mp4 (Composer ha 9de1fc9 →