← 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, '&').replace(/</g, '<').replace(/>/g, '>');
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)`);