Generate B-Roll Footage with OpenAI Sora API in Python

Use the OpenAI Sora API to programmatically generate B-roll video clips in Python. Full workflow from prompt to MP4 in under 20 minutes.

Problem: Creating B-Roll at Scale is Slow and Expensive

Stock footage licensing is expensive, and shooting custom B-roll takes time you don't have. OpenAI's Sora API lets you generate professional-quality video clips programmatically — but the API is async, has quirks around polling, and the docs skip the production-ready patterns.

You'll learn:

  • How to authenticate and call the Sora video generation endpoint
  • How to handle async job polling correctly (without hammering the API)
  • How to download and save finished clips to disk

Time: 20 min | Level: Intermediate


Why This Happens

Sora doesn't return video immediately. You submit a generation job and get back a job_id. You then poll a status endpoint until the job state is "succeeded", at which point a signed download URL is available. Most errors come from polling too aggressively (rate limits) or not checking the status field before trying to fetch the download URL.

Common symptoms:

  • 429 Too Many Requests when polling
  • KeyError: 'download_url' because the job hasn't finished
  • Saved files that are 0 bytes because the download URL expired

Solution

Step 1: Install Dependencies and Set Up Auth

pip install openai httpx python-dotenv

Store your key in .env — never hardcode it:

# .env
OPENAI_API_KEY=sk-...
# sora_client.py
import os
from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

# The client reads OPENAI_API_KEY from env automatically
client = OpenAI()

Expected: No errors on import. If you see AuthenticationError, double-check your .env path.


Step 2: Submit a Video Generation Job

import time
import httpx
from pathlib import Path

def generate_broll(prompt: str, output_path: str, duration: int = 5) -> str:
    """
    Submit a Sora generation job and return the job ID.
    duration: seconds of video (5 or 10 supported)
    """
    response = client.videos.generate(
        model="sora",
        prompt=prompt,
        duration=duration,          # 5 or 10 seconds
        resolution="1080p",         # 720p | 1080p
        n=1,                        # number of variants to generate
    )

    # Response contains a job ID, not the video itself
    job_id = response.id
    print(f"Job submitted: {job_id}")
    return job_id

Expected: A string like vg-abc123xyz printed to stdout.

If it fails:

  • InvalidRequestError: duration: Sora only supports 5 or 10 second clips as of Feb 2026
  • model_not_found: Ensure your API key has Sora access (currently requires Tier 4+ or waitlist approval)

Step 3: Poll for Completion with Exponential Backoff

def poll_until_done(job_id: str, max_wait: int = 300) -> dict:
    """
    Poll the job status endpoint until done or timeout.
    Uses exponential backoff to avoid rate limiting.
    """
    interval = 5      # Start polling every 5 seconds
    elapsed = 0

    while elapsed < max_wait:
        job = client.videos.retrieve(job_id)

        if job.status == "succeeded":
            return job  # Has download_url at this point

        if job.status == "failed":
            raise RuntimeError(f"Generation failed: {job.error}")

        # Still running — wait before next poll
        time.sleep(interval)
        elapsed += interval
        interval = min(interval * 1.5, 30)  # Cap backoff at 30s

    raise TimeoutError(f"Job {job_id} did not complete within {max_wait}s")

Why exponential backoff matters: Sora jobs take 30–120 seconds. Polling every second burns through rate limits fast. Starting at 5s intervals and capping at 30s keeps you well under the limit.


Step 4: Download the Finished Clip

def download_clip(job: dict, output_path: str) -> Path:
    """
    Download the finished video to disk.
    The signed URL expires in ~1 hour — download immediately.
    """
    url = job.download_url  # Only present when status == "succeeded"
    output = Path(output_path)
    output.parent.mkdir(parents=True, exist_ok=True)

    # Stream download to avoid loading entire file into memory
    with httpx.stream("GET", url) as r:
        r.raise_for_status()
        with open(output, "wb") as f:
            for chunk in r.iter_bytes(chunk_size=8192):
                f.write(chunk)

    print(f"Saved: {output} ({output.stat().st_size / 1024:.1f} KB)")
    return output

If it fails:

  • 0-byte file: The signed URL expired. Re-retrieve the job (client.videos.retrieve(job_id)) to get a fresh URL — it regenerates on each call
  • 403 Forbidden: Same cause — URL expired. Don't store URLs, store job IDs

Step 5: Putting It Together

def create_broll(prompt: str, filename: str, duration: int = 5) -> Path:
    """End-to-end: prompt in, MP4 file out."""
    job_id = generate_broll(prompt, filename, duration)
    job = poll_until_done(job_id)
    return download_clip(job, f"./broll/{filename}.mp4")


if __name__ == "__main__":
    clips = [
        ("Aerial drone shot of a city skyline at golden hour", "city-skyline"),
        ("Close-up of hands typing on a mechanical keyboard", "keyboard-typing"),
        ("Coffee being poured into a white ceramic mug, slow motion", "coffee-pour"),
    ]

    for prompt, name in clips:
        path = create_broll(prompt, name)
        print(f"Done: {path}")

Verification

python sora_client.py

You should see:

Job submitted: vg-abc123xyz
Job submitted: vg-def456uvw
Job submitted: vg-ghi789rst
Saved: ./broll/city-skyline.mp4 (18432.0 KB)
Saved: ./broll/keyboard-typing.mp4 (12288.0 KB)
Saved: ./broll/coffee-pour.mp4 (15360.0 KB)
Done: broll/city-skyline.mp4

Check the files play correctly:

ls -lh ./broll/
# Should show 3 non-zero .mp4 files

Terminal output showing successful Sora job completion Three clips generated and saved — all non-zero file sizes confirm successful download


Generating Batches Efficiently

For larger workflows, submit all jobs first, then poll them together instead of waiting serially:

import concurrent.futures

def batch_generate(clips: list[tuple[str, str]]) -> list[Path]:
    # Submit all jobs immediately
    jobs = [(name, generate_broll(prompt, name)) for prompt, name in clips]

    # Poll all in parallel
    results = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        futures = {
            executor.submit(poll_until_done, job_id): (name, job_id)
            for name, job_id in jobs
        }
        for future in concurrent.futures.as_completed(futures):
            name, _ = futures[future]
            job = future.result()
            results.append(download_clip(job, f"./broll/{name}.mp4"))

    return results

This cuts total wait time from n * avg_generation_time to roughly 1 * avg_generation_time for batches.


What You Learned

  • Sora is async — always poll by job_id, never assume immediate results
  • Exponential backoff prevents rate limiting on longer jobs
  • Signed download URLs expire; re-retrieve the job object to get a fresh one rather than storing URLs
  • Batch-submitting jobs then polling in parallel is dramatically faster than serial generation

Limitation: As of Feb 2026, Sora clips max out at 10 seconds and 1080p. For longer sequences, generate multiple clips and stitch with ffmpeg. API access still requires Tier 4 OpenAI account or explicit waitlist approval.

When NOT to use this: If you need 4K footage or clips longer than 30 seconds, Sora isn't the right tool yet. Runway ML Gen-3 handles longer-form generation.


Tested on Python 3.12, openai SDK 1.x, macOS & Ubuntu 24.04