#!/usr/bin/env node // Writes the shot list (board.json) for one storyboard board: 4 to 6 shots in film order, with every // creative field marked FILL for the agent to write from the brief. The shots use the same fields as the // cinematic-brand-film and destination-teaser shot lists, so scripts/handoff.mjs can turn the board into // either film. board.json is the single source of truth for build-requests.mjs, compose-board.mjs, // price-table.mjs, check-board.mjs and handoff.mjs. // // Usage: // node scripts/build-shot-list.mjs --out board.json --name "Brand, product or place" --idea "one line" // --kind place|product|brand --grade teal-amber|warm-kodak|cool-fuji|blue-hour|natural // [--shots 4|5|6] [--length 20|30] [--aspect 16:9|9:16] [--board 16:9|9:16] [--resolution 480p|720p|1080p] // [--people none|fictional] [--place-photos a.jpg,b.jpg] [--product-photo p.jpg] [--real-place yes|no] // // Node 18 or newer. No npm packages. Calls no API. import { existsSync, readFileSync, writeFileSync } from 'node:fs' const FLAGS = new Set(['out', 'name', 'idea', 'kind', 'grade', 'shots', 'length', 'aspect', 'board', 'resolution', 'people', 'place-photos', 'product-photo', 'real-place']) 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++ } const usage = 'Usage: node scripts/build-shot-list.mjs --out board.json --name "Name" --idea "one line" --kind place|product|brand --grade warm-kodak [--shots 6] [--length 30] [--aspect 16:9] [--people none] [--place-photos a.jpg] [--product-photo p.jpg]' for (const f of ['out', 'name', 'idea', 'kind', 'grade']) if (!opts[f]) fail(`Missing --${f}.\n${usage}`) if (opts.name.length > 60) fail('--name is over 60 characters. Use the brand, product or place name only; the idea goes in --idea.') if (opts.idea.length > 240) fail('--idea is over 240 characters. Keep it to one line; the shot list holds the detail.') const kind = opts.kind if (!['place', 'product', 'brand'].includes(kind)) fail(`--kind must be place, product or brand, not "${kind}".`) const count = Number(opts.shots ?? 6) if (![4, 5, 6].includes(count)) fail(`--shots must be 4, 5 or 6, not "${opts.shots}".`) const length = Number(opts.length ?? 30) if (![20, 30].includes(length)) fail(`--length must be 20 (a teaser) or 30 (a short film), not "${opts.length}".`) if (length === 30 && count !== 6) fail('A 30 s film needs 6 shots (each about 4-6 s on screen). Use --shots 6, or --length 20 for a 4 or 5 shot teaser.') const aspect = opts.aspect ?? '16:9' if (!['16:9', '9:16'].includes(aspect)) fail(`--aspect must be 16:9 or 9:16 (the shape of the film), not "${aspect}". Square and 4:5 versions are crops of the finished film.`) const boardShape = opts.board ?? '16:9' if (!['16:9', '9:16'].includes(boardShape)) fail(`--board must be 16:9 or 9:16 (the shape of the board itself), not "${boardShape}".`) const resolution = opts.resolution ?? '720p' if (!['480p', '720p', '1080p'].includes(resolution)) fail(`--resolution must be 480p, 720p or 1080p (the planned film), not "${resolution}".`) const people = opts.people ?? 'none' if (!['none', 'fictional'].includes(people)) fail(`--people must be none or fictional, not "${people}". The board never uses a photo of a real person; real people need their own consent and real footage.`) const placePhotos = opts['place-photos'] ? opts['place-photos'].split(',').map((p) => p.trim()).filter(Boolean) : [] if (placePhotos.length > 2) fail('--place-photos takes at most 2 photos (with a product photo that is 3 references, the most a frame uses). Pick the clearest exterior and one interior or detail.') const productPhoto = opts['product-photo'] ?? '' for (const p of [...placePhotos, productPhoto].filter(Boolean)) { if (!existsSync(p)) fail(`Photo not found: ${p}`) if (!/\.(jpe?g|png|webp)$/i.test(p)) fail(`${p} must be a .jpg, .png or .webp photo. For a video, export one still frame first.`) } const realPlace = opts['real-place'] ?? (placePhotos.length ? 'yes' : 'no') if (!['yes', 'no'].includes(realPlace)) fail('--real-place must be yes or no.') const looks = { 'teal-amber': 'Shot on 35mm motion picture film, Kodak Vision3 250D look, gentle film grain, soft halation on highlights, teal shadows and water with warm amber light, no HDR, no digital over-sharpening.', 'warm-kodak': 'Shot on 35mm film, Kodak Portra 400 look, warm natural colour, soft contrast, gentle grain, creamy highlights, no HDR, no digital over-sharpening.', 'cool-fuji': 'Shot on 35mm film, Fujifilm Eterna look, cool muted greens and blues, soft low contrast, fine grain, no HDR, no digital over-sharpening.', 'blue-hour': 'Shot on 35mm motion picture film, Kodak Vision3 500T look, gentle film grain, soft halation around warm lights, cool blue ambient light with warm amber practical lights, no HDR, no digital over-sharpening.', natural: 'Shot on a cinema camera with vintage spherical lenses, natural true-to-life colour, soft contrast, fine grain, no HDR, no digital over-sharpening.', } if (!(opts.grade in looks)) fail(`--grade must be one of ${Object.keys(looks).join(', ')}. See references/shot-grammar.md, "Choosing the look".`) const hasPeople = people === 'fictional' const hasProduct = kind === 'product' || Boolean(productPhoto) const FILL = (what) => `FILL: ${what}` // Film order. [role, label, framing, camera, person, product] const SLOTS = { establishing: ['establishing', 'Establishing', 'Wide establishing shot of the world: the place in its landscape, the street or shop front, or the room the product lives in; ideally a drone aerial. No people, or figures too small to read.', 'slow drone glide forward', false, false], detail: ['detail', 'Detail', 'Close insert of one texture or material that says what this is: steam, fabric, grain, light on glass, the product surface. No people.', 'very slow push-in', false, hasProduct], signature: ['signature', 'Signature moment', 'The one moment only this brand, product or place offers: a pour, a swim, first light on the water, the product doing its job. Hands may show; faces do not.', 'slow tracking move', hasPeople, hasProduct], people: ['people', 'People', 'One fictional person or a couple enjoying it, seen from behind, in silhouette or small in frame; faces turned away.', 'locked off, slow push-in', true, false], 'second-angle': ['second-angle', 'Second angle', 'A contrasting angle on the same world: overhead, low, a reflection or through a doorway. No people.', 'slow rise', false, false], light: ['light', 'Light change', 'The same world in a change of light: golden hour, blue hour, candlelight or neon. Any person is backlit or in silhouette.', 'slow lateral dolly', false, false], hero: ['hero', 'Hero close', 'Closing hero: the product or the place at its best, calm and still, with room for an end card or caption.', 'slow push-in, then hold', false, hasProduct], } const fourth = hasPeople ? 'people' : 'second-angle' const order = { 4: ['establishing', 'detail', 'signature', 'hero'], 5: ['establishing', 'detail', 'signature', hasPeople ? 'people' : 'light', 'hero'], 6: ['establishing', 'detail', 'signature', fourth, 'light', 'hero'], }[count] // Generation lengths in seconds (Seedance needs at least 4). The film Skills trim about 0.2 s at each end. const seconds = length === 30 ? [6, 4, 5, 5, 5, 6] : order.map(() => (count === 4 ? 5 : 4)) const shots = order.map((slot, i) => { const [role, label, framing, camera, person, product] = SLOTS[slot] const duration = seconds[i] return { n: i + 1, id: `${String(i + 1).padStart(2, '0')}-${role}`, role, label, caption: FILL('what we see, in at most 60 characters, for the board (plain words, no camera jargon)'), camera, framing, suggested_camera: camera, person_in_shot: person, face_visible: false, product_in_shot: product, use_place_ref: i > 0 || placePhotos.length > 0, keyframe_prompt: FILL(`describe this ${role} frame: shot size and angle, subject, what in the frame will move, light, lens, composition for ${aspect}. Use {{PLACE}}, {{PRODUCT}}, {{PEOPLE}} and {{WARDROBE}} tokens instead of retyping the locks. Never number reference images.`), motion_prompt: FILL('for the film later: one camera move + what moves in the scene, slow and real-time; end with "One continuous shot, no cuts."'), duration, transition_in: i === 0 ? null : 'cut', frame: '', keyframe: '', draft_clip: '', clip: '', in: 0.2, out: Number((duration - 0.2).toFixed(1)), } }) let size = { '16:9': { width: 1280, height: 720 }, '9:16': { width: 720, height: 1280 } }[aspect] try { const gen = JSON.parse(readFileSync(new URL('../assets/generation-input.json', import.meta.url), 'utf8')) size = gen.frame?.image_sizes?.[aspect] ?? size } catch { // assets/generation-input.json is optional here; the sizes above are the same. } const board = { schema: 'film-storyboard-board/1', brief: { name: opts.name, idea: opts.idea, kind, aspect, board: boardShape, length_s: length, final_resolution: resolution, people, real_place: realPlace === 'yes', place_photos: placePhotos, product_photo: productPhoto || null, created: new Date().toISOString().slice(0, 10), }, locks: { place: FILL('one sentence: the place or world, architecture, materials, landscape, season and time of day (exactly as the real place looks if it is real)'), product: hasProduct ? FILL('one sentence product lock: shape, material, colours, label layout and the exact visible label text') : '', people: hasPeople ? FILL('one sentence per person: fictional adult, apparent age range, build, hair (no names, no real people)') : '', wardrobe: hasPeople ? FILL('one sentence: every garment with colour and cut, the same in every shot') : '', look: looks[opts.grade], }, references: { place: placePhotos[0] ?? '', extra_place_photos: placePhotos.slice(1), product: productPhoto }, look: { grade: opts.grade }, board: { title: opts.name, subtitle: opts.idea.length <= 110 ? opts.idea : FILL('the idea in at most 110 characters for the board header'), frame_size: size }, shots, } writeFileSync(opts.out, JSON.stringify(board, null, 2) + '\n') const gen = shots.reduce((a, s) => a + s.duration, 0) console.log(`${opts.out}: ${shots.length} shots (${order.join(', ')}), ${gen} s of video planned, ${aspect} frames at ${size.width} x ${size.height} on a ${boardShape} board.`) if (realPlace === 'yes') console.log('Real place: show only rooms, views and features the place really has (references/shot-grammar.md, "Real places").') console.log('Next: fill every FILL field (references/shot-grammar.md and references/prompt-patterns.md), then run build-requests.mjs --stage estimate and get the user\'s yes on the board total.')