Created
          October 7, 2025 12:00 
        
      - 
      
- 
        Save danielehrhardt/7783d7d88c6f65c2d99e397a4e10b0de to your computer and use it in GitHub Desktop. 
    Sora2 NodeJS CLI
  
        
  
    
      This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
      Learn more about bidirectional Unicode characters
    
  
  
    
  | #!/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