← back to Dw Marketing Reels
scripts/novasuede-run-all.mjs
125 lines
#!/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 (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';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WS = join(ROOT, 'hf-workspace');
const NS = process.env.NS_DIR || join(ROOT, '..', '_prod-mirror', 'novasuede');
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.
// 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 || '';
const BASE = process.env.PUBLIC_BASE || 'https://marketing.designerwallcoverings.com';
const IG = process.env.IG_AGENT_URL || 'http://127.0.0.1:9810';
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 || []); })();
// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 || []);
// 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; };
function findMp4(d) { let n=null,mt=0; const w=x=>{for(const e of readdirSync(x,{withFileTypes:true})){if(e.name==='node_modules'||e.name.startsWith('.'))continue;const p=join(x,e.name);if(e.isDirectory())w(p);else if(e.name.endsWith('.mp4')){const t=statSync(p).mtimeMs;if(t>mt){mt=t;n=p;}}}};w(d);return n; }
function buildReel(cols, tag) {
const A = join(WS, 'assets', 'nsr'); mkdirSync(A, { recursive: true });
// 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 (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)} 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>
html,body{margin:0;width:1080px;height:1920px;overflow:hidden;background:#0e0e10;font-family:"Helvetica Neue",system-ui,sans-serif;}#main-composition{position:relative;width:1080px;height:1920px;overflow:hidden;background:#0e0e10;}
.scene{position:absolute;inset:0;}.scene .ph{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;transform-origin:center;}
.scrim{position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,.30),rgba(0,0,0,.05) 42%,rgba(0,0,0,.68));}
.cap{position:absolute;left:72px;right:72px;bottom:180px;}.cap .kick{font-size:32px;letter-spacing:.34em;text-transform:uppercase;color:#DDAB1E;}
.cap .big{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:104px;color:#fff;margin:14px 0 12px;text-shadow:0 2px 24px rgba(0,0,0,.6);}
.cap .sub{font-size:30px;letter-spacing:.16em;text-transform:uppercase;color:#f5f1e8;}
#outro{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:#0e0e10;}
#outro .s{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:72px;color:#fff;margin:0 72px 30px;}#outro .s b{color:#DDAB1E;font-style:italic;}
#outro .rule{width:120px;height:2px;background:#DDAB1E;margin:6px 0 30px;}#outro .b{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:70px;color:#f5f1e8;}#outro .u{margin-top:20px;font-size:32px;letter-spacing:.26em;text-transform:uppercase;color:#DDAB1E;}
</style></head><body><div id="main-composition" data-composition-id="main-video" data-width="1080" data-height="1920" data-start="0" data-duration="${TOTAL}">${scenes}
<div id="outro"><div class="s">Samples are always<br/><b>no charge for professionals</b></div><div class="rule"></div><div class="b">Novasuede™</div><div class="u">novasuede.com</div></div>
<script>const tl=gsap.timeline({paused:true});window.__timelines=window.__timelines||{};
${anim}
gsap.set("#outro",{opacity:0});gsap.set("#outro .s",{y:40,opacity:0});gsap.set("#outro .rule,#outro .b,#outro .u",{opacity:0});
tl.to("#outro",{opacity:1,duration:0.5},${OUTRO_AT});tl.to("#outro .s",{opacity:1,y:0,duration:0.6},${OUTRO_AT+0.2});tl.to("#outro .rule,#outro .b",{opacity:1,duration:0.5},${OUTRO_AT+0.8});tl.to("#outro .u",{opacity:1,duration:0.5},${OUTRO_AT+1.2});
window.__timelines["main-video"]=tl;</script></div></body></html>`;
writeFileSync(join(WS, 'index.html'), html);
for (const f of ['compositions/intro.html','compositions/graphics.html','compositions/captions.html']) { const p = join(WS,f); if (existsSync(p)) writeFileSync(p,'<div></div>'); }
execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'ignore', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
const found = findMp4(WS); if (!found) throw new Error('no mp4');
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const outName = `novasuede-${tag}-${stamp}.mp4`;
const outPath = join(ROOT, 'reels', outName);
execSync(`ffmpeg -y -i ${JSON.stringify(found)} -stream_loop -1 -i ${JSON.stringify(MUSIC)} -filter_complex "[1:a]volume=0.55,afade=t=out:st=13:d=2[a]" -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 160k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
return outName;
}
async function postTo(account, file, cols) {
const url = `${BASE}/reels/${encodeURIComponent(file)}`;
const names = cols.map(c => tc(c.slug)).join(', ');
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 });
clearTimeout(t); const txt = await res.text(); let b = {}; try { b = JSON.parse(txt); } catch {} const x = b.result || b;
const st = !res.ok ? (res.status === 401 ? 'pending-creds' : 'error') : (x.status === 'no-creds-for-account' ? 'no-creds' : (b.success === false || x.status === 'error' || x.error ? 'error' : (x.simulated ? 'simulated' : 'posted')));
return { account, status: st, permalink: x.permalink || null, note: x.note || x.error || null };
} catch (e) { clearTimeout(t); return { account, status: /abort|timeout/i.test(e.message) ? 'posted-unverified' : 'error', note: e.message.slice(0, 80) }; }
}
(async () => {
const results = [];
for (const account of ACCOUNTS) {
const cols = takeColors(PER_ACCT);
if (cols.length < PER_ACCT) { console.log(`[skip] ${account}: only ${cols.length} colors left`); if (!cols.length) continue; }
const file = buildReel(cols, account);
if (POST) { execFileSync('rsync', ['-az', join(ROOT, 'reels', file), `${PUSH_HOST}:${PUSH_PATH}/reels/`]); }
const r = POST ? await postTo(account, file, cols) : { account, status: 'built (not posted)', permalink: null };
// mark colors posted ONLY when actually published (never on a build-only dry run)
if (POST && ['posted', 'simulated', 'posted-unverified'].includes(r.status)) cols.forEach(c => done.add(c.slug));
results.push({ ...r, file, colors: cols.map(c => c.slug) });
console.log(`@${account}: ${r.status} [${cols.map(c => c.slug).join(', ')}] ${r.permalink || ''}${r.note ? ' — ' + r.note : ''}`);
}
state.posted = [...done];
state.last_run = { at: new Date().toISOString(), results };
writeFileSync(STATE, JSON.stringify(state, null, 2));
console.log(`DONE — posted list now ${state.posted.length} colors`);
})();