← back to Dw Marketing Reels
scripts/build-novasuede-color-post.mjs
131 lines
#!/usr/bin/env node
// build-novasuede-color-post.mjs — "new color 3x/week" generator.
// Picks the NEXT unposted Novasuede color (rotation state), builds a ~15s vertical POST:
// jewelry-display hero → suede swatch → jewelry detail → samples-free outro,
// with a MUSIC bed (no voiceover). First frame = the jewelry-display image (real product context).
// Marks the color posted in data/novasuede-post-state.json. $0 render (HyperFrames + ffmpeg).
import { writeFileSync, readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync } from 'node:fs';
import { execSync } 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 A = join(WS, 'assets', 'nscp');
mkdirSync(A, { recursive: true });
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 TOTAL = Number(process.env.NS_TARGET || 15);
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 state = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : { posted: [], acctIdx: 0 };
const done = new Set(state.posted);
// ROTATE between all target accounts (Steve): round-robin — each run posts to the next account.
// @novasuede is NOT Steve's IG handle — excluded. Content posts to Steve's own accounts.
const ACCOUNTS = (process.env.NS_ACCOUNTS || 'suedewallpaper,textilewallpaper,commercialwallcoverings,designerwallcoverings')
.split(',').map(s => s.trim().replace(/^@/, '')).filter(Boolean);
const acctIdx = (state.acctIdx || 0) % ACCOUNTS.length;
const account = ACCOUNTS[acctIdx];
// deterministic rotation: colors-data order, first eligible unposted color
const next = process.env.NS_SLUG
? colors.find(c => c.slug === process.env.NS_SLUG)
: colors.find(c => !done.has(c.slug) && hasJewel(c.slug) && hasSwatch(c.slug));
if (!next) { console.error('✗ no eligible unposted color left (all posted or missing assets)'); process.exit(3); }
const slug = next.slug;
const title = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[m]));
copyFileSync(join(NS, 'images', 'jewelry', `novasuede-${slug}.jpg`), join(A, 'jewel.jpg'));
copyFileSync(join(NS, 'images', 'colors', `novasuede-${slug}.jpg`), join(A, 'swatch.jpg'));
const INTRO = 4, MID1 = 4, MID2 = 4, OUTRO = TOTAL - 12; // 3s outro at 15s
const S = [
{ at: 0, d: INTRO, img: 'jewel.jpg', kick: 'Novasuede™ · New Colour', big: esc(title), sub: 'Microfiber suede' },
{ at: INTRO, d: MID1, img: 'swatch.jpg', kick: 'The colour', big: esc(title), sub: `Soft · durable · ${esc(next.hex)}` },
{ at: INTRO + MID1, d: MID2, img: 'jewel.jpg', kick: 'In the display case', big: 'Jewelry-case ready', sub: 'Cases · walls · drapery' },
];
const OUTRO_AT = INTRO + MID1 + MID2;
const scenes = S.map((s, i) => `
<div class="scene" id="s${i}">
<img class="ph" src="assets/nscp/${s.img}" alt="Novasuede ${esc(title)} suede — jewelry display and 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 = S.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.5 }, ${s.at});`}
tl.to("#s${i} .ph", { scale: 1.0, duration: ${s.d}, ease: "none" }, ${s.at});
tl.to("#s${i} .cap", { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, ${(s.at + 0.3).toFixed(2)});
tl.to("#s${i}", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${(s.at + s.d - 0.5).toFixed(2)});`).join('\n');
const html = `<!doctype html><html lang="en" 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;padding: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) 0%,rgba(0,0,0,.05) 42%,rgba(0,0,0,.68) 100%);}
.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;line-height:1.0;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 .samples{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:72px;line-height:1.1;color:#fff;margin:0 72px 34px;}
#outro .samples b{color:#DDAB1E;font-style:italic;}
#outro .rule{width:120px;height:2px;background:#DDAB1E;margin:6px 0 34px;}
#outro .brand{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:70px;color:#f5f1e8;}
#outro .url{margin-top:22px;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="samples">Samples are always<br/><b>no charge for professionals</b></div>
<div class="rule"></div><div class="brand">Novasuede™</div><div class="url">novasuede.com</div>
</div>
<script>
const tl = gsap.timeline({ paused: true }); window.__timelines = window.__timelines || {};
${anim}
gsap.set("#outro", { opacity: 0 }); gsap.set("#outro .samples", { y: 40, opacity: 0 });
gsap.set("#outro .rule, #outro .brand, #outro .url", { opacity: 0 });
tl.to("#outro", { opacity: 1, duration: 0.5 }, ${OUTRO_AT});
tl.to("#outro .samples", { opacity: 1, y: 0, duration: 0.6, ease: "power2.out" }, ${OUTRO_AT + 0.2});
tl.to("#outro .rule, #outro .brand", { opacity: 1, duration: 0.5 }, ${OUTRO_AT + 0.8});
tl.to("#outro .url", { 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>');
}
console.log(`✓ composition written — colour "${title}" (${TOTAL}s, first frame = jewelry display)`);
execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
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; }
const found = findMp4(WS); if (!found) { console.error('✗ no mp4'); process.exit(2); }
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
const outName = `novasuede-colour-${slug}-${stamp}.mp4`;
const outPath = join(ROOT, 'reels', outName);
// MUSIC bed: loop to length, gentle volume, 2s fade-out; NO voiceover.
const fadeSt = Math.max(0, TOTAL - 2);
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=${fadeSt}: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' });
// record rotation state
state.posted = [...new Set([...(state.posted || []), slug])];
state.acctIdx = (acctIdx + 1) % ACCOUNTS.length; // advance rotation for the next run
state.last = { slug, title, account, file: outName, built_at: new Date().toISOString() };
writeFileSync(STATE, JSON.stringify(state, null, 2));
console.log(`✓ novasuede colour post -> reels/${outName} → @${account} (posted ${state.posted.length}, nextAcct=@${ACCOUNTS[state.acctIdx]})`);
console.log(JSON.stringify({ file: outName, account }));