#!/usr/bin/env node // Writes the shot list (teaser.json) for one destination teaser: 4-6 text-free shots in teaser order, // with every creative field marked FILL for the agent to write from the brief. // teaser.json is the single source of truth for build-requests.mjs and assemble-teaser.mjs. // // Usage: // node scripts/build-shot-list.mjs --out teaser.json --place "Property or destination" // --season-mood "season, time of day, mood" --grade blue-hour|teal-amber|warm-kodak|cool-fuji|natural // [--shots 4|5|6] [--aspect 9:16|16:9] [--resolution 720p|1080p] [--people none|synthetic] // [--place-photos a.jpg,b.jpg,c.jpg] [--real-place yes|no] // // Node 18 or newer. No npm packages. Calls no API. import { existsSync, writeFileSync } from 'node:fs' const FLAGS = new Set(['out', 'place', 'season-mood', 'grade', 'shots', 'aspect', 'resolution', 'people', 'place-photos', '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 teaser.json --place "Name" --season-mood "winter, blue hour, quiet" --grade blue-hour [--shots 5] [--aspect 9:16] [--people synthetic] [--place-photos a.jpg,b.jpg]' for (const f of ['out', 'place', 'season-mood', 'grade']) if (!opts[f]) fail(`Missing --${f}.\n${usage}`) const count = Number(opts.shots ?? 5) if (![4, 5, 6].includes(count)) fail(`--shots must be 4, 5 or 6, not "${opts.shots}". A 15-20 s teaser holds 4-6 shots of about 3-4 s.`) const aspect = opts.aspect ?? '9:16' if (!['9:16', '16:9'].includes(aspect)) fail(`--aspect must be 9:16 or 16:9, not "${aspect}". For 4:5 or 1:1 feed posts, crop the finished teaser with assemble-teaser.mjs --crop.`) const resolution = opts.resolution ?? '720p' if (!['720p', '1080p'].includes(resolution)) fail(`--resolution must be 720p or 1080p, not "${resolution}".`) const people = opts.people ?? 'synthetic' if (!['none', 'synthetic'].includes(people)) fail(`--people must be none or synthetic, not "${people}". Real guests or staff need their own consent and real footage, not generated likenesses.`) const realPlace = opts['real-place'] ?? (opts['place-photos'] ? 'yes' : 'no') if (!['yes', 'no'].includes(realPlace)) fail('--real-place must be yes or no.') const photos = opts['place-photos'] ? opts['place-photos'].split(',').map((p) => p.trim()).filter(Boolean) : [] if (photos.length > 3) fail('--place-photos takes at most 3 photos (more references dilute each one). Pick the clearest exterior, one interior and one detail.') for (const p of photos) { if (!existsSync(p)) fail(`Place 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 looks = { '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.', '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, 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 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/look-and-light.md to choose by season and time of day.`) // Teaser order. Each shot is generated at 4 s (5 s when there are only 4 shots) and trimmed to about 3.4-4.2 s. const slots = { 4: ['aerial', 'detail', 'people', 'hero'], 5: ['aerial', 'detail', 'amenity', 'people', 'hero'], 6: ['aerial', 'detail', 'amenity', 'people', 'activity', 'hero'], }[count] const guide = { aerial: ['Establishing aerial: the place in its landscape, the property small enough to read its setting.', 'slow drone glide forward or descent toward the property'], detail: ['Place detail that says the season: a window, a texture, a table, a doorway; no people.', 'very slow push-in while something moves (snow, steam, curtains, candle)'], amenity: ['The signature amenity or experience: pool, sauna steam, terrace, bar, boat; usually no people.', 'slow rise or slow lateral move'], people: ['People enjoying it: one person or a couple, from behind or in profile, faces away from camera.', 'locked off or very slow push-in'], activity: ['An activity on site: a walk, a swim, a ride, dinner being served; figures small or from behind.', 'gimbal follow or tracking'], hero: ['Closing hero: the whole property at its most beautiful moment; holds still for the end.', 'slow push toward the property or slow pull back'], } const shots = slots.map((role, i) => ({ n: i + 1, id: `${String(i + 1).padStart(2, '0')}-${role}`, role, framing: guide[role][0], suggested_camera: guide[role][1], person_in_shot: people !== 'none' && (role === 'people' || role === 'activity'), face_visible: false, use_place_ref: i > 0 || photos.length > 0, keyframe_prompt: `FILL: describe this ${role} frame for a vertical-first composition (subject in the middle third): what, where, light, lens. Use {{PLACE}} and {{PEOPLE}} tokens instead of retyping the locks.`, motion_prompt: 'FILL: one camera move + what moves in the scene (snow, steam, water, flames, fabric), slow and real-time; end with "One continuous shot, no cuts."', duration: count === 4 ? 5 : 4, transition_in: i === 0 ? null : 'cut', keyframe: '', draft_clip: '', clip: '', in: 0.2, out: count === 4 ? 4.4 : 3.6, })) const teaser = { schema: 'destination-teaser/1', brief: { place: opts.place, season_mood: opts['season-mood'], aspect, final_resolution: resolution, people, real_place: realPlace === 'yes', place_photos: photos }, locks: { place: 'FILL: one sentence: architecture, materials, setting and landscape, exactly as the real place looks if it is real', people: people === 'none' ? '' : 'FILL: one sentence per person: apparent age range, build, hair, clothing colours (fictional people only)', look: looks[opts.grade], }, references: { place: photos[0] ?? '', extra_place_photos: photos.slice(1) }, look: { grade: opts.grade, grain: 6, bloom: opts.grade === 'blue-hour' ? 0.12 : 0.08, vignette: 0.3, dissolve: 0.5, fade_out: 0.5 }, shots, } writeFileSync(opts.out, JSON.stringify(teaser, null, 2) + '\n') const gen = shots.reduce((a, s) => a + s.duration, 0) const cut = shots.reduce((a, s) => a + (s.out - s.in), 0) console.log(`${opts.out}: ${shots.length} shots, ${gen} s generated, about ${Math.round(cut)} s after trims.`) if (realPlace === 'yes') console.log('Real place: only show rooms, views and amenities the place really has (references/shot-grammar.md, "Real places").') console.log('Next: fill every FILL field, then run build-requests.mjs --stage keyframe --shot 1.')