← back to Dw Marketing Reels

scripts/build-collection-flipbook.mjs

307 lines

#!/usr/bin/env node
/**
 * build-collection-flipbook.mjs — turn any DW collection/vendor into a narrated
 * 3D page-turn "flip book" HyperFrames composition (spread layout), then render.
 *
 * SPREAD: left verso = DW wordmark + a crossfading ROOM-setting of the current
 * product + its name; right recto = the page-turn stack of pattern swatches.
 * Chapter-ordered, slowed for readability, with cloned-voice VO + a ducked jazz bed.
 *
 * Reusable engine behind the /collection-flipbook skill.
 *
 * Data JSON: [{ title, image, room?, has_room?, chapter?, url? }, ...]
 * Images (image + room) are downloaded + resized LOCALLY (no network in the comp).
 *
 * Key flags:
 *   --data <json> --slug <slug> --title "..." --subtitle "..."
 *   --ratio landscape|portrait|square   --page-interval 0.95   --page-turn 0.6
 *   --cover 2.2 --hold 2.8 --outro 3.0
 *   --audio <audio.json>   (see AUDIO MANIFEST below)   --no-images
 *
 * AUDIO MANIFEST (audio.json):
 *   { "bgm": {"src":"assets/audio/jazz.wav","volume":0.2},
 *     "vo": [ {"src":"assets/audio/vo1.wav","anchor":"intro"},
 *             {"src":"assets/audio/vo2.wav","anchor":"+6"},
 *             {"src":"assets/audio/vo3.wav","anchor":"chapter:characters"},
 *             {"src":"assets/audio/vo4.wav","anchor":"chapter:stags"},
 *             {"src":"assets/audio/vo5.wav","anchor":"hold"},
 *             {"src":"assets/audio/vo6.wav","anchor":"outro"} ] }
 *   anchor := "intro" | "hold" | "outro" | "chapter:NAME" | "+N" (N pages into riffle) | <seconds>
 */
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import https from 'node:https';

const argv = process.argv.slice(2);
const args = {};
for (let i = 0; i < argv.length; i++) {
  if (argv[i].startsWith('--')) {
    const k = argv[i].slice(2);
    const v = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true;
    args[k] = v;
  }
}

const PROJ = path.resolve(path.join(path.dirname(new URL(import.meta.url).pathname), '..'));
const dataPath = path.resolve(args.data || `${PROJ}/data/graduate-ordered.json`);
const slug = args.slug || 'collection-flipbook';
const title = args.title || 'Collection';
const subtitle = args.subtitle || 'Designer Wallcoverings';
const ratio = args.ratio || 'landscape';
const doImages = !args['no-images'];
const PAGE_INTERVAL = parseFloat(args['page-interval'] || '0.95');
const PAGE_TURN = parseFloat(args['page-turn'] || '0.6');
const COVER = parseFloat(args.cover || '2.2');
const HOLD = parseFloat(args.hold || '2.8');
const OUTRO = parseFloat(args.outro || '3.0');
const audioCfg = args.audio ? JSON.parse(fs.readFileSync(path.resolve(args.audio), 'utf8')) : null;

const RATIOS = { landscape: [1920, 1080], portrait: [1080, 1920], square: [1080, 1080] };
const [W, H] = RATIOS[ratio] || RATIOS.landscape;

const wsDir = `${PROJ}/flipbooks/${slug}`;
const imgDir = `${wsDir}/assets/img`;
const roomDir = `${wsDir}/assets/room`;
fs.mkdirSync(imgDir, { recursive: true });
fs.mkdirSync(roomDir, { recursive: true });

const products = JSON.parse(fs.readFileSync(dataPath, 'utf8')).filter(p => p.image);
const N = products.length;
if (!N) { console.error('No products in', dataPath); process.exit(1); }
console.log(`Flipbook "${title}" — ${N} spreads @ ${W}x${H}, interval ${PAGE_INTERVAL}s`);

// ---------- localize + resize ----------
function download(url, dest) {
  return new Promise((resolve, reject) => {
    const f = fs.createWriteStream(dest);
    https.get(url, { headers: { 'User-Agent': 'Mozilla/5.0 DW-flipbook' } }, (res) => {
      if (res.statusCode >= 300 && res.headers.location) { f.close(); fs.rmSync(dest, { force: true }); return download(res.headers.location, dest).then(resolve, reject); }
      if (res.statusCode !== 200) { f.close(); return reject(new Error('HTTP ' + res.statusCode)); }
      res.pipe(f); f.on('finish', () => f.close(resolve));
    }).on('error', reject);
  });
}
async function grab(url, dest, cap) {
  if (!doImages) return true;
  if (/^https?:/.test(url)) { await download(url.replace(/\?.*$/, '') + '?width=1400', dest); }
  else if (fs.existsSync(url)) { fs.copyFileSync(url, dest); }
  else return false;
  try { execSync(`sips -Z ${cap} "${dest}" >/dev/null 2>&1`); } catch {}
  return true;
}
const PAGE_CAP = Math.round(H * 0.95);
for (let i = 0; i < N; i++) {
  const p = products[i];
  try { await grab(p.image, `${imgDir}/p${i}.jpg`, PAGE_CAP); } catch (e) { console.warn(`  ! swatch ${i}: ${e.message}`); }
  const roomUrl = p.room || p.image;
  try { await grab(roomUrl, `${roomDir}/r${i}.jpg`, PAGE_CAP); } catch (e) { console.warn(`  ! room ${i}: ${e.message}`); }
  p._img = `assets/img/p${i}.jpg`; p._room = `assets/room/r${i}.jpg`;
  p._fallback = !p.has_room;
  p._name = p.title;
}
if (doImages) console.log(`  localized ${N} swatches + ${N} rooms`);

// ---------- geometry ----------
const bookW = Math.round(W * (ratio === 'portrait' ? 0.9 : 0.8));
const pageW = Math.round(bookW / 2);
const pageH = Math.round(H * 0.82);
const bookLeft = Math.round((W - bookW) / 2);
const spineX = Math.round(W / 2);
const bookTop = Math.round((H - pageH) / 2);

// ---------- timing ----------
const riffleStart = COVER;
const LEAD = 0.7; // opening spread breathes before the first turn
const flipStart = i => +(riffleStart + LEAD + i * PAGE_INTERVAL).toFixed(3); // i in 0..N-2 flips
const holdStart = +(riffleStart + LEAD + (N - 1) * PAGE_INTERVAL).toFixed(3);
const outroStart = +(holdStart + HOLD).toFixed(3);
const TOTAL = +(outroStart + OUTRO).toFixed(2);

// chapter first-page time
const chapterStartTime = {};
products.forEach((p, i) => { const c = p.chapter || 'all'; if (!(c in chapterStartTime)) chapterStartTime[c] = flipStart(Math.max(0, i - 1)); });

function resolveAnchor(a) {
  if (typeof a === 'number') return a;
  if (a === 'intro') return +(riffleStart + 0.2).toFixed(2);
  if (a === 'hold') return +(holdStart + 0.1).toFixed(2);
  if (a === 'outro') return +(outroStart + 0.15).toFixed(2);
  if (typeof a === 'string' && a.startsWith('+')) return flipStart(parseInt(a.slice(1), 10));
  if (typeof a === 'string' && a.startsWith('chapter:')) return chapterStartTime[a.slice(8)] ?? riffleStart;
  return riffleStart;
}

// ---------- html helpers ----------
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

const rooms = products.map((p, i) => `
      <div class="room${p._fallback ? ' fallback' : ''}" id="room${i}">
        <img class="rimg" src="${p._room}" alt="${esc(p._name)} room" />
        <div class="rtint"></div>
        ${p.logo ? `<div class="rlogo"><img src="${esc(p.logo)}" alt="${esc(p.chapter || '')}" /></div>` : ''}
        <div class="rcap"><div class="rpat">${esc(p._name)}</div><div class="rcol">${esc(title)} · ${esc(p.chapter || '')}</div></div>
      </div>`).join('');

const leaves = products.map((p, i) => `
      <div class="leaf" id="leaf${i}">
        <div class="face front"><img class="art" src="${p._img}" alt="${esc(p._name)}" /><div class="shade"></div></div>
        <div class="face back"></div>
      </div>`).join('');

// flip + room-crossfade tweens
let tweens = '';
for (let i = 0; i <= N - 2; i++) {
  const s = flipStart(i), mid = +(s + PAGE_TURN / 2).toFixed(3);
  tweens += `
    tl.to("#leaf${i}", { rotateY: -180, duration: ${PAGE_TURN}, ease: "power2.inOut" }, ${s});
    tl.to("#leaf${i} .shade", { opacity: 0.5, duration: ${(PAGE_TURN/2).toFixed(3)}, ease:"power1.in" }, ${s});
    tl.to("#leaf${i} .shade", { opacity: 0, duration: ${(PAGE_TURN/2).toFixed(3)}, ease:"power1.out" }, ${mid});
    tl.set("#leaf${i}", { zIndex: ${1000 + i} }, ${mid});
    tl.set("#leaf${i}", { opacity: 0 }, ${(s + PAGE_TURN).toFixed(3)});
    tl.to("#room${i}", { opacity: 0, duration: 0.4, ease:"power1.inOut" }, ${mid});
    tl.to("#room${i+1}", { opacity: 1, duration: 0.4, ease:"power1.inOut" }, ${mid});`;
}
// hero hold push-in on the final resting spread
tweens += `
    tl.to("#leaf${N-1} .art", { scale: 1.05, duration: ${(HOLD+0.4).toFixed(2)}, ease:"power1.out" }, ${holdStart});
    tl.to("#room${N-1} .rimg", { scale: 1.06, duration: ${(HOLD+0.4).toFixed(2)}, ease:"power1.out" }, ${holdStart});`;

// audio elements + ducking
let audioEls = '', audioTweens = '';
if (audioCfg) {
  if (audioCfg.bgm) {
    const v = audioCfg.bgm.volume ?? 0.2;
    audioEls += `\n    <audio id="bgm" src="${audioCfg.bgm.src}" data-start="0" data-duration="${TOTAL}" data-track-index="20" data-volume="${v}"></audio>`;
    audioTweens += `\n      gsap.set("#bgm", { volume: ${v} });`;
    (audioCfg.vo || []).forEach((vo) => {
      const at = resolveAnchor(vo.anchor);
      audioTweens += `
      tl.to("#bgm", { volume: ${(v*0.5).toFixed(2)}, duration: 0.4 }, ${(+at-0.3).toFixed(2)});
      tl.to("#bgm", { volume: ${v}, duration: 0.6 }, ${(+at+2.6).toFixed(2)});`;
    });
    audioTweens += `\n      tl.to("#bgm", { volume: 0, duration: 1.0, ease:"power1.in" }, ${(TOTAL-1.1).toFixed(2)});`;
  }
  (audioCfg.vo || []).forEach((vo, k) => {
    const at = resolveAnchor(vo.anchor);
    audioEls += `\n    <audio id="vo${k}" src="${vo.src}" data-start="${at}" data-track-index="${21+k}" data-volume="1"></audio>`;
  });
}

const html = `<!doctype html>
<html lang="en" data-resolution="${ratio}">
<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; --paper:#fbf8f1; --gold:#b08d57; }
  html,body { margin:0; padding:0; width:${W}px; height:${H}px; overflow:hidden;
    background:radial-gradient(120% 120% at 50% 0%, #2a2622 0%, #14110e 72%);
    font-family:"Helvetica Neue",system-ui,sans-serif; }
  #main-composition { position:relative; width:${W}px; height:${H}px; overflow:hidden; }
  .stage { position:absolute; inset:0; perspective:3000px; perspective-origin:50% 46%; }

  /* LEFT verso: brand + room + name */
  .verso { position:absolute; top:${bookTop}px; left:${bookLeft}px; width:${pageW}px; height:${pageH}px;
    background:var(--paper); border-radius:8px 2px 2px 8px; overflow:hidden;
    box-shadow: inset -30px 0 46px rgba(0,0,0,.12), 0 34px 66px rgba(0,0,0,.5);
    display:flex; flex-direction:column; }
  .wordmark { flex:0 0 auto; text-align:center; padding:${Math.round(pageH*0.055)}px 0 ${Math.round(pageH*0.03)}px; }
  .wordmark .wm { font-family:Georgia, serif; font-size:${Math.round(pageW*0.072)}px; color:var(--ink); letter-spacing:.02em; line-height:1; }
  .wordmark .wsub { margin-top:8px; font-size:${Math.round(pageW*0.024)}px; letter-spacing:.34em; text-transform:uppercase; color:var(--gold); }
  .wordmark .wr { width:64px; height:2px; background:var(--gold); margin:${Math.round(pageH*0.028)}px auto 0; }
  .roomstack { position:relative; flex:1 1 auto; margin:0 ${Math.round(pageW*0.055)}px ${Math.round(pageW*0.055)}px; border-radius:4px; overflow:hidden; background:#e9e3d7; }
  .room { position:absolute; inset:0; opacity:0; }
  .room .rimg { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
  .room.fallback .rimg { transform:scale(1.4); filter:blur(3px) brightness(.82) saturate(1.05); }
  .room .rtint { position:absolute; inset:0; background:linear-gradient(180deg, rgba(0,0,0,0) 55%, rgba(0,0,0,.55) 100%); }
  .room.fallback .rtint { background:linear-gradient(180deg, rgba(20,16,12,.30) 0%, rgba(20,16,12,.62) 100%); }
  .rcap { position:absolute; left:0; right:0; bottom:0; padding:${Math.round(pageW*0.05)}px ${Math.round(pageW*0.055)}px; color:#fff; }
  .rcap .rpat { font-family:Georgia, serif; font-size:${Math.round(pageW*0.062)}px; line-height:1.05; text-shadow:0 2px 14px rgba(0,0,0,.5); }
  .rcap .rcol { margin-top:8px; font-size:${Math.round(pageW*0.026)}px; letter-spacing:.2em; text-transform:uppercase; color:#e7d8bd; }
  .room .rlogo { position:absolute; top:${Math.round(pageW*0.05)}px; left:${Math.round(pageW*0.055)}px;
    background:rgba(255,255,255,.95); border-radius:6px; padding:${Math.round(pageW*0.022)}px ${Math.round(pageW*0.032)}px;
    box-shadow:0 8px 22px rgba(0,0,0,.34); max-width:56%; }
  .room .rlogo img { display:block; height:${Math.round(pageH*0.052)}px; width:auto; max-width:100%; object-fit:contain; }

  /* RIGHT recto: page-turn stack of pattern swatches */
  .leaf { position:absolute; top:${bookTop}px; left:${spineX}px; width:${pageW}px; height:${pageH}px;
    transform-origin:left center; transform-style:preserve-3d; will-change:transform; }
  .leaf .face { position:absolute; inset:0; backface-visibility:hidden; overflow:hidden; border-radius:2px 8px 8px 2px;
    background:var(--paper); box-shadow: 24px 28px 54px rgba(0,0,0,.42), inset 24px 0 42px rgba(0,0,0,.08); }
  .leaf .back { transform:rotateY(180deg); background:linear-gradient(90deg,#efe9dc,#fbf8f1 22%);
    box-shadow:-22px 28px 54px rgba(0,0,0,.36), inset -24px 0 42px rgba(0,0,0,.08); }
  .leaf .art { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
  .leaf .shade { position:absolute; inset:0; opacity:0; pointer-events:none; background:linear-gradient(90deg, rgba(0,0,0,.42), rgba(0,0,0,0) 62%); }

  /* cover + outro cards */
  .card { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; color:var(--cream); }
  .card .kicker { font-size:${Math.round(W*0.016)}px; letter-spacing:.44em; text-transform:uppercase; color:var(--gold); }
  .card .big { font-family:Georgia, serif; font-size:${Math.round(W*0.07)}px; line-height:1.0; margin:22px 60px; }
  .card .sub { font-size:${Math.round(W*0.018)}px; letter-spacing:.18em; text-transform:uppercase; color:#c9bfae; }
  .card .rule { width:120px; height:2px; background:var(--gold); margin:32px 0; }
  .card .url { margin-top:28px; font-size:${Math.round(W*0.016)}px; letter-spacing:.24em; text-transform:uppercase; color:var(--cream); }
</style>
</head>
<body>
  <div id="main-composition" data-composition-id="flipbook" data-width="${W}" data-height="${H}" data-start="0" data-duration="${TOTAL}">

    <div class="card" id="cover">
      <div class="kicker">Designer Wallcoverings presents</div>
      <div class="big">${esc(title)}</div>
      <div class="rule"></div>
      <div class="sub">${esc(subtitle)}</div>
    </div>

    <div class="stage" id="stage">
      <div class="verso">
        <div class="wordmark"><div class="wm">Designer Wallcoverings</div><div class="wsub">est. lookbook</div><div class="wr"></div></div>
        <div class="roomstack">${rooms}
        </div>
      </div>
      ${leaves}
    </div>

    <div class="card" id="outro">
      <div class="kicker">Shop the collection</div>
      <div class="big">${esc(title)}</div>
      <div class="url">designerwallcoverings.com</div>
    </div>
${audioEls}

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

      ${products.map((p,i)=>`gsap.set("#leaf${i}", { rotateY:0, zIndex:${N-i} });`).join('\n      ')}
      ${products.map((p,i)=>`gsap.set("#room${i}", { opacity:${i===0?1:0} });`).join('\n      ')}
      gsap.set("#stage", { opacity: 0 });
      gsap.set("#outro", { opacity: 0 }); gsap.set("#outro .big", { y: 34, opacity: 0 }); gsap.set("#outro .url", { opacity: 0 });
      gsap.set("#cover .big", { y: 40, opacity: 0 }); gsap.set("#cover .kicker,#cover .sub,#cover .rule", { opacity: 0 });

      // cover
      tl.to("#cover .kicker", { opacity:1, duration:0.5 }, 0.2);
      tl.to("#cover .big", { opacity:1, y:0, duration:0.8, ease:"power3.out" }, 0.4);
      tl.to("#cover .rule,#cover .sub", { opacity:1, duration:0.5 }, 1.0);
      tl.to("#cover", { opacity:0, duration:0.5, ease:"power1.in" }, ${(COVER-0.5).toFixed(2)});
      tl.to("#stage", { opacity:1, duration:0.6, ease:"power1.out" }, ${(COVER-0.5).toFixed(2)});

      // page turns + room crossfades
      ${tweens}

      // outro
      tl.to("#stage", { opacity:0, duration:0.6, ease:"power1.in" }, ${outroStart.toFixed(2)});
      tl.to("#outro", { opacity:1, duration:0.5 }, ${(outroStart+0.2).toFixed(2)});
      tl.to("#outro .big", { opacity:1, y:0, duration:0.7, ease:"power2.out" }, ${(outroStart+0.4).toFixed(2)});
      tl.to("#outro .url", { opacity:1, duration:0.5 }, ${(outroStart+0.9).toFixed(2)});
${audioTweens}

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

fs.writeFileSync(`${wsDir}/index.html`, html);
console.log(`  wrote ${wsDir}/index.html  (duration ${TOTAL}s, ${N} spreads, hold@${holdStart}s outro@${outroStart}s)`);