feat(video-render): providers/sora.py — Sora 2 client (SP-7)
POST /v1/videos → GET /v1/videos/{id} 폴링 (15초 × 40) → /content?variant=video 다운로드.
sora-2 / sora-2-pro 모델. aspect_ratio → size 매핑.
⚠️ OpenAI Sora 2 API deprecated 2026-09-24.
Plan-B-Video Phase 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
0
services/video-render/providers/__init__.py
Normal file
0
services/video-render/providers/__init__.py
Normal file
119
services/video-render/providers/sora.py
Normal file
119
services/video-render/providers/sora.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Sora 2 video generation — OpenAI Videos API.
|
||||||
|
|
||||||
|
POST /v1/videos → poll GET /v1/videos/{id} → GET /v1/videos/{id}/content download.
|
||||||
|
⚠️ Deprecated, shutdown 2026-09-24. Spec 진행은 박재오 결정 따름.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from nas_client import webhook_update_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SORA_BASE_URL = "https://api.openai.com/v1"
|
||||||
|
VIDEO_MEDIA_ROOT = os.getenv("VIDEO_MEDIA_ROOT", "/mnt/nas/webpage/data/video")
|
||||||
|
VIDEO_MEDIA_URL_PREFIX = os.getenv("VIDEO_MEDIA_URL_PREFIX", "/media/video")
|
||||||
|
|
||||||
|
POLL_INTERVAL = 15 # OpenAI 권장: 10~20초
|
||||||
|
POLL_MAX_ATTEMPTS = 40 # 최대 ~10분
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "sora-2"
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
api_key = os.getenv("OPENAI_API_KEY", "")
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_sora_generation(task_id: str, params: dict) -> None:
|
||||||
|
"""Sora 2로 영상 생성 → mp4 → NAS SMB 저장 → webhook."""
|
||||||
|
try:
|
||||||
|
if not os.getenv("OPENAI_API_KEY"):
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="OPENAI_API_KEY 미설정 (Windows .env)")
|
||||||
|
return
|
||||||
|
|
||||||
|
webhook_update_task(task_id, "processing", 5, "Sora API 호출 중...")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": params.get("model") or DEFAULT_MODEL,
|
||||||
|
"prompt": params["prompt"][:5000],
|
||||||
|
}
|
||||||
|
if params.get("duration"):
|
||||||
|
payload["seconds"] = params["duration"]
|
||||||
|
if params.get("size"):
|
||||||
|
payload["size"] = params["size"]
|
||||||
|
elif params.get("aspect_ratio") == "9:16":
|
||||||
|
payload["size"] = "1080x1920"
|
||||||
|
elif params.get("aspect_ratio") == "16:9":
|
||||||
|
payload["size"] = "1920x1080"
|
||||||
|
|
||||||
|
resp = requests.post(f"{SORA_BASE_URL}/videos", headers=_headers(), json=payload, timeout=30)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=f"Sora API 오류: {resp.status_code} {resp.text[:300]}")
|
||||||
|
return
|
||||||
|
|
||||||
|
body = resp.json()
|
||||||
|
video_id = body.get("id", "")
|
||||||
|
if not video_id:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="Sora 응답에 video id 없음")
|
||||||
|
return
|
||||||
|
|
||||||
|
webhook_update_task(task_id, "processing", 15, f"Sora 작업 생성됨 (id={video_id[:16]})")
|
||||||
|
|
||||||
|
# 폴링
|
||||||
|
for attempt in range(POLL_MAX_ATTEMPTS):
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
sr = requests.get(f"{SORA_BASE_URL}/videos/{video_id}", headers=_headers(), timeout=30)
|
||||||
|
if sr.status_code != 200:
|
||||||
|
continue
|
||||||
|
sd = sr.json()
|
||||||
|
status = sd.get("status", "")
|
||||||
|
progress = sd.get("progress", 0)
|
||||||
|
scaled = min(15 + int(progress * 0.65), 79)
|
||||||
|
webhook_update_task(task_id, "processing", scaled, f"Sora 생성 중... {progress}%")
|
||||||
|
|
||||||
|
if status == "completed":
|
||||||
|
break
|
||||||
|
elif status == "failed":
|
||||||
|
err = sd.get("error", {}).get("message", "Sora 작업 실패")
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=err)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="Sora 폴링 timeout (10분)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 다운로드
|
||||||
|
webhook_update_task(task_id, "processing", 80, "Sora 결과 다운로드 중...")
|
||||||
|
filename = f"{task_id}.mp4"
|
||||||
|
os.makedirs(VIDEO_MEDIA_ROOT, exist_ok=True)
|
||||||
|
file_path = os.path.join(VIDEO_MEDIA_ROOT, filename)
|
||||||
|
|
||||||
|
dl = requests.get(
|
||||||
|
f"{SORA_BASE_URL}/videos/{video_id}/content",
|
||||||
|
headers=_headers(),
|
||||||
|
params={"variant": "video"},
|
||||||
|
stream=True,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
dl.raise_for_status()
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
for chunk in dl.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
local_url = f"{VIDEO_MEDIA_URL_PREFIX}/{filename}"
|
||||||
|
webhook_update_task(task_id, "succeeded", 100, "Sora 생성 완료", video_url=local_url)
|
||||||
|
|
||||||
|
except requests.Timeout:
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error="Sora API 타임아웃")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Sora generation error task=%s", task_id)
|
||||||
|
webhook_update_task(task_id, "failed", 0, "", error=str(e))
|
||||||
Reference in New Issue
Block a user