← back to Dw Marketing Reels
scripts/build-spotlight.mjs
132 lines
#!/usr/bin/env node
// build-spotlight.mjs — one-off 30s single-product SPOTLIGHT reel:
// brand intro → material swatch → 3 jewelry-display-case shots (product applied) → free-samples + outro,
// with Steve's cloned-voice VO muxed over it. First frame is a product image (clean IG index thumbnail).
// Assets are passed via env; $0 render (local HyperFrames + ffmpeg).
import { readFileSync, writeFileSync, mkdirSync, existsSync, 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', 'spot');
mkdirSync(A, { recursive: true });
const BRAND = process.env.SPOT_BRAND || 'Majilite';
const ITEM = process.env.SPOT_ITEM || 'Stature Rose Gold';
const VO = process.env.SPOT_VO || '/tmp/vo-majilite.wav';
const SW = process.env.SPOT_SWATCH; // swatch png
const J = (process.env.SPOT_JEWELS || '').split(',').filter(Boolean); // 3 jewelry pngs
const SLUG = (process.env.SPOT_SLUG || 'majilite-stature-rose-gold-spotlight').toLowerCase();
// copy assets local (deterministic render)
copyFileSync(SW, join(A, 'swatch.png'));
J.forEach((p, i) => copyFileSync(p, join(A, `j${i}.png`)));
const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const TOTAL = 30;
// scene table (start,dur,img,kicker,title)
const S = [
{ at: 0, d: 5, img: 'j0.png', kick: BRAND.toUpperCase(), big: esc(ITEM), sub: 'Luxury metallic wallcovering' },
{ at: 5, d: 5, img: 'swatch.png', kick: 'The material', big: esc(ITEM), sub: 'Hand-hammered metallic surface' },
{ at: 10, d: 5, img: 'j0.png', kick: 'In the display case', big: 'Fine-jewelry finish', sub: `${esc(BRAND)} · applied` },
{ at: 15, d: 5, img: 'j1.png', kick: 'Boutique-ready', big: 'Catches the light', sub: `${esc(BRAND)} · applied` },
{ at: 20, d: 4, img: 'j2.png', kick: 'Contract-grade', big: 'Beautiful & durable', sub: `${esc(BRAND)} · applied` },
];
const OUTRO_AT = 24;
const scenes = S.map((s, i) => `
<div class="scene" id="s${i}">
<img class="ph" src="assets/spot/${s.img}" alt="${esc(s.big)}" />
<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) => {
const fadeOut = i < S.length ? s.at + s.d - 0.5 : s.at + s.d;
return `
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" }, ${fadeOut.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:#161310;font-family:"Helvetica Neue",system-ui,sans-serif;}
#main-composition{position:relative;width:1080px;height:1920px;overflow:hidden;background:#161310;}
.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) 40%,rgba(0,0,0,.70) 100%);}
.cap{position:absolute;left:72px;right:72px;bottom:180px;}
.cap .kick{font-size:34px;letter-spacing:.34em;text-transform:uppercase;color:#e8c98f;}
.cap .big{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:104px;line-height:1.0;color:#fff;margin:16px 0 12px;text-shadow:0 2px 24px rgba(0,0,0,.6);}
.cap .sub{font-size:32px;letter-spacing:.18em;text-transform:uppercase;color:#f4efe6;}
/* outro */
#outro{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:#161310;}
#outro .samples{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:76px;line-height:1.08;color:#fff;margin:0 70px 40px;}
#outro .samples b{color:#e8c98f;font-style:italic;}
#outro .rule{width:120px;height:2px;background:#b08d57;margin:8px 0 40px;}
#outro .brand{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:64px;color:#f4efe6;}
#outro .url{margin-top:26px;font-size:32px;letter-spacing:.26em;text-transform:uppercase;color:#e8c98f;}
</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">Designer Wallcoverings</div>
<div class="url">designerwallcoverings.com</div>
</div>
<script>
const tl = gsap.timeline({ paused: true });
window.__timelines = window.__timelines || {};
${anim}
// outro
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.7, ease: "power2.out" }, ${OUTRO_AT + 0.2});
tl.to("#outro .rule, #outro .brand", { opacity: 1, duration: 0.5 }, ${OUTRO_AT + 0.9});
tl.to("#outro .url", { opacity: 1, duration: 0.5 }, ${OUTRO_AT + 1.3});
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(`✓ spotlight composition written (${TOTAL}s, first frame = jewelry case)`);
console.log('→ rendering (local, $0)…');
execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });
function findMp4(dir) { let n=null,mt=0; const walk=d=>{for(const e of readdirSync(d,{withFileTypes:true})){if(e.name==='node_modules'||e.name.startsWith('.'))continue;const p=join(d,e.name);if(e.isDirectory())walk(p);else if(e.name.endsWith('.mp4')){const t=statSync(p).mtimeMs;if(t>mt){mt=t;n=p;}}}};walk(dir);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 = `${SLUG}-${stamp}.mp4`;
const outPath = join(ROOT, 'reels', outName);
mkdirSync(join(ROOT, 'reels'), { recursive: true });
// mux Steve VO: keep full 30s video, pad VO with silence to the end, faststart for IG
execSync(`ffmpeg -y -i ${JSON.stringify(found)} -i ${JSON.stringify(VO)} -filter_complex "[1:a]apad" -c:v copy -c:a aac -b:a 160k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
console.log(`✓ spotlight reel -> reels/${outName}`);
console.log(outName);