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