Created
January 10, 2026 14:03
-
-
Save roelven/8d68e5199e5d2a99eb238e337b465eb0 to your computer and use it in GitHub Desktop.
Bash CLI wrapper for OpenAI Sora 2 video generation API
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 bash | |
| # Simple CLI for OpenAI Sora 2 video generation. | |
| # Requirements: bash, curl, jq | |
| # Usage examples at bottom. | |
| set -euo pipefail | |
| # Load environment variables from .env file if it exists | |
| if [[ -f "$(dirname "$0")/.env" ]]; then | |
| set -a | |
| source "$(dirname "$0")/.env" | |
| set +a | |
| fi | |
| API_BASE="${OPENAI_API_BASE:-https://api.openai.com}" | |
| API_KEY="${OPENAI_API_KEY:-}" | |
| MODEL="${MODEL:-sora-2}" # or "sora-2-pro" | |
| PROMPT="${PROMPT:-}" | |
| SIZE="${SIZE:-1280x720}" # e.g. 1280x720, 720x1280, 1792x1024 | |
| SECONDS="${SECONDS:-8}" # typical allowed values: 4, 8, 12 (per docs) | |
| REF_IMAGE="${REF_IMAGE:-}" # optional: path to a JPEG/PNG/WebP; should match SIZE | |
| OUT="${OUT:-sora_output.mp4}" # output filename | |
| POLL_INTERVAL="${POLL_INTERVAL:-8}" | |
| if [[ -z "$API_KEY" ]]; then | |
| echo "ERROR: Set OPENAI_API_KEY in your environment." >&2 | |
| exit 1 | |
| fi | |
| if [[ -z "$PROMPT" ]]; then | |
| echo "ERROR: Provide a prompt via PROMPT='your text' sora2.sh" >&2 | |
| exit 1 | |
| fi | |
| # 1) Create video job (multipart to support optional image reference) | |
| echo "Submitting video job..." | |
| CREATE_URL="${API_BASE}/v1/videos" | |
| # Build -F arguments | |
| FORM=(-F "model=${MODEL}" -F "prompt=${PROMPT}" -F "size=${SIZE}" -F "seconds=${SECONDS}") | |
| if [[ -n "${REF_IMAGE}" ]]; then | |
| # Best effort to infer mime; override with REF_MIME if needed. | |
| EXT="${REF_IMAGE##*.}" | |
| case "${REF_MIME:-$EXT}" in | |
| jpg|jpeg|image/jpeg) MIME="image/jpeg" ;; | |
| png|image/png) MIME="image/png" ;; | |
| webp|image/webp) MIME="image/webp" ;; | |
| *) MIME="application/octet-stream" ;; | |
| esac | |
| FORM+=(-F "input_reference=@${REF_IMAGE};type=${MIME}") | |
| fi | |
| CREATE_RESP="$(curl -sS -X POST "$CREATE_URL" \ | |
| -H "Authorization: Bearer ${API_KEY}" \ | |
| -H "Accept: application/json" \ | |
| -H "Expect:" \ | |
| -H "Connection: keep-alive" \ | |
| -F "model=${MODEL}" \ | |
| -F "prompt=${PROMPT}" \ | |
| -F "size=${SIZE}" \ | |
| -F "seconds=${SECONDS}" \ | |
| ${REF_IMAGE:+-F "input_reference=@${REF_IMAGE};type=${MIME}"} )" | |
| if [[ -z "$CREATE_RESP" ]]; then | |
| echo "ERROR: empty response creating video job" >&2 | |
| exit 1 | |
| fi | |
| echo "$CREATE_RESP" | jq . >/dev/null || { echo "ERROR: non-JSON response:"; echo "$CREATE_RESP"; exit 1; } | |
| VIDEO_ID="$(echo "$CREATE_RESP" | jq -r '.id')" | |
| STATUS="$(echo "$CREATE_RESP" | jq -r '.status')" | |
| if [[ "$VIDEO_ID" == "null" || -z "$VIDEO_ID" ]]; then | |
| echo "ERROR: Could not find video id in response:" | |
| echo "$CREATE_RESP" | |
| exit 1 | |
| fi | |
| echo "Video ID: $VIDEO_ID (status: $STATUS)" | |
| # 2) Poll for completion | |
| RETRIEVE_URL="${API_BASE}/v1/videos/${VIDEO_ID}" | |
| while true; do | |
| RESP="$(curl -sS -X GET "$RETRIEVE_URL" \ | |
| -H "Authorization: Bearer ${API_KEY}" \ | |
| -H "Accept: application/json")" | |
| STATUS="$(echo "$RESP" | jq -r '.status')" | |
| PROG="$(echo "$RESP" | jq -r '.progress // 0')" | |
| echo "Status: $STATUS Progress: ${PROG}%" | |
| case "$STATUS" in | |
| completed) break ;; | |
| failed|canceled) | |
| echo "Job ended with status: $STATUS" | |
| echo "$RESP" | jq . | |
| exit 2 | |
| ;; | |
| queued|in_progress) | |
| sleep "$POLL_INTERVAL" | |
| ;; | |
| *) | |
| echo "Unexpected status: $STATUS" | |
| echo "$RESP" | jq . | |
| sleep "$POLL_INTERVAL" | |
| ;; | |
| esac | |
| done | |
| # 3) Download MP4 content | |
| CONTENT_URL="${API_BASE}/v1/videos/${VIDEO_ID}/content" | |
| echo "Downloading video content to ${OUT} ..." | |
| curl -sS -L "$CONTENT_URL" \ | |
| -H "Authorization: Bearer ${API_KEY}" \ | |
| --output "$OUT" | |
| # (Optional) also fetch a thumbnail | |
| # THUMB_URL="${CONTENT_URL}?variant=thumbnail" | |
| # curl -sS -L "$THUMB_URL" -H "Authorization: Bearer ${API_KEY}" --output "${OUT%.*}.webp" | |
| echo "Done. File saved: $OUT" | |
| echo "Tip: delete remote artifact when you’re done:" | |
| echo "curl -X DELETE \"${API_BASE}/v1/videos/${VIDEO_ID}\" -H \"Authorization: Bearer ${API_KEY}\" | jq ." | |
| # --- Example usage --- | |
| # PROMPT="Wide shot of a child flying a red kite across a grassy park, soft golden-hour light, cinematic" \ | |
| # SIZE=1280x720 SECONDS=8 OUT=park.mp4 bash sora2.sh | |
| # | |
| # With image reference (must match SIZE): | |
| # PROMPT="Animate this scene so the kite lifts and the camera slowly dollies right" \ | |
| # SIZE=1280x720 SECONDS=8 REF_IMAGE=./first_frame_1280x720.jpg OUT=guided.mp4 bash sora2.sh | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment