← back to Dw Marketing Reels
reels: novasuede-run-all.mjs — 3 new colours per account, fan to all suggested accounts
951f996145b6a999ed2f923154b5c669c07eb9fb · 2026-08-10 17:20:40 -0700 · Steve
One campaign 'drop': for each account [novasuede, suedewallpaper, textilewallpaper,
commercialwallcoverings, designerwallcoverings] pick 3 NEW colours (global de-dup), build a 15s
post (jewelry-display + swatch per colour + music bed, first frame = jewelry display), push +
post to that account. Colours marked posted ONLY on real publish (dry run = build-only, no state
change). Live multi-account fire is armed via NS_POST=1 (Steve-gated). Feeds the 3x/week schedule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A scripts/novasuede-run-all.mjs
Diff
commit 951f996145b6a999ed2f923154b5c669c07eb9fb
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 10 17:20:40 2026 -0700
reels: novasuede-run-all.mjs — 3 new colours per account, fan to all suggested accounts
One campaign 'drop': for each account [novasuede, suedewallpaper, textilewallpaper,
commercialwallcoverings, designerwallcoverings] pick 3 NEW colours (global de-dup), build a 15s
post (jewelry-display + swatch per colour + music bed, first frame = jewelry display), push +
post to that account. Colours marked posted ONLY on real publish (dry run = build-only, no state
change). Live multi-account fire is armed via NS_POST=1 (Steve-gated). Feeds the 3x/week schedule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/novasuede-run-all.mjs | 110 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 110 insertions(+)
diff --git a/scripts/novasuede-run-all.mjs b/scripts/novasuede-run-all.mjs
new file mode 100644
index 0000000..ebc47d7
--- /dev/null
+++ b/scripts/novasuede-run-all.mjs
@@ -0,0 +1,110 @@
+#!/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.
+// $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');
+const ACCOUNTS = (process.env.NS_ACCOUNTS || 'novasuede,suedewallpaper,textilewallpaper,commercialwallcoverings,designerwallcoverings')
+ .split(',').map(s => s.trim().replace(/^@/, '')).filter(Boolean);
+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 || []); })();
+const hasJewel = s => existsSync(join(NS, 'images', 'jewelry', `novasuede-${s}.jpg`));
+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 || []);
+const eligible = colors.filter(c => !done.has(c.slug) && hasJewel(c.slug) && hasSwatch(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 });
+ 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`)); });
+ 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)}` }))];
+ 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 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 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 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`);
+})();
← 1651946 auto-data-snapshot: 2026-08-10T17:11:22 (1 data files) — dat
·
back to Dw Marketing Reels
·
auto-data-snapshot: 2026-08-10T17:42:11 (1 data files) — dat 57ef021 →