← back to Rentv Tour
generate.mjs
147 lines
import fs from 'fs';
const M = JSON.parse(fs.readFileSync('videos/site-tour/assets/manifest.json', 'utf8'));
const VO = JSON.parse(fs.readFileSync('videos/site-tour/assets/vo-durations.json', 'utf8'));
const W = 1920, H = 1080;
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// 3-act structure (Growth -> Front End -> Backend), paced to the 3-act VO. Each act opens with a
// 1.5s act card, then its frames fill the remainder of that act's narration length exactly.
const CARD = 1.5;
const GROWTH_DUR = (VO.growth - CARD) / M.growth.length;
const FRONT_DUR = (VO.front - CARD) / M.front.length;
const ADMIN_DUR = (VO.admin - CARD) / M.admin.length;
const clips = []; // {html}
const tweens = []; // gsap lines
const bgFill = (id) => `#${id}`;
let t = 0;
// helpers
function card(id, tag, title, sub, start, dur, accent) {
clips.push(` <section id="${id}" class="clip card" data-start="${start}" data-duration="${dur}" data-track-index="1">
<div class="card-inner">
${tag ? `<div class="card-tag" id="${id}-tag" style="color:${accent}">${esc(tag)}</div>` : ''}
<h1 id="${id}-title">${esc(title)}</h1>
${sub ? `<p id="${id}-sub">${esc(sub)}</p>` : ''}
</div>
</section>`);
tweens.push(`tl.from('#${id}-title',{y:36,autoAlpha:0,duration:0.6,ease:'power3.out'},${start.toFixed(3)});`);
if (tag) tweens.push(`tl.from('#${id}-tag',{y:-18,autoAlpha:0,duration:0.5,ease:'power2.out'},${(start+0.1).toFixed(3)});`);
if (sub) tweens.push(`tl.from('#${id}-sub',{y:20,autoAlpha:0,duration:0.5,ease:'power2.out'},${(start+0.25).toFixed(3)});`);
}
function frame(id, src, label, idx, count, actTag, accent, start, dur) {
// .clip WRAPPER (framework controls its visibility) with an inner <img> we animate.
clips.push(` <div id="${id}" class="clip shot" data-start="${start}" data-duration="${dur}" data-track-index="2">
<img id="${id}-img" class="shot-img" src="${src}" alt="${esc(label)}">
</div>`);
clips.push(` <div id="${id}-lbl" class="clip label" data-start="${start}" data-duration="${dur}" data-track-index="3">
<div class="lbl-inner" id="${id}-lbl-in">
<span class="lbl-num" style="background:${accent}">${String(idx).padStart(2,'0')} / ${count}</span>
<span class="lbl-txt">${esc(label)}</span>
</div>
</div>`);
// fade the inner img in, then a slow ken-burns zoom-out across the whole clip.
tweens.push(`tl.from('#${id}-img',{opacity:0,duration:0.35,ease:'power1.out'},${start.toFixed(3)});`);
tweens.push(`tl.fromTo('#${id}-img',{scale:1.06},{scale:1.0,duration:${dur.toFixed(3)},ease:'none'},${start.toFixed(3)});`);
tweens.push(`tl.from('#${id}-lbl-in',{y:26,autoAlpha:0,duration:0.4,ease:'power2.out'},${(start+0.05).toFixed(3)});`);
}
// ---- TITLE ----
card('title', 'RENTV.COM', 'Full Site Walkthrough', 'Growth · Front End · Admin Backend', 0, 2.5, '#34d399');
t = 2.5;
// ---- GROWTH ACT (ACT I) ----
card('growth-act', 'ACT I', 'The Growth Play', 'The strategy — build the owned audience', t, 1.5, '#34d399');
t += 1.5;
const growthStart = t;
M.growth.forEach((g, i) => {
const st = growthStart + i * GROWTH_DUR;
frame(`g${i}`, `assets/growth/${g.file.split('/').pop()}`, g.label, i + 1, M.growth.length, 'GROWTH', '#10b981', st, GROWTH_DUR);
});
const growthEnd = growthStart + M.growth.length * GROWTH_DUR;
clips.push(` <div id="tag-growth" class="clip corner-tag" data-start="${growthStart}" data-duration="${(growthEnd-growthStart).toFixed(3)}" data-track-index="4"><span class="dot" style="background:#34d399"></span>GROWTH · strategy</div>`);
tweens.push(`tl.from('#tag-growth',{autoAlpha:0,duration:0.4},${growthStart.toFixed(3)});`);
t = growthEnd;
// ---- FRONT END ACT (ACT II) ----
card('front-act', 'ACT II', 'Front End', 'The public CRE news platform', t, 1.5, '#57b0ff');
t += 1.5;
const frontStart = t;
M.front.forEach((f, i) => {
const st = frontStart + i * FRONT_DUR;
frame(`f${i}`, `assets/front/${f.file.split('/').pop()}`, f.label, i + 1, M.front.length, 'FRONT END', '#0a6cff', st, FRONT_DUR);
});
const frontEnd = frontStart + M.front.length * FRONT_DUR;
// persistent act corner tag
clips.push(` <div id="tag-front" class="clip corner-tag" data-start="${frontStart}" data-duration="${(frontEnd-frontStart).toFixed(3)}" data-track-index="4"><span class="dot" style="background:#57b0ff"></span>FRONT END · user</div>`);
tweens.push(`tl.from('#tag-front',{autoAlpha:0,duration:0.4},${frontStart.toFixed(3)});`);
t = frontEnd;
// ---- ADMIN ACT (ACT III) ----
card('admin-act', 'ACT III', 'Admin Backend', 'Internal intelligence — admin only', t, 1.5, '#c8a15a');
t += 1.5;
const adminStart = t;
M.admin.forEach((a, i) => {
const st = adminStart + i * ADMIN_DUR;
frame(`a${i}`, `assets/admin/${a.file.split('/').pop()}`, a.label, i + 1, M.admin.length, 'ADMIN', '#c8a15a', st, ADMIN_DUR);
});
const adminEnd = adminStart + M.admin.length * ADMIN_DUR;
clips.push(` <div id="tag-admin" class="clip corner-tag" data-start="${adminStart}" data-duration="${(adminEnd-adminStart).toFixed(3)}" data-track-index="4"><span class="dot" style="background:#c8a15a"></span>ADMIN · backend</div>`);
tweens.push(`tl.from('#tag-admin',{autoAlpha:0,duration:0.4},${adminStart.toFixed(3)});`);
t = adminEnd;
// ---- OUTRO ----
card('outro', '', 'rentv.agentabrams.com', 'One unified app · user + admin', t, 3.0, '#57b0ff');
const TOTAL = +(t + 3.0).toFixed(2);
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>RENTV — Full Site Walkthrough</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
body { margin:0; background:#070b10; font-family: system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif; }
#root { position:relative; width:${W}px; height:${H}px; overflow:hidden; }
.clip { position:absolute; inset:0; }
#bg { background:radial-gradient(120% 120% at 50% 0%,#0d1725 0%,#070b10 60%); }
.shot { overflow:hidden; }
.shot-img { width:100%; height:100%; object-fit:cover; object-position:top center; will-change:transform,opacity; display:block; }
.card { display:grid; place-items:center; }
.card-inner { text-align:center; padding:0 120px; }
.card-tag { font-size:22px; letter-spacing:.42em; font-weight:600; margin-bottom:22px; }
.card h1 { margin:0; color:#f3f7fc; font-size:104px; font-weight:500; letter-spacing:-.01em; line-height:1.02; }
.card p { margin:22px 0 0; color:#8ea3b8; font-size:30px; font-weight:300; letter-spacing:.01em; }
.label { left:56px; bottom:56px; right:auto; top:auto; width:auto; height:auto; }
.lbl-inner { display:flex; align-items:center; gap:16px; }
.label .lbl-num { color:#fff; font-size:20px; font-weight:600; letter-spacing:.05em; padding:9px 14px; border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,.35); }
.label .lbl-txt { color:#fff; font-size:30px; font-weight:500; letter-spacing:.005em;
background:rgba(9,15,24,.9); padding:9px 20px; border-radius:10px;
box-shadow:0 6px 20px rgba(0,0,0,.35); }
.corner-tag { display:flex; align-items:center; gap:11px; right:56px; top:50px; left:auto; bottom:auto;
width:auto; height:auto; color:#cdd9e6; font-size:19px; font-weight:600; letter-spacing:.14em;
background:rgba(9,15,24,.6); padding:9px 17px; border-radius:999px; }
.corner-tag .dot { width:10px; height:10px; border-radius:50%; display:inline-block; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-width="${W}" data-height="${H}" data-duration="${TOTAL}" data-fps="30">
<div id="bg" class="clip" data-start="0" data-duration="${TOTAL}" data-track-index="0"></div>
<audio id="vo" src="assets/rentv-vo-3act.m4a" data-start="0" data-volume="1"></audio>
${clips.join('\n')}
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
${tweens.map(x => ' ' + x).join('\n')}
window.__timelines['main'] = tl;
</script>
</body>
</html>
`;
fs.writeFileSync('videos/site-tour/index.html', html);
console.log(`Wrote index.html — ${TOTAL}s, ${M.front.length} front + ${M.admin.length} admin frames, ${clips.length} clips, ${tweens.length} tweens`);