#!/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).`) }