#!/usr/bin/env node // Prints the Seedance image-to-video request that animates the approved keyframe. It does not call any API. // Usage: // node scripts/build-video-input.mjs --keyframe keyframe.jpg --product "matte black over-ear headphones" --mood dark-luxe // [--move push-in|locked|rise|orbit] [--duration 8] [--resolution 720p] [--label-text "EXACT TEXT" | --unbranded] // [--colour "tangerine orange"] [--audio] [--draft] [--out video.json] // --draft uses the cheap draft model (Seedance 2.0 mini, 480p) to check the motion before the final. 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-video-input.mjs --keyframe --product "short description" --mood dark-luxe|glass-glow|bright-clean|colour-pop [--move push-in|locked|rise|orbit] [--duration 8] [--resolution 480p|720p|1080p] [--label-text "EXACT TEXT" | --unbranded] [--colour "name or #hex"] [--audio] [--draft] [--out video.json]' const VALUE_FLAGS = new Set(['keyframe', 'product', 'mood', 'move', 'duration', 'resolution', 'label-text', 'colour', 'out']) const SWITCHES = new Set(['unbranded', 'audio', '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 ['keyframe', 'product', 'mood']) if (!options[key]) fail(`--${key} is required.`) const presets = JSON.parse(readFileSync(join(root, 'assets/reveal-presets.json'), 'utf8')) const video = JSON.parse(readFileSync(join(root, 'assets/generation-input.json'), 'utf8')).video const tier = options.draft ? video.draft : video.final const mood = presets.moods[options.mood] if (!mood) fail(`Unknown mood "${options.mood}". Use one of: ${Object.keys(presets.moods).join(', ')}.`) if (options['label-text'] && options.unbranded) fail('Use --label-text or --unbranded, not both.') // Printed text drifts most when the camera moves, so a product with a label defaults to a locked camera. const moveName = options.move ?? (options['label-text'] ? 'locked' : 'push-in') const move = presets.moves[moveName] if (!move) fail(`Unknown move "${moveName}". Use one of: ${Object.keys(presets.moves).join(', ')}.`) if (options['label-text']) { console.error(moveName === 'locked' ? 'Note: --label-text given, so the camera is locked (only the light moves). Printed text can still drift: read the label in the first, middle and last frames.' : `Warning: printed text drifts most when the camera moves. With --move ${moveName}, read the label in every frame of the contact sheet, and use --move locked if it changes.`) } if (moveName === 'orbit') console.error('Warning: orbit made a complex product morph in testing. Use it only for simple, symmetric shapes (cans, round bottles).') // Say how far each choice has been proven; only the preview's own mood and move have made a final video. for (const [kind, name] of [['moods', options.mood], ['moves', moveName]]) { const tested = presets.tested?.[kind]?.[name] if (tested && !tested.includes('(the preview)')) console.error(`Note: ${kind.slice(0, -1)} "${name}" so far: ${tested}. Run a --draft first and check it.`) } 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.') const duration = Number(options.duration ?? tier.input.duration) const maxSeconds = options.draft ? 15 : 30 if (!Number.isInteger(duration) || duration < 4 || duration > maxSeconds) fail(`--duration must be a whole number of seconds from 4 to ${maxSeconds}.`) const resolution = options.resolution ?? tier.input.resolution if (!tier.resolutions.includes(resolution)) fail(`--resolution must be one of ${tier.resolutions.join(', ')} for ${tier.model}.`) const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp' } let keyframe = options.keyframe if (!/^https:\/\//.test(keyframe)) { if (!existsSync(keyframe)) fail(`Keyframe not found: ${keyframe}`) const mime = MIME[extname(keyframe).toLowerCase()] if (!mime) fail('The keyframe must be a still image (.jpg, .png or .webp): the approved hero frame from build-keyframe-input.mjs.') keyframe = `data:${mime};base64,${readFileSync(keyframe).toString('base64')}` } const labelClause = options['label-text'] ? `, and its printed text stays exactly "${options['label-text'].trim()}"` : options.unbranded ? ', and it stays plain with no logo or lettering' : ', including its logos and printed text' const template = readFileSync(join(root, 'assets/prompt-template.md'), 'utf8') const section = template.match(/## Video\s+([\s\S]*?)(?=\n## |$)/)?.[1]?.trim() if (!section) fail('assets/prompt-template.md is missing its "## Video" section.') const values = { move, light_move: mood.light_move, product: options.product.trim(), label_clause: labelClause, feel: mood.feel } // Replacer functions keep any "$" in the user's text literal. const prompt = section.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? '').replace(/\{\{colour\}\}/g, () => colour).replace(/\s{2,}/g, ' ') const input = { ...tier.input, prompt, image_url: keyframe, duration: String(duration), resolution, generate_audio: Boolean(options.audio) } const rate = tier.approx_cost_per_second_usd[resolution] const output = JSON.stringify({ model: tier.model, approx_cost_usd: Number((rate * duration).toFixed(2)), input }, null, 2) if (options.out) { writeFileSync(options.out, output) console.log(`Wrote the video request to ${options.out} (about $${(rate * duration).toFixed(2)})`) } else console.log(output)