← back to Dw Marketing Reels
scripts/video-runner.mjs
128 lines
#!/usr/bin/env node
// video-runner.mjs — fulfills queued Video Studio jobs (data/video-jobs.json).
//
// GATE: this only AUTO-executes a video skill when the host is armed with
// VIDEO_AUTORUN=1. Autonomous skill execution runs `claude -p` headless, which is a
// Steve-gated action (see ~/.claude/yolo-queue). Unarmed (default), the runner simply
// reports that jobs are waiting for a live studio session to run them — nothing fires.
//
// Each job maps to a renderer:
// new-arrivals-reel (9:16) → scripts/build-reel.mjs ($0 local, no API/arming,
// IG-publishable silent-AAC track, writes to reels/)
// new-arrivals-reel (1:1) → reels-producer skill (claude -p, square)
// url-reel → product-launch-video skill (promo/tour reel from a URL)
// newsletter-video → newsletter-to-video skill (.eml → promo video)
// Only the claude -p (skill) paths need VIDEO_AUTORUN=1 + CLAUDE_BIN; the local
// build-reel.mjs path renders even unarmed (same $0 script the /api/build button uses).
import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { homedir } from 'node:os';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const JOBS = join(ROOT, 'data', 'video-jobs.json');
const ARMED = process.env.VIDEO_AUTORUN === '1';
// HyperFrames needs a Python venv for TTS/BGM audio. Honour an explicit env var if set,
// else auto-resolve the standard local venv so auto-rendered reels get narrated audio
// (no arming/env change needed — this only affects the render child's environment).
const HF_PY = process.env.HYPERFRAMES_PYTHON
|| (existsSync(join(homedir(), '.hyperframes/tts-venv/bin/python')) ? join(homedir(), '.hyperframes/tts-venv/bin/python') : '');
// A single transient Anthropic API blip ("Server is temporarily limiting requests",
// overloaded, 429/529) used to permanently kill a render. Retry those with backoff.
const MAX_TRIES = Math.max(1, Number(process.env.VIDEO_MAX_TRIES || 3));
const BACKOFF_MS = Math.max(1000, Number(process.env.VIDEO_BACKOFF_MS || 20000));
const sleep = ms => new Promise(r => setTimeout(r, ms));
const isTransient = s => /rate limit|temporarily limiting|overloaded|Overloaded|\b429\b|\b529\b|ECONNRESET|ETIMEDOUT|socket hang up/i.test(s || '');
// Run one `claude -p` render, capturing stderr (still echoed to the parent) so we can
// classify failures. Resolves { ok, code, err }.
function runClaude(CLAUDE_BIN, pr) {
return new Promise(resolve => {
let err = '';
// Give the render its audio toolchain without mutating the pm2/server env.
const env = { ...process.env, ...(HF_PY ? { HYPERFRAMES_PYTHON: HF_PY } : {}) };
const ch = spawn(CLAUDE_BIN, ['--model', 'opus', '-p', pr], { cwd: ROOT, env, stdio: ['ignore', 'inherit', 'pipe'] });
ch.stderr.on('data', d => { err += d; process.stderr.write(d); });
ch.on('close', code => resolve({ ok: code === 0, code, err }));
ch.on('error', e => resolve({ ok: false, code: -1, err: String(e.message) }));
});
}
// A catalog reel in 9:16 is rendered by the deterministic local builder — no agent.
const isLocalReel = j => j.kind === 'new-arrivals-reel' && j.format !== '1:1';
// Deterministic $0 render via scripts/build-reel.mjs (portrait New Arrivals reel with an
// IG-publishable audio track, written to reels/). No claude, no API, no arming needed.
function runLocalBuild() {
return new Promise(resolve => {
let err = '';
const env = { ...process.env, PATH: `/opt/homebrew/bin:/usr/local/bin:${process.env.PATH || ''}`,
HYPERFRAMES_SKIP_SKILLS: '1', REEL_COUNT: String(process.env.REEL_COUNT || 6) };
const ch = spawn(process.execPath, [join(ROOT, 'scripts', 'build-reel.mjs')], { cwd: ROOT, env, stdio: ['ignore', 'inherit', 'pipe'] });
ch.stderr.on('data', d => { err += d; process.stderr.write(d); });
ch.on('close', code => resolve({ ok: code === 0, code, err }));
ch.on('error', e => resolve({ ok: false, code: -1, err: String(e.message) }));
});
}
const prompt = (j) => {
const fmt = j.format === '1:1' ? '1:1 (1080×1080)' : '9:16 Instagram Reels (1080×1920)';
if (j.kind === 'new-arrivals-reel')
return `Use the reels-producer skill to build ONE ${fmt} Instagram reel for Designer Wallcoverings New Arrivals from ~/Projects/dw-marketing-reels/data/new-arrivals.json (pick 6-8 varied products, download images locally, add a ready-to-post caption). $0 local. Report the MP4 path + caption.`;
if (j.kind === 'url-reel')
return `Use the product-launch-video skill to build ONE ${fmt} promo reel for: ${j.url} . $0 local (export HYPERFRAMES_PYTHON=~/.hyperframes/tts-venv/bin/python for audio). Report the MP4 path.`;
if (j.kind === 'newsletter-video')
return `Use the newsletter-to-video skill on the .eml at: ${j.eml} , output ${fmt}. $0 local. Report the MP4 path.`;
return null;
};
(async () => {
const jobs = existsSync(JOBS) ? JSON.parse(await readFile(JOBS, 'utf8')) : [];
const queued = jobs.filter(j => j.status === 'queued');
if (!queued.length) { console.log('video-runner: no queued jobs.'); return; }
// Agent (claude -p) jobs are gated behind VIDEO_AUTORUN=1 + a real CLAUDE_BIN.
// Local build-reel.mjs jobs (9:16 catalog reels) are $0 and always run.
const CLAUDE_BIN = process.env.CLAUDE_BIN || '';
const canAgent = ARMED && CLAUDE_BIN && existsSync(CLAUDE_BIN);
const agentWaiting = queued.filter(j => !isLocalReel(j));
if (agentWaiting.length && !canAgent)
console.log(`video-runner: ${agentWaiting.length} agent job(s) waiting for a live/armed studio session (VIDEO_AUTORUN + CLAUDE_BIN).`);
const toRun = queued.filter(j => isLocalReel(j) || canAgent);
if (!toRun.length) { console.log('video-runner: nothing runnable right now.'); return; }
for (const j of toRun) {
const local = isLocalReel(j);
const pr = local ? null : prompt(j);
if (!local && !pr) { j.status = 'error'; j.note = 'unknown kind'; await writeFile(JOBS, JSON.stringify(jobs, null, 2)); continue; }
j.status = 'running'; j.started_at = new Date().toISOString(); j.renderer = local ? 'build-reel' : 'claude-skill';
await writeFile(JOBS, JSON.stringify(jobs, null, 2));
let res, tries = 0;
for (;;) {
tries++;
res = local ? await runLocalBuild() : await runClaude(CLAUDE_BIN, pr);
j.attempts = tries;
if (res.ok) break;
// Only retry self-healing API throttles — a real skill/code failure fails fast.
if (tries < MAX_TRIES && isTransient(res.err)) {
const wait = BACKOFF_MS * tries;
j.note = `transient API error (try ${tries}/${MAX_TRIES}) — backing off ${Math.round(wait/1000)}s`;
await writeFile(JOBS, JSON.stringify(jobs, null, 2));
console.error(`video-runner: ${j.id} ${j.note}`);
await sleep(wait);
continue;
}
break;
}
if (res.ok) { j.status = 'done'; j.finished_at = new Date().toISOString(); delete j.note; }
else {
j.status = 'error'; j.finished_at = new Date().toISOString();
j.note = (isTransient(res.err) ? `rate-limited after ${tries} tr${tries===1?'y':'ies'} — safe to requeue` : (`${local ? 'build-reel' : 'claude'} exit ${res.code}`))
+ (res.err ? (' · ' + res.err.replace(/\s+/g, ' ').trim().slice(0, 140)) : '');
}
await writeFile(JOBS, JSON.stringify(jobs, null, 2));
}
})().catch(e => { console.error('video-runner FAILED:', e.message); process.exit(1); });