← back to Dw Marketing Reels

scripts/build-novasuede.mjs

128 lines

#!/usr/bin/env node
// build-novasuede.mjs — 15s Novasuede color-story reel: hero intro → N color cards → outro
// (samples-free), with Steve's cloned-voice VO. First frame is the hero image (clean IG index
// thumbnail). Assets pulled from the novasuede prod-mirror. $0 render (HyperFrames + ffmpeg).
import { writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync, readFileSync } 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', 'ns');
mkdirSync(A, { recursive: true });
const NS = process.env.NS_DIR || join(ROOT, '..', '_prod-mirror', 'novasuede');
const VO = process.env.NS_VO || '/tmp/vo-novasuede.wav';
const pick = JSON.parse(readFileSync(process.env.NS_PICK || '/tmp/ns-pick.json', 'utf8'));
const TOTAL = Number(process.env.NS_TARGET || 15);

// assets: hero (a real suede swatch — NOT a lifestyle photo) + one swatch per picked color.
// Open on the most saturated picked color so frame 0 is unmistakably novasuede suede.
const heroColor = [...pick].sort((a, b) => (b.hsl?.[1] || 0) - (a.hsl?.[1] || 0))[0];
copyFileSync(join(NS, 'images', 'colors', `novasuede-${(process.env.NS_HERO_SLUG || heroColor.slug)}.jpg`), join(A, 'hero.jpg'));
pick.forEach((c, i) => copyFileSync(join(NS, 'images', 'colors', `novasuede-${c.slug}.jpg`), join(A, `c${i}.jpg`)));

const esc = s => String(s || '').replace(/[&<>"]/g, m => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[m]));
const titleCase = s => s.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');

const INTRO = 3.5, OUTRO = 3.5;
const PER = +(((TOTAL - INTRO - OUTRO) / pick.length)).toFixed(3);

// scenes: [intro] + one per color
const scenes = [`
    <div class="scene" id="s_intro">
      <img class="ph" src="assets/ns/hero.jpg" alt="Novasuede microfiber suede swatch" />
      <div class="scrim"></div>
      <div class="cap">
        <div class="kick">Novasuede™</div>
        <div class="big">176 Colours</div>
        <div class="sub">Luxury microfiber suede</div>
      </div>
    </div>`,
  ...pick.map((c, i) => `
    <div class="scene" id="s${i}">
      <img class="ph" src="assets/ns/c${i}.jpg" alt="Novasuede ${esc(titleCase(c.slug))} microfiber suede swatch" />
      <div class="scrim"></div>
      <div class="cap">
        <div class="kick">Novasuede™</div>
        <div class="big">${esc(titleCase(c.slug))}</div>
        <div class="sub">Soft · durable · ${esc(c.hex)}</div>
      </div>
    </div>`)].join('');

// timeline
const starts = [0, ...pick.map((_, i) => +(INTRO + i * PER).toFixed(3))];
const durs = [INTRO, ...pick.map(() => PER)];
const ids = ['s_intro', ...pick.map((_, i) => `s${i}`)];
const anim = ids.map((id, i) => {
  const at = starts[i], d = durs[i], fo = +(at + d - 0.5).toFixed(2);
  return `
    gsap.set("#${id}", { opacity: ${i === 0 ? 1 : 0} });
    gsap.set("#${id} .ph", { scale: 1.1 });
    gsap.set("#${id} .cap", { y: 40, opacity: 0 });
    ${i === 0 ? '' : `tl.to("#${id}", { opacity: 1, duration: 0.5 }, ${at});`}
    tl.to("#${id} .ph", { scale: 1.0, duration: ${d}, ease: "none" }, ${at});
    tl.to("#${id} .cap", { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, ${(at + 0.3).toFixed(2)});
    tl.to("#${id}", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${fo});`;
}).join('\n');
const OUTRO_AT = +(INTRO + pick.length * PER).toFixed(3);

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,.28) 0%,rgba(0,0,0,.05) 42%,rgba(0,0,0,.70) 100%);}
  .cap{position:absolute;left:72px;right:72px;bottom:180px;}
  .cap .kick{font-size:32px;letter-spacing:.36em;text-transform:uppercase;color:#DDAB1E;}
  .cap .big{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:110px;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:74px;line-height:1.1;color:#fff;margin:0 72px 36px;}
  #outro .samples b{color:#DDAB1E;font-style:italic;}
  #outro .rule{width:120px;height:2px;background:#DDAB1E;margin:6px 0 36px;}
  #outro .brand{font-family:"Didot","Bodoni 72",Georgia,serif;font-size:70px;color:#f5f1e8;}
  #outro .url{margin-top:24px;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(`✓ novasuede composition written (${TOTAL}s, ${pick.length} colors, first frame = hero)`);
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-colors-15s-${stamp}.mp4`;
const outPath = join(ROOT, 'reels', outName);
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(`✓ novasuede reel -> reels/${outName}`);
console.log(outName);