diff --git a/services/video-render/providers/__init__.py b/services/video-render/providers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/video-render/providers/sora.py b/services/video-render/providers/sora.py new file mode 100644 index 0000000..3dd83f0 --- /dev/null +++ b/services/video-render/providers/sora.py @@ -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))