#!/usr/bin/env node
// Builds the exact image requests for the board's keyframes from board.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 photos into any GPT-Image-class editor.
//
// Stages:
// estimate no requests: the board's cost at low and medium quality, plus the video price table
// (scripts/price-table.mjs), to quote to the user and get one yes on before the first paid call
// keyframe one still per shot (GPT Image 2, low or medium quality), with the place photo or the
// approved establishing frame, and the product photo, as references
// draft, final not part of this Skill: the board makes no video. Hand it to a film Skill (scripts/handoff.mjs).
//
// Usage:
// node scripts/build-requests.mjs --board board.json --stage estimate|keyframe
// [--shot 1|all|rest|2,3,4] [--quality low|medium] [--out requests/] [--inline]
//
// --shot rest means every shot after the first (use it once shot 1 is approved as the place reference).
// --inline embeds local images as data URIs (needs --out), so no photo goes on a public URL.
// After a rough pass, put each approved low-quality frame in that shot's "rough_frame": at medium the
// request carries it as one more reference, so the clean frame keeps the composition the user agreed.
// 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'
import { DEFAULT_PRICES, loadPrices, planFromBoard, priceAge, quote, toText } from './price-table.mjs'
const FLAGS = new Set(['board', 'stage', 'shot', 'out', 'inline', 'quality', 'prices'])
const SWITCHES = new Set(['inline'])
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.board || !opts.stage) fail('Usage: node scripts/build-requests.mjs --board board.json --stage estimate|keyframe [--shot N|all|rest] [--quality low|medium] [--out dir] [--inline]')
if (opts.stage === 'draft' || opts.stage === 'final') fail('This Skill makes no video. Turn the board into a film Skill shot list with scripts/handoff.mjs, then run that Skill\'s --stage draft and --stage final (it quotes its own total first).')
if (!['estimate', 'keyframe'].includes(opts.stage)) fail(`--stage must be estimate or keyframe, not "${opts.stage}".`)
if (opts.inline && !opts.out) fail('--inline writes large data URIs; use it with --out
.')
if (!existsSync(opts.board)) fail(`Shot list not found: ${opts.board}. Create it with scripts/build-shot-list.mjs.`)
let board
try {
board = JSON.parse(readFileSync(opts.board, 'utf8'))
} catch {
fail(`${opts.board} is not valid JSON.`)
}
let gen
try {
gen = JSON.parse(readFileSync(new URL('../assets/generation-input.json', import.meta.url), 'utf8'))
} catch {
fail('assets/generation-input.json is missing or not valid JSON. Re-install the Skill folder.')
}
const base = dirname(resolve(opts.board))
const rel = (p) => (isAbsolute(p) ? p : join(base, p))
const brief = board.brief ?? {}
const locks = board.locks ?? {}
const refsIn = board.references ?? {}
const quality = opts.quality ?? brief.quality ?? gen.frame.input.quality ?? 'medium'
if (!['low', 'medium', 'high'].includes(quality)) fail(`--quality must be low or medium, not "${quality}".`)
if (quality === 'high') console.error('warning: high quality costs about four times medium. The film Skill re-renders every keyframe at high quality anyway, so the board only needs low or medium.')
const aspect = brief.aspect === '9:16' ? '9:16' : '16:9'
const SIZE = board.board?.frame_size ?? gen.frame.image_sizes[aspect]
if (!Number.isInteger(SIZE.width) || !Number.isInteger(SIZE.height) || SIZE.width % 16 || SIZE.height % 16) fail('board.frame_size must be whole multiples of 16 (1280 x 720 or 720 x 1280).')
const shots = (board.shots ?? []).filter((s) => !s.skip)
if (!shots.length) fail('The shot list has no shots.')
const per1024 = gen.frame.approx_cost.per_1024_square
const perRef = gen.frame.approx_cost.per_reference_image
const frameCost = (q, refs) => (per1024[q] * SIZE.width * SIZE.height) / (1024 * 1024) + refs * perRef
/** The reference images one shot will carry, in order, before any file is read. */
function plannedRefs(s, afterRough = false) {
const out = []
if (s.use_place_ref) {
if (refsIn.place) out.push(['place', refsIn.place])
else if (s.n !== 1) out.push(['place', '(the approved establishing frame)'])
for (const extra of refsIn.extra_place_photos ?? []) out.push(['extra', extra])
}
if (s.product_in_shot && refsIn.product) out.push(['product', refsIn.product])
const max = gen.frame.max_references ?? 3
if (afterRough && out.length < max) out.push(['rough', s.rough_frame || '(the approved rough frame)'])
return out.slice(0, max)
}
if (opts.stage === 'estimate') {
const refCounts = shots.map((s) => plannedRefs(s).length)
const roughCounts = shots.map((s) => plannedRefs(s, true).length)
const total = (q, counts) => shots.reduce((a, s, i) => a + frameCost(q, counts[i]), 0)
const low = total('low', refCounts)
const chosen = total(quality, refCounts)
const afterRough = total(quality, roughCounts)
const reroll = frameCost(quality, 2)
console.log(`Board: ${shots.length} frames at ${SIZE.width} x ${SIZE.height} (GPT Image 2), ${refCounts.reduce((a, b) => a + b, 0)} reference images in all.`)
console.log(` ${'Rough board (quality low, optional)'.padEnd(52)} $${low.toFixed(2)}`)
console.log(` ${`Clean board (quality ${quality})`.padEnd(52)} $${chosen.toFixed(2)}`)
console.log(` ${`Clean board after a rough pass (+1 ref a frame)`.padEnd(52)} $${afterRough.toFixed(2)}`)
console.log(` ${'A re-roll of one frame (about)'.padEnd(52)} $${reroll.toFixed(2)}`)
const withRough = low + afterRough
const top = withRough + 2 * reroll
console.log(`\nQuote to the user: about $${chosen.toFixed(2)} for the board, or about $${withRough.toFixed(2)} with a rough pass first, up to about $${top.toFixed(2)} with two re-rolls. Nothing else in this Skill is paid.`)
console.log('Get one explicit yes on this total before the first paid call, and ask again if it will be exceeded.\n')
try {
const prices = loadPrices(opts.prices ?? DEFAULT_PRICES)
const q = quote(planFromBoard(board), prices)
console.log(toText(q))
const age = priceAge(prices)
if (age > 30) console.error(`warning: the video prices were checked ${age} days ago (${prices.checked}). Re-read the fal model pages before quoting them.`)
console.log('The video prices are for information only: this Skill makes no video. Write them out with scripts/price-table.mjs --board board.json --out prices.md.')
} catch (error) {
console.error(`Video price table skipped: ${error.message}`)
}
process.exit(0)
}
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 board.json first.`)
if (unfilled(text)) fail(`${where} still contains "FILL:". Write it in board.json first.`)
const tokens = { PLACE: locks.place, PRODUCT: locks.product, PEOPLE: locks.people, WARDROBE: locks.wardrobe }
return text.replace(/\{\{(PLACE|PRODUCT|PEOPLE|WARDROBE)\}\}/g, (_, name) => {
const value = tokens[name]
if (!value || unfilled(value)) fail(`${where} uses {{${name}}} but locks.${name.toLowerCase()} is not written yet.`)
return value.trim().replace(/[.]$/, '')
})
}
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')}`
}
let picked = shots
if (opts.shot && opts.shot !== 'all') {
if (opts.shot === 'rest') picked = shots.filter((s) => s.n !== 1)
else {
const wanted = opts.shot.split(',').map((x) => Number(x.trim()))
if (wanted.some((n) => !Number.isInteger(n))) fail(`--shot takes a shot number, a list like 2,3,4, "rest" or "all", not "${opts.shot}".`)
picked = wanted.map((n) => shots.find((s) => s.n === n) ?? fail(`There is no shot ${n} in the shot list.`))
}
}
const vertical = aspect === '9:16'
const requests = []
for (const s of picked) {
const where = `shot ${s.n}`
const body = fillTokens(s.keyframe_prompt, `${where} keyframe_prompt`)
if (/reference image\s*\d/i.test(body)) console.error(`warning: ${where} keyframe_prompt numbers a reference image. The script adds the reference lines itself, and the film Skills number them differently; say "the place" or "the product" instead.`)
if (s.person_in_shot && s.face_visible) console.error(`warning: ${where} has face_visible: true. Seedance, the default film route, refuses start frames with a clearly visible realistic face; plan this shot for Kling or Veo, or hide the face.`)
const refs = []
const lines = []
if (s.use_place_ref) {
if (refsIn.place) {
refs.push(imageRef(refsIn.place, 'Place reference'))
// An aerial establishing frame as the only place reference invites the model to paint the building
// into its own windows (seen in the OSTRA sample), so every place line says what windows show.
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, rooms or features it does not have. Seen from inside, its windows show the surroundings, never the building itself.`
: `The place matches reference image ${refs.length}: same architecture, materials and landscape. Seen from inside, its windows show the surroundings, never the building itself.`)
for (const extra of refsIn.extra_place_photos ?? []) {
if (refs.length >= 2) break
refs.push(imageRef(extra, 'Extra place photo'))
lines.push(`Reference image ${refs.length} shows more of the same place.`)
}
} else if (s.n !== 1) {
console.error(`warning: ${where} has use_place_ref but references.place is empty. Approve shot 1 first and put its path in references.place, so every frame shows the same place.`)
}
}
if (s.product_in_shot) {
if (refsIn.product) {
refs.push(imageRef(refsIn.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.`)
} else if (!locks.product || unfilled(locks.product)) {
fail(`${where} shows the product, but there is no product photo and locks.product is not written. Add --product-photo or write locks.product.`)
}
}
if (s.rough_frame && quality !== 'low') {
if (refs.length >= (gen.frame.max_references ?? 3)) {
console.error(`warning: ${where} already carries ${refs.length} references, so its rough_frame is left out; describe the agreed framing in the prompt instead.`)
} else {
refs.push(imageRef(s.rough_frame, 'Rough frame'))
lines.push(`Reference image ${refs.length} is the approved rough draft of this frame: keep its composition, camera angle, framing and the place of every element, and render it cleanly in full detail.`)
}
}
if (refs.length > (gen.frame.max_references ?? 3)) fail(`${where} would carry ${refs.length} references; the most is ${gen.frame.max_references ?? 3}.`)
const people = s.person_in_shot
? `Any person is a fictional adult who must not resemble any real or famous person. ${s.face_visible ? '' : 'Faces are turned away from the camera, in silhouette or too small to read. '}No other people anywhere in the frame.`
: 'Nobody in frame.'
const textRule = s.product_in_shot && refsIn.product
? "No text, signage or logos anywhere except the product's own label, exactly as in its reference. No watermark."
: 'No text, no signage, no logos, no watermark.'
const prompt = `A ${vertical ? 'vertical 9:16' : '16:9'} still frame from a cinematic short film. ${body} ${lines.join(' ')} ${people} ${locks.look ?? ''} Photorealistic, true-to-life scale and anatomy. ${textRule}`
.replace(/\s+/g, ' ')
.trim()
const input = { prompt, image_size: SIZE, quality, num_images: 1, output_format: 'jpeg' }
if (refs.length) input.image_urls = refs
requests.push({ id: `${s.id}-frame-${quality}`, model: refs.length ? gen.frame.model_with_references : gen.frame.model, input, approx_cost_usd: Number(frameCost(quality, refs.length).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).`)
}