← back to Dw Marketing Reels

scripts/build-reel.mjs

249 lines

#!/usr/bin/env node
// Generate a portrait "New Arrivals" reel composition from data/new-arrivals.json,
// download product images locally (deterministic render), then render to MP4 via HyperFrames.
// $0 — local ffmpeg/Chrome render, no paid API.
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { execSync } from 'node:child_process';
import { LEAK_DENY, safe } from './leak-deny.mjs';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WS = join(ROOT, 'hf-workspace');
const N = Number(process.env.REEL_COUNT || 6);          // products per reel
const PER = Number(process.env.REEL_SECS || 2.4);        // seconds per product
const INTRO = 2.0, OUTRO = 2.6;
const SLUG = (process.env.REEL_SLUG || 'new-arrivals').replace(/[^a-z0-9-]+/gi, '-').toLowerCase(); // output filename prefix

// Reel theming — all env-overridable, defaulting to the generic "New Arrivals" reel.
// A themed run (e.g. Phillipe Romano corks) sets these to rebrand the cards + caption
// without touching the render pipeline.
const THEME = {
  kicker:       process.env.REEL_KICKER       || 'New Arrivals',
  title:        process.env.REEL_TITLE        || 'Just In',
  subtitle:     process.env.REEL_SUBTITLE     || '',              // '' → "Designer Wallcoverings · <Month Year>"
  outroKicker:  process.env.REEL_OUTRO_KICKER || 'Shop the collection',
  outroBig:     process.env.REEL_OUTRO_BIG    || 'Designer<br/>Wallcoverings', // raw HTML by design (supports <br/>); operator-set only, not user input
  url:          process.env.REEL_URL          || 'designerwallcoverings.com',
  captionLead:  process.env.REEL_CAPTION_LEAD || 'New arrivals just landed ✨',
  captionTail:  process.env.REEL_CAPTION_TAIL || 'Discover the latest wallcoverings — shop the collection at designerwallcoverings.com 🔗',
  tagsExtra:   (process.env.REEL_HASHTAGS_EXTRA || '#NewArrivals').split(/\s+/).filter(Boolean),
};

const esc = s => String(s || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

// Private-label / upstream leak filter (LEAK_DENY + safe) imported from ./leak-deny.mjs —
// single source synced to canonical dw-leak-scanner. Captions pull from the LIVE storefront,
// so no denied vendor name reaches IG/TikTok.

// On-brand social caption + hashtags from the reel's products ($0 local, deterministic).
function captionFor(items) {
  const pats = [...new Set(items.map(i => i.pattern).filter(safe))];
  const vendors = [...new Set(items.map(i => i.vendor).filter(safe))];
  const lead = pats.slice(0, 3).join(', ');
  const body = `${THEME.captionLead}  ${lead}${pats.length > 3 ? ' & more' : ''}`
    + `${vendors.length ? ` from ${vendors.slice(0, 3).join(', ')}` : ''}.`
    + `\n\n${THEME.captionTail}`;
  const tagify = s => '#' + String(s).replace(/[^a-z0-9]+/gi, '');
  const tags = [...new Set([
    '#DesignerWallcoverings', ...THEME.tagsExtra, '#Wallcovering', '#Wallcoverings',
    '#InteriorDesign', '#InteriorDecor', '#HomeDesign', '#LuxuryInteriors', '#DesignInspo',
    ...vendors.slice(0, 3).map(tagify),
  ])].slice(0, 14);
  // final safety net: never emit a denylisted token even if one slipped through a field
  const clean = s => s.split(/\s+/).filter(w => !LEAK_DENY.test(w)).join(' ');
  return { text: `${clean(body)}\n\n${tags.join(' ')}`, hashtags: tags };
}

async function dl(url, dest) {
  try {
    const r = await fetch(url, { headers: { 'User-Agent': 'dw-marketing-reels' } });
    if (!r.ok) return false;
    const buf = Buffer.from(await r.arrayBuffer());
    if (buf.length < 1000) return false;
    writeFileSync(dest, buf);
    return true;
  } catch { return false; }
}

async function main() {
  const data = JSON.parse(readFileSync(join(ROOT, 'data', 'new-arrivals.json'), 'utf8'));
  const items = data.items.slice(0, N);
  if (!items.length) throw new Error('no items in data/new-arrivals.json — run fetch first');

  const imgDir = join(WS, 'assets', 'img');
  mkdirSync(imgDir, { recursive: true });
  for (const [i, it] of items.entries()) {
    const dest = join(imgDir, `p${i}.jpg`);
    const ok = (await dl(it.image_hi, dest)) || (await dl(it.image, dest));
    it._local = ok ? `assets/img/p${i}.jpg` : it.image;
    console.log(`  img ${i}: ${ok ? '✓ local' : '↯ remote'} ${it.pattern}`);
  }

  const total = +(INTRO + items.length * PER + OUTRO).toFixed(2);
  const dateStr = new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
  const introSub = THEME.subtitle || `Designer Wallcoverings · ${dateStr}`;

  // --- slides markup ---
  const slides = items.map((it, i) => `
    <div class="slide" id="slide${i}">
      <img class="ph" src="${esc(it._local)}" alt="${esc(it.pattern)}" />
      <div class="scrim"></div>
      <div class="cap">
        <div class="pattern">${esc(it.pattern)}</div>
        <div class="meta">${esc([it.color, it.vendor].filter(Boolean).join(' · '))}</div>
      </div>
    </div>`).join('');

  // --- gsap timeline (seek-safe) ---
  const anim = items.map((it, i) => {
    const at = +(INTRO + i * PER).toFixed(3);
    return `
    gsap.set("#slide${i}", { opacity: 0 });
    gsap.set("#slide${i} .ph", { scale: 1.12 });
    gsap.set("#slide${i} .cap", { y: 40, opacity: 0 });
    tl.to("#slide${i}", { opacity: 1, duration: 0.5, ease: "power1.out" }, ${at});
    tl.to("#slide${i} .ph", { scale: 1.0, duration: ${PER}, ease: "none" }, ${at});
    tl.to("#slide${i} .cap", { y: 0, opacity: 1, duration: 0.6, ease: "power2.out" }, ${(at + 0.25).toFixed(3)});
    tl.to("#slide${i}", { opacity: 0, duration: 0.45, ease: "power1.in" }, ${(at + PER - 0.35).toFixed(3)});`;
  }).join('\n');

  const outroAt = +(INTRO + items.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>
  :root { --ink:#1c1a17; --cream:#f4efe6; --gold:#b08d57; }
  html,body { margin:0; padding:0; width:1080px; height:1920px; overflow:hidden;
    background:var(--ink); font-family:"Helvetica Neue",system-ui,sans-serif; }
  #main-composition { position:relative; width:1080px; height:1920px; overflow:hidden; background:var(--ink); }
  .slide { position:absolute; inset:0; }
  .slide .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,.15) 0%,rgba(0,0,0,0) 38%,rgba(0,0,0,.72) 100%); }
  .cap { position:absolute; left:72px; right:72px; bottom:150px; }
  .cap .pattern { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:96px; line-height:1.02;
    color:#fff; letter-spacing:.01em; text-shadow:0 2px 20px rgba(0,0,0,.5); }
  .cap .meta { margin-top:18px; font-size:34px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
  /* intro / outro cards */
  .card { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
    text-align:center; background:var(--cream); color:var(--ink); }
  .card .kicker { font-size:32px; letter-spacing:.42em; text-transform:uppercase; color:var(--gold); }
  .card .big { font-family:"Didot","Bodoni 72",Georgia,serif; font-size:150px; line-height:.98; margin:26px 60px; }
  .card .sub { font-size:34px; letter-spacing:.16em; text-transform:uppercase; color:#5a544c; }
  .card .rule { width:120px; height:2px; background:var(--gold); margin:44px 0; }
  .card .url { position:absolute; bottom:150px; font-size:30px; letter-spacing:.24em; text-transform:uppercase; color:var(--ink); }
</style>
</head>
<body>
  <div id="main-composition" data-composition-id="main-video"
       data-width="1080" data-height="1920" data-start="0" data-duration="${total}">

    <div class="card" id="intro">
      <div class="kicker">${esc(THEME.kicker)}</div>
      <div class="big">${esc(THEME.title)}</div>
      <div class="rule"></div>
      <div class="sub">${esc(introSub)}</div>
    </div>

    ${slides}

    <div class="card" id="outro">
      <div class="kicker">${esc(THEME.outroKicker)}</div>
      <div class="big">${THEME.outroBig}</div>
      <div class="url">${esc(THEME.url)}</div>
    </div>

    <script>
      const tl = gsap.timeline({ paused: true });
      window.__timelines = window.__timelines || {};

      // intro
      gsap.set("#intro .big", { y: 40, opacity: 0 });
      gsap.set("#intro .kicker, #intro .sub, #intro .rule", { opacity: 0 });
      tl.to("#intro .kicker", { opacity: 1, duration: 0.5 }, 0.2);
      tl.to("#intro .big", { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, 0.4);
      tl.to("#intro .rule, #intro .sub", { opacity: 1, duration: 0.5 }, 0.9);
      tl.to("#intro", { opacity: 0, duration: 0.5, ease: "power1.in" }, ${(INTRO - 0.4).toFixed(2)});

      // slides
${anim}

      // outro
      gsap.set("#outro", { opacity: 0 });
      gsap.set("#outro .big", { y: 40, opacity: 0 });
      gsap.set("#outro .url", { opacity: 0 });
      tl.to("#outro", { opacity: 1, duration: 0.5 }, ${outroAt.toFixed(2)});
      tl.to("#outro .big", { opacity: 1, y: 0, duration: 0.7, ease: "power2.out" }, ${(outroAt + 0.2).toFixed(2)});
      tl.to("#outro .url", { opacity: 1, duration: 0.5 }, ${(outroAt + 0.7).toFixed(2)});

      window.__timelines["main-video"] = tl;
    </script>
  </div>
</body>
</html>`;

  writeFileSync(join(WS, 'index.html'), html);
  // strip the starter sub-compositions so they don't get pulled in
  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 (${items.length} products, ${total}s portrait)`);

  // --- render ---
  console.log('→ rendering (local ffmpeg/Chrome, $0)…');
  execSync('npx --yes hyperframes render', { cwd: WS, stdio: 'inherit', env: { ...process.env, HYPERFRAMES_SKIP_SKILLS: '1' } });

  // move newest mp4 to reels/  (REEL_SLUG names themed reels; default keeps new-arrivals)
  const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
  const outName = `${SLUG}-${stamp}.mp4`;
  const found = findMp4(WS);
  if (found) {
    mkdirSync(join(ROOT, 'reels'), { recursive: true });
    const outPath = join(ROOT, 'reels', outName);
    // Instagram Reels REQUIRE an audio track — a silent (no-audio) MP4 fails
    // Meta processing with error 2207082. Mux in a silent AAC track so every
    // reel is Reels-publishable. Falls back to a plain copy if ffmpeg is absent.
    try {
      execSync(`ffmpeg -y -i ${JSON.stringify(found)} -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -c:v copy -c:a aac -b:a 128k -shortest -movflags +faststart ${JSON.stringify(outPath)}`, { stdio: 'ignore' });
    } catch {
      console.warn('  (ffmpeg silent-audio mux failed — falling back to raw copy; reel may not be IG-Reels-publishable)');
      copyFileSync(found, outPath);
    }
    // manifest for the gallery
    const manPath = join(ROOT, 'data', 'reels.json');
    const man = existsSync(manPath) ? JSON.parse(readFileSync(manPath, 'utf8')) : [];
    const cap = captionFor(items);
    man.unshift({ file: outName, created_at: new Date().toISOString(), products: items.length,
      seconds: total, titles: items.map(i => `${i.pattern} / ${i.color}`),
      caption: cap.text, hashtags: cap.hashtags,
      publish: { instagram: { status: 'pending' }, tiktok: { status: 'pending' } } });
    writeFileSync(manPath, JSON.stringify(man, null, 2));
    console.log(`✓ reel -> reels/${outName}`);
  } else {
    console.error('✗ no MP4 produced');
    process.exit(2);
  }
}

function findMp4(dir) {
  let newest = 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; newest = p; }
      }
    }
  };
  walk(dir);
  return newest;
}

main().catch(e => { console.error('✗', e.message); process.exit(1); });