← back to Afterlight Exhibition

scripts/gen-seedance.mjs

100 lines

#!/usr/bin/env node
/* Generate one Seedance motion clip per object via Replicate.
   Model: bytedance/seedance-1-lite · 720p · 5s · 16:9  (~$0.036/s => $0.18/clip)
   Reads REPLICATE_API_TOKEN from ~/Projects/secrets-manager/.env
   Downloads MP4s to media/obj-N.mp4. Cost is printed before + after. */

import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const MEDIA = path.join(ROOT, "media");
fs.mkdirSync(MEDIA, { recursive: true });

/* ---- token ---- */
function readToken() {
  if (process.env.REPLICATE_API_TOKEN) return process.env.REPLICATE_API_TOKEN;
  const env = path.join(os.homedir(), "Projects/secrets-manager/.env");
  const line = fs.readFileSync(env, "utf8").split("\n").find(l => l.startsWith("REPLICATE_API_TOKEN="));
  if (!line) throw new Error("REPLICATE_API_TOKEN not found");
  return line.slice("REPLICATE_API_TOKEN=".length).trim();
}
const TOKEN = readToken();
const MODEL = "bytedance/seedance-1-lite";
const PRICE_PER_SEC = 0.036;   // 720p lite
const DURATION = 5;

const OBJECTS = [
  { id: 1, camera_fixed: false, prompt:
    "Cinematic museum shot, slow dolly-in on a charred antique oak schoolroom bench inside a dark gallery vitrine, children's initials carved into scorched wood grain, a warm amber shaft of light, dust motes drifting slowly, shallow depth of field, 35mm film grain, quiet restrained motion, no people, no text" },
  { id: 2, camera_fixed: false, prompt:
    "Cinematic close shot in a dark gallery, a translucent hanging textile woven with glowing fibre-optic threads, tiny points of cool signal-blue light slowly migrating across the weave like a data cascade, gentle sway, shallow depth of field, moody low light, no people, no text" },
  { id: 3, camera_fixed: false, prompt:
    "Cinematic macro shot, a modified antique brass-and-glass camera lens on a steel stand in a dark room, soft teal rim light, the focus slowly breathing in and out and never resolving, faint reflections, shallow depth of field, contemplative, no people, no text" },
  { id: 4, camera_fixed: true, prompt:
    "Cinematic macro shot of an ornate brass clock with motionless hands in a dark room, faint violet rim light, a single dust particle drifting slowly through a thin light beam, utter stillness, shallow depth of field, solemn, no people, no text" },
  { id: 5, camera_fixed: true, prompt:
    "Cinematic wide shot of an empty minimalist chamber whose smooth walls slowly shift and breathe pale-green and mint hues, generative shifting light across the surfaces, still locked-off camera, eerie calm, soft ambient glow, no people, no text" },
];

const projected = OBJECTS.length * DURATION * PRICE_PER_SEC;
console.log(`\n💵 Projected cost: ${OBJECTS.length} clips × ${DURATION}s × $${PRICE_PER_SEC}/s = $${projected.toFixed(2)}`);
if (projected > 2) { console.error("Refusing: projected cost over $2 safety guard."); process.exit(1); }

const H = { "Authorization": `Bearer ${TOKEN}`, "Content-Type": "application/json" };
const sleep = ms => new Promise(r => setTimeout(r, ms));

async function createPrediction(o) {
  const body = { input: {
    prompt: o.prompt, resolution: "720p", aspect_ratio: "16:9",
    duration: DURATION, fps: 24, camera_fixed: o.camera_fixed, seed: 1000 + o.id
  }};
  const r = await fetch(`https://api.replicate.com/v1/models/${MODEL}/predictions`,
    { method: "POST", headers: H, body: JSON.stringify(body) });
  const j = await r.json();
  if (!r.ok) throw new Error(`create obj-${o.id}: ${r.status} ${JSON.stringify(j).slice(0,200)}`);
  return j;
}
async function poll(url) {
  for (let i = 0; i < 200; i++) {
    const r = await fetch(url, { headers: H });
    const j = await r.json();
    if (j.status === "succeeded") return j;
    if (j.status === "failed" || j.status === "canceled") throw new Error(`prediction ${j.status}: ${j.error}`);
    await sleep(3000);
  }
  throw new Error("timeout");
}
async function download(url, dest) {
  const r = await fetch(url);
  if (!r.ok) throw new Error(`download ${r.status}`);
  const buf = Buffer.from(await r.arrayBuffer());
  fs.writeFileSync(dest, buf);
  return buf.length;
}

const run = async (o) => {
  const t0 = Date.now();
  console.log(`  ▸ obj-${o.id}: submitting…`);
  const p = await createPrediction(o);
  const done = await poll(p.urls.get);
  let out = done.output;
  if (Array.isArray(out)) out = out[0];
  if (!out) throw new Error(`obj-${o.id}: no output`);
  const dest = path.join(MEDIA, `obj-${o.id}.mp4`);
  const bytes = await download(out, dest);
  const secs = ((Date.now() - t0) / 1000).toFixed(0);
  console.log(`  ✓ obj-${o.id}: ${(bytes/1e6).toFixed(2)} MB in ${secs}s → media/obj-${o.id}.mp4`);
  return { id: o.id, bytes };
};

const results = await Promise.allSettled(OBJECTS.map(run));
const ok = results.filter(r => r.status === "fulfilled").length;
const actual = ok * DURATION * PRICE_PER_SEC;
console.log(`\n✅ ${ok}/${OBJECTS.length} clips generated. 💵 Actual cost ≈ $${actual.toFixed(2)}`);
results.filter(r => r.status === "rejected").forEach(r => console.error("  ✗", r.reason.message));
process.exit(ok === OBJECTS.length ? 0 : 1);