#!/usr/bin/env node // Turns an approved board into the shot list a Meleon film Skill reads, so the board goes straight into a // film run: film.json for cinematic-brand-film, or teaser.json for destination-teaser. The shots keep the // same fields; the locks are written out in full (no {{TOKENS}}), paths are made relative to the new file, // and the board frame of each shot is kept as "board_frame" so the film's keyframes can be compared with it. // It calls no API and spends nothing. // // Usage: // node scripts/handoff.mjs --board board.json --to cinematic-brand-film --out film.json // [--grade teal-amber|warm-kodak|cool-fuji|natural] [--title "On-screen title"] [--end-card "End card text"] [--use-board-frames] // node scripts/handoff.mjs --board board.json --to destination-teaser --out teaser.json // [--season-mood "winter, blue hour, quiet"] [--use-board-frames] // // --use-board-frames puts each board frame in "keyframe", so the film Skill can run a cheap 480p draft // straight away. Re-render the keyframes at high quality before paying for the finals. // Node 18 or newer. No npm packages. import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve } from 'node:path' const FLAGS = new Set(['board', 'to', 'out', 'grade', 'title', 'end-card', 'season-mood', 'use-board-frames', 'force']) const SWITCHES = new Set(['use-board-frames', 'force']) const FILM_GRADES = ['teal-amber', 'warm-kodak', 'cool-fuji', 'natural'] const TEASER_GRADES = ['blue-hour', 'teal-amber', 'warm-kodak', 'cool-fuji', 'natural'] 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.to || !opts.out) fail('Usage: node scripts/handoff.mjs --board board.json --to cinematic-brand-film|destination-teaser --out film.json [--use-board-frames]') if (!['cinematic-brand-film', 'destination-teaser'].includes(opts.to)) fail(`--to must be cinematic-brand-film or destination-teaser, not "${opts.to}".`) if (!existsSync(opts.board)) fail(`Shot list not found: ${opts.board}`) if (existsSync(opts.out) && !opts.force) fail(`${opts.out} already exists. Pick another --out, or pass --force to overwrite it.`) let board try { board = JSON.parse(readFileSync(opts.board, 'utf8')) } catch { fail(`${opts.board} is not valid JSON.`) } const boardDir = dirname(resolve(opts.board)) const outDir = dirname(resolve(opts.out)) const abs = (p) => (isAbsolute(p) ? p : join(boardDir, p)) const fromOut = (p) => (p ? relative(outDir, abs(p)) || '.' : '') const brief = board.brief ?? {} const locks = board.locks ?? {} const refs = board.references ?? {} const shots = (board.shots ?? []).filter((s) => !s.skip) if (!shots.length) fail('The shot list has no shots.') const trimStop = (t) => t.trim().replace(/[.]$/, '') const unfilled = (t) => typeof t === 'string' && t.includes('FILL:') for (const [k, v] of Object.entries(locks)) if (unfilled(v)) fail(`locks.${k} still says FILL. Finish the board first.`) const expand = (text, where) => { if (typeof text !== 'string' || !text.trim() || unfilled(text)) fail(`${where} is not written yet. Finish the board first.`) return text.replace(/\{\{(PLACE|PRODUCT|PEOPLE|WARDROBE)\}\}/g, (_, name) => { const value = locks[name.toLowerCase()] if (!value) fail(`${where} uses {{${name}}} but locks.${name.toLowerCase()} is empty.`) return trimStop(value) }) } const frameOf = (s) => (s.frame ? fromOut(s.frame) : '') if (opts['use-board-frames']) for (const s of shots) if (!s.frame) fail(`Shot ${s.n} has no board frame, so --use-board-frames cannot fill its keyframe.`) const changes = [] const warnings = [] let out if (opts.to === 'cinematic-brand-film') { const grade = opts.grade ?? board.look?.grade if (!FILM_GRADES.includes(grade)) fail(`cinematic-brand-film grades are ${FILM_GRADES.join(', ')}; the board uses "${grade}". Pass --grade (the keyframes keep the board's look line; only the final colour grade changes).`) if (opts.grade && opts.grade !== board.look?.grade) changes.push(`Grade ${board.look?.grade} → ${grade} for assembly; locks.look keeps the board's film-stock line.`) const resolution = brief.final_resolution === '1080p' ? '1080p' : '720p' if (brief.final_resolution === '480p') changes.push('Planned resolution 480p → 720p (the film Skill drafts at 480p and makes finals at 720p or 1080p).') const placePhoto = refs.place && (brief.place_photos ?? []).includes(refs.place) ? refs.place : '' if ((refs.extra_place_photos ?? []).length) warnings.push('cinematic-brand-film takes one location photo; the extra place photo is not carried over.') out = { schema: 'cinematic-brand-film/1', brief: { brand: brief.name, story: brief.idea, length_s: brief.length_s ?? 30, aspect: brief.aspect ?? '16:9', final_resolution: resolution, person: brief.people === 'fictional' ? 'synthetic' : 'none', person_photo: null, consent_quote: null, consent_recorded: null, product_photo: refs.product ? fromOut(refs.product) : null, }, locks: { cast: locks.people ?? '', wardrobe: locks.wardrobe ?? '', location: locks.place, product: locks.product ?? '', look: locks.look, }, references: { cast_bible: '', location: placePhoto ? fromOut(placePhoto) : opts['use-board-frames'] ? frameOf(shots[0]) : '', product: refs.product ? fromOut(refs.product) : '', }, title_card: { title: opts.title ?? brief.name, kicker: '', subline: '', on_shot: 1, start: 0.9, duration: 3.6, position: 'center' }, end_card: { text: opts['end-card'] ?? brief.name, subline: '', duration: 3 }, look: { grade, grain: 6, bloom: 0.08, vignette: 0.35, letterbox: 'none', dissolve: 0.6 }, from_board: { file: fromOut(opts.board), made: brief.created ?? null }, shots: shots.map((s, i) => ({ n: s.n, id: s.id, role: s.role, framing: s.framing, suggested_camera: s.suggested_camera ?? s.camera ?? '', person_in_shot: Boolean(s.person_in_shot), face_visible: Boolean(s.face_visible), product_in_shot: Boolean(s.product_in_shot), use_location_ref: i > 0 || Boolean(placePhoto), keyframe_prompt: expand(s.keyframe_prompt, `shot ${s.n} keyframe_prompt`), motion_prompt: expand(s.motion_prompt, `shot ${s.n} motion_prompt`), duration: Number(s.duration), transition_in: i === 0 ? null : s.transition_in ?? 'cut', keyframe: opts['use-board-frames'] ? frameOf(s) : '', draft_clip: '', clip: '', in: 0.2, out: Number((Number(s.duration) - 0.2).toFixed(1)), board_frame: frameOf(s), board_caption: s.caption ?? '', })), } changes.push('locks.place → locks.location; locks.people → locks.cast; use_place_ref → use_location_ref.') changes.push('A title card and an end card with the name were added (change them with --title and --end-card, or in film.json).') if (out.brief.person === 'synthetic') changes.push('People are a synthetic cast: run the film Skill\'s --stage bible first and set references.cast_bible.') } else { if (brief.kind === 'product') fail('destination-teaser is for places and stays; it does not use a product photo. Hand a product board to cinematic-brand-film.') if (Number(brief.length_s) !== 20) fail(`This board plans a ${brief.length_s} s film; a destination teaser is 15-20 s. Rebuild the board with --length 20, or hand it to cinematic-brand-film.`) if (shots.length < 4 || shots.length > 6) fail('destination-teaser takes 4 to 6 shots.') const grade = opts.grade ?? board.look?.grade if (!TEASER_GRADES.includes(grade)) fail(`destination-teaser grades are ${TEASER_GRADES.join(', ')}.`) if (refs.product) warnings.push('destination-teaser does not use the product photo; product shots are guided by locks.product in the prompts only.') const resolution = brief.final_resolution === '1080p' ? '1080p' : '720p' if (brief.final_resolution === '480p') changes.push('Planned resolution 480p → 720p (the teaser drafts at 480p and makes finals at 720p or 1080p).') const placePhotos = (brief.place_photos ?? []).map(fromOut) out = { schema: 'destination-teaser/1', brief: { place: brief.name, season_mood: opts['season-mood'] ?? brief.idea, aspect: brief.aspect ?? '9:16', final_resolution: resolution, people: brief.people === 'fictional' ? 'synthetic' : 'none', real_place: Boolean(brief.real_place), place_photos: placePhotos, }, locks: { place: [locks.place, locks.product].filter(Boolean).join(' '), people: locks.people ? (locks.wardrobe ? `${trimStop(locks.people)}, wearing ${trimStop(locks.wardrobe)}.` : locks.people) : '', look: locks.look, }, references: { place: placePhotos[0] ?? (opts['use-board-frames'] ? frameOf(shots[0]) : ''), extra_place_photos: placePhotos.slice(1), }, look: { grade, grain: 6, bloom: grade === 'blue-hour' ? 0.12 : 0.08, vignette: 0.3, dissolve: 0.5, fade_out: 0.5 }, from_board: { file: fromOut(opts.board), made: brief.created ?? null }, shots: shots.map((s, i) => ({ n: s.n, id: s.id, role: s.role, framing: s.framing, suggested_camera: s.suggested_camera ?? s.camera ?? '', person_in_shot: Boolean(s.person_in_shot), face_visible: Boolean(s.face_visible), use_place_ref: i > 0 || placePhotos.length > 0, keyframe_prompt: expand(s.keyframe_prompt, `shot ${s.n} keyframe_prompt`), motion_prompt: expand(s.motion_prompt, `shot ${s.n} motion_prompt`), duration: Number(s.duration), transition_in: i === 0 ? null : s.transition_in ?? 'cut', keyframe: opts['use-board-frames'] ? frameOf(s) : '', draft_clip: '', clip: '', in: 0.2, out: Number(s.duration) >= 5 ? 4.4 : 3.6, board_frame: frameOf(s), board_caption: s.caption ?? '', })), } changes.push('locks.people and locks.wardrobe → one locks.people sentence; locks.product (if any) joins locks.place.') if (!opts['season-mood']) changes.push('brief.season_mood is the board idea line; pass --season-mood for a shorter one.') } changes.unshift('Every {{TOKEN}} in the prompts is written out with the lock text, so the film Skill finds no FILL or token.') changes.push(opts['use-board-frames'] ? 'keyframe = the board frame (1280 x 720 or 720 x 1280, low or medium quality): good enough for a 480p draft; re-render at high quality before the finals.' : 'keyframe is empty: the film Skill renders each keyframe at high quality from the same prompt; board_frame keeps the approved plan to compare with.') writeFileSync(opts.out, JSON.stringify(out, null, 2) + '\n') const file = opts.to === 'cinematic-brand-film' ? '--film' : '--teaser' console.log(`Wrote ${opts.out} for ${opts.to} (${out.shots.length} shots, ${out.shots.reduce((a, s) => a + s.duration, 0)} s).`) console.log('What changes:') for (const c of changes) console.log(` - ${c}`) for (const w of warnings) console.log(`warning: ${w}`) console.log(`Next, with the ${opts.to} Skill installed: node scripts/build-requests.mjs ${file} ${opts.out} --stage estimate. Quote its total and get a yes before any paid call.`)