← back to Beverlyhillsvideos

filmgen/heygen_batch.mjs

190 lines

#!/usr/bin/env node
// Self-driving HeyGen restaurant-film batch.
// Idempotent: reconciles data/heygen-batch.jsonl each run. Keeps <=CONCURRENCY in flight,
// downloads completed videos to public/video/, updates data/films.json for the site build.
// Cost guard: never submits if wallet < FLOOR. Every script is ~22s -> <$1/video.
import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';

const ROOT = new URL('..', import.meta.url).pathname;
// KIND runs a separate typed stream (restaurant | store) with its own ledger + films
// file, so store films never leak into the restaurants-only IG poster. Defaults preserve
// the original restaurant behavior byte-for-byte.
const KIND = process.env.FILM_KIND || 'restaurant';
const KIND_LABEL = KIND === 'store' ? 'luxury boutique / store' : 'restaurant';
const _sfx = KIND === 'restaurant' ? '' : `-${KIND}`;
const LEDGER = ROOT + `data/heygen-batch${_sfx}.jsonl`;
const SCRIPTS = ROOT + `data/${KIND === 'restaurant' ? 'film' : KIND}-scripts.json`;
const FILMS = ROOT + `data/${KIND === 'restaurant' ? 'films' : KIND + '-films'}.json`;
const LOCS = ROOT + 'data/map-locations.json';
const CONCURRENCY = 3;
const FLOOR = 130;              // stop submitting if wallet drops below this
const RATE = 0.0337;           // $/sec, measured from wallet delta
const VIDEO_DIR = ROOT + 'public/video';
// social-media aspect ratios (blurred-fill, whole 16:9 frame preserved, no crop)
const SOCIAL = [
  { dir: 'vertical', w: 1080, h: 1920 },  // 9:16 — Reels / TikTok / YouTube Shorts
  { dir: 'portrait', w: 1080, h: 1350 },  // 4:5  — Instagram feed (best engagement)
  { dir: 'square',   w: 1080, h: 1080 },  // 1:1  — Instagram feed
];
SOCIAL.forEach(s => { try { mkdirSync(`${VIDEO_DIR}/${s.dir}`, { recursive: true }); } catch {} });
function reframe(src, w, h, out) {
  sh(`ffmpeg -y -i ${src} -filter_complex "[0:v]scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h},gblur=sigma=24,eq=brightness=-0.10[bg];[0:v]scale=${w}:-1[fg];[bg][fg]overlay=(W-w)/2:(H-h)/2,format=yuv420p[v]" -map "[v]" -map 0:a -c:v libx264 -preset medium -crf 21 -c:a aac -b:a 128k ${out} 2>/dev/null`);
}

const sh = (c) => execSync(c, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
const slug = (n) => n.toLowerCase().replace(/&/g,'and').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
const jget = (s) => { try { return JSON.parse(s); } catch { return null; } };

function wallet() {
  try { return jget(sh('heygen auth status 2>/dev/null')).data.wallet.remaining_balance; }
  catch { return null; }
}
function videoStatus(vid) {
  try { const d = jget(sh(`heygen video get ${vid} 2>/dev/null`)).data; return { status: d.status, duration: d.duration }; }
  catch { return { status: 'unknown' }; }
}
function loadLedger() {
  const rows = {};
  if (existsSync(LEDGER)) for (const ln of readFileSync(LEDGER,'utf8').trim().split('\n')) { const r = jget(ln); if (r) rows[r.name] = r; }
  return rows;
}
function saveLedger(rows) {
  writeFileSync(LEDGER, Object.values(rows).map(r => JSON.stringify(r)).join('\n') + '\n');
}
const cats = existsSync(LOCS) ? Object.fromEntries(jget(readFileSync(LOCS,'utf8')).locations.filter(l=>l.type==='restaurant').map(l=>[l.name.replace(' Beverly Hills',''), l.category])) : {};
function catFor(name){ return cats[name] || cats[name.replace(' Beverly Hills','')] || 'Beverly Hills'; }

function updateFilms(rows, scripts) {
  const films = existsSync(FILMS) ? jget(readFileSync(FILMS,'utf8')) : [];
  const bySlug = Object.fromEntries(films.map(f=>[f.slug,f]));
  for (const r of Object.values(rows)) {
    if (r.status === 'completed' && r.downloaded && !bySlug[r.slug]) {
      // first sentence, but abbreviation-safe: truncate at a word boundary near ~100 chars
      const s = (scripts[r.name]||'').trim();
      let blurb = catFor(r.name);
      if (s) {
        if (s.length <= 100) blurb = s;
        else { const cut = s.slice(0,100); blurb = cut.slice(0, cut.lastIndexOf(' ')) + '…'; }
      }
      films.push({ name: r.name, slug: r.slug, type: KIND, blurb, src: `/video/${r.slug}.mp4`, poster: `/video/${r.slug}.jpg` });
    }
  }
  writeFileSync(FILMS, JSON.stringify(films, null, 1));
  return films.length;
}

// Detect leading silence so the film starts talking at t=0 (no initial gap).
function leadStart(raw) {
  try {
    const sd = sh(`ffmpeg -i ${raw} -af silencedetect=noise=-35dB:d=0.2 -f null - 2>&1 || true`);
    const firstStart = (sd.match(/silence_start:\s*(-?[0-9.]+)/) || [])[1];
    const firstEnd = (sd.match(/silence_end:\s*([0-9.]+)/) || [])[1];
    if (firstStart !== undefined && parseFloat(firstStart) <= 0.3 && firstEnd !== undefined) {
      return Math.min(2.5, Math.max(0, parseFloat(firstEnd) - 0.05)); // trim the lead-in, cap at 2.5s
    }
  } catch {}
  return 0;
}
const REVIEW_DIR = ROOT + 'filmgen/review';
// Extract 1 fps of frames so a human (or vision model) can confirm no brand logo,
// monogram, branded storefront/product, or foreign face was rendered before publish.
function extractReviewFrames(mp4, slug) {
  const dir = `${REVIEW_DIR}/${slug}`;
  try { mkdirSync(dir, { recursive: true }); } catch {}
  try { sh(`ffmpeg -y -i ${mp4} -vf fps=1 -q:v 3 ${dir}/frame-%02d.jpg 2>/dev/null`); } catch {}
  return dir;
}

function download(r) {
  if (!r.slug) r.slug = slug(r.name);          // ledger rows predating the driver lack a slug
  const raw = `${VIDEO_DIR}/${r.slug}-raw.mp4`;
  const mp4 = `${VIDEO_DIR}/${r.slug}.mp4`;
  sh(`heygen video download ${r.video_id} --output-path ${raw} --force`);
  const start = leadStart(raw);
  // trim leading gap + hard-cap to 29s (guarantees the delivered film is <=29s, talk at t=0)
  sh(`ffmpeg -y -ss ${start} -i ${raw} -t 29 -c:v libx264 -preset medium -crf 20 -c:a aac -b:a 128k -movflags +faststart ${mp4} 2>/dev/null`);
  try { sh(`rm -f ${raw}`); } catch {}
  try { sh(`ffmpeg -y -ss 1 -i ${mp4} -vframes 1 -q:v 3 ${VIDEO_DIR}/${r.slug}.jpg 2>/dev/null`); } catch {}

  // COMPLIANCE GATE (store kind): hold the film for a per-frame logo/mark review before
  // it reaches the social ratios or store-films.json. Reframe + integrate only after a
  // human/vision approval flips reviewState to 'approved' (filmgen/approve-store-film.mjs).
  if (KIND === 'store' && r.reviewState !== 'approved') {
    const dir = extractReviewFrames(mp4, r.slug);
    r.reviewState = 'pending';
    r.mp4Ready = true;      // mp4 exists; NOT reframed, NOT downloaded, NOT published
    console.log(`  ⏸ HELD for review: ${r.name} — frames in ${dir.replace(ROOT,'')}`);
    return;
  }

  // social-media aspect ratios (vertical 9:16, portrait 4:5, square 1:1), from the trimmed film
  for (const s of SOCIAL) {
    try { reframe(mp4, s.w, s.h, `${VIDEO_DIR}/${s.dir}/${r.slug}.mp4`); } catch {}
  }
  r.downloaded = true;
}

function main() {
  const scripts = jget(readFileSync(SCRIPTS,'utf8'));
  const rows = loadLedger();
  const w0 = wallet();

  // 1) POLL: advance generating rows; download completed
  for (const name of Object.keys(scripts)) {
    const r = rows[name];
    if (!r || !r.video_id) continue;
    if (r.status === 'completed' && r.downloaded) continue;
    if (r.reviewState === 'pending') continue;   // held for compliance review — don't reprocess
    const s = videoStatus(r.video_id);
    if (s.status && s.status !== 'unknown') r.status = s.status;
    if (s.duration) r.duration = Math.round(s.duration*10)/10;
    if (r.status === 'completed' && !r.downloaded) {
      try { download(r); r.est_cost = r.duration ? +(r.duration*RATE).toFixed(2) : null; console.log(`  ⬇ downloaded ${name} (${r.duration}s ~$${r.est_cost})`); }
      catch(e){ console.log(`  ⚠ download failed ${name}: ${String(e).slice(0,80)}`); }
    }
  }

  // 2) SUBMIT: keep CONCURRENCY in flight
  const inflight = () => Object.values(rows).filter(r => scripts[r.name] && ['generating','pending','processing','waiting'].includes(r.status)).length;
  const queue = Object.keys(scripts).filter(n => !rows[n] || !rows[n].video_id);
  let submitted = 0;
  const wnow = wallet();
  for (const name of queue) {
    if (inflight() >= CONCURRENCY) break;
    if (wnow !== null && wnow < FLOOR) { console.log(`  ⛔ wallet $${wnow} < floor $${FLOOR} — not submitting`); break; }
    const script = scripts[name];
    // Store films name real brands → hard visual constraints so HeyGen doesn't render
    // trademarked marks/trade-dress into b-roll. NOTE: these are advisory to the model,
    // NOT enforceable — the per-frame review gate in download() is the real backstop.
    const NEG = KIND === 'store'
      ? ' Show the store name ONLY as plain elegant serif typography — never a stylized brand logotype. Do NOT render any brand logo, monogram, wordmark, emblem, crest, crown, or trademarked repeating pattern (no LV monogram, no interlocking-C, no GG, no Burberry check). Do NOT render recognizable brand products, packaging, or the real branded storefront/facade/interior. Backgrounds: GENERIC upscale Beverly Hills retail street and neutral studio only. No third-party faces or celebrity likenesses.'
      : '';
    const prompt = `Create a SHORT ~25 second editorial spotlight video for a Beverly Hills city-guide website about the ${KIND_LABEL} ${name}. Use EXACTLY this narration and do not lengthen it: "${script}". Elegant, warm, upscale editorial tone. Landscape 16:9.${NEG}`;
    try {
      const out = jget(sh(`heygen video-agent create --orientation landscape --prompt ${JSON.stringify(prompt)} 2>/dev/null`));
      const d = out.data || out;
      rows[name] = { name, slug: slug(name), session_id: d.session_id || d.id, video_id: d.video_id, status: d.status || 'generating', downloaded: false };
      submitted++; console.log(`  ▶ submitted ${name} (video ${rows[name].video_id})`);
    } catch(e){ console.log(`  ✗ submit failed ${name}: ${String(e).slice(0,100)}`); }
  }

  saveLedger(rows);
  const filmCount = updateFilms(rows, scripts);

  // 3) SUMMARY
  const by = (s) => Object.values(rows).filter(r => scripts[r.name] && r.status===s).length;
  const done = Object.values(rows).filter(r => scripts[r.name] && r.status==='completed').length;
  const total = Object.keys(scripts).length;
  console.log('─'.repeat(48));
  console.log(`films batch: ${done}/${total} completed · ${inflight()} in flight · ${queue.length-submitted} queued`);
  console.log(`downloaded+integrated: ${Object.values(rows).filter(r=>scripts[r.name]&&r.downloaded).length} · films.json entries: ${filmCount}`);
  console.log(`wallet: $${wnow}  (pre-batch marker in filmgen/work/wallet_pre.txt)`);
  const pre = existsSync(ROOT+'filmgen/work/wallet_pre.txt') ? +readFileSync(ROOT+'filmgen/work/wallet_pre.txt','utf8').trim() : null;
  if (pre && wnow!==null) console.log(`measured batch spend so far: $${(pre-wnow).toFixed(2)}`);
  const allDone = done === total;
  console.log(allDone ? 'STATUS: ALL DONE ✅' : 'STATUS: in progress — re-run to advance');
  process.exit(allDone ? 0 : 2);
}
main();