← back to Rentv Promos
recap-v2/scripts/build-composition.mjs
218 lines
#!/usr/bin/env node
/* Build master narration track + generate index.html for the RENTV recap.
Deterministic: all timing baked statically from media/vo/durations.json. */
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const ROOT = new URL('..', import.meta.url).pathname;
const FF = '/opt/homebrew/bin/ffmpeg';
const durs = JSON.parse(readFileSync(ROOT + 'media/vo/durations.json', 'utf8'));
const ORDER = ['01-intro','02-frontpage','03-reader','04-review','05-markets','06-cretalk',
'07-versions','08-themes','09-admin','10-publish','11-close'];
const LEAD = 0.8, GAP = 0.8, TAIL = 3.5;
// ---- timeline math: scene i visuals start when its VO block starts minus LEAD-ish pad
const r2 = (x) => Math.round(x * 100) / 100;
let t = LEAD;
const vo = {}; // vo start per scene
for (const id of ORDER) { vo[id] = t; t += durs[id] + GAP; }
const TOTAL = Math.ceil((t - GAP + TAIL) * 10) / 10;
const sceneStart = {};
ORDER.forEach((id, i) => { sceneStart[id] = i === 0 ? 0 : r2(vo[id] - GAP); });
const sceneEnd = {};
ORDER.forEach((id, i) => { sceneEnd[id] = i === ORDER.length - 1 ? TOTAL : sceneStart[ORDER[i + 1]]; });
// ---- master.wav: silence LEAD + vo + GAP ... + TAIL (all 44.1k mono pcm)
mkdirSync(ROOT + 'media', { recursive: true });
const mk = (f, secs) => execFileSync(FF, ['-y','-loglevel','error','-f','lavfi','-i',
`anullsrc=r=44100:cl=mono`, '-t', String(secs), '-c:a','pcm_s16le', f]);
mk(ROOT + 'media/vo/_sil-lead.wav', LEAD);
mk(ROOT + 'media/vo/_sil-gap.wav', GAP);
mk(ROOT + 'media/vo/_sil-tail.wav', TAIL);
const list = [];
list.push('_sil-lead.wav');
ORDER.forEach((id, i) => { list.push(id + '.wav'); if (i < ORDER.length - 1) list.push('_sil-gap.wav'); });
list.push('_sil-tail.wav');
writeFileSync(ROOT + 'media/vo/concat.txt', list.map(f => `file '${f}'`).join('\n'));
execFileSync(FF, ['-y','-loglevel','error','-f','concat','-safe','0','-i', ROOT + 'media/vo/concat.txt',
'-ar','44100','-ac','1','-c:a','pcm_s16le', ROOT + 'media/narration.wav']);
// ---- shot plan. Each shot: [img, h(px), label?] laid sequentially inside its scene.
const IMG = (n) => `media/captures/${n}.png`;
const shotsByScene = {
'02-frontpage': [['home', 2593]],
'03-reader': [['article', 2242]],
'04-review': [['review', 3328]],
'05-markets': [['markets', 1545]],
'06-cretalk': [['cretalk', 2760]],
'07-versions': [['versions', 1186, 'The Versions Picker'], ['v-wire', 1080, 'The Wire'],
['v-broadsheet', 1080, 'Broadsheet'], ['v-terminal', 1080, 'Terminal'],
['v-magazine', 1080, 'Magazine'], ['v-dashboard', 1080, 'Dashboard']],
'08-themes': [['theme-midnight', 1080, 'Theme · Midnight Capital'], ['theme-salmon', 1080, 'Theme · FT Salmon']],
'09-admin': [['admin-index', 1080, 'All 10 Dashboards'], ['admin-command', 1080, 'Command Center'],
['admin-kanban', 1080, 'Kanban'], ['admin-analytics', 1080, 'Analytics']],
'10-publish': [['blog', 1080, 'The Blog'], ['publish', 1080, 'The Publishing Desk']],
};
const chips = {
'02-frontpage': ['The Front Page', 'RENTV Originals · leading the live wire'],
'03-reader': ['The Article Reader', 'stories open in-shell — readers stay on RENTV'],
'04-review': ['The REview', 'face to face with the dealmakers'],
'05-markets': ['Markets — Live', 'rates + REITs · maintains itself'],
'06-cretalk': ['CRE Talk', 'the video conversations, in one place'],
'07-versions': ['Ten Complete Site Versions', 'whole directions — all on live data'],
'08-themes': ['Ten Professional Themes', 'one click restyles the entire site'],
'09-admin': ['Ten Admin Dashboards', 'same live data · pick your workflow'],
'10-publish': ['The Publishing Desk', 'write → publish → live, plus subscribers'],
};
let clips = '', tw = '';
const esc = (s) => s.replace(/&/g,'&').replace(/</g,'<');
// scene 01: title card
{
const s = 0, d = sceneEnd['01-intro'];
clips += `
<section id="sc01" class="clip card" data-start="0" data-duration="${d.toFixed(2)}" data-track-index="1">
<div class="cardin" id="sc01in">
<div class="kicker">A BUILD RECAP · WITH ADMIRATION</div>
<h1>RENTV<span class="red">.com</span></h1>
<p class="sub">For Boomer — the new build, on your live feed.<br/>Ten versions · ten themes · ten dashboards · built in a day.</p>
</div>
</section>`;
tw += `
tl.to("#sc01", { opacity: 1, duration: 0.35 }, 0);
tl.from("#sc01in", { opacity: 0, y: 40, duration: 1.2, ease: "power3.out" }, ${(s + 0.3).toFixed(2)});
tl.fromTo("#sc01in", { scale: 1 }, { scale: 1.045, duration: ${(d - 1).toFixed(2)}, ease: "none" }, ${(s + 0.5).toFixed(2)});
tl.to("#sc01in", { opacity: 0, duration: 0.6, ease: "power2.in" }, ${(d - 0.6).toFixed(2)});
tl.set("#sc01in", { opacity: 0 }, ${d.toFixed(2)});`;
}
// scenes 02..10: image shots
for (const id of ORDER.slice(1, 10)) {
const s0 = sceneStart[id], s1 = sceneEnd[id], dur = s1 - s0;
const shots = shotsByScene[id];
const per = dur / shots.length;
// rounded shot boundaries so adjacent clips never overlap by float dust
const bounds = shots.map((_, i) => r2(s0 + i * per)).concat([s1]);
shots.forEach((sh, i) => {
const [name, h, label] = sh;
const cs = bounds[i], cd = r2(bounds[i + 1] - bounds[i]);
const cid = `s${id.slice(0, 2)}i${i}`;
clips += `
<section id="${cid}" class="clip shot" data-start="${cs.toFixed(2)}" data-duration="${cd.toFixed(2)}" data-track-index="1">
<img id="${cid}m" src="${IMG(name)}" alt=""/>
</section>`;
tw += `
tl.to("#${cid}", { opacity: 1, duration: 0.45, ease: "power2.out" }, ${cs.toFixed(2)});`;
if (h > 1120) {
const travel = Math.min(h - 1080, Math.max(60, 78 * cd));
tw += `
tl.fromTo("#${cid}m", { y: 0 }, { y: ${-Math.round(travel)}, duration: ${(cd - 0.2).toFixed(2)}, ease: "sine.inOut" }, ${(cs + 0.1).toFixed(2)});`;
} else {
const zin = i % 2 === 0;
tw += `
tl.fromTo("#${cid}m", { scale: ${zin ? 1 : 1.07}, transformOrigin: "50% 30%" }, { scale: ${zin ? 1.07 : 1}, duration: ${cd.toFixed(2)}, ease: "none" }, ${cs.toFixed(2)});`;
}
if (label) {
const ld = Math.min(4.5, cd - 0.4);
clips += `
<div class="clip sublabel" id="${cid}l" data-start="${(cs + 0.3).toFixed(2)}" data-duration="${ld.toFixed(2)}" data-track-index="3"><span>${esc(label)}</span></div>`;
tw += `
tl.from("#${cid}l", { opacity: 0, x: -18, duration: 0.4, ease: "power2.out" }, ${(cs + 0.3).toFixed(2)});
tl.to("#${cid}l", { opacity: 0, duration: 0.35 }, ${(cs + 0.3 + ld - 0.35).toFixed(2)});`;
}
});
// scene chip (lower-third)
const c = chips[id];
const chd = Math.min(7, dur - 1);
clips += `
<div class="clip chip" id="ch${id.slice(0, 2)}" data-start="${(s0 + 0.7).toFixed(2)}" data-duration="${chd.toFixed(2)}" data-track-index="4">
<div class="chipin" id="ch${id.slice(0, 2)}in"><div class="bar"></div><div class="txt"><b>${esc(c[0])}</b><span>${esc(c[1])}</span></div></div>
</div>`;
tw += `
tl.from("#ch${id.slice(0, 2)}", { opacity: 0, y: 26, duration: 0.55, ease: "power3.out" }, ${(s0 + 0.7).toFixed(2)});
tl.to("#ch${id.slice(0, 2)}in", { opacity: 0, duration: 0.4 }, ${(s0 + 0.7 + chd - 0.4).toFixed(2)});
tl.set("#ch${id.slice(0, 2)}in", { opacity: 0 }, ${(s0 + 0.7 + chd).toFixed(2)});`;
}
// scene 11: home glimpse then close card
{
const s0 = sceneStart['11-close'];
const glimpse = 7.5;
clips += `
<section id="s11a" class="clip shot" data-start="${s0.toFixed(2)}" data-duration="${glimpse.toFixed(2)}" data-track-index="1">
<img id="s11am" src="${IMG('home')}" alt=""/>
</section>
<section id="s11b" class="clip card" data-start="${(s0 + glimpse).toFixed(2)}" data-duration="${(TOTAL - s0 - glimpse).toFixed(2)}" data-track-index="1">
<div class="cardin" id="s11bin">
<div class="kicker">THANK YOU, BOOMER — THESE ARE JUST SUGGESTIONS</div>
<h2>rentv-v1<span class="red">.agentabrams.com</span></h2>
<p class="logins">login <b>admin / DW2024!</b> or <b>Boomer / Rentv2024!</b></p>
<p class="sub2">Every headline you saw is live rentv.com data · built in a day · your call, always.</p>
</div>
</section>`;
tw += `
tl.to("#s11a", { opacity: 1, duration: 0.5 }, ${s0.toFixed(2)});
tl.fromTo("#s11am", { y: 0 }, { y: -420, duration: ${(glimpse - 0.2).toFixed(2)}, ease: "sine.inOut" }, ${(s0 + 0.1).toFixed(2)});
tl.to("#s11b", { opacity: 1, duration: 0.5 }, ${(s0 + glimpse).toFixed(2)});
tl.from("#s11bin", { opacity: 0, y: 36, duration: 1, ease: "power3.out" }, ${(s0 + glimpse + 0.1).toFixed(2)});
tl.fromTo("#s11bin", { scale: 1 }, { scale: 1.035, duration: ${(TOTAL - s0 - glimpse - 1).toFixed(2)}, ease: "none" }, ${(s0 + glimpse + 0.4).toFixed(2)});`;
}
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=1920, height=1080"/>
<title>RENTV Recap for Boomer</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
html,body{margin:0;background:#0c0f13}
#root{position:relative;width:1920px;height:1080px;overflow:hidden;background:#0c0f13;
font-family:'Inter','Helvetica Neue',system-ui,sans-serif;color:#fff}
.clip{position:absolute}
.shot{inset:0;overflow:hidden;background:#0c0f13;opacity:0}
.shot img{display:block;width:1920px;height:auto;will-change:transform}
.card{inset:0;display:grid;place-items:center;opacity:0;background:
radial-gradient(1200px 700px at 50% 38%, #171d26 0%, #0c0f13 70%)}
.cardin{text-align:center;max-width:1400px}
.kicker{font-size:26px;letter-spacing:.42em;color:#c8102e;font-weight:800;margin-bottom:34px}
h1{font-family:Georgia,'Times New Roman',serif;font-size:170px;margin:0;font-weight:700;letter-spacing:.01em}
h2{font-family:Georgia,'Times New Roman',serif;font-size:96px;margin:0;font-weight:700}
.red{color:#c8102e}
.sub{font-size:34px;line-height:1.55;color:#aeb6c2;margin-top:38px;font-weight:300}
.sub2{font-size:28px;color:#aeb6c2;margin-top:26px;font-weight:300}
.logins{font-size:36px;margin-top:44px;color:#e8ecf2}
.logins b{color:#fff;background:#1a212c;border:1px solid #2c3644;border-radius:8px;padding:8px 18px}
.chip{left:80px;bottom:76px;display:flex;align-items:stretch;background:rgba(10,13,17,.82);
border:1px solid rgba(255,255,255,.14);border-radius:10px;overflow:hidden;
box-shadow:0 12px 40px rgba(0,0,0,.45)}
.chipin{display:flex;align-items:stretch}
.chip .bar{width:10px;background:#c8102e}
.chip .txt{padding:20px 30px}
.chip b{display:block;font-family:Georgia,serif;font-size:40px;font-weight:700}
.chip span{display:block;font-size:23px;color:#aeb6c2;margin-top:6px}
.sublabel{right:80px;top:64px}
.sublabel span{background:#c8102e;color:#fff;font-size:24px;font-weight:800;letter-spacing:.08em;
padding:12px 22px;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.4)}
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-width="1920" data-height="1080" data-duration="${TOTAL}">
${clips}
</div>
<audio id="vo" src="media/narration.wav" data-start="0" data-duration="${TOTAL}" data-track-index="10" data-volume="1"></audio>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
${tw}
window.__timelines["main"] = tl;
</script>
</body>
</html>
`;
writeFileSync(ROOT + 'index.html', html);
console.log('TOTAL duration', TOTAL + 's');
console.log(ORDER.map(id => `${id} @ ${sceneStart[id].toFixed(1)}s`).join('\n'));