#!/usr/bin/env node
// Builds the exact model requests for one stage of the film from film.json and prints them
// (or writes one JSON file per request with --out). It calls no API.
// Each request is { model, input, approx_cost_usd }: POST `input` to fal model `model`
// with your own FAL_KEY (scripts/fal-run.mjs does this), or paste the prompt and images into
// any GPT-Image-class editor / Seedance-class image-to-video tool.
//
// Stages:
// bible the cast reference sheet (skip when --person none)
// keyframe one still per shot, with the cast bible / location / product as references
// draft cheap motion test per shot: Seedance 2.0 mini, 480p
// final the real clip per shot: Seedance 2.5 image-to-video at the film's resolution
// estimate no requests: the expected total for the whole film from the shot list, to quote to
// the user and get one yes on before the first paid call
//
// Usage:
// node scripts/build-requests.mjs --film film.json --stage estimate|bible|keyframe|draft|final
// [--shot 3|all] [--out requests/] [--inline] [--resolution 480p|720p|1080p]
//
// --inline embeds local images as data URIs (needs --out) so no photo has to be put on a public URL.
// Node 18 or newer. No npm packages.
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
import { dirname, extname, isAbsolute, join, resolve } from 'node:path'
const FLAGS = new Set(['film', 'stage', 'shot', 'out', 'inline', 'resolution'])
const SWITCHES = new Set(['inline'])
const MODELS = {
image: 'openai/gpt-image-2',
edit: 'openai/gpt-image-2/edit',
draft: 'bytedance/seedance-2.0/mini/image-to-video',
final: 'bytedance/seedance-2.5/image-to-video',
}
const PER_SECOND = { draft: { '480p': 0.0721, '720p': 0.1547 }, final: { '480p': 0.2205, '720p': 0.473, '1080p': 1.164 } }
const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' }
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.film || !opts.stage) fail('Usage: node scripts/build-requests.mjs --film film.json --stage estimate|bible|keyframe|draft|final [--shot N|all] [--out dir] [--inline]')
if (!['estimate', 'bible', 'keyframe', 'draft', 'final'].includes(opts.stage)) fail(`--stage must be estimate, bible, keyframe, draft or final, not "${opts.stage}".`)
if (opts.inline && !opts.out) fail('--inline writes large data URIs; use it with --out
.')
if (!existsSync(opts.film)) fail(`Shot list not found: ${opts.film}. Create it with scripts/build-shot-list.mjs.`)
let film
try {
film = JSON.parse(readFileSync(opts.film, 'utf8'))
} catch {
fail(`${opts.film} is not valid JSON.`)
}
const base = dirname(resolve(opts.film))
const rel = (p) => (isAbsolute(p) ? p : join(base, p))
const brief = film.brief ?? {}
const locks = film.locks ?? {}
const vertical = brief.aspect === '9:16'
const hi = (opts.resolution ?? brief.final_resolution) === '1080p'
// Multiples of 16, at or above the video size, so frames are only ever downscaled.
const SIZE = vertical ? (hi ? { width: 1152, height: 2048 } : { width: 864, height: 1536 }) : hi ? { width: 2048, height: 1152 } : { width: 1536, height: 864 }
const unfilled = (text) => typeof text === 'string' && text.includes('FILL:')
function fillTokens(text, where) {
if (typeof text !== 'string' || !text.trim()) fail(`${where} is empty. Write it in film.json first.`)
if (unfilled(text)) fail(`${where} still contains "FILL:". Write it in film.json first.`)
const tokens = { CAST: locks.cast, WARDROBE: locks.wardrobe, LOCATION: locks.location, PRODUCT: locks.product }
return text.replace(/\{\{(CAST|WARDROBE|LOCATION|PRODUCT)\}\}/g, (_, name) => {
const value = tokens[name]
if (!value || unfilled(value)) fail(`${where} uses {{${name}}} but locks.${name.toLowerCase()} is not written yet.`)
return value
})
}
function imageRef(path, what) {
const abs = rel(path)
if (!existsSync(abs)) fail(`${what} not found: ${abs}`)
const ext = extname(abs).toLowerCase()
if (!MIME[ext]) fail(`${what} must be a .jpg, .png or .webp image, not ${ext || 'a file without extension'}. If you have a video, export one still frame first.`)
if (!opts.inline) return `file:${abs}`
if (statSync(abs).size > 8 * 1024 * 1024) fail(`${what} is over 8 MB. Save it as a JPEG under 8 MB first.`)
return `data:${MIME[ext]};base64,${readFileSync(abs).toString('base64')}`
}
const requests = []
if (opts.stage === 'estimate') {
const shots = (film.shots ?? []).filter((s) => !s.skip)
if (!shots.length) fail('The shot list has no shots.')
const res = opts.resolution ?? brief.final_resolution ?? '720p'
if (!PER_SECOND.final[res]) fail(`--resolution ${res} is not available. Use 720p or 1080p.`)
const seconds = shots.reduce((a, s) => a + Number(s.duration || 0), 0)
const lines = [
['Cast bible (GPT Image 2)', brief.person === 'none' ? 0 : 0.25],
[`Keyframes: ${shots.length} stills (GPT Image 2 high)`, 0.25 + (shots.length - 1) * 0.28],
[`Drafts: ${seconds} s on Seedance 2.0 mini 480p`, seconds * PER_SECOND.draft['480p']],
[`Finals: ${seconds} s on Seedance 2.5 ${res}`, seconds * PER_SECOND.final[res]],
]
const base = lines.reduce((a, [, v]) => a + v, 0)
const reroll = res === '1080p' ? [6, 18] : [2.5, 7.5]
for (const [label, v] of lines) console.log(` ${label.padEnd(48)} $${v.toFixed(2)}`)
console.log(` ${'Without re-rolls'.padEnd(48)} $${base.toFixed(2)}`)
console.log(`\nQuote to the user: about $${Math.floor(base + reroll[0])}-${Math.ceil(base + reroll[1])} with the usual re-rolls (finals are $${lines[3][1].toFixed(2)} of it).`)
console.log('Get one explicit yes on this total before the first paid call, and ask again if it will be exceeded.')
process.exit(0)
}
const lookLine = `${locks.look ?? ''} Photorealistic, true-to-life anatomy and proportions. No text, no logos, no watermark.`.trim()
if (opts.stage === 'bible') {
if (brief.person === 'none') fail('This film has --person none, so there is no cast bible. Go straight to --stage keyframe.')
const sentence = (t) => (/[.!?]$/.test(t.trim()) ? t.trim() : `${t.trim()}.`)
const cast = sentence(fillTokens(locks.cast, 'locks.cast'))
const wardrobe = sentence(fillTokens(locks.wardrobe, 'locks.wardrobe'))
const sheet = `Photorealistic casting and wardrobe reference sheet for a brand film. One wide photograph showing the same person three times side by side, evenly spaced, full length, on a warm off-white seamless studio backdrop: left, a relaxed three-quarter front view; middle, exact side profile; right, back view. ${cast} Wardrobe identical in all three views: ${wardrobe} Soft even daylight, 50mm lens, natural unretouched skin texture, true-to-life proportions and hands. Tasteful editorial casting style. No text, no labels, no logos.`
const size = vertical ? { width: 1536, height: 864 } : SIZE // the sheet is always landscape so three full figures fit
if (brief.person === 'own-photo') {
if (!brief.person_photo) fail('brief.person is own-photo but brief.person_photo is empty.')
if (!brief.consent_quote || String(brief.consent_quote).trim().length < 3) fail('brief.consent_quote is empty. Ask the user whether the photo is of them or whether they hold written consent, and record their words with build-shot-list.mjs --consent-quote. Never fill it yourself.')
requests.push({ id: 'bible', model: MODELS.edit, input: { prompt: `Use the person in the reference photo: keep their face, hair, skin tone and build exactly as they are. ${sheet}`, image_urls: [imageRef(brief.person_photo, 'Person photo')], image_size: size, quality: 'high', num_images: 1, output_format: 'jpeg' }, approx_cost_usd: 0.28 })
} else {
requests.push({ id: 'bible', model: MODELS.image, input: { prompt: `${sheet} The person is fictional and must not resemble any real or famous person.`, image_size: size, quality: 'high', num_images: 1, output_format: 'jpeg' }, approx_cost_usd: 0.25 })
}
} else {
const shots = (film.shots ?? []).filter((s) => !s.skip)
let picked = shots
if (opts.shot && opts.shot !== 'all') {
const n = Number(opts.shot)
if (!Number.isInteger(n)) fail(`--shot takes a shot number or "all", not "${opts.shot}".`)
picked = shots.filter((s) => s.n === n)
if (!picked.length) fail(`There is no shot ${n} in the shot list.`)
}
for (const s of picked) {
const where = `shot ${s.n}`
if (opts.stage === 'keyframe') {
const refs = []
const lines = []
if (s.person_in_shot) {
if (!film.references?.cast_bible) fail(`${where} has a person but references.cast_bible is empty. Run --stage bible, approve it, and put its path in film.json.`)
refs.push(imageRef(film.references.cast_bible, 'Cast bible'))
lines.push(`The person is the same as in reference image ${refs.length}: same face, hair, skin tone, build and wardrobe.`)
if (!s.face_visible) lines.push('Their face is turned away from the camera, in profile shadow, in silhouette or too small to read.')
}
if (s.use_location_ref && film.references?.location) {
refs.push(imageRef(film.references.location, 'Location reference'))
lines.push(`The place matches reference image ${refs.length}: same architecture, materials and landscape.`)
}
if (s.product_in_shot) {
if (!film.references?.product) fail(`${where} shows the product but references.product is empty.`)
refs.push(imageRef(film.references.product, 'Product photo'))
lines.push(`The product is exactly the one in reference image ${refs.length}: same shape, colours, materials, label layout and text; do not re-letter or redesign it.`)
}
const prompt = `A still frame from a cinematic brand film. ${fillTokens(s.keyframe_prompt, `${where} keyframe_prompt`)} ${lines.join(' ')} ${s.person_in_shot ? 'No other people anywhere in the frame.' : ''} ${lookLine}`.replace(/\s+/g, ' ').trim()
const input = { prompt, image_size: SIZE, quality: 'high', num_images: 1, output_format: 'jpeg' }
if (refs.length) input.image_urls = refs
requests.push({ id: `${s.id}-keyframe`, model: refs.length ? MODELS.edit : MODELS.image, input, approx_cost_usd: refs.length ? 0.28 : 0.25 })
} else {
if (!s.keyframe) fail(`${where} has no approved keyframe yet. Generate it, look at it, and put its path in "keyframe".`)
if (s.person_in_shot && s.face_visible) console.error(`warning: ${where} has face_visible: true. Seedance on fal rejects start frames with a clearly visible realistic face (content_policy_violation). Expect a refusal; see references/character-consistency.md.`)
let motion = fillTokens(s.motion_prompt, `${where} motion_prompt`)
if (s.person_in_shot && !/no other people/i.test(motion)) motion += ' No other people appear.'
if (!/no cuts/i.test(motion)) motion += ' One continuous shot, no cuts.'
const duration = Number(s.duration)
if (!Number.isInteger(duration) || duration < 4 || duration > 15) fail(`${where} duration must be a whole number of seconds from 4 to 15 (Seedance), not "${s.duration}".`)
const stage = opts.stage
const res = stage === 'draft' ? '480p' : opts.resolution ?? brief.final_resolution ?? '720p'
if (!PER_SECOND[stage][res]) fail(`--resolution ${res} is not available for the ${stage} model. Use ${Object.keys(PER_SECOND[stage]).join(' or ')}.`)
const input = { prompt: motion, image_url: imageRef(s.keyframe, `${where} keyframe`), resolution: res, duration: String(duration), aspect_ratio: 'auto', generate_audio: stage === 'final' }
if (stage === 'final') Object.assign(input, { bitrate_mode: 'high', codec: 'H264' })
requests.push({ id: `${s.id}-${stage}`, model: MODELS[stage], input, approx_cost_usd: Number((PER_SECOND[stage][res] * duration).toFixed(3)) })
}
}
}
const total = requests.reduce((a, r) => a + r.approx_cost_usd, 0)
if (opts.out) {
mkdirSync(opts.out, { recursive: true })
for (const r of requests) writeFileSync(join(opts.out, `${r.id}.json`), JSON.stringify(r, null, 2))
console.log(`Wrote ${requests.length} request(s) to ${opts.out}. Approximate cost: $${total.toFixed(2)}.`)
} else {
console.log(JSON.stringify(requests, null, 2))
console.error(`${requests.length} request(s), approximate cost $${total.toFixed(2)}. Local images are shown as file: paths; use --out with --inline to embed them, or fal-run.mjs (which embeds them).`)
}