#!/usr/bin/env node // Writes the shot list (film.json) for one brand film: the shot grammar for the chosen length, // with every creative field marked FILL for the agent to write from the brief. // film.json is the single source of truth for build-requests.mjs and assemble-film.mjs. // // Usage: // node scripts/build-shot-list.mjs --out film.json --brand "Brand or place" --story "one line" // --person synthetic|own-photo|none --grade teal-amber|warm-kodak|cool-fuji|natural // [--length 30|45] [--aspect 16:9|9:16] [--resolution 720p|1080p] // [--person-photo me.jpg --consent-quote "the user's own words confirming it is them or that they have written consent"] // [--product-photo product.jpg] [--title "On-screen title"] [--end-card "End card text"] // // Node 18 or newer. No npm packages. Calls no API. import { existsSync, writeFileSync } from 'node:fs' const FLAGS = new Set(['out', 'brand', 'story', 'person', 'grade', 'length', 'aspect', 'resolution', 'person-photo', 'consent-quote', 'product-photo', 'title', 'end-card']) 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 film.json --brand "Name" --story "one line" --person synthetic|own-photo|none --grade teal-amber|warm-kodak|cool-fuji|natural [--length 30|45] [--aspect 16:9|9:16]' for (const f of ['out', 'brand', 'story', 'person', 'grade']) if (!opts[f]) fail(`Missing --${f}.\n${usage}`) const length = Number(opts.length ?? 30) if (![30, 45].includes(length)) fail(`--length must be 30 or 45 (seconds), not "${opts.length}".`) const aspect = opts.aspect ?? '16:9' if (!['16:9', '9:16'].includes(aspect)) fail(`--aspect must be 16:9 or 9:16, not "${aspect}". For other shapes, crop the finished 16:9 film with assemble-film.mjs --crop.`) const resolution = opts.resolution ?? '720p' if (!['720p', '1080p'].includes(resolution)) fail(`--resolution must be 720p or 1080p, not "${resolution}".`) if (!['synthetic', 'own-photo', 'none'].includes(opts.person)) fail(`--person must be synthetic, own-photo or none, not "${opts.person}".`) if (opts.person === 'own-photo' && !opts['person-photo']) fail('--person own-photo needs --person-photo path/to/photo.jpg (the person\'s own photo, or one they gave written consent for).') if (opts.person === 'own-photo') { const quote = (opts['consent-quote'] ?? '').trim() if (quote.length < 3) fail('--person own-photo needs --consent-quote "": ask whether the photo is of them, or whether they hold the person\'s written consent, and paste their answer word for word. Never write it yourself. Celebrities and public figures are refused whatever the answer.') if (quote.length > 600) fail('--consent-quote is over 600 characters; quote the user\'s answer itself, not the whole conversation.') } if (opts['consent-quote'] && opts.person !== 'own-photo') fail('--consent-quote only applies with --person own-photo.') if (opts['person-photo'] && opts.person !== 'own-photo') fail('--person-photo only applies with --person own-photo (and a --consent-quote).') if (opts['person-photo'] && !existsSync(opts['person-photo'])) fail(`Person photo not found: ${opts['person-photo']}`) if (opts['product-photo'] && !existsSync(opts['product-photo'])) fail(`Product photo not found: ${opts['product-photo']}`) const grades = { '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 skin and 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.', 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 grades)) fail(`--grade must be one of ${Object.keys(grades).join(', ')}. See references/film-look.md to choose by mood.`) const hasPerson = opts.person !== 'none' const hasProduct = Boolean(opts['product-photo']) const FILL = (what) => `FILL: ${what}` // Shot grammar. Durations are generation lengths (Seedance minimum 4 s); assembly trims each shot. const grammar45 = [ ['establishing', 'Wide establishing shot of the place, ideally a drone aerial. Carries the title card.', 'slow drone glide forward or reveal', false, false, 6], ['arrival', 'Wide or medium: the subject enters the world (walking away from camera, arriving, opening a door).', 'gimbal follow behind the subject at walking pace', true, false, 5], ['insert', 'Insert detail of the place or the product: texture, fabric, light, an object. No people.', 'very slow push-in, the detail moves (fabric, steam, water, light)', false, hasProduct, 4], ['medium', 'Medium shot of the subject at rest, three-quarter back or profile, face turned away.', 'locked off or very slow push-in', true, false, 5], ['signature', 'The signature moment of this brand: the one action only this place or product offers (a swim, a pour, a ride).', 'tracking alongside the action', true, hasProduct, 5], ['close', 'Close-up on hands, hair, skin or the product in use; face turned away or out of frame.', 'steady with slight handheld drift', true, hasProduct, 4], ['second-angle', 'A contrasting angle: overhead drone, low angle or a reflection. Keeps the film from feeling flat.', 'slow rise or rotation', true, false, 5], ['golden-hour', 'Golden hour or practical-light moment: the subject in silhouette or backlit.', 'slow lateral dolly', true, false, 5], ['closing', 'Closing wide: the place at dusk or the product hero, calm, room for the end card.', 'slow pull back', false, hasProduct, 6], ] const grammar30 = [grammar45[0], grammar45[1], grammar45[2], grammar45[4], grammar45[5], grammar45[7], grammar45[8]].map((g, i) => (i === 0 ? [...g.slice(0, 5), 5] : g)) const rows = length === 45 ? grammar45 : grammar30 const shots = rows.map(([role, framing, camera, person, product, duration], i) => ({ n: i + 1, id: `${String(i + 1).padStart(2, '0')}-${role}`, role, framing, suggested_camera: camera, person_in_shot: hasPerson && person, face_visible: false, product_in_shot: product, use_location_ref: i > 0, keyframe_prompt: FILL(`describe this ${role} frame: who or what, where, action frozen mid-moment, lens, light, composition. Use {{CAST}}, {{WARDROBE}}, {{LOCATION}}, {{PRODUCT}} tokens instead of retyping the locks.`), motion_prompt: FILL('one camera move + one subject action + environment motion, slow and real-time; end with "One continuous shot, no cuts."'), duration, transition_in: i === 0 ? null : 'cut', keyframe: '', draft_clip: '', clip: '', in: 0.2, out: duration - 0.2, })) if (!hasPerson) for (const s of shots) s.person_in_shot = false const film = { schema: 'cinematic-brand-film/1', brief: { brand: opts.brand, story: opts.story, length_s: length, aspect, final_resolution: resolution, person: opts.person, person_photo: opts['person-photo'] ?? null, consent_quote: opts.person === 'own-photo' ? opts['consent-quote'].trim() : null, consent_recorded: opts.person === 'own-photo' ? new Date().toISOString() : null, product_photo: opts['product-photo'] ?? null }, locks: { cast: hasPerson ? FILL('one sentence: apparent age range, build, skin tone, hair colour/length/style. No names of real people.') : '', wardrobe: hasPerson ? FILL('one sentence: every garment with colour and cut, kept identical in every shot') : '', location: FILL('one sentence: the place, architecture, materials, landscape, time of day/season'), product: hasProduct ? FILL('one sentence product lock: shape, material, colours, label layout and exact visible text') : '', look: grades[opts.grade], }, references: { cast_bible: '', location: '', product: opts['product-photo'] ?? '' }, title_card: { title: opts.title ?? opts.brand, kicker: '', subline: '', on_shot: 1, start: 0.9, duration: 3.6, position: 'center' }, end_card: { text: opts['end-card'] ?? opts.brand, subline: '', duration: 3 }, look: { grade: opts.grade, grain: 6, bloom: 0.08, vignette: 0.35, letterbox: 'none', dissolve: 0.6 }, shots, } writeFileSync(opts.out, JSON.stringify(film, null, 2) + '\n') const gen = shots.reduce((a, s) => a + s.duration, 0) console.log(`${opts.out}: ${shots.length} shots, ${gen} s generated, about ${length} s after trims and the end card.`) console.log('Next: fill every FILL field (see references/shot-grammar.md and references/prompt-patterns.md), run build-requests.mjs --stage estimate and get the user\'s yes on the total, then --stage bible.')