#!/usr/bin/env node // Prints one image-edit request per look in a shoot plan. It does not call any API. // Usage: // node scripts/build-requests.mjs --photo person.jpg --plan my-plan.json [--extra-image person-side.jpg ...] [--look 02-city-dusk] [--draft] [--out requests.json] // (assets/example-shoot-plan.json is the plan behind the preview; copy it and write your own looks) // --photo is the main photo of the model. --extra-image (repeatable) adds more photos of the SAME person from other // angles, to hold the identity. A look can name its own garment photo(s) with a "garment" field in the plan // (a path relative to the plan file, or an https URL), so the model wears that exact garment. // Local files are embedded as data URIs, so a person's photo never has to be uploaded anywhere public. // --draft uses the cheaper draft quality from assets/generation-input.json (for checking a look before the final). import { readFileSync, writeFileSync, existsSync } from 'node:fs' import { dirname, join, extname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const root = join(dirname(fileURLToPath(import.meta.url)), '..') const usage = 'Usage: node scripts/build-requests.mjs --photo --plan my-plan.json [--extra-image ...] [--look ] [--draft] [--out requests.json]' const VALUE_FLAGS = new Set(['photo', 'plan', 'look', 'out']) const REPEAT_FLAGS = new Set(['extra-image']) const SWITCHES = new Set(['draft']) const MAX_IMAGES = 16 // GPT Image 2 edit accepts up to 16 reference images const fail = (message) => { console.error(`${message}\n${usage}`); process.exit(1) } const options = { 'extra-image': [] } 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) && !REPEAT_FLAGS.has(key)) fail(`Unknown flag --${key}.`) const value = args[i + 1] if (value === undefined || value.startsWith('--')) fail(`--${key} needs a value.`) if (REPEAT_FLAGS.has(key)) options[key].push(value) else options[key] = value i += 1 } if (!options.photo) fail('--photo is required.') if (!options.plan) fail('--plan is required: copy assets/example-shoot-plan.json, write your own looks, and pass that file.') const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' } function imageValue(source, what) { if (/^https:\/\//.test(source)) return source if (!existsSync(source)) fail(`${what} must be a public https URL or a local file. Not found: ${source}`) const mime = MIME[extname(source).toLowerCase()] if (!mime) fail(`${what} must be a still image (.jpg, .png or .webp). Convert HEIC to JPEG, or export a clear frame from a video.`) return `data:${mime};base64,${readFileSync(source).toString('base64')}` } const photo = imageValue(options.photo, 'The photo') const extras = options['extra-image'].map((source) => imageValue(source, 'Each --extra-image')) if (!existsSync(options.plan)) fail(`Plan not found: ${options.plan}`) let plan try { plan = JSON.parse(readFileSync(options.plan, 'utf8')) } catch { fail(`${options.plan} is not valid JSON. Compare it with assets/example-shoot-plan.json.`) } const planDir = dirname(resolve(options.plan)) const settings = JSON.parse(readFileSync(join(root, 'assets/generation-input.json'), 'utf8')) const template = readFileSync(join(root, 'assets/prompt-template.md'), 'utf8').trim() const FIELDS = ['wardrobe', 'location', 'light', 'pose', 'camera', 'film', 'framing'] let looks = Array.isArray(plan.looks) ? plan.looks : [] if (options.look) looks = looks.filter((look) => look.id === options.look) if (!looks.length) fail(options.look ? `No look with id "${options.look}" in the plan.` : 'The plan has no looks.') if (looks.length > 8) fail('Plan at most 8 looks per shoot.') const numbers = (from, to) => (from === to ? `Reference image ${from}` : `Reference images ${from} to ${to}`) const requests = looks.map((look) => { for (const field of FIELDS) if (typeof look[field] !== 'string' || !look[field].trim()) fail(`Look "${look.id}" is missing "${field}".`) const garmentList = look.garment === undefined ? [] : Array.isArray(look.garment) ? look.garment : [look.garment] if (garmentList.some((g) => typeof g !== 'string' || !g)) fail(`Look "${look.id}": "garment" must be a file path or https URL, or a list of them.`) const garments = garmentList.map((g) => imageValue(/^https:\/\//.test(g) ? g : resolve(planDir, g), `Garment photo for look "${look.id}"`)) const images = [photo, ...extras, ...garments] if (images.length > MAX_IMAGES) fail(`Look "${look.id}" has ${images.length} reference images; the limit is ${MAX_IMAGES}.`) // With more than one image, say what each one is. With one photo the sentence is empty and the prompt is unchanged. const notes = [] if (images.length > 1) notes.push('Reference image 1 is the model.') if (extras.length) notes.push(`${numbers(2, 1 + extras.length)} ${extras.length === 1 ? 'shows' : 'show'} the same person from ${extras.length === 1 ? 'another angle' : 'other angles'}; use ${extras.length === 1 ? 'it' : 'them'} only to get the face and body right.`) if (garments.length) { const first = 2 + extras.length notes.push(`${numbers(first, first + garments.length - 1)} ${garments.length === 1 ? 'is a garment photo' : 'are garment photos'}: the model wears ${garments.length === 1 ? 'that exact garment' : 'those exact garments'}, with the same colour, fabric, pattern or print, buttons, collar, length and fit; ignore everything else in ${garments.length === 1 ? 'that photo' : 'those photos'}.`) } const values = { collection: plan.collection ?? 'new season', model_note: plan.model_note ?? 'the person in the reference photo', ...look, references: notes.join(' ') } // Replacer functions keep any "$" in the plan's text literal. const prompt = template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(values[name] ?? '')).replace(/\s{2,}/g, ' ') const input = { ...settings.input, ...(options.draft ? { quality: settings.draft_quality } : {}), image_urls: images, prompt } return { id: look.id, model: settings.model, input } }) const output = JSON.stringify(options.look ? requests[0] : { model: settings.model, label: plan.label, looks: requests }, null, 2) if (options.out) { writeFileSync(options.out, output) console.log(`Wrote ${requests.length} request(s) to ${options.out}`) } else console.log(output)