#!/usr/bin/env node
// Builds the exact model requests for one stage of the teaser from teaser.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:
// people optional reference sheet of the fictional guests (when they appear in 2+ shots)
// keyframe one still per shot, with the place photo / approved aerial (and people sheet) 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 teaser's resolution
// estimate no requests: the expected total for the whole teaser 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 --teaser teaser.json --stage estimate|people|keyframe|draft|final
// [--shot 2|all] [--out requests/] [--inline] [--resolution 720p|1080p]
//
// --inline embeds local images as data URIs (needs --out), so no photo goes 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(['teaser', '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 }, final: { '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.teaser || !opts.stage) fail('Usage: node scripts/build-requests.mjs --teaser teaser.json --stage estimate|people|keyframe|draft|final [--shot N|all] [--out dir] [--inline]')
if (!['estimate', 'people', 'keyframe', 'draft', 'final'].includes(opts.stage)) fail(`--stage must be estimate, people, 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.teaser)) fail(`Shot list not found: ${opts.teaser}. Create it with scripts/build-shot-list.mjs.`)
let teaser
try {
teaser = JSON.parse(readFileSync(opts.teaser, 'utf8'))
} catch {
fail(`${opts.teaser} is not valid JSON.`)
}
const base = dirname(resolve(opts.teaser))
const rel = (p) => (isAbsolute(p) ? p : join(base, p))
const brief = teaser.brief ?? {}
const locks = teaser.locks ?? {}
const vertical = brief.aspect !== '16:9'
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 teaser.json first.`)
if (unfilled(text)) fail(`${where} still contains "FILL:". Write it in teaser.json first.`)
const tokens = { PLACE: locks.place, PEOPLE: locks.people }
return text.replace(/\{\{(PLACE|PEOPLE)\}\}/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'}. For 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 lookLine = `${locks.look ?? ''} Photorealistic, true-to-life scale and anatomy. No text, no signage, no logos, no watermark.`.trim()
const requests = []
if (opts.stage === 'estimate') {
const shots = (teaser.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 = [
[`Keyframes: ${shots.length} stills (GPT Image 2 high)`, 0.25 + (shots.length - 1) * 0.27],
[`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' ? [0, 8] : [0, 3.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])} including a re-roll or two (finals are $${lines[2][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)
}
if (opts.stage === 'people') {
if (brief.people === 'none' || !locks.people) fail('This teaser has no people. Go straight to --stage keyframe.')
const people = fillTokens(locks.people, 'locks.people').trim()
const sheet = `Photorealistic wardrobe reference photograph for a travel film: ${/[.!?]$/.test(people) ? people : `${people}.`} Shown full length, side by side, from behind and in three-quarter back view on a plain warm grey backdrop, soft daylight, 50mm lens, natural unretouched texture, true-to-life proportions and hands. The people are fictional adults and must not resemble any real or famous person. No text, no logos.`
requests.push({ id: 'people', model: MODELS.image, input: { prompt: sheet, image_size: { width: 1536, height: 864 }, quality: 'high', num_images: 1, output_format: 'jpeg' }, approx_cost_usd: 0.25 })
} else {
const shots = (teaser.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.use_place_ref && teaser.references?.place) {
refs.push(imageRef(teaser.references.place, 'Place reference'))
lines.push(brief.real_place
? `The place is exactly the real one in reference image ${refs.length}: same architecture, materials, windows and setting; do not add buildings, pools or features it does not have.`
: `The place matches reference image ${refs.length}: same architecture, materials and landscape.`)
for (const extra of teaser.references.extra_place_photos ?? []) {
refs.push(imageRef(extra, 'Extra place photo'))
lines.push(`Reference image ${refs.length} shows more of the same place.`)
}
}
if (s.person_in_shot) {
if (teaser.references?.people) {
refs.push(imageRef(teaser.references.people, 'People reference'))
lines.push(`The people are the same as in reference image ${refs.length}: same build, hair and clothes.`)
}
if (!s.face_visible) lines.push('Their faces are turned away from the camera, in profile shadow, or too small to read.')
}
const prompt = `A vertical-first still frame from a cinematic travel teaser. ${fillTokens(s.keyframe_prompt, `${where} keyframe_prompt`)} ${lines.join(' ')} ${s.person_in_shot ? 'No other people anywhere in the frame.' : 'Nobody in frame.'} ${lookLine}`
.replace('A vertical-first still', vertical ? 'A vertical 9:16 still' : 'A 16:9 still').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.27 : 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.`)
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 (!s.person_in_shot && !/nobody|no people/i.test(motion)) motion += ' Nobody in frame.'
if (!/no cuts/i.test(motion)) motion += ' One continuous shot, no cuts.'
const duration = Number(s.duration)
if (!Number.isInteger(duration) || duration < 4 || duration > 10) fail(`${where} duration must be a whole number of seconds from 4 to 10, 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).`)
}