← 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();