#!/usr/bin/env node // Prints the image-edit request for the hero keyframe: the product from the photo, unchanged, placed in the mood's set. // It does not call any API. // Usage: // node scripts/build-keyframe-input.mjs --photo product.jpg --product "matte black over-ear headphones" --mood dark-luxe // [--aspect 16:9|9:16|1:1] [--size standard|tall|tiny] [--label-text "EXACT TEXT" | --unbranded] [--colour "tangerine orange"] [--draft] [--out keyframe.json] // --photo takes a local file (embedded as a data URI) or a public https URL. import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { dirname, join, extname } from 'node:path' import { fileURLToPath } from 'node:url' const root = join(dirname(fileURLToPath(import.meta.url)), '..') const usage = 'Usage: node scripts/build-keyframe-input.mjs --photo --product "short description" --mood dark-luxe|glass-glow|bright-clean|colour-pop [--aspect 16:9|9:16|1:1] [--size standard|tall|tiny] [--label-text "EXACT TEXT" | --unbranded] [--colour "name or #hex"] [--draft] [--out keyframe.json]' const VALUE_FLAGS = new Set(['photo', 'product', 'mood', 'aspect', 'size', 'label-text', 'colour', 'out']) const SWITCHES = new Set(['unbranded', 'draft']) const fail = (message) => { console.error(`${message}\n${usage}`); process.exit(1) } const options = {} const args = process.argv.slice(2) for (let i = 0; i < args.length; i += 1) { const arg = args[i] if (!arg.startsWith('--')) fail(`Unexpected argument "${arg}".`) const key = arg.slice(2) if (SWITCHES.has(key)) { options[key] = true; continue } if (!VALUE_FLAGS.has(key)) fail(`Unknown flag --${key}.`) const value = args[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) options[key] = value i += 1 } for (const key of ['photo', 'product', 'mood']) if (!options[key]) fail(`--${key} is required.`) const presets = JSON.parse(readFileSync(join(root, 'assets/reveal-presets.json'), 'utf8')) const settings = JSON.parse(readFileSync(join(root, 'assets/generation-input.json'), 'utf8')).keyframe const mood = presets.moods[options.mood] if (!mood) fail(`Unknown mood "${options.mood}". Use one of: ${Object.keys(presets.moods).join(', ')}.`) const aspect = options.aspect ?? '16:9' const size = options.size ?? 'standard' const sizes = Object.keys(presets.sizes).filter((key) => !key.startsWith('_')) if (!sizes.includes(size)) fail(`Unknown size "${size}". Use one of: ${sizes.join(', ')} (tall for bottles and shoes standing up, tiny for rings and earbuds).`) if (!presets.sizes[size][aspect]) fail(`Unknown aspect "${aspect}". Use 16:9, 9:16 or 1:1.`) for (const [kind, name] of [['moods', options.mood], ['sizes', size]]) { const tested = presets.tested?.[kind]?.[name] if (tested && !tested.includes('(the preview)') && !tested.includes('keyframes')) console.error(`Note: ${kind.slice(0, -1)} "${name}" so far: ${tested}. Make a --draft keyframe first and check it beside the photo.`) } if (options['label-text'] && options.unbranded) fail('Use --label-text or --unbranded, not both.') const colour = options.colour ?? 'tangerine orange' if (!/^(#[0-9a-f]{6}|[a-z][a-z -]{1,30})$/i.test(colour)) fail('--colour must be a colour name (letters, spaces, dashes) or a #rrggbb hex value.') if (options.mood !== 'colour-pop' && options.colour) fail('--colour only applies to --mood colour-pop.') const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' } let photo = options.photo if (!/^https:\/\//.test(photo)) { if (!existsSync(photo)) fail(`Photo not found: ${photo}`) const mime = MIME[extname(photo).toLowerCase()] if (!mime) fail('The product photo must be a still image (.jpg, .png or .webp). For a video, export one clear frame first.') photo = `data:${mime};base64,${readFileSync(photo).toString('base64')}` } const product = options.product.trim() const labelClause = options['label-text'] ? `, and the printed text reads exactly "${options['label-text'].trim()}", in the same place, size and typeface` : options.unbranded ? ', and it stays plain and unbranded: no logo, no monogram, no lettering anywhere on it' : ', and the same logos and printed text exactly as in the photo' const template = readFileSync(join(root, 'assets/prompt-template.md'), 'utf8') const section = template.match(/## Keyframe\s+([\s\S]*?)(?=\n## |$)/)?.[1]?.trim() if (!section) fail('assets/prompt-template.md is missing its "## Keyframe" section.') const values = { mood_name: mood.name, product, label_clause: labelClause, scene: mood.scene.replace('{{colour}}', () => colour), framing: presets.sizes[size][aspect], } // Replacer functions keep any "$" in the user's text literal. const prompt = section.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? '').replace(/\s{2,}/g, ' ') const input = { ...settings.input, ...(options.draft ? { quality: settings.draft_quality } : {}), image_size: presets.keyframe_size[aspect], image_urls: [photo], prompt, } const output = JSON.stringify({ model: settings.model, input }, null, 2) if (options.out) { writeFileSync(options.out, output) console.log(`Wrote the keyframe request to ${options.out}`) } else console.log(output)