← back to Prestige Car Wash
scripts/gen-media.js
156 lines
#!/usr/bin/env node
'use strict';
/**
* gen-media.js — wash/wax service media pipeline.
* Nano Banana (Gemini 2.5 Flash Image) -> brand-consistent hero STILL
* └─ that still is the first frame handed to ->
* SeeDance (bytedance/seedance-1-* on Replicate) -> 5s image-to-video CLIP
*
* SAFETY: dry-run by default (spends $0). Real generation requires `--go`.
* Running the paid pilot batch is Steve-gated — get the go before --go.
*
* Usage:
* node scripts/gen-media.js # dry run: plan + cost estimate
* node scripts/gen-media.js --stills # dry run limited to stills
* node scripts/gen-media.js --go # REAL generation (spends money)
* node scripts/gen-media.js --go --lite # use seedance-1-lite (cheaper)
* node scripts/gen-media.js --go --only svc-hand-wash,svc-wax-seal
*
* Keys: read from process.env, falling back to the master secrets .env (read-only).
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const MEDIA = path.join(ROOT, 'media');
const LEDGER = path.join(process.env.HOME, '.claude', 'cost-ledger.jsonl');
// ---- key resolution (env first, then master secrets .env) ------------------
function keyFrom(name) {
if (process.env[name]) return process.env[name];
try {
const master = fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8');
const m = master.match(new RegExp('^' + name + '=(.+)$', 'm'));
return m ? m[1].trim() : '';
} catch { return ''; }
}
const GEMINI = keyFrom('GEMINI_API_KEY');
const REPLICATE = keyFrom('REPLICATE_API_TOKEN');
// ---- args ------------------------------------------------------------------
const argv = process.argv.slice(2);
const GO = argv.includes('--go');
const LITE = argv.includes('--lite');
const STILLS_ONLY = argv.includes('--stills');
const onlyIdx = argv.indexOf('--only');
const onlyArg = onlyIdx >= 0 ? (argv[onlyIdx + 1] || '').split(',').filter(Boolean) : [];
// Pricing (approx, for estimate + ledger)
const COST_STILL = 0.039; // Gemini 2.5 Flash Image per image
const COST_CLIP = LITE ? 0.09 : 0.30; // SeeDance lite vs pro, ~5s
const SEEDANCE_MODEL = LITE ? 'bytedance/seedance-1-lite' : 'bytedance/seedance-1-pro';
// Brand prompt scaffold — keeps every asset on-brand (deep navy, chrome, cinematic).
const BRAND = 'Cinematic, photorealistic, professional automotive detailing shot, deep navy and chrome palette, dramatic studio lighting, glossy reflections, shallow depth of field, premium car-wash brand aesthetic, no text, no logos, no watermark';
const PROMPTS = {
'svc-express-wash': 'a clean sedan under a spray of water and white foam at an express car wash bay',
'svc-full-service': 'a detailer vacuuming and wiping the interior of a car, water beading on the exterior',
'svc-hand-wash': 'gloved hands washing a luxury car with a foam cannon and two buckets, thick suds sliding down the door',
'svc-wax-seal': 'a microfiber applicator spreading ceramic wax on a glossy black hood, water beading into perfect droplets',
'svc-interior-detail':'close-up of a steam cleaner and brush restoring a leather car seat and dashboard to like-new',
'svc-ceramic-coating':'a technician applying 9H ceramic coating to a sports car panel, rainbow sheen on the fresh coat',
'svc-headlight': 'before-and-after of a foggy yellow headlight being polished clear, crisp and bright',
'svc-complete-detail':'a fully detailed car in a showroom, mirror-like paint, spotless wheels, glowing under lights'
};
const services = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'services.json'), 'utf8'));
let targets = services.filter(s => PROMPTS[s.id]);
if (onlyArg.length) targets = targets.filter(s => onlyArg.includes(s.id));
function logCost(model, units, cost, note) {
try {
fs.mkdirSync(path.dirname(LEDGER), { recursive: true });
fs.appendFileSync(LEDGER, JSON.stringify({
ts: new Date().toISOString(), project: 'prestige-car-wash', model, units, cost_usd: +cost.toFixed(4), note
}) + '\n');
} catch {}
}
async function nanoBananaStill(svc) {
const prompt = `${PROMPTS[svc.id]}. ${BRAND}`;
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI}`;
const r = await fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }] })
});
if (!r.ok) throw new Error(`Gemini ${r.status}: ${(await r.text()).slice(0, 200)}`);
const j = await r.json();
const part = (j.candidates?.[0]?.content?.parts || []).find(p => p.inlineData);
if (!part) throw new Error('no image in Gemini response');
const buf = Buffer.from(part.inlineData.data, 'base64');
const out = path.join(MEDIA, `${svc.id}.png`);
fs.writeFileSync(out, buf);
logCost('gemini-2.5-flash-image', 1, COST_STILL, svc.id);
return { path: out, dataUri: `data:image/png;base64,${part.inlineData.data}` };
}
async function seedanceClip(svc, imageDataUri) {
const motion = `Smooth cinematic push-in, water and foam moving realistically, ${PROMPTS[svc.id]}`;
// Replicate model-based prediction with polling.
let pred = await (await fetch(`https://api.replicate.com/v1/models/${SEEDANCE_MODEL}/predictions`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${REPLICATE}`, 'Content-Type': 'application/json', 'Prefer': 'wait' },
body: JSON.stringify({ input: { image: imageDataUri, prompt: motion, duration: 5 } })
})).json();
const started = Date.now();
while (pred.status && !['succeeded', 'failed', 'canceled'].includes(pred.status)) {
if (Date.now() - started > 300000) throw new Error('SeeDance timeout');
await new Promise(r => setTimeout(r, 3000));
pred = await (await fetch(pred.urls.get, { headers: { 'Authorization': `Bearer ${REPLICATE}` } })).json();
}
if (pred.status !== 'succeeded') throw new Error(`SeeDance ${pred.status}: ${pred.error || ''}`);
const videoUrl = Array.isArray(pred.output) ? pred.output[0] : pred.output;
const buf = Buffer.from(await (await fetch(videoUrl)).arrayBuffer());
const out = path.join(MEDIA, `${svc.id}.mp4`);
fs.writeFileSync(out, buf);
logCost(SEEDANCE_MODEL, 1, COST_CLIP, svc.id);
return { path: out };
}
async function main() {
fs.mkdirSync(MEDIA, { recursive: true });
const nStills = targets.length;
const nClips = STILLS_ONLY ? 0 : targets.length;
const est = nStills * COST_STILL + nClips * COST_CLIP;
console.log(`\n🎬 Prestige media pilot (${SEEDANCE_MODEL})`);
console.log(` services: ${targets.map(t => t.id).join(', ')}`);
console.log(` stills: ${nStills} × $${COST_STILL} = $${(nStills * COST_STILL).toFixed(2)}`);
console.log(` clips: ${nClips} × $${COST_CLIP} = $${(nClips * COST_CLIP).toFixed(2)}`);
console.log(` EST TOTAL: $${est.toFixed(2)}`);
console.log(` keys: GEMINI ${GEMINI ? 'ok' : 'MISSING'} · REPLICATE ${REPLICATE ? 'ok' : 'MISSING'}`);
if (!GO) {
console.log(`\n DRY RUN — no spend. Re-run with --go to generate (Steve-gated).\n`);
return;
}
if (!GEMINI || !REPLICATE) { console.error('\n ✖ missing key(s) — route via /secrets first.\n'); process.exit(1); }
let spent = 0;
for (const svc of targets) {
try {
process.stdout.write(` • ${svc.id}: still…`);
const still = await nanoBananaStill(svc); spent += COST_STILL;
process.stdout.write(` ok ($${spent.toFixed(2)})`);
if (!STILLS_ONLY) {
process.stdout.write(` · clip…`);
await seedanceClip(svc, still.dataUri); spent += COST_CLIP;
process.stdout.write(` ok ($${spent.toFixed(2)})`);
}
console.log('');
} catch (e) { console.log(` ✖ ${e.message}`); }
}
console.log(`\n ✔ done. Actual spend ≈ $${spent.toFixed(2)} (logged to cost-ledger.jsonl)\n`);
}
main().catch(e => { console.error(e); process.exit(1); });