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