#!/usr/bin/env node
// Lays the approved frames out on one board PNG, with the shot numbers, labels, captions, camera moves and
// the video prices drawn locally, so every word is exact. The image model never draws text on the board.
//
// Usage:
// node scripts/compose-board.mjs --board board.json --out board.png [--preview preview.webp]
// [--font Helvetica] [--font-file brand.ttf] [--no-prices] [--guides] [--placeholders]
// [--resolution 480p|720p|1080p] [--prices file.json]
//
// --font-file takes a .ttf; set --font to that font's family name so it is picked.
// --guides draws the 9:16 safe zones (top 12%, bottom 20%) on vertical frames.
// --placeholders draws an empty panel for any shot without a frame yet (a free plan board; check-board.mjs fails it).
// Writes board.png and board.png.json (layout, frames, fonts and the price snapshot) for check-board.mjs.
// Needs sharp: run `npm install sharp` in the Skill folder (Node 20.9 or newer).
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { DEFAULT_PRICES, loadPrices, planFromBoard, quote } from './price-table.mjs'
let sharp
try {
sharp = (await import('sharp')).default
} catch {
console.error('This script needs the sharp package. Run `npm install sharp` in the Skill folder (Node 20.9 or newer), then try again.')
process.exit(1)
}
const FLAGS = new Set(['board', 'out', 'preview', 'font', 'font-file', 'no-prices', 'guides', 'placeholders', 'resolution', 'prices'])
const SWITCHES = new Set(['no-prices', 'guides', 'placeholders'])
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.out) fail('Usage: node scripts/compose-board.mjs --board board.json --out board.png [--preview preview.webp] [--font-file brand.ttf --font "Family"] [--no-prices] [--guides]')
if (!/\.png$/i.test(opts.out)) fail('--out must end in .png (the board is a lossless PNG; use --preview for a small WebP).')
if (opts.preview && !/\.webp$/i.test(opts.preview)) fail('--preview must end in .webp.')
if (!existsSync(opts.board)) fail(`Shot list not found: ${opts.board}`)
let board
try {
board = JSON.parse(readFileSync(opts.board, 'utf8'))
} catch {
fail(`${opts.board} is not valid JSON.`)
}
const gen = JSON.parse(readFileSync(new URL('../assets/generation-input.json', import.meta.url), 'utf8'))
const base = dirname(resolve(opts.board))
const rel = (p) => (isAbsolute(p) ? p : join(base, p))
const brief = board.brief ?? {}
const meta = board.board ?? {}
const shots = (board.shots ?? []).filter((s) => !s.skip)
if (!shots.length) fail('The shot list has no shots.')
if (shots.length > 9) fail('A board holds at most 9 shots.')
const has = (t) => typeof t === 'string' && t.trim() && !t.includes('FILL:')
for (const s of shots) {
if (!has(s.caption)) fail(`Shot ${s.n} needs a caption (at most 60 characters) in board.json.`)
if (s.caption.length > 60) fail(`Shot ${s.n} caption is ${s.caption.length} characters; the board takes at most 60.`)
if (!has(s.label) || s.label.length > 18) fail(`Shot ${s.n} needs a label of at most 18 characters.`)
if (s.camera && s.camera.length > 40) fail(`Shot ${s.n} camera note is over 40 characters; shorten it.`)
if (!Number.isFinite(Number(s.duration))) fail(`Shot ${s.n} needs a duration.`)
if (!s.frame && !opts.placeholders) fail(`Shot ${s.n} has no approved frame yet. Put its path in "frame", or pass --placeholders for a plan board.`)
if (s.frame && !existsSync(rel(s.frame))) fail(`Shot ${s.n} frame not found: ${rel(s.frame)}`)
}
const title = has(meta.title) ? meta.title : brief.name
const subtitle = has(meta.subtitle) ? meta.subtitle : ''
if (!has(title)) fail('board.title is empty.')
if (subtitle.length > 110) fail('board.subtitle is over 110 characters. Shorten it for the board header.')
const shape = brief.board === '9:16' ? '9:16' : '16:9'
const { width: W, height: H } = gen.defaults.board_sizes[shape]
const frameAspect = brief.aspect === '9:16' ? 9 / 16 : 16 / 9
const S = Math.min(W, H)
const C = { bg: '#111111', panel: '#1c1c1c', line: '#2e2e2e', ink: '#f2ede4', muted: '#9d978d', accent: '#e8a54b', dark: '#1a120a' }
const margin = Math.round(S * 0.042)
const gap = Math.round(S * 0.026)
const fontFile = opts['font-file'] ? resolve(opts['font-file']) : undefined
if (fontFile && !existsSync(fontFile)) fail(`Font file not found: ${fontFile}`)
const family = opts.font ?? 'Helvetica'
if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,60}$/.test(family)) fail('--font must be a plain font family name.')
const xml = (s) => String(s).replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' })[c])
const cache = new Map()
async function text(str, { size, bold = false, color = C.ink, spacing = 0 }) {
const key = JSON.stringify([str, size, bold, color, spacing])
if (cache.has(key)) return cache.get(key)
const markup = `${xml(str)}`
const options = { text: markup, font: `${family}${bold ? ' Bold' : ''} ${Math.round(size)}`, rgba: true, dpi: 72 }
if (fontFile) options.fontfile = fontFile
const { data, info } = await sharp({ text: options }).png().toBuffer({ resolveWithObject: true })
const out = { input: data, width: info.width, height: info.height }
cache.set(key, out)
return out
}
// Fewest lines (at most maxLines) that fit maxWidth, or null.
async function wrap(str, style, maxWidth, maxLines) {
const words = str.split(/\s+/).filter(Boolean)
const lines = []
let cur = ''
for (const w of words) {
const next = cur ? `${cur} ${w}` : w
if (cur && (await text(next, style)).width > maxWidth) { lines.push(cur); cur = w } else cur = next
}
if (cur) lines.push(cur)
if (lines.length > maxLines) return null
for (const l of lines) if ((await text(l, style)).width > maxWidth) return null
return lines
}
async function fit(str, style, maxWidth, maxLines, minScale = 0.78) {
for (let k = 1; k >= minScale; k -= 0.04) {
const st = { ...style, size: style.size * k }
const lines = await wrap(str, st, maxWidth, maxLines)
if (lines) return { lines, style: st }
}
return null
}
// ---------- prices ----------
let q = null
if (!opts['no-prices']) {
try {
q = quote(planFromBoard(board, { resolution: opts.resolution }), loadPrices(opts.prices ?? DEFAULT_PRICES))
} catch (error) {
fail(`Could not build the price line: ${error.message} (or pass --no-prices).`)
}
}
// ---------- header ----------
const layers = []
const shapes = []
// Drawn over the frames: the safe-zone guides.
const overlay = []
const vertical = W < H
const seconds = shots.reduce((a, s) => a + Number(s.duration), 0)
const metaLine = `${shots.length} shots · ${seconds} s · ${brief.aspect ?? '16:9'} film · ${opts.resolution ?? brief.final_resolution ?? '720p'}`
const dateLine = `Storyboard, ${brief.created ?? new Date().toISOString().slice(0, 10)}`
const metaStyle = { size: S * 0.016, color: C.muted, spacing: 0.5 }
const metaA = await text(metaLine, metaStyle)
const metaB = await text(dateLine, metaStyle)
const metaW = Math.max(metaA.width, metaB.width)
const headW = vertical ? W - 2 * margin : W - 2 * margin - metaW - gap * 2
const t = await fit(title, { size: S * 0.038, bold: true }, headW, 1, 0.6)
if (!t) fail('board.title does not fit on one line. Shorten it.')
const sub = subtitle ? await fit(subtitle, { size: S * 0.019, color: C.ink }, headW, vertical ? 2 : 1, 0.7) : { lines: [], style: { size: S * 0.019, color: C.ink } }
if (!sub) fail('board.subtitle does not fit the header. Shorten it.')
let y = margin
const titleImg = await text(t.lines[0], t.style)
layers.push({ input: titleImg.input, left: margin, top: y })
y += titleImg.height + Math.round(S * 0.004)
for (const l of sub.lines) {
const img = await text(l, sub.style)
layers.push({ input: img.input, left: margin, top: y })
y += img.height
}
if (vertical) {
y += Math.round(S * 0.008)
layers.push({ input: metaA.input, left: margin, top: y })
y += metaA.height + Math.round(S * 0.004)
layers.push({ input: metaB.input, left: margin, top: y })
y += metaB.height
} else {
layers.push({ input: metaA.input, left: W - margin - metaA.width, top: margin + Math.round(S * 0.008) })
layers.push({ input: metaB.input, left: W - margin - metaB.width, top: margin + Math.round(S * 0.012) + metaA.height })
}
const headerBottom = y + Math.round(S * 0.014)
shapes.push(``)
// ---------- footer (prices) ----------
const footer = []
if (q) {
const style = { size: S * 0.0145, color: C.ink }
const mutedStyle = { size: S * 0.013, color: C.muted }
const lead = `To film it (${q.total_seconds} s, ${q.resolution}, fal prices checked ${q.checked}):`
const items = q.footer.map((i) => `${i.name} ${i.text}`)
const maxW = W - 2 * margin
const rows = []
let cur = lead
for (const item of items) {
const next = `${cur}${cur === lead ? ' ' : ' · '}${item}`
if ((await text(next, style)).width > maxW && cur !== lead) { rows.push(cur); cur = item } else cur = next
}
rows.push(cur)
for (const r of rows) if ((await text(r, style)).width > maxW) fail('The price line does not fit the board. Use --no-prices and deliver prices.md beside it.')
for (const r of rows) footer.push(await text(r, style))
if (q.film?.lines) {
const label = { 'cinematic-brand-film': 'Cinematic brand film', 'destination-teaser': 'Destination teaser' }[q.film.target] ?? q.film.target
const whole = await text(`Whole film on the ${label} Skill (high keyframes, drafts, Seedance finals): about $${q.film.total.toFixed(2)} before re-rolls. Every route and source: prices.md.`, mutedStyle)
if (whole.width <= maxW) footer.push(whole)
}
}
const footerGap = Math.round(S * 0.005)
const footerH = footer.length ? footer.reduce((a, f) => a + f.height + footerGap, 0) + Math.round(S * 0.02) : 0
// ---------- grid ----------
const lab = { size: S * 0.0165, bold: true, color: C.ink, spacing: 1.2 }
const capStyle = { size: S * 0.0168, color: C.ink }
const camStyle = { size: S * 0.0138, color: C.muted }
const lineH = async (st) => (await text('Hg', st)).height
const badgeH = Math.round((await lineH(lab)) * 1.18)
const capAdvance = (st) => lineH(st).then((h) => Math.round(h * 1.12))
const labelH = Math.round(S * 0.012) + badgeH + Math.round(S * 0.009) + 2 * (await capAdvance(capStyle)) + Math.round(S * 0.006) + (await lineH(camStyle)) + Math.round(S * 0.01)
const areaTop = headerBottom + Math.round(S * 0.028)
const areaBottom = H - margin - footerH
const n = shots.length
let best = null
for (let cols = 1; cols <= n; cols++) {
const rows = Math.ceil(n / cols)
const fw = (W - 2 * margin - (cols - 1) * gap) / cols
const fh = (areaBottom - areaTop - (rows - 1) * gap - rows * labelH) / rows
const w = Math.floor(Math.min(fw, fh * frameAspect))
if (w > 0 && (!best || w > best.w)) best = { cols, rows, w, h: Math.floor(w / frameAspect) }
}
if (!best || best.w < 160) fail('The frames would be too small on this board. Use fewer shots or the other board shape (--board in build-shot-list.mjs).')
const { cols, rows, w: fw, h: fh } = best
const gridW = cols * fw + (cols - 1) * gap
const gridH = rows * (fh + labelH) + (rows - 1) * gap
const left0 = Math.round((W - gridW) / 2)
const top0 = Math.round(areaTop + (areaBottom - areaTop - gridH) / 2)
const used = []
for (const [i, s] of shots.entries()) {
const col = i % cols
const row = Math.floor(i / cols)
const x = left0 + col * (fw + gap)
const fy = top0 + row * (fh + labelH + gap)
if (s.frame) {
const src = rel(s.frame)
const info = await sharp(src).metadata().catch(() => fail(`Cannot read the frame for shot ${s.n}: ${src}`))
const ratio = info.width / info.height
if (Math.abs(ratio - frameAspect) / frameAspect > 0.02) console.warn(`warning: shot ${s.n} frame is ${info.width} x ${info.height}, not ${brief.aspect}; it is centre-cropped on the board.`)
const img = await sharp(src).resize(fw, fh, { fit: 'cover' }).png().toBuffer()
layers.push({ input: img, left: x, top: fy })
used.push({ n: s.n, frame: s.frame, width: info.width, height: info.height })
} else {
shapes.push(``)
const ph = await text('Frame not made yet', { size: S * 0.015, color: C.muted })
layers.push({ input: ph.input, left: Math.round(x + (fw - ph.width) / 2), top: Math.round(fy + (fh - ph.height) / 2) })
used.push({ n: s.n, frame: '', width: 0, height: 0 })
}
shapes.push(``)
if (opts.guides && frameAspect < 1) {
// The top 12% and bottom 20% of a vertical frame sit under the app's name, caption and buttons.
for (const p of [0.12, 0.8]) {
const gy = Math.round(fy + fh * p)
overlay.push(``)
}
}
// Label row: number badge, slot label, seconds.
let ly = fy + fh + Math.round(S * 0.012)
const num = await text(String(s.n).padStart(2, '0'), { size: lab.size, bold: true, color: C.dark, spacing: 0.5 })
const padX = Math.round(lab.size * 0.45)
const bh = Math.max(badgeH, Math.round(num.height * 1.18))
shapes.push(``)
layers.push({ input: num.input, left: x + padX, top: ly + Math.round((bh - num.height) / 2) })
const secs = await text(`${Number(s.duration)} s`, { size: lab.size, color: C.muted })
const roomForLabel = fw - (num.width + 2 * padX) - Math.round(lab.size * 0.6) - secs.width - Math.round(lab.size * 0.6)
const lf = await fit(s.label.toUpperCase(), lab, roomForLabel, 1, 0.7)
if (!lf) fail(`Shot ${s.n} label "${s.label}" does not fit beside its number. Shorten it.`)
const labImg = await text(lf.lines[0], lf.style)
layers.push({ input: labImg.input, left: x + num.width + 2 * padX + Math.round(lab.size * 0.6), top: ly + Math.round((bh - labImg.height) / 2) })
layers.push({ input: secs.input, left: x + fw - secs.width, top: ly + Math.round((bh - secs.height) / 2) })
ly += bh + Math.round(S * 0.009)
const cf = await fit(s.caption, capStyle, fw, 2, 0.75)
if (!cf) fail(`Shot ${s.n} caption does not fit in two lines under its frame. Shorten it.`)
const advance = await capAdvance(cf.style)
for (const l of cf.lines) {
const img = await text(l, cf.style)
layers.push({ input: img.input, left: x, top: ly })
ly += advance
}
// Two caption lines are always reserved, so the camera notes line up across a row.
if (cf.lines.length === 1) ly += advance
ly += Math.round(S * 0.006)
const camera = s.camera || s.suggested_camera || ''
if (camera) {
const kf = await fit(`Camera: ${camera}`, camStyle, fw, 1, 0.7)
if (!kf) fail(`Shot ${s.n} camera note does not fit under its frame. Shorten it.`)
const img = await text(kf.lines[0], kf.style)
layers.push({ input: img.input, left: x, top: ly })
}
}
// ---------- footer placement ----------
if (footer.length) {
let fy = H - margin - footerH + Math.round(S * 0.02)
shapes.push(``)
for (const f of footer) {
layers.push({ input: f.input, left: margin, top: fy })
fy += f.height + footerGap
}
}
const svg = (parts) => Buffer.from(``)
const stack = [{ input: svg(shapes), left: 0, top: 0 }, ...layers]
if (overlay.length) stack.push({ input: svg(overlay), left: 0, top: 0 })
const png = await sharp({ create: { width: W, height: H, channels: 3, background: C.bg } }).composite(stack).png({ compressionLevel: 9 }).toBuffer()
writeFileSync(opts.out, png)
let preview = null
if (opts.preview) {
const maxW = gen.defaults.preview_max_width ?? 2048
const pw = vertical ? Math.round((maxW * W) / H) : maxW
const info = await sharp(png).resize({ width: pw }).webp({ quality: 85 }).toFile(opts.preview)
preview = { file: opts.preview, width: info.width, height: info.height, bytes: info.size }
}
const manifest = {
file: opts.out,
width: W,
height: H,
board: shape,
frame_aspect: brief.aspect ?? '16:9',
grid: { cols, rows, frame_width: fw, frame_height: fh },
shots: used,
placeholders: used.filter((u) => !u.frame).map((u) => u.n),
font: fontFile ? { family, file: fontFile } : { family, note: 'system font resolved by fontconfig; a missing family falls back' },
prices: q ? { checked: q.checked, resolution: q.resolution, total_seconds: q.total_seconds, footer: q.footer, film_total: q.film?.total ?? null } : null,
preview,
created: new Date().toISOString(),
}
writeFileSync(`${opts.out}.json`, JSON.stringify(manifest, null, 2) + '\n')
console.log(`${opts.out} ${W}x${H}, ${n} frames in ${cols} x ${rows} at ${fw}x${fh}${q ? `, prices checked ${q.checked}` : ''}.`)
console.log(`Font: ${fontFile ?? family} (system fonts resolve through fontconfig; pass --font-file and --font for exact type).`)
if (preview) console.log(`${preview.file} ${preview.width}x${preview.height}, ${Math.round(preview.bytes / 1024)} KB.`)
if (manifest.placeholders.length) console.log(`Placeholders for shot(s) ${manifest.placeholders.join(', ')}: this is a plan board, not the delivered board.`)
console.log(`Next: node scripts/check-board.mjs --board ${opts.board} --file ${opts.out}, then open the board and read every label.`)