Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127#!/usr/bin/env node// pop/bin/gen-motion.mjs — animate one storyboard panel into a motion// shot via Seedance 2.0 on fal.ai (image-to-video, first frame = the// illy panel, optional last frame for panel→panel morphs).//// The illy panel carries the LOOK; the --prompt carries only MOTION// (what moves, how the camera behaves). Tiers: fast (~$0.24/s @720p)// or standard (~$0.30/s @720p) — keep pilots on fast.//// Usage:// node pop/bin/gen-motion.mjs \// --image pop/hellsine/out/hellsine-yt-sec-13-climax-a.png \// --prompt "felt figures dance, flames flicker..." \// [--end-image path.png] # last frame → shot morph// [--duration 5] # 4..15 | auto// [--ratio 16:9] # auto | 21:9 | 16:9 | 4:3 | 1:1 | 3:4 | 9:16// [--resolution 720p] # 480p | 720p// [--tier fast] # fast | standard// [--audio] # default OFF — the track is the audio// [--seed N] [--out path.mp4]//// FAL_KEY from env or vault devcontainer.env.
import { readFileSync, writeFileSync, existsSync } from "node:fs";import { resolve, dirname, basename } from "node:path";import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));const REPO = resolve(HERE, "../..");
const flags = {};for (let i = 2; i < process.argv.length; i++) { const a = process.argv[i]; if (!a.startsWith("--")) continue; const next = process.argv[i + 1]; if (next === undefined || next.startsWith("--")) flags[a.slice(2)] = true; else { flags[a.slice(2)] = next; i++; }}
function falKey() { if (process.env.FAL_KEY) return process.env.FAL_KEY; const vault = `${REPO}/aesthetic-computer-vault/.devcontainer/envs/devcontainer.env`; if (existsSync(vault)) { for (const line of readFileSync(vault, "utf8").split("\n")) { if (line.startsWith("FAL_KEY=")) { return line.slice("FAL_KEY=".length).trim().replace(/^['"]|['"]$/g, ""); } } } throw new Error("FAL_KEY not set and not found in vault devcontainer.env");}
const KEY = falKey();const IMAGE = flags.image && resolve(process.cwd(), flags.image);const PROMPT = flags.prompt;if (!IMAGE || !existsSync(IMAGE)) { console.error("✗ --image missing or not found"); process.exit(1); }if (!PROMPT) { console.error("✗ --prompt required (motion description)"); process.exit(1); }
const TIER = flags.tier || "fast";const ENDPOINT = TIER === "standard" ? "bytedance/seedance-2.0/image-to-video" : "bytedance/seedance-2.0/fast/image-to-video";const DURATION = String(flags.duration ?? "5");const RESOLUTION = flags.resolution || "720p";const RATIO = flags.ratio || "auto"; // 16:9 for the YT cut; auto follows the input panelconst OUT = flags.out ? resolve(process.cwd(), flags.out) : IMAGE.replace(/\.png$/i, "") + `.motion-${TIER}-${DURATION}s.mp4`;
const mime = (p) => p.match(/\.jpe?g$/i) ? "image/jpeg" : p.match(/\.webp$/i) ? "image/webp" : "image/png";const dataUri = (p) => `data:${mime(p)};base64,${readFileSync(p).toString("base64")}`;
const input = { prompt: PROMPT, image_url: dataUri(IMAGE), duration: DURATION, resolution: RESOLUTION, aspect_ratio: RATIO, generate_audio: flags.audio === true, end_image_url: undefined, seed: undefined,};if (flags["end-image"]) { const end = resolve(process.cwd(), flags["end-image"]); if (!existsSync(end)) { console.error("✗ --end-image not found"); process.exit(1); } input.end_image_url = dataUri(end);}if (flags.seed) input.seed = Number(flags.seed);
const auth = { Authorization: `Key ${KEY}`, "Content-Type": "application/json" };
console.log(`▸ ${basename(IMAGE)} → ${TIER} ${RESOLUTION} ${DURATION}s ${input.end_image_url ? "(first+last frame)" : "(first frame)"}`);console.log(` prompt: ${PROMPT.slice(0, 120)}${PROMPT.length > 120 ? "…" : ""}`);
const submit = await fetch(`https://queue.fal.run/${ENDPOINT}`, { method: "POST", headers: auth, body: JSON.stringify(input),});if (!submit.ok) { console.error(`✗ submit failed ${submit.status}: ${(await submit.text()).slice(0, 500)}`); process.exit(1);}const { request_id, status_url, response_url } = await submit.json();console.log(` queued: ${request_id}`);
let status = "";const t0 = Date.now();while (status !== "COMPLETED") { await new Promise((r) => setTimeout(r, 4000)); const res = await fetch(status_url, { headers: auth }); const body = await res.json(); if (body.status !== status) { status = body.status; console.log(` ${status.toLowerCase()} · ${((Date.now() - t0) / 1000).toFixed(0)}s`); } if (status === "FAILED" || body.error) { console.error(`✗ generation failed: ${JSON.stringify(body).slice(0, 500)}`); process.exit(1); }}
const result = await (await fetch(response_url, { headers: auth })).json();const videoUrl = result.video?.url;if (!videoUrl) { console.error(`✗ no video in response: ${JSON.stringify(result).slice(0, 400)}`); process.exit(1); }const mp4 = Buffer.from(await (await fetch(videoUrl)).arrayBuffer());writeFileSync(OUT, mp4);console.log(`✓ ${OUT.replace(REPO + "/", "")} (${(mp4.length / 1e6).toFixed(1)} MB, seed ${result.seed})`);