← back to Builds Nightly
lib/composition.mjs
103 lines
// Generate a HyperFrames index.html for one day's builds recap.
// export buildComposition({ day, durationSec, voFile }) -> HTML string.
// Brand matches builds.agentabrams.com: cream ground, forest-green + gold accents.
// Timeline contract: a paused GSAP timeline registered as window.__timelines["main"].
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
export function buildComposition({ day, durationSec, voFile }) {
const dur = Math.max(6, Math.round(durationSec * 100) / 100 + 1.6); // +tail
const bullets = (day.bullets || []).slice(0, 8);
const n = bullets.length;
// Win rows appear staggered from firstAt across the narration body, leaving a
// ~2.6s intro and a ~2.4s outro. Even spacing so they track the voiceover.
const firstAt = 2.6;
const lastAt = Math.max(firstAt + 0.6, dur - 2.4);
const span = n > 1 ? (lastAt - firstAt) / (n - 1) : 0;
const stepAt = (i) => +(firstAt + span * i).toFixed(2);
const rows = bullets.map((b, i) => `
<div class="row" id="r${i}">
<span class="rnum">${String(i + 1).padStart(2, '0')}</span>
<span class="rtext">${esc(b.title)}</span>
${b.project ? `<span class="rproj">${esc(b.project)}</span>` : ''}
</div>`).join('');
const anims = bullets.map((b, i) =>
`tl.from("#r${i}", { opacity: 0, x: -40, duration: 0.55, ease: "power3.out" }, ${stepAt(i)});`).join('\n ');
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:1920px; height:1080px; overflow:hidden; background:#f7f1e6; }
body { font-family:"Inter","SF Pro Display",system-ui,sans-serif; color:#1a1714; }
.full-bleed { position:absolute; inset:0; width:1920px; height:1080px; }
#bg { background:
radial-gradient(60% 55% at 78% 18%, rgba(40,65,47,.10) 0%, transparent 60%),
radial-gradient(50% 50% at 12% 88%, rgba(201,164,58,.10) 0%, transparent 60%),
#f7f1e6; }
.wrap { position:absolute; inset:0; padding:96px 120px; display:flex; flex-direction:column; }
.brand { font-family:Georgia, serif; font-weight:400; font-size:40px; letter-spacing:-.5px; color:#28412f; }
.brand em { font-style:italic; }
.eyebrow { margin-top:8px; font-size:22px; letter-spacing:5px; text-transform:uppercase; color:#8a7d72; font-weight:700; }
.headline { margin-top:26px; font-family:Georgia, serif; font-weight:400; font-size:96px; line-height:1.02; color:#1a1714; }
.headline b { color:#28412f; font-weight:700; }
.count { display:inline-block; margin-top:14px; font-size:30px; color:#6b5f56; }
.count strong { color:#c9a43a; font-weight:800; font-size:34px; }
.list { margin-top:46px; display:flex; flex-direction:column; gap:18px; }
.row { display:flex; align-items:baseline; gap:22px; }
.rnum { font-family:Menlo,Monaco,monospace; font-size:26px; color:#c9a43a; font-weight:700; flex:none; width:42px; }
.rtext { font-size:36px; font-weight:600; color:#1a1714; line-height:1.2; }
.rproj { font-size:20px; color:#8a7d72; font-family:Menlo,Monaco,monospace; }
.outro { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; opacity:0; }
.outro .ob { font-family:Georgia, serif; font-size:120px; color:#28412f; } .outro .ob em { font-style:italic; }
.outro .ou { margin-top:16px; font-family:Menlo,Monaco,monospace; font-size:30px; color:#8a7d72; letter-spacing:2px; }
.rule { height:4px; width:0; background:#28412f; border-radius:4px; margin-top:20px; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="${dur}" data-width="1920" data-height="1080">
<div id="bg" class="full-bleed"></div>
<audio id="vo" src="assets/${esc(voFile)}" data-start="0" data-volume="1"></audio>
<div class="wrap" id="main-wrap">
<div class="brand" id="brand">Builds <em>· agentabrams</em></div>
<div class="eyebrow" id="eyebrow">Nightly recap</div>
<div class="headline" id="headline"><b>${esc(day.shortLabel || day.dateLabel)}</b><br/>${esc(day.dateLabel.split(', ').slice(0, 1)[0])}</div>
<div class="count" id="count"><strong>${day.count}</strong> ${day.count === 1 ? 'build' : 'builds'} shipped</div>
<div class="rule" id="rule"></div>
<div class="list" id="list">${rows}</div>
</div>
<div class="outro" id="outro">
<div class="ob">Builds <em>·</em></div>
<div class="ou">builds.agentabrams.com</div>
</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.from("#brand", { opacity:0, y:-18, duration:0.5 }, 0.15)
.from("#eyebrow", { opacity:0, y:14, duration:0.4 }, 0.4)
.from("#headline", { opacity:0, y:40, duration:0.7, ease:"power3.out" }, 0.6)
.from("#count", { opacity:0, y:18, duration:0.5 }, 1.2)
.to("#rule", { width:520, duration:0.6, ease:"power2.out" }, 1.4);
${anims}
// Outro: hold list, cross to the wordmark card for the last ~2.2s.
tl.to("#main-wrap", { opacity:0, duration:0.6 }, ${(dur - 2.2).toFixed(2)})
.to("#outro", { opacity:1, duration:0.6 }, ${(dur - 2.0).toFixed(2)})
.from(".outro .ob", { scale:0.86, opacity:0, duration:0.6, ease:"back.out(1.5)" }, ${(dur - 1.9).toFixed(2)})
.from(".outro .ou", { y:16, opacity:0, duration:0.5 }, ${(dur - 1.5).toFixed(2)});
window.__timelines["main"] = tl;
</script>
</body>
</html>`;
}