[object Object]

← back to Builds Nightly

auto-save: 2026-07-22T22:17:02 (4 files) — .gitignore build-day.sh lib/ package.json

84b3c47e464640820d213aa8ec43442799dabd67 · 2026-07-22 22:17:09 -0700 · Steve Abrams

Files touched

Diff

commit 84b3c47e464640820d213aa8ec43442799dabd67
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 22 22:17:09 2026 -0700

    auto-save: 2026-07-22T22:17:02 (4 files) — .gitignore build-day.sh lib/ package.json
---
 .gitignore           |   6 +++
 build-day.sh         |  92 ++++++++++++++++++++++++++++++++++++++++++++++
 lib/composition.mjs  | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/wins-for-day.mjs |  86 +++++++++++++++++++++++++++++++++++++++++++
 package.json         |   7 ++++
 5 files changed, 293 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..efa51e1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+node_modules/
+work/
+out/
+*.log
+.DS_Store
+.env*
diff --git a/build-day.sh b/build-day.sh
new file mode 100644
index 0000000..fd86478
--- /dev/null
+++ b/build-day.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# Build ONE day's builds-recap video → HyperFrames MP4 → deploy to builds.agentabrams.com.
+# Usage: bash build-day.sh 2026-07-22 [--no-deploy]
+# Voice = Steve's ElevenLabs clone (per Steve 2026-07-22). Visuals = HyperFrames (free/local).
+set -euo pipefail
+export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
+
+DAY="${1:-}"; DEPLOY=1
+[[ "${2:-}" == "--no-deploy" ]] && DEPLOY=0
+[[ "$DAY" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "usage: build-day.sh YYYY-MM-DD [--no-deploy]" >&2; exit 2; }
+
+SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+WORK="$SELF/work/$DAY"; OUT="$SELF/out"
+HF_VER="hyperframes@0.7.54"
+REMOTE="root@45.61.58.125"
+REMOTE_DIR="/var/www/builds.agentabrams.com/nightly"
+log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
+
+mkdir -p "$WORK/assets" "$OUT"
+
+# 1) Wins for the day → JSON. Zero wins → skip (no video for an empty day).
+log "pulling CNCP wins for $DAY…"
+node "$SELF/lib/wins-for-day.mjs" "$DAY" > "$WORK/day.json"
+COUNT=$(python3 -c "import json;print(json.load(open('$WORK/day.json'))['count'])")
+if [[ "$COUNT" -eq 0 ]]; then log "no wins on $DAY — skipping"; echo "SKIP:$DAY:no-wins"; exit 0; fi
+log "$COUNT wins."
+
+# 2) Narration → ElevenLabs cloned voice MP3.  (~\$0.11/video — shown per Steve's cost rule)
+ELEVENLABS_API_KEY="${ELEVENLABS_API_KEY:-$(grep -E '^ELEVENLABS_API_KEY=' "$HOME/Projects/secrets-manager/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '"')}"
+[[ -n "$ELEVENLABS_API_KEY" ]] || { echo "ELEVENLABS_API_KEY missing" >&2; exit 3; }
+VOICE_ID="$(python3 -c "import json;d=json.load(open('$HOME/.claude/skills/clone-voice/active.json'));print((d.get('engines',{}).get('elevenlabs',{}) or {}).get('voice_id',''))")"
+[[ -n "$VOICE_ID" ]] || { echo "no ElevenLabs voice_id" >&2; exit 3; }
+NARR=$(python3 -c "import json;print(json.load(open('$WORK/day.json'))['narration'])")
+log "TTS via ElevenLabs (voice ${VOICE_ID:0:6}…)  ~\$0.11"
+curl -sS -X POST "https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}?output_format=mp3_44100_128" \
+  -H "xi-api-key: $ELEVENLABS_API_KEY" -H "Content-Type: application/json" \
+  -d "$(python3 -c "import json,sys;print(json.dumps({'text':json.load(open('$WORK/day.json'))['narration'],'model_id':'eleven_turbo_v2_5','voice_settings':{'stability':0.5,'similarity_boost':0.8}}))")" \
+  -o "$WORK/assets/narration.mp3"
+[[ -s "$WORK/assets/narration.mp3" ]] || { echo "TTS produced no audio" >&2; head -c 300 "$WORK/assets/narration.mp3" >&2; exit 4; }
+# log to the cost ledger if present
+node "$HOME/.claude/skills/cost-tracker/log.js" elevenlabs tts 1 "builds-nightly $DAY" 2>/dev/null || true
+DURSEC=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/assets/narration.mp3" 2>/dev/null || echo 40)
+log "narration ${DURSEC}s."
+
+# 3) Compose the HyperFrames project (index.html + hyperframes.json + assets).
+node --input-type=module -e "
+import { buildComposition } from '$SELF/lib/composition.mjs';
+import fs from 'node:fs';
+const day = JSON.parse(fs.readFileSync('$WORK/day.json','utf8'));
+const html = buildComposition({ day, durationSec: Number('$DURSEC'), voFile:'narration.mp3' });
+fs.writeFileSync('$WORK/index.html', html);
+"
+cat > "$WORK/hyperframes.json" <<'JSON'
+{ "$schema":"https://hyperframes.heygen.com/schema/hyperframes.json",
+  "paths": { "blocks":"compositions", "components":"compositions/components", "assets":"assets" } }
+JSON
+cat > "$WORK/package.json" <<'JSON'
+{ "name":"builds-nightly-day", "private":true, "scripts":{ "render":"npx --yes hyperframes@0.7.54 render" } }
+JSON
+
+# 4) Render → MP4.
+log "rendering via HyperFrames (bundled Chrome)…"
+( cd "$WORK" && npx --yes "$HF_VER" render >/tmp/hf-render-$DAY.log 2>&1 ) || { echo "render failed — see /tmp/hf-render-$DAY.log" >&2; tail -20 /tmp/hf-render-$DAY.log >&2; exit 5; }
+MP4="$(ls -t "$WORK"/renders/*.mp4 2>/dev/null | head -1)"
+[[ -s "$MP4" ]] || { echo "no mp4 produced" >&2; exit 5; }
+FINAL="$OUT/$DAY.mp4"; cp "$MP4" "$FINAL"
+SIZE=$(( $(stat -f%z "$FINAL") / 1024 / 1024 ))
+log "mp4 ready: $FINAL (${SIZE}MB)"
+
+# 5) Deploy to builds.aa /nightly + refresh manifest.
+if [[ "$DEPLOY" -eq 1 ]]; then
+  log "deploying to builds.agentabrams.com/nightly…"
+  ssh "$REMOTE" "mkdir -p $REMOTE_DIR"
+  scp -q "$FINAL" "$REMOTE:$REMOTE_DIR/$DAY.mp4"
+  TITLE=$(python3 -c "import json;d=json.load(open('$WORK/day.json'));print(d['dateLabel'])")
+  # append/update this day in the remote manifest.json (idempotent by date)
+  ssh "$REMOTE" "python3 - <<PY
+import json, os
+p='$REMOTE_DIR/manifest.json'
+m=[]
+if os.path.exists(p):
+    try: m=json.load(open(p))
+    except: m=[]
+m=[x for x in m if x.get('date')!='$DAY']
+m.append({'date':'$DAY','file':'$DAY.mp4','title':'''$TITLE''','count':$COUNT,'size_mb':$SIZE})
+m.sort(key=lambda x:x['date'], reverse=True)
+json.dump(m, open(p,'w'), indent=1)
+print('manifest:', len(m), 'entries')
+PY"
+  log "deployed."
+fi
+echo "OK:$DAY:$COUNT:${SIZE}MB"
diff --git a/lib/composition.mjs b/lib/composition.mjs
new file mode 100644
index 0000000..8e5081c
--- /dev/null
+++ b/lib/composition.mjs
@@ -0,0 +1,102 @@
+// 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>`;
+}
diff --git a/lib/wins-for-day.mjs b/lib/wins-for-day.mjs
new file mode 100644
index 0000000..7b05ca6
--- /dev/null
+++ b/lib/wins-for-day.mjs
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+// Pull CNCP wins for ONE calendar day (local time) and shape them into
+// { date, dateLabel, count, bullets[], narration } for the nightly video.
+// Usage: node lib/wins-for-day.mjs 2026-07-22   → prints JSON to stdout.
+import http from 'node:http';
+
+const CNCP = process.env.CNCP_URL || 'http://127.0.0.1:3333/api/wins';
+const day = process.argv[2];
+if (!/^\d{4}-\d{2}-\d{2}$/.test(day || '')) {
+  console.error('usage: wins-for-day.mjs YYYY-MM-DD');
+  process.exit(2);
+}
+
+function getJSON(url) {
+  return new Promise((resolve, reject) => {
+    const req = http.get(url, { timeout: 12000 }, (res) => {
+      let b = '';
+      res.on('data', (c) => (b += c));
+      res.on('end', () => { try { resolve(JSON.parse(b)); } catch (e) { reject(e); } });
+    });
+    req.on('timeout', () => req.destroy(new Error('timeout')));
+    req.on('error', reject);
+  });
+}
+
+// A win's timestamp, best-effort across the field names CNCP has used.
+function winTime(w) {
+  for (const k of ['created_at', 'ts', 'date', 'time', 'createdAt', 'at']) {
+    if (w[k]) { const d = new Date(String(w[k])); if (!isNaN(d)) return d; }
+  }
+  return null;
+}
+// Local-time calendar-day key (YYYY-MM-DD) for a Date.
+function dayKey(d) {
+  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
+const clean = (s) => String(s || '').replace(/\s+/g, ' ').trim();
+
+const data = await getJSON(CNCP).catch((e) => { console.error('CNCP unreachable:', e.message); process.exit(9); });
+const wins = Array.isArray(data) ? data : (data.wins || data.items || []);
+
+const dayWins = [];
+for (const w of wins) {
+  // CNCP stamps a clean 'date' string (YYYY-MM-DD) — use it verbatim (no TZ math).
+  // Fall back to parsing a timestamp only when 'date' is absent.
+  const dstr = typeof w.date === 'string' && /^\d{4}-\d{2}-\d{2}/.test(w.date) ? w.date.slice(0, 10) : null;
+  if (dstr) { if (dstr === day) dayWins.push(w); continue; }
+  const t = winTime(w);
+  if (t && dayKey(t) === day) dayWins.push(w);
+}
+
+// Dedupe by title; keep first-seen order.
+const seen = new Set();
+const uniq = [];
+for (const w of dayWins) {
+  const title = clean(w.title || w.name || w.summary);
+  if (!title || seen.has(title.toLowerCase())) continue;
+  seen.add(title.toLowerCase());
+  uniq.push({ title, project: clean(w.project || w.area || '') });
+}
+
+const dt = new Date(day + 'T12:00:00');
+const dateLabel = dt.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
+const shortLabel = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+
+// On-screen: up to 8 bullets. Narration: a flowing recap, capped ~780 chars
+// (ElevenLabs turbo does ~15 chars/sec → keeps clips ~45-70s).
+const bullets = uniq.slice(0, 8);
+let narration = '';
+if (uniq.length === 0) {
+  narration = '';
+} else {
+  const n = uniq.length;
+  const lead = `${dateLabel}. ${n === 1 ? 'One build landed' : `${n} builds landed`} today.`;
+  const parts = [lead];
+  for (const w of uniq) {
+    if (parts.join(' ').length > 720) break;
+    parts.push(w.project ? `${w.title} on ${w.project}.` : `${w.title}.`);
+  }
+  narration = parts.join(' ').slice(0, 800);
+}
+
+console.log(JSON.stringify({
+  date: day, dateLabel, shortLabel,
+  count: uniq.length, bullets, narration,
+}, null, 1));
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..7ac0d84
--- /dev/null
+++ b/package.json
@@ -0,0 +1,7 @@
+{
+  "name": "builds-nightly",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Nightly HyperFrames recap videos of each day's builds (CNCP wins), Steve's cloned voice, deployed to builds.agentabrams.com",
+  "type": "module"
+}

(oldest)  ·  back to Builds Nightly  ·  builds-nightly pipeline: CNCP wins -> ElevenLabs voice -> Hy 8601a05 →