← 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); });