#!/usr/bin/env node // Prints what it would cost to turn the board into a film on each video route, from the shot lengths in // board.json and the dated price file assets/video-prices.json. Nothing in the table is typed by hand. // It calls no API and spends nothing. // // Usage: // node scripts/price-table.mjs --board board.json [--resolution 480p|720p|1080p] [--all] // [--target cinematic-brand-film|destination-teaser] [--out prices.md] [--json prices.json] [--prices file.json] // node scripts/price-table.mjs --seconds 6,4,5,5,5,6 [--resolution 720p] [--people yes|no] (no board needed) // // --all lists every resolution a route offers instead of only the planned one. // compose-board.mjs, build-requests.mjs and check-board.mjs import the functions below. // Node 18 or newer. No npm packages. import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' export const DEFAULT_PRICES = fileURLToPath(new URL('../assets/video-prices.json', import.meta.url)) const TARGETS = ['cinematic-brand-film', 'destination-teaser'] const money = (n) => `$${n.toFixed(2)}` const rate = (n) => `$${n.toFixed(Math.max(2, (String(n).split('.')[1] ?? '').length))}` export function loadPrices(path = DEFAULT_PRICES) { if (!existsSync(path)) throw new Error(`Price file not found: ${path}`) let prices try { prices = JSON.parse(readFileSync(path, 'utf8')) } catch { throw new Error(`${path} is not valid JSON.`) } if (!/^\d{4}-\d{2}-\d{2}$/.test(prices.checked ?? '')) throw new Error(`${path} needs "checked": "YYYY-MM-DD", the day the prices were read on fal.`) if (!Array.isArray(prices.routes) || !prices.routes.length) throw new Error(`${path} has no routes.`) for (const r of prices.routes) { if (!r.id || !r.name || !r.model || !r.source) throw new Error(`Every route in ${path} needs id, name, model and source.`) if (!Array.isArray(r.rates) || !r.rates.length) throw new Error(`Route ${r.id} has no rates.`) for (const x of r.rates) if (!(Number(x.per_second) > 0)) throw new Error(`Route ${r.id} has a rate without a positive per_second.`) if (!['included', 'extra', 'none'].includes(r.audio)) throw new Error(`Route ${r.id}: audio must be included, extra or none.`) } return prices } /** Days between the price file date and today (UTC). */ export function priceAge(prices, today = new Date()) { return Math.floor((Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()) - Date.parse(`${prices.checked}T00:00:00Z`)) / 86400000) } /** Seconds a route bills for one planned shot, and a note when it differs from the plan. */ export function billedSeconds(route, duration) { const d = Number(duration) const rule = route.durations ?? { type: 'range', min: 1, max: 600 } if (rule.type === 'set') { const values = [...rule.values].sort((a, b) => a - b) const fit = values.find((v) => v >= d) if (fit === undefined) return { seconds: values.at(-1), note: `${route.name} makes at most ${values.at(-1)} s per clip; a ${d} s shot needs two clips or a trim.` } return { seconds: fit, note: fit === d ? '' : `${route.name} makes ${values.join(', ')} s clips, so a ${d} s shot is billed as ${fit} s.` } } if (rule.type === 'frames') { const wanted = Math.round(d * rule.fps) + 1 const frames = Math.min(rule.max_frames, Math.max(rule.min_frames, wanted)) const note = wanted > rule.max_frames ? `${route.name} makes at most ${rule.max_frames} frames (${(rule.max_frames / rule.fps).toFixed(1)} s) per clip; a ${d} s shot needs two clips.` : '' return { seconds: frames / rule.fps, frames, note } } if (d < rule.min) return { seconds: rule.min, note: `${route.name} makes clips of at least ${rule.min} s, so a ${d} s shot is billed as ${rule.min} s.` } if (d > rule.max) return { seconds: rule.max, note: `${route.name} makes at most ${rule.max} s per clip; a ${d} s shot needs two clips.` } return { seconds: d, note: '' } } function audioLabel(route, r) { if (route.audio === 'included') return 'included' if (route.audio === 'none') return 'none' return r.audio ? 'with audio' : 'silent' } /** The rates of one route for the planned resolution (or every resolution with all). */ function ratesFor(route, resolution, all) { const fixed = route.rates.filter((r) => r.resolution === null || r.resolution === undefined) if (fixed.length) return { rates: fixed } if (all) return { rates: route.rates } const match = route.rates.filter((r) => r.resolution === resolution) if (match.length) return { rates: match } return { rates: [], reason: `no ${resolution} (offers ${[...new Set(route.rates.map((r) => r.resolution))].join(', ')})` } } /** Which film Skill a board hands off to by default. */ export function recommendTarget(brief = {}) { return brief.kind === 'place' && Number(brief.length_s) === 20 ? 'destination-teaser' : 'cinematic-brand-film' } /** * The whole quote. plan: { name, seconds: [..], aspect, resolution, people (bool), target }. * Returns rows per route and rate, routes left out and why, notes, and the full film estimate on a Meleon film Skill. */ export function quote(plan, prices, { all = false } = {}) { const seconds = plan.seconds.map(Number) if (!seconds.length || seconds.some((s) => !Number.isFinite(s) || s <= 0)) throw new Error('Every shot needs a duration in seconds.') const resolution = plan.resolution ?? '720p' const rows = [] const skipped = [] const notes = new Set() for (const route of prices.routes) { const { rates, reason } = ratesFor(route, resolution, all) if (!rates.length) { skipped.push({ name: route.name, model: route.model, reason }) continue } const billed = seconds.map((d) => billedSeconds(route, d)) for (const b of billed) if (b.note) notes.add(b.note) if (route.note) notes.add(`${route.name}: ${route.note}`) const billedTotal = billed.reduce((a, b) => a + b.seconds, 0) for (const r of rates) { rows.push({ id: route.id, name: route.name, model: route.model, source: route.source, resolution: r.resolution ?? 'set by model', audio: audioLabel(route, r), per_second: r.per_second, billed_seconds: Number(billedTotal.toFixed(4)), cost: Number((billedTotal * r.per_second).toFixed(4)), faces: route.faces ?? '', used_by: route.used_by ?? '', }) } } const total = seconds.reduce((a, b) => a + b, 0) const cheapest = (pred) => rows.filter(pred).sort((a, b) => a.cost - b.cost)[0] return { name: plan.name ?? '', checked: prices.checked, source: prices.source, shots: seconds.length, seconds, total_seconds: total, aspect: plan.aspect ?? '16:9', resolution, rows, skipped, notes: [...notes], cheapest_silent: cheapest((r) => r.audio !== 'with audio'), cheapest_with_audio: cheapest((r) => r.audio === 'with audio' || r.audio === 'included'), film: filmEstimate(plan, prices, resolution), footer: footerItems(rows, prices.board_footer ?? []), } } /** The whole film on the Meleon film Skill the board hands off to: keyframes, drafts and finals, before re-rolls. */ export function filmEstimate(plan, prices, resolution) { const target = plan.target ?? 'cinematic-brand-film' const skill = prices.film_skills?.[target] if (!skill) return null const routeById = (id) => prices.routes.find((r) => r.id === id) const draftRoute = routeById(skill.draft.route) const finalRoute = routeById(skill.final.route) const draftRate = draftRoute?.rates.find((r) => r.resolution === skill.draft.resolution) const finalRate = finalRoute?.rates.find((r) => r.resolution === resolution) if (!draftRate || !finalRate) return { target, unavailable: `${finalRoute?.name ?? skill.final.route} has no ${resolution} rate in the price file.` } const n = plan.seconds.length const total = plan.seconds.reduce((a, b) => a + Number(b), 0) const lines = [] if (plan.people && skill.cast_bible_when_people) lines.push(['cast sheet', 'Cast reference sheet (GPT Image 2 high)', skill.cast_bible_when_people]) lines.push(['keyframes', `Keyframes: ${n} stills at high quality (GPT Image 2)`, skill.keyframe_first + (n - 1) * skill.keyframe_with_references]) lines.push(['drafts', `Drafts: ${total} s on ${draftRoute.name} ${skill.draft.resolution}`, total * draftRate.per_second]) lines.push(['finals', `Finals: ${total} s on ${finalRoute.name} ${resolution}`, total * finalRate.per_second]) const sum = lines.reduce((a, [, , v]) => a + v, 0) return { target, lines: lines.map(([short, label, cost]) => ({ short, label, cost: Number(cost.toFixed(4)) })), total: Number(sum.toFixed(4)) } } /** Short "route: cost" items for the board footer, in the order the price file names them. */ export function footerItems(rows, ids) { const items = [] for (const id of ids) { const mine = rows.filter((r) => r.id === id) if (!mine.length) continue const silent = mine.find((r) => r.audio === 'silent') const loud = mine.find((r) => r.audio === 'with audio') const text = silent && loud ? `${money(silent.cost)} silent, ${money(loud.cost)} with audio` : money(mine[0].cost) items.push({ name: mine[0].name, text }) } return items } export function toMarkdown(q) { const out = [] out.push(`# Video price table${q.name ? `: ${q.name}` : ''}`, '') out.push(`Plan: ${q.shots} shots, ${q.total_seconds} s of video (${q.seconds.join(', ')} s), ${q.aspect}, ${q.resolution}. One image-to-video clip per shot, from its approved keyframe.`, '') out.push(`Prices checked ${q.checked}: fal list prices from each model page (sources below). Billed to your own fal account.`, '') out.push('| Route | Model id | Size | Audio | Per second | Billed | Cost |', '| --- | --- | --- | --- | --- | --- | --- |') for (const r of q.rows) out.push(`| ${r.name} | \`${r.model}\` | ${r.resolution} | ${r.audio} | ${rate(r.per_second)} | ${Number(r.billed_seconds.toFixed(2))} s | ${money(r.cost)} |`) out.push('') if (q.cheapest_silent) out.push(`Cheapest silent: ${q.cheapest_silent.name}, ${money(q.cheapest_silent.cost)}.${q.cheapest_with_audio ? ` Cheapest with sound: ${q.cheapest_with_audio.name}, ${money(q.cheapest_with_audio.cost)}.` : ''}`, '') for (const s of q.skipped) out.push(`Not listed: ${s.name} (${s.reason}).`) if (q.skipped.length) out.push('') if (q.film?.lines) { out.push(`## The whole film on ${q.film.target} (before re-rolls)`, '', '| Step | Cost |', '| --- | --- |') for (const l of q.film.lines) out.push(`| ${l.label} | ${money(l.cost)} |`) out.push(`| **Total** | **${money(q.film.total)}** |`, '') out.push('The film Skill re-renders every keyframe at high quality and drafts every shot cheaply before the finals. Its own estimate stage adds an allowance for re-rolls.', '') } else if (q.film?.unavailable) out.push(`Whole-film estimate: ${q.film.unavailable}`, '') out.push('## Notes', '') out.push('- Seedance routes refuse start frames that show a clearly visible realistic face, so keep faces hidden in the keyframes. Kling and Veo accepted a synthetic face in testing (see each route below).') for (const n of q.notes) out.push(`- ${n}`) out.push('', '## Sources', '') const seen = new Set() for (const r of q.rows) { if (seen.has(r.id)) continue seen.add(r.id) out.push(`- ${r.name}: ${r.source}${r.faces ? `. Faces: ${r.faces}` : ''}${r.used_by ? ` ${r.used_by}` : ''}`) } return out.join('\n') + '\n' } export function toText(q) { const out = [`Video routes for ${q.shots} shots, ${q.total_seconds} s (${q.seconds.join(', ')} s), ${q.aspect}, ${q.resolution}. fal list prices checked ${q.checked}.`] for (const r of q.rows) out.push(` ${(r.resolution === 'set by model' ? r.name : `${r.name} ${r.resolution}`).padEnd(30)} ${r.audio.padEnd(11)} ${rate(r.per_second).padStart(8)}/s ${`${Number(r.billed_seconds.toFixed(2))} s`.padStart(8)} ${money(r.cost).padStart(8)}`) for (const s of q.skipped) out.push(` ${s.name.padEnd(30)} not listed: ${s.reason}`) if (q.film?.lines) out.push(`Whole film on ${q.film.target}, before re-rolls: ${money(q.film.total)} (${q.film.lines.map((l) => `${l.short} ${money(l.cost)}`).join(', ')}).`) return out.join('\n') } /** Plan from a board.json object. */ export function planFromBoard(board, { resolution, target } = {}) { const shots = (board.shots ?? []).filter((s) => !s.skip) if (!shots.length) throw new Error('The shot list has no shots.') const brief = board.brief ?? {} return { name: board.board?.title ?? brief.name ?? '', seconds: shots.map((s) => Number(s.duration)), aspect: brief.aspect ?? '16:9', resolution: resolution ?? brief.final_resolution ?? '720p', people: brief.people === 'fictional', target: target ?? recommendTarget(brief), } } // ---------- CLI ---------- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { const FLAGS = new Set(['board', 'seconds', 'aspect', 'resolution', 'all', 'target', 'people', 'prices', 'out', 'json']) const SWITCHES = new Set(['all']) const 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.seconds) fail('Usage: node scripts/price-table.mjs --board board.json [--resolution 720p] [--all] [--out prices.md] [--json prices.json]\n or: node scripts/price-table.mjs --seconds 6,4,5,5,5,6 [--resolution 720p]') if (opts.resolution && !['480p', '720p', '1080p'].includes(opts.resolution)) fail(`--resolution must be 480p, 720p or 1080p, not "${opts.resolution}".`) if (opts.target && !TARGETS.includes(opts.target)) fail(`--target must be ${TARGETS.join(' or ')}.`) if (opts.aspect && !['16:9', '9:16'].includes(opts.aspect)) fail('--aspect must be 16:9 or 9:16.') if (opts.people && !['yes', 'no'].includes(opts.people)) fail('--people must be yes or no.') try { const prices = loadPrices(opts.prices ?? DEFAULT_PRICES) let plan if (opts.board) { 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.`) } plan = planFromBoard(board, { resolution: opts.resolution, target: opts.target }) } else { const seconds = opts.seconds.split(',').map((s) => Number(s.trim())) if (seconds.some((s) => !Number.isInteger(s) || s < 1 || s > 30)) fail('--seconds takes whole seconds per shot, like 6,4,5,5,5,6.') plan = { name: '', seconds, aspect: opts.aspect ?? '16:9', resolution: opts.resolution ?? '720p', people: opts.people === 'yes', target: opts.target ?? 'cinematic-brand-film' } } const q = quote(plan, prices, { all: Boolean(opts.all) }) const age = priceAge(prices) console.log(toText(q)) if (age > 30) console.error(`warning: the prices were checked ${age} days ago (${prices.checked}). Re-read the fal model pages and update assets/video-prices.json before quoting them.`) if (opts.out) { writeFileSync(opts.out, toMarkdown(q)) console.log(`Wrote ${opts.out}.`) } if (opts.json) { writeFileSync(opts.json, JSON.stringify(q, null, 2) + '\n') console.log(`Wrote ${opts.json}.`) } console.log('This table is for information: nothing here is paid. The film Skill quotes its own total and asks before spending.') } catch (error) { fail(error.message) } }