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