Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save danielehrhardt/7783d7d88c6f65c2d99e397a4e10b0de to your computer and use it in GitHub Desktop.
Save danielehrhardt/7783d7d88c6f65c2d99e397a4e10b0de to your computer and use it in GitHub Desktop.
Sora2 NodeJS CLI
#!/usr/bin/env node
/* create-sora2-video.mjs
* Generate a Sora 2 video (with audio) via OpenAI v1/videos.
*
* Usage examples:
* node create-sora2-video.mjs --prompt "A golden retriever surfing a wave at sunset, cinematic, 24 fps" --orientation landscape --duration 6
* node create-sora2-video.mjs --prompt "Close-up of raindrops on a window with city lights bokeh" --orientation portrait --duration 8 --image ./guide.jpg
*
* Env:
* OPENAI_API_KEY=sk-...
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout as sleep } from "node:timers/promises";
import crypto from "node:crypto";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const API_KEY = process.env.OPENAI_API_KEY;
if (!API_KEY) {
console.error("Missing OPENAI_API_KEY in environment.");
process.exit(1);
}
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
const n = (k) => (argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : "");
if (a === "--prompt") args.prompt = n("prompt");
else if (a === "--duration") args.duration = Number(n("duration"));
else if (a === "--orientation") args.orientation = n("orientation"); // portrait|landscape
else if (a === "--image") args.imagePath = n("image");
else if (a === "--outdir") args.outdir = n("outdir");
else if (a === "--seed") args.seed = Number(n("seed"));
else if (a === "--fps") args.fps = Number(n("fps"));
else if (a === "--model") args.model = n("model"); // default sora-2
else if (a === "--help") args.help = true;
}
return args;
}
const args = parseArgs(process.argv);
if (args.help || !args.prompt) {
console.log(`
Sora 2 video generator (OpenAI v1/videos)
Required:
--prompt Text prompt
Optional:
--duration Seconds (default: 6)
--orientation portrait | landscape (default: landscape)
--image Optional guide image (local path)
--seed Integer for determinism (optional)
--fps Frames per second (e.g., 24 or 30)
--model Defaults to "sora-2"
--outdir Output directory (default: ./out)
Examples:
node ${path.basename(__filename)} --prompt "A futuristic city timelapse, neon reflections, moody score" --orientation landscape --duration 10
node ${path.basename(__filename)} --prompt "A watercolor koi fish looping in a pond" --orientation portrait --duration 8 --image ./koi.jpg
`);
process.exit(0);
}
const OUTDIR = path.resolve(args.outdir || path.join(__dirname, "out"));
await fs.promises.mkdir(OUTDIR, { recursive: true });
const orientation = (args.orientation || "landscape").toLowerCase();
const size = orientation === "portrait" ? "720x1280" : "1280x720";
const duration = Number.isFinite(args.duration) ? Math.max(1, Math.floor(args.duration)) : 6;
const model = args.model || "sora-2";
const fps = Number.isFinite(args.fps) ? args.fps : undefined;
const seed = Number.isFinite(args.seed) ? args.seed : undefined;
// Helper to read/encode optional guide image
async function maybeEncodeImage(imagePath) {
if (!imagePath) return null;
const abs = path.resolve(imagePath);
const data = await fs.promises.readFile(abs);
const ext = path.extname(abs).slice(1).toLowerCase() || "jpg";
const mime = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
return `data:${mime};base64,${data.toString("base64")}`;
}
async function createJob() {
const imageDataUrl = await maybeEncodeImage(args.imagePath);
// Minimal, forward-compatible payload. Some fields may evolve — keep them easily tweakable.
const payload = {
model, // "sora-2"
prompt: args.prompt, // natural language description
size, // "1280x720" | "720x1280"
duration, // seconds (integer)
// Request synced audio in the output (Sora 2 supports audio output)
audio: { include: true },
// Optional quality/controls (tweak or remove if your account lacks access)
...(fps ? { fps } : {}),
...(seed !== undefined ? { seed } : {}),
...(imageDataUrl ? { image: imageDataUrl } : {}), // optional guide image
};
const res = await fetch("https://api.openai.com/v1/videos", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Failed to create video job (${res.status}): ${errTxt}`);
}
return res.json();
}
async function getJob(jobId) {
const res = await fetch(`https://api.openai.com/v1/videos/${jobId}`, {
headers: { "Authorization": `Bearer ${API_KEY}` },
});
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Failed to fetch job (${res.status}): ${errTxt}`);
}
return res.json();
}
async function downloadToFile(url, destPath) {
const res = await fetch(url, {
headers: { "Authorization": `Bearer ${API_KEY}` }, // include in case URLs are auth-gated
});
if (!res.ok) {
const errTxt = await res.text().catch(() => "");
throw new Error(`Download failed (${res.status}): ${errTxt}`);
}
const file = fs.createWriteStream(destPath);
await new Promise((resolve, reject) => {
res.body.pipe(file);
res.body.on("error", reject);
file.on("finish", resolve);
});
}
function logStep(msg) {
const ts = new Date().toISOString();
console.log(`[${ts}] ${msg}`);
}
(async () => {
try {
logStep(`Creating Sora 2 video job (model=${model}, size=${size}, duration=${duration}s)…`);
const create = await createJob();
// Expect structure like: { id, status, ... }
const jobId = create.id || create.video_id || create.job_id;
if (!jobId) {
// If API returns assets immediately, handle that path:
if (create.assets || create.output) {
logStep("Job returned immediate assets. Saving…");
await saveAssetsAndExit(create, OUTDIR);
return;
}
throw new Error(`No job id found in response: ${JSON.stringify(create)}`);
}
logStep(`Job created: ${jobId}. Polling status…`);
let status = create.status || "queued";
let lastPct = -1;
// Poll until finished
for (;;) {
const state = await getJob(jobId);
status = state.status || status;
// Optional progress field
const pct = typeof state.progress === "number" ? Math.round(state.progress) : null;
if (pct !== null && pct !== lastPct) {
lastPct = pct;
logStep(`Progress: ${pct}%`);
} else {
logStep(`Status: ${status}`);
}
if (["succeeded", "completed", "complete"].includes(status)) {
logStep("Rendering complete. Downloading assets…");
await saveAssetsAndExit(state, OUTDIR);
break;
}
if (["failed", "error", "canceled", "cancelled"].includes(status)) {
const reason = state.error?.message || state.message || "Unknown error";
throw new Error(`Job ${status}: ${reason}`);
}
await sleep(5000); // 5s between polls
}
} catch (err) {
console.error(`\nERROR: ${err.message}\n`);
process.exit(1);
}
})();
async function saveAssetsAndExit(state, outdir) {
// Try a few likely response shapes; adjust as the API stabilizes for your account.
const baseName =
"sora2_" +
(state.id || state.video_id || crypto.randomBytes(4).toString("hex"));
// Common patterns we handle:
// 1) state.assets = { video: { url }, audio: { url }, preview: { url } }
// 2) state.output = [{ type: 'video', url }, { type: 'audio', url }, …]
// 3) state.video_url / state.audio_url
const assets = state.assets || {};
const outputs = Array.isArray(state.output) ? state.output : [];
const videoUrl =
assets.video?.url ||
outputs.find((o) => (o.type || o.kind) === "video")?.url ||
state.video_url ||
state.url; // sometimes a single url
const audioUrl =
assets.audio?.url ||
outputs.find((o) => (o.type || o.kind) === "audio")?.url ||
state.audio_url;
if (!videoUrl) {
throw new Error("No video URL found in job response.");
}
const mp4Path = path.join(outdir, `${baseName}.mp4`);
logStep(`Downloading video → ${mp4Path}`);
await downloadToFile(videoUrl, mp4Path);
if (audioUrl) {
const audioPath = path.join(outdir, `${baseName}.aac`);
logStep(`Downloading audio → ${audioPath}`);
await downloadToFile(audioUrl, audioPath);
} else {
logStep("No separate audio URL exposed (audio likely muxed into the MP4).");
}
logStep("Done ✅");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment