#!/usr/bin/env node // Sends one request file from build-requests.mjs to fal.ai with YOUR OWN FAL_KEY, waits, and // downloads the result. This is the only script that spends money, so it refuses to run // unless you pass --approved after the user has agreed to the cost it prints. // Local "file:" images are sent inline as data URIs (never put on a public URL). // The key is only ever sent to https://queue.fal.run/. // // Usage: // FAL_KEY=... node scripts/fal-run.mjs --request requests/02-detail-final.json --out clips/ --approved [--force] // FAL_KEY=... node scripts/fal-run.mjs --resume --out clips/ --name 02-detail-final [--force] // // Exit code 1 on any failure, including a failed download. // Node 18 or newer. No npm packages. import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { extname, join, resolve } from 'node:path' const FLAGS = new Set(['request', 'out', 'approved', 'resume', 'name', 'timeout-min', 'force']) const SWITCHES = new Set(['approved', 'force']) const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' } const QUEUE = 'https://queue.fal.run/' const SAFE_NAME = /^[A-Za-z0-9._-]{1,80}$/ const MODEL_ID = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)+$/i function fail(message) { console.error(message) process.exit(1) } const opts = {} const argv = process.argv.slice(2) for (let i = 0; i < argv.length; i++) { const a = argv[i] if (!a.startsWith('--')) fail(`Unexpected argument "${a}". Flags look like --name value.`) const key = a.slice(2) if (!FLAGS.has(key)) fail(`Unknown flag --${key}. Allowed: ${[...FLAGS].map((f) => `--${f}`).join(', ')}`) if (SWITCHES.has(key)) { opts[key] = true; continue } const value = argv[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) opts[key] = value i++ } if (!opts.out || (!opts.request && !opts.resume)) fail('Usage: node scripts/fal-run.mjs --request request.json --out dir --approved [--force] (or --resume --out dir --name )') if (opts.request && opts.resume) fail('Use either --request or --resume, not both.') const timeoutMin = Number(opts['timeout-min'] ?? 25) if (!Number.isFinite(timeoutMin) || timeoutMin < 1 || timeoutMin > 120) fail('--timeout-min must be from 1 to 120.') const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) const onQueue = (url) => typeof url === 'string' && url.startsWith(QUEUE) function safeName(value, where) { if (!SAFE_NAME.test(value)) fail(`${where} "${value}" can only use letters, numbers, dot, dash and underscore (it becomes a file name).`) return value } function keyHeaders() { const key = process.env.FAL_KEY if (!key) fail('FAL_KEY is not set. Use your own fal.ai key: export FAL_KEY=... (the Skill never stores keys).') return { Authorization: `Key ${key}`, 'Content-Type': 'application/json' } } // Refuse to overwrite earlier results unless --force. function guardOutput(name) { const result = join(opts.out, `${name}-result.json`) if (existsSync(result) && !opts.force) fail(`${result} already exists. Pick another --out or --name, or pass --force to overwrite.`) } function inlineFiles(value) { if (typeof value === 'string' && value.startsWith('file:')) { const path = resolve(value.slice(5)) if (!existsSync(path)) fail(`Image not found: ${path}`) const mime = MIME[extname(path).toLowerCase()] if (!mime) fail(`${path} must be .jpg, .png or .webp.`) if (statSync(path).size > 8 * 1024 * 1024) fail(`${path} is over 8 MB; save it as a smaller JPEG first.`) return `data:${mime};base64,${readFileSync(path).toString('base64')}` } if (Array.isArray(value)) return value.map(inlineFiles) if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, inlineFiles(v)])) return value } function explain(body) { const text = JSON.stringify(body) if (/content_policy_violation|likenesses of real people|partner_validation/i.test(text)) { return 'Refused by the model\'s safety check. For Seedance this almost always means a clearly visible realistic face in the start frame. Regenerate the keyframe with the face turned away, in silhouette or small in frame (references/prompt-patterns.md), or see the face alternatives in SKILL.md. Nothing to retry as-is.' } return text.slice(0, 600) } async function download(result, name) { mkdirSync(opts.out, { recursive: true }) const urls = [] const walk = (n) => { if (typeof n === 'string' && /^https:\/\//.test(n) && /\.(mp4|webm|png|jpe?g|webp|mp3|wav)(\?|$)/i.test(n)) urls.push(n) else if (Array.isArray(n)) n.forEach(walk) else if (n && typeof n === 'object') Object.values(n).forEach(walk) } walk(result) writeFileSync(join(opts.out, `${name}-result.json`), JSON.stringify(result, null, 2)) const saved = [] const failed = [] for (const [i, url] of [...new Set(urls)].entries()) { const ext = (url.split('?')[0].match(/\.(\w+)$/)?.[1] ?? 'bin').toLowerCase() const dest = join(opts.out, `${name}-${i + 1}.${ext}`) let ok = false for (let attempt = 1; attempt <= 3 && !ok; attempt++) { // Output files are public CDN links: fetched without the key. const res = await fetch(url).catch(() => null) if (res?.ok) { writeFileSync(dest, Buffer.from(await res.arrayBuffer())); saved.push(dest); ok = true } else await sleep(3000) } if (!ok) failed.push(url) } if (!urls.length) fail(`The job finished but returned no files. See ${join(opts.out, `${name}-result.json`)}.`) if (failed.length) fail(`Download failed for:\n ${failed.join('\n ')}\nThe files stay on fal for a while; download them by hand or re-run with --resume and --force.`) return saved } async function poll(statusUrl, responseUrl, name, headers) { if (!onQueue(statusUrl) || !onQueue(responseUrl)) fail('fal returned job URLs outside https://queue.fal.run/; not sending the key there.') const t0 = Date.now() let delay = 3000 while (Date.now() - t0 < timeoutMin * 60 * 1000) { await sleep(delay) delay = Math.min(delay * 1.3, 15000) const st = await fetch(statusUrl, { headers }).catch(() => null) const body = st ? await st.json().catch(() => ({})) : {} if (body.status === 'COMPLETED') { const r = await fetch(responseUrl, { headers }) const result = await r.json().catch(() => ({})) if (!r.ok) fail(`The job finished with an error: ${explain(result)}`) const saved = await download(result, name) console.log(`done: ${saved.join(', ')}`) return } process.stdout.write(` ${Math.round((Date.now() - t0) / 1000)}s ${body.status ?? st?.status ?? 'no answer'}\n`) } fail(`Still running after ${timeoutMin} min. It may finish later; fetch it with:\n node scripts/fal-run.mjs --resume ${responseUrl} --out ${opts.out} --name ${name}`) } if (opts.resume) { const name = safeName(opts.name ?? 'result', '--name') if (!onQueue(opts.resume) || /[\s?#]/.test(opts.resume)) fail('--resume takes the response_url printed by an earlier run (https://queue.fal.run/...).') guardOutput(name) await poll(`${opts.resume}/status`, opts.resume, name, keyHeaders()) } else { if (!existsSync(opts.request)) fail(`Request file not found: ${opts.request}`) let req try { req = JSON.parse(readFileSync(opts.request, 'utf8')) } catch { fail(`${opts.request} is not valid JSON.`) } if (!req.model || !req.input) fail(`${opts.request} must have "model" and "input" (make it with scripts/build-requests.mjs).`) if (!MODEL_ID.test(req.model)) fail(`"${req.model}" is not a fal model id like bytedance/seedance-2.5/image-to-video.`) const name = safeName(opts.name ?? req.id ?? 'result', opts.name ? '--name' : 'The request id') guardOutput(name) console.log(`Model ${req.model}, approximate cost $${req.approx_cost_usd ?? '?'}`) if (!opts.approved) fail('Not sent. Show the user the model and cost above, and pass --approved only after they agree.') const headers = keyHeaders() const sub = await fetch(`${QUEUE}${req.model}`, { method: 'POST', headers, body: JSON.stringify(inlineFiles(req.input)) }) const body = await sub.json().catch(() => ({})) if (!sub.ok) fail(`fal refused the request (${sub.status}): ${explain(body)}`) console.log(`submitted ${body.request_id}\n response_url ${body.response_url}`) await poll(body.status_url, body.response_url, name, headers) }