← 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>`;
}