#!/usr/bin/env node // Checks a finished board: board.json is complete (no FILL left, captions and labels short enough, faces // hidden), every frame exists at the planned size and shape, board.png is the right size and was composed // from those frames, the prices on it match the dated price file, and (optionally) prices.md, the preview // and the film handoff file are in order. Then open the board and check it by eye (references/output-spec.md). // // Usage: // node scripts/check-board.mjs --board board.json --file board.png [--manifest board.png.json] // [--prices-md prices.md] [--preview preview.webp] [--handoff film.json] [--prices file.json] // // Node 18 or newer. No npm packages. Exit code 1 when a check fails. import { existsSync, readFileSync, statSync } from 'node:fs' import { dirname, isAbsolute, join, resolve } from 'node:path' import { DEFAULT_PRICES, loadPrices, priceAge } from './price-table.mjs' const FLAGS = new Set(['board', 'file', 'manifest', 'prices-md', 'preview', 'handoff', 'prices']) 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(', ')}`) const value = argv[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) opts[key] = value i++ } if (!opts.board || !opts.file) fail('Usage: node scripts/check-board.mjs --board board.json --file board.png [--prices-md prices.md] [--preview preview.webp] [--handoff film.json]') const readJson = (path, what) => { if (!existsSync(path)) fail(`${what} not found: ${path}`) try { return JSON.parse(readFileSync(path, 'utf8')) } catch { fail(`${what} is not valid JSON: ${path}`) } } /** Width and height from a PNG, JPEG or WebP header, without decoding the image. */ function imageSize(path) { const b = readFileSync(path) if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47) return { type: 'png', width: b.readUInt32BE(16), height: b.readUInt32BE(20) } if (b.length > 4 && b[0] === 0xff && b[1] === 0xd8) { let p = 2 while (p + 9 < b.length) { if (b[p] !== 0xff) { p++; continue } const marker = b[p + 1] if (marker === 0xff) { p++; continue } if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { p += 2; continue } const len = b.readUInt16BE(p + 2) if ((marker >= 0xc0 && marker <= 0xcf) && ![0xc4, 0xc8, 0xcc].includes(marker)) return { type: 'jpeg', width: b.readUInt16BE(p + 7), height: b.readUInt16BE(p + 5) } p += 2 + len } return null } if (b.length > 30 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP') { const chunk = b.toString('ascii', 12, 16) if (chunk === 'VP8 ') return { type: 'webp', width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff } if (chunk === 'VP8L') { const bits = b.readUInt32LE(21) return { type: 'webp', width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 } } if (chunk === 'VP8X') return { type: 'webp', width: 1 + b.readUIntLE(24, 3), height: 1 + b.readUIntLE(27, 3) } } return null } const issues = [] const warnings = [] const notes = [] const board = readJson(opts.board, 'Shot list') const base = dirname(resolve(opts.board)) const rel = (p) => (isAbsolute(p) ? p : join(base, p)) const gen = JSON.parse(readFileSync(new URL('../assets/generation-input.json', import.meta.url), 'utf8')) const brief = board.brief ?? {} if (board.schema !== 'film-storyboard-board/1') issues.push(`board.json schema is "${board.schema}", expected film-storyboard-board/1 (make it with scripts/build-shot-list.mjs).`) const unfilled = [] const walk = (v, path) => { if (typeof v === 'string' && v.includes('FILL:')) unfilled.push(path) else if (Array.isArray(v)) v.forEach((x, i) => walk(x, `${path}[${i}]`)) else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) walk(x, path ? `${path}.${k}` : k) } walk(board, '') if (unfilled.length) issues.push(`Still marked FILL: ${unfilled.join(', ')}.`) const shots = (board.shots ?? []).filter((s) => !s.skip) if (shots.length < 4 || shots.length > 9) issues.push(`The board has ${shots.length} shots; plan 4 to 6 (at most 9).`) shots.forEach((s, i) => { if (s.n !== i + 1) issues.push(`Shots are not numbered 1 to ${shots.length} in order (found ${s.n} at position ${i + 1}).`) }) const size = board.board?.frame_size ?? gen.frame.image_sizes[brief.aspect === '9:16' ? '9:16' : '16:9'] const wantRatio = brief.aspect === '9:16' ? 9 / 16 : 16 / 9 for (const s of shots) { const where = `Shot ${s.n}` if (typeof s.caption !== 'string' || !s.caption.trim()) issues.push(`${where} has no caption.`) else if (s.caption.length > 60) issues.push(`${where} caption is ${s.caption.length} characters (at most 60).`) if (typeof s.label !== 'string' || !s.label.trim() || s.label.length > 18) issues.push(`${where} label must be 1 to 18 characters.`) const d = Number(s.duration) if (!Number.isInteger(d) || d < 3 || d > 15) issues.push(`${where} duration must be a whole number of seconds from 3 to 15, not "${s.duration}".`) else if (d < 4) warnings.push(`${where} is ${d} s; Seedance makes clips of at least 4 s, so the price table bills it as 4 s there.`) if (s.person_in_shot && s.face_visible) warnings.push(`${where} plans a visible face. Seedance refuses such start frames; plan Kling or Veo for it (references/video-routes.md).`) if (typeof s.motion_prompt === 'string' && !s.motion_prompt.includes('FILL:') && !/no cuts/i.test(s.motion_prompt)) warnings.push(`${where} motion_prompt does not end with "One continuous shot, no cuts." (the film Skills add it, but say it).`) if (!s.frame) { issues.push(`${where} has no approved frame in "frame".`) continue } const path = rel(s.frame) if (!existsSync(path)) { issues.push(`${where} frame not found: ${path}`) continue } const dim = imageSize(path) if (!dim) issues.push(`${where} frame is not a PNG, JPEG or WebP image: ${path}`) else { if (Math.abs(dim.width / dim.height - wantRatio) / wantRatio > 0.01) issues.push(`${where} frame is ${dim.width} x ${dim.height}, not ${brief.aspect ?? '16:9'}.`) else if (dim.width !== size.width || dim.height !== size.height) warnings.push(`${where} frame is ${dim.width} x ${dim.height}, not the planned ${size.width} x ${size.height}.`) } } // The board image and its manifest. const shape = brief.board === '9:16' ? '9:16' : '16:9' const want = gen.defaults.board_sizes[shape] if (!existsSync(opts.file)) issues.push(`Board image not found: ${opts.file}. Run scripts/compose-board.mjs.`) else { const dim = imageSize(opts.file) if (!dim || dim.type !== 'png') issues.push(`${opts.file} is not a PNG.`) else if (dim.width !== want.width || dim.height !== want.height) issues.push(`${opts.file} is ${dim.width} x ${dim.height}; a ${shape} board is ${want.width} x ${want.height}.`) notes.push(`Board ${opts.file}: ${(statSync(opts.file).size / 1024 / 1024).toFixed(1)} MB.`) } const manifestPath = opts.manifest ?? `${opts.file}.json` let manifest = null if (!existsSync(manifestPath)) issues.push(`Board manifest not found: ${manifestPath}. Re-run compose-board.mjs (it writes one beside the PNG).`) else { manifest = readJson(manifestPath, 'Board manifest') if (manifest.placeholders?.length) issues.push(`The board still has placeholder panels for shot(s) ${manifest.placeholders.join(', ')}.`) const onBoard = (manifest.shots ?? []).map((x) => `${x.n}:${x.frame}`).join('|') const planned = shots.map((s) => `${s.n}:${s.frame}`).join('|') if (onBoard !== planned) issues.push('The board was composed from other frames than board.json lists now. Re-run compose-board.mjs.') } // Prices. let prices = null try { prices = loadPrices(opts.prices ?? DEFAULT_PRICES) const age = priceAge(prices) if (age > 90) issues.push(`The video prices were checked ${age} days ago (${prices.checked}). Re-read the fal model pages and update assets/video-prices.json.`) else if (age > 30) warnings.push(`The video prices were checked ${age} days ago (${prices.checked}); re-read them on fal before quoting.`) } catch (error) { issues.push(error.message) } if (manifest && prices) { if (!manifest.prices) warnings.push('The board was composed with --no-prices; deliver prices.md beside it.') else if (manifest.prices.checked !== prices.checked) issues.push(`The board shows prices checked ${manifest.prices.checked}, but the price file says ${prices.checked}. Re-run compose-board.mjs.`) } if (opts['prices-md']) { if (!existsSync(opts['prices-md'])) issues.push(`Price table not found: ${opts['prices-md']}. Run scripts/price-table.mjs --board ${opts.board} --out ${opts['prices-md']}.`) else { const md = readFileSync(opts['prices-md'], 'utf8') if (prices && !md.includes(`Prices checked ${prices.checked}`)) issues.push(`${opts['prices-md']} is not from the current price file (${prices.checked}). Re-run price-table.mjs.`) const total = shots.reduce((a, s) => a + Number(s.duration), 0) if (!md.includes(`${total} s of video`)) issues.push(`${opts['prices-md']} was made for other shot lengths than board.json has now (${total} s). Re-run price-table.mjs.`) } } // Preview for a web page. if (opts.preview) { if (!existsSync(opts.preview)) issues.push(`Preview not found: ${opts.preview}`) else { const dim = imageSize(opts.preview) const kb = statSync(opts.preview).size / 1024 if (!dim || dim.type !== 'webp') issues.push(`${opts.preview} is not a WebP.`) else if (Math.max(dim.width, dim.height) > 2048) issues.push(`${opts.preview} is ${dim.width} x ${dim.height}; keep previews at most 2048 px on the long edge.`) if (kb > 400) warnings.push(`${opts.preview} is ${Math.round(kb)} KB; aim for 150-400 KB (lower --preview quality or size).`) notes.push(`Preview ${opts.preview}: ${dim ? `${dim.width} x ${dim.height}, ` : ''}${Math.round(kb)} KB.`) } } // The film handoff file. if (opts.handoff) { const film = readJson(opts.handoff, 'Handoff file') if (!['cinematic-brand-film/1', 'destination-teaser/1'].includes(film.schema)) issues.push(`${opts.handoff} schema is "${film.schema}", not a film Skill shot list. Make it with scripts/handoff.mjs.`) const text = JSON.stringify(film) if (text.includes('FILL:')) issues.push(`${opts.handoff} still has FILL fields.`) if (/\{\{[A-Z]+\}\}/.test(text)) issues.push(`${opts.handoff} still has {{TOKENS}}; handoff.mjs writes the locks out in full.`) if ((film.shots ?? []).length !== shots.length) issues.push(`${opts.handoff} has ${(film.shots ?? []).length} shots; the board has ${shots.length}.`) } const total = shots.reduce((a, s) => a + Number(s.duration || 0), 0) console.log(`${opts.board}: ${shots.length} shots, ${total} s planned, ${brief.aspect ?? '16:9'} frames on a ${shape} board.`) for (const n of notes) console.log(`note: ${n}`) for (const w of warnings) console.log(`warning: ${w}`) if (issues.length) { for (const i of issues) console.log(`FAIL: ${i}`) process.exit(1) } console.log('PASS: technical checks. Now open the board at full size and go through references/output-spec.md by eye: same place and product in every frame, faces hidden, no extra people, no stray text, every label read letter by letter.')