← back to Builds Nightly
build-day.sh
106 lines
#!/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"
python3 - "$WORK/day.json" "$WORK/payload.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
json.dump({'text': d['narration'], 'model_id': 'eleven_turbo_v2_5',
'voice_settings': {'stability': 0.5, 'similarity_boost': 0.8}}, open(sys.argv[2], 'w'))
PY
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 @"$WORK/payload.json" \
-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)
import sys; print('manifest:', len(m), 'entries', file=sys.stderr)
PY"
# refresh the searchable transcript index (all win titles + projects per film day)
if node "$SELF/lib/build-transcripts.mjs" > "$WORK/transcripts.json" 2>/dev/null; then
scp -q "$WORK/transcripts.json" "$REMOTE:$REMOTE_DIR/transcripts.json"
log "transcripts index refreshed."
else
log "WARN: transcripts rebuild failed (search index stale, film still deployed)."
fi
log "deployed."
fi
echo "OK:$DAY:$COUNT:${SIZE}MB"