#!/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(`${parts.join('')}`) 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.`)