Files
2025-12-13 12:21:10 +08:00

848 lines
31 KiB
Python

"""
ComfyUI custom nodes for interacting with a Sora-compatible API.
This module avoids external dependencies so it can run inside a vanilla
ComfyUI installation. Configure the Sora API key through the node input,
the `SORA_API_KEY` environment variable, or `OPENAI_API_KEY`.
"""
from __future__ import annotations
import base64
import io
import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from typing import Any, Dict, Optional, Union, Tuple
# Try to import PIL and numpy for image handling (optional dependencies)
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
Image = None
try:
import numpy as np
NUMPY_AVAILABLE = True
except ImportError:
NUMPY_AVAILABLE = False
np = None
DEFAULT_SORA_BASE_URL = "https://api.openai.com/v1/sora"
class SoraAPIError(RuntimeError):
"""Raised when the upstream Sora service reports an error."""
@dataclass
class SoraJobResult:
job_id: str
status: str
response_json: Dict[str, Any]
downloaded_file: Optional[str]
class SoraClient:
"""Minimal HTTP client for the Sora REST endpoints."""
def __init__(
self,
api_key: str,
base_url: str = DEFAULT_SORA_BASE_URL,
endpoint: str = "/videos",
timeout: float = 60.0,
) -> None:
if not api_key:
raise ValueError("Missing Sora API key")
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.endpoint = endpoint if endpoint.startswith("/") else f"/{endpoint}"
self.timeout = timeout
def create_job(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", self.endpoint, payload)
def create_job_with_files(
self,
payload: Dict[str, Any],
files: Optional[Dict[str, Tuple[bytes, str]]] = None,
) -> Dict[str, Any]:
"""
创建任务并上传文件(multipart/form-data)。
Args:
payload: JSON 数据
files: 文件字典,格式为 {field_name: (file_bytes, mime_type)}
"""
return self._request_multipart("POST", self.endpoint, payload, files)
def fetch_job(self, job_id: str) -> Dict[str, Any]:
return self._request("GET", f"{self.endpoint}/{job_id}")
def download_asset(self, url: str, target_path: str) -> str:
request = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(request, timeout=self.timeout) as response:
data = response.read()
os.makedirs(os.path.dirname(target_path), exist_ok=True)
with open(target_path, "wb") as file:
file.write(data)
return target_path
def _request(self, method: str, path: str, payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
url = f"{self.base_url}{path}"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
data: Optional[bytes] = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as http_error:
error_body = http_error.read().decode("utf-8")
raise SoraAPIError(f"Sora API error ({http_error.code}): {error_body}") from http_error
except urllib.error.URLError as url_error:
raise SoraAPIError(f"Sora request failed: {url_error.reason}") from url_error
return json.loads(body or "{}")
def _request_multipart(
self,
method: str,
path: str,
payload: Dict[str, Any],
files: Optional[Dict[str, Tuple[bytes, str]]] = None,
) -> Dict[str, Any]:
"""
发送 multipart/form-data 请求(用于文件上传)。
"""
import uuid
url = f"{self.base_url}{path}"
boundary = f"----WebKitFormBoundary{uuid.uuid4().hex}"
# 构建 multipart 数据
parts = []
# 添加 JSON 字段
for key, value in payload.items():
if value is not None:
parts.append(f'--{boundary}\r\n'.encode())
parts.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
if isinstance(value, (dict, list)):
parts.append(json.dumps(value).encode("utf-8"))
else:
parts.append(str(value).encode("utf-8"))
parts.append(b'\r\n')
# 添加文件字段
if files:
for field_name, (file_bytes, mime_type) in files.items():
parts.append(f'--{boundary}\r\n'.encode())
parts.append(
f'Content-Disposition: form-data; name="{field_name}"; filename="file"\r\n'.encode()
)
parts.append(f'Content-Type: {mime_type}\r\n\r\n'.encode())
parts.append(file_bytes)
parts.append(b'\r\n')
parts.append(f'--{boundary}--\r\n'.encode())
data = b''.join(parts)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
}
request = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as http_error:
error_body = http_error.read().decode("utf-8")
raise SoraAPIError(f"Sora API error ({http_error.code}): {error_body}") from http_error
except urllib.error.URLError as url_error:
raise SoraAPIError(f"Sora request failed: {url_error.reason}") from url_error
return json.loads(body or "{}")
def _resolve_api_key(explicit_key: Optional[str]) -> str:
if explicit_key:
return explicit_key.strip()
env_key = os.getenv("SORA_API_KEY") or os.getenv("OPENAI_API_KEY")
if not env_key:
raise ValueError("Provide a Sora API key via the node input or SORA_API_KEY/OPENAI_API_KEY env vars.")
return env_key.strip()
def _process_image_input(image_input: Any) -> Tuple[bytes, str]:
"""
处理 ComfyUI 的图像输入,转换为字节数据。
支持:
- PIL.Image 对象
- numpy 数组
- 文件路径字符串
- base64 编码的字符串
返回: (image_bytes, mime_type)
"""
# 如果是字符串,可能是文件路径或 base64
if isinstance(image_input, str):
# 检查是否是 base64
if image_input.startswith("data:image") or (len(image_input) > 100 and not os.path.exists(image_input)):
# 可能是 base64,尝试解码
if "," in image_input:
# data:image/png;base64,xxx 格式
header, data = image_input.split(",", 1)
image_bytes = base64.b64decode(data)
mime_type = "image/png" # 默认
if "image/" in header:
mime_type = header.split("image/")[1].split(";")[0]
return image_bytes, mime_type
else:
# 纯 base64
image_bytes = base64.b64decode(image_input)
return image_bytes, "image/png"
elif os.path.exists(image_input):
# 文件路径
with open(image_input, "rb") as f:
image_bytes = f.read()
ext = os.path.splitext(image_input)[1].lower()
mime_types = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp"}
mime_type = mime_types.get(ext, "image/png")
return image_bytes, mime_type
# 如果是 PIL Image
if PIL_AVAILABLE and isinstance(image_input, Image.Image):
buffer = io.BytesIO()
# 转换为 RGB 如果必要
if image_input.mode != "RGB":
image_input = image_input.convert("RGB")
image_input.save(buffer, format="PNG")
return buffer.getvalue(), "image/png"
# 如果是 numpy 数组
if NUMPY_AVAILABLE and isinstance(image_input, np.ndarray):
# 处理不同的数组形状
img_array = image_input
if len(img_array.shape) == 4: # [batch, H, W, C]
img_array = img_array[0]
elif len(img_array.shape) == 3 and img_array.shape[0] < img_array.shape[2]: # [C, H, W]
img_array = np.transpose(img_array, (1, 2, 0))
# 确保值在 0-255 范围
if img_array.max() <= 1.0:
img_array = (img_array * 255).astype(np.uint8)
else:
img_array = img_array.astype(np.uint8)
# 转换为 PIL Image 然后编码
if PIL_AVAILABLE:
pil_image = Image.fromarray(img_array)
buffer = io.BytesIO()
pil_image.save(buffer, format="PNG")
return buffer.getvalue(), "image/png"
else:
raise ValueError("PIL is required to process numpy arrays. Install Pillow.")
# 如果已经是字节数据
if isinstance(image_input, bytes):
return image_input, "image/png"
raise ValueError(f"Unsupported image input type: {type(image_input)}")
def _process_video_input(video_input: Any) -> Tuple[bytes, str]:
"""
处理视频输入,转换为字节数据。
支持:
- 文件路径字符串
- base64 编码的字符串
- 字节数据
返回: (video_bytes, mime_type)
"""
if isinstance(video_input, str):
# 检查是否是 base64
if video_input.startswith("data:video") or (len(video_input) > 100 and not os.path.exists(video_input)):
if "," in video_input:
header, data = video_input.split(",", 1)
video_bytes = base64.b64decode(data)
mime_type = "video/mp4"
if "video/" in header:
mime_type = header.split("video/")[1].split(";")[0]
return video_bytes, mime_type
else:
video_bytes = base64.b64decode(video_input)
return video_bytes, "video/mp4"
elif os.path.exists(video_input):
with open(video_input, "rb") as f:
video_bytes = f.read()
ext = os.path.splitext(video_input)[1].lower()
mime_types = {".mp4": "video/mp4", ".mov": "video/quicktime", ".avi": "video/x-msvideo", ".webm": "video/webm"}
mime_type = mime_types.get(ext, "video/mp4")
return video_bytes, mime_type
if isinstance(video_input, bytes):
return video_input, "video/mp4"
raise ValueError(f"Unsupported video input type: {type(video_input)}")
class SoraTextToVideo:
"""
Submits a job to the Sora API and optionally polls until a video is ready.
The node returns diagnostic metadata along with the downloaded file path (if requested).
"""
CATEGORY = "Sora"
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("job_id", "status", "result")
FUNCTION = "generate"
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]:
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": "A cinematic drone shot of waves crashing."}),
},
"optional": {
"api_key": ("STRING", {"multiline": False, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"duration_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}),
"aspect_ratio": ("STRING", {"default": "16:9"}),
"seed": ("STRING", {"default": ""}),
"fps": ("INT", {"default": 24, "min": 1}),
"guidance_scale": ("FLOAT", {"default": 7.5, "min": 0.0, "step": 0.1}),
"base_url": ("STRING", {"default": DEFAULT_SORA_BASE_URL}),
"endpoint": ("STRING", {"default": "/videos"}),
"metadata": ("STRING", {"multiline": True, "default": ""}),
"webhook_url": ("STRING", {"default": ""}),
"wait_for_result": ("BOOLEAN", {"default": True}),
"poll_interval": ("FLOAT", {"default": 3.0, "min": 1.0}),
"max_wait_seconds": ("FLOAT", {"default": 120.0, "min": 5.0}),
"download_path": ("STRING", {"default": ""}),
},
}
def generate(
self,
prompt: str,
api_key: str = "",
negative_prompt: str = "",
duration_seconds: float = 5.0,
aspect_ratio: str = "16:9",
seed: str = "",
fps: int = 24,
guidance_scale: float = 7.5,
base_url: str = DEFAULT_SORA_BASE_URL,
endpoint: str = "/videos",
metadata: str = "",
webhook_url: str = "",
wait_for_result: bool = True,
poll_interval: float = 3.0,
max_wait_seconds: float = 120.0,
download_path: str = "",
) -> tuple[str, str, str]:
client = SoraClient(
api_key=_resolve_api_key(api_key),
base_url=base_url,
endpoint=endpoint,
)
payload: Dict[str, Any] = {
"prompt": prompt,
"negative_prompt": negative_prompt or None,
"duration_seconds": duration_seconds,
"aspect_ratio": aspect_ratio,
"seed": seed or None,
"fps": fps,
"guidance_scale": guidance_scale,
}
if metadata:
try:
payload["metadata"] = json.loads(metadata)
except json.JSONDecodeError as error:
raise ValueError(f"Metadata must be valid JSON: {error.msg}") from error
if webhook_url:
payload["webhook_url"] = webhook_url
response = client.create_job(payload)
job_id = str(response.get("id") or response.get("job_id") or "")
status = response.get("status", "submitted")
job_result = SoraJobResult(job_id=job_id, status=status, response_json=response, downloaded_file=None)
if wait_for_result and job_id:
job_result = self._poll_until_complete(
client=client,
job_id=job_id,
poll_interval=poll_interval,
max_wait_seconds=max_wait_seconds,
download_path=download_path,
)
elif wait_for_result and not job_id:
raise SoraAPIError("Sora API response did not include a job id.")
result_payload = {
"job_id": job_result.job_id,
"status": job_result.status,
"downloaded_file": job_result.downloaded_file,
"raw_response": job_result.response_json,
}
return (
job_result.job_id or "",
job_result.status,
json.dumps(result_payload, ensure_ascii=False),
)
def _poll_until_complete(
self,
client: SoraClient,
job_id: str,
poll_interval: float,
max_wait_seconds: float,
download_path: str,
) -> SoraJobResult:
deadline = time.time() + max_wait_seconds
last_response: Dict[str, Any] = {}
downloaded_file: Optional[str] = None
while time.time() < deadline:
last_response = client.fetch_job(job_id)
status = str(last_response.get("status", "unknown")).lower()
if status in {"succeeded", "completed", "ready"}:
downloaded_file = self._maybe_download(last_response, client, download_path, job_id)
return SoraJobResult(
job_id=job_id,
status=status,
response_json=last_response,
downloaded_file=downloaded_file,
)
if status in {"failed", "error"}:
message = last_response.get("error") or last_response
raise SoraAPIError(f"Sora generation failed: {message}")
time.sleep(poll_interval)
raise TimeoutError(f"Sora job {job_id} did not finish within {max_wait_seconds} seconds.")
def _maybe_download(
self,
job_response: Dict[str, Any],
client: SoraClient,
download_path: str,
job_id: str,
) -> Optional[str]:
if not download_path:
return None
video_url = self._extract_video_url(job_response)
if not video_url:
return None
target = download_path
if os.path.isdir(download_path):
safe_job_id = job_id or "sora"
filename = f"{safe_job_id}.mp4"
target = os.path.join(download_path, filename)
return client.download_asset(video_url, target)
@staticmethod
def _extract_video_url(job_response: Dict[str, Any]) -> Optional[str]:
video_url = job_response.get("result_url") or job_response.get("video_url")
if video_url:
return str(video_url)
outputs = job_response.get("outputs") or job_response.get("videos")
if isinstance(outputs, list) and outputs:
first = outputs[0]
if isinstance(first, dict):
url = first.get("url") or first.get("download_url")
if url:
return str(url)
return None
class SoraImageToVideo:
"""
Image-to-Video node: Generates video from an image input.
Supports ComfyUI image inputs (PIL Image, numpy arrays, file paths).
"""
CATEGORY = "Sora"
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("job_id", "status", "result")
FUNCTION = "generate"
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]:
return {
"required": {
"image": ("IMAGE",), # ComfyUI image input
"prompt": ("STRING", {"multiline": True, "default": "A cinematic video based on this image."}),
},
"optional": {
"api_key": ("STRING", {"multiline": False, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"duration_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}),
"aspect_ratio": ("STRING", {"default": "16:9"}),
"seed": ("STRING", {"default": ""}),
"fps": ("INT", {"default": 24, "min": 1}),
"guidance_scale": ("FLOAT", {"default": 7.5, "min": 0.0, "step": 0.1}),
"base_url": ("STRING", {"default": DEFAULT_SORA_BASE_URL}),
"endpoint": ("STRING", {"default": "/videos"}),
"metadata": ("STRING", {"multiline": True, "default": ""}),
"webhook_url": ("STRING", {"default": ""}),
"wait_for_result": ("BOOLEAN", {"default": True}),
"poll_interval": ("FLOAT", {"default": 3.0, "min": 1.0}),
"max_wait_seconds": ("FLOAT", {"default": 120.0, "min": 5.0}),
"download_path": ("STRING", {"default": ""}),
},
}
def generate(
self,
image: Any,
prompt: str,
api_key: str = "",
negative_prompt: str = "",
duration_seconds: float = 5.0,
aspect_ratio: str = "16:9",
seed: str = "",
fps: int = 24,
guidance_scale: float = 7.5,
base_url: str = DEFAULT_SORA_BASE_URL,
endpoint: str = "/videos",
metadata: str = "",
webhook_url: str = "",
wait_for_result: bool = True,
poll_interval: float = 3.0,
max_wait_seconds: float = 120.0,
download_path: str = "",
) -> tuple[str, str, str]:
client = SoraClient(
api_key=_resolve_api_key(api_key),
base_url=base_url,
endpoint=endpoint,
)
# 处理图像输入
try:
image_bytes, mime_type = _process_image_input(image)
except Exception as e:
raise ValueError(f"Failed to process image input: {e}") from e
# 构建 payload
payload: Dict[str, Any] = {
"prompt": prompt,
"negative_prompt": negative_prompt or None,
"duration_seconds": duration_seconds,
"aspect_ratio": aspect_ratio,
"seed": seed or None,
"fps": fps,
"guidance_scale": guidance_scale,
}
if metadata:
try:
payload["metadata"] = json.loads(metadata)
except json.JSONDecodeError as error:
raise ValueError(f"Metadata must be valid JSON: {error.msg}") from error
if webhook_url:
payload["webhook_url"] = webhook_url
# 上传图像和创建任务
files = {"image": (image_bytes, mime_type)}
response = client.create_job_with_files(payload, files)
job_id = str(response.get("id") or response.get("job_id") or "")
status = response.get("status", "submitted")
job_result = SoraJobResult(job_id=job_id, status=status, response_json=response, downloaded_file=None)
if wait_for_result and job_id:
job_result = self._poll_until_complete(
client=client,
job_id=job_id,
poll_interval=poll_interval,
max_wait_seconds=max_wait_seconds,
download_path=download_path,
)
elif wait_for_result and not job_id:
raise SoraAPIError("Sora API response did not include a job id.")
result_payload = {
"job_id": job_result.job_id,
"status": job_result.status,
"downloaded_file": job_result.downloaded_file,
"raw_response": job_result.response_json,
}
return (
job_result.job_id or "",
job_result.status,
json.dumps(result_payload, ensure_ascii=False),
)
def _poll_until_complete(
self,
client: SoraClient,
job_id: str,
poll_interval: float,
max_wait_seconds: float,
download_path: str,
) -> SoraJobResult:
deadline = time.time() + max_wait_seconds
last_response: Dict[str, Any] = {}
downloaded_file: Optional[str] = None
while time.time() < deadline:
last_response = client.fetch_job(job_id)
status = str(last_response.get("status", "unknown")).lower()
if status in {"succeeded", "completed", "ready"}:
downloaded_file = self._maybe_download(last_response, client, download_path, job_id)
return SoraJobResult(
job_id=job_id,
status=status,
response_json=last_response,
downloaded_file=downloaded_file,
)
if status in {"failed", "error"}:
message = last_response.get("error") or last_response
raise SoraAPIError(f"Sora generation failed: {message}")
time.sleep(poll_interval)
raise TimeoutError(f"Sora job {job_id} did not finish within {max_wait_seconds} seconds.")
def _maybe_download(
self,
job_response: Dict[str, Any],
client: SoraClient,
download_path: str,
job_id: str,
) -> Optional[str]:
if not download_path:
return None
video_url = self._extract_video_url(job_response)
if not video_url:
return None
target = download_path
if os.path.isdir(download_path):
safe_job_id = job_id or "sora"
filename = f"{safe_job_id}.mp4"
target = os.path.join(download_path, filename)
return client.download_asset(video_url, target)
@staticmethod
def _extract_video_url(job_response: Dict[str, Any]) -> Optional[str]:
return SoraTextToVideo._extract_video_url(job_response)
class SoraVideoToVideo:
"""
Video-to-Video node: Edits or extends an existing video.
Supports video extension, inpainting, and style transfer.
"""
CATEGORY = "Sora"
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("job_id", "status", "result")
FUNCTION = "generate"
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]:
return {
"required": {
"video": ("STRING", {"default": ""}), # Video file path or base64
"prompt": ("STRING", {"multiline": True, "default": "Extend this video with smooth continuation."}),
},
"optional": {
"api_key": ("STRING", {"multiline": False, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
"operation": (["extend", "edit", "inpaint", "style_transfer"], {"default": "extend"}),
"extension_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}),
"duration_seconds": ("FLOAT", {"default": 5.0, "min": 1.0, "step": 0.5}),
"aspect_ratio": ("STRING", {"default": "16:9"}),
"seed": ("STRING", {"default": ""}),
"fps": ("INT", {"default": 24, "min": 1}),
"guidance_scale": ("FLOAT", {"default": 7.5, "min": 0.0, "step": 0.1}),
"base_url": ("STRING", {"default": DEFAULT_SORA_BASE_URL}),
"endpoint": ("STRING", {"default": "/videos"}),
"metadata": ("STRING", {"multiline": True, "default": ""}),
"webhook_url": ("STRING", {"default": ""}),
"wait_for_result": ("BOOLEAN", {"default": True}),
"poll_interval": ("FLOAT", {"default": 3.0, "min": 1.0}),
"max_wait_seconds": ("FLOAT", {"default": 120.0, "min": 5.0}),
"download_path": ("STRING", {"default": ""}),
},
}
def generate(
self,
video: str,
prompt: str,
api_key: str = "",
negative_prompt: str = "",
operation: str = "extend",
extension_seconds: float = 5.0,
duration_seconds: float = 5.0,
aspect_ratio: str = "16:9",
seed: str = "",
fps: int = 24,
guidance_scale: float = 7.5,
base_url: str = DEFAULT_SORA_BASE_URL,
endpoint: str = "/videos",
metadata: str = "",
webhook_url: str = "",
wait_for_result: bool = True,
poll_interval: float = 3.0,
max_wait_seconds: float = 120.0,
download_path: str = "",
) -> tuple[str, str, str]:
client = SoraClient(
api_key=_resolve_api_key(api_key),
base_url=base_url,
endpoint=endpoint,
)
# 处理视频输入
try:
video_bytes, mime_type = _process_video_input(video)
except Exception as e:
raise ValueError(f"Failed to process video input: {e}") from e
# 构建 payload
payload: Dict[str, Any] = {
"prompt": prompt,
"negative_prompt": negative_prompt or None,
"operation": operation,
"duration_seconds": duration_seconds,
"aspect_ratio": aspect_ratio,
"seed": seed or None,
"fps": fps,
"guidance_scale": guidance_scale,
}
# 根据操作类型添加特定参数
if operation == "extend":
payload["extension_seconds"] = extension_seconds
if metadata:
try:
payload["metadata"] = json.loads(metadata)
except json.JSONDecodeError as error:
raise ValueError(f"Metadata must be valid JSON: {error.msg}") from error
if webhook_url:
payload["webhook_url"] = webhook_url
# 上传视频和创建任务
files = {"video": (video_bytes, mime_type)}
response = client.create_job_with_files(payload, files)
job_id = str(response.get("id") or response.get("job_id") or "")
status = response.get("status", "submitted")
job_result = SoraJobResult(job_id=job_id, status=status, response_json=response, downloaded_file=None)
if wait_for_result and job_id:
job_result = self._poll_until_complete(
client=client,
job_id=job_id,
poll_interval=poll_interval,
max_wait_seconds=max_wait_seconds,
download_path=download_path,
)
elif wait_for_result and not job_id:
raise SoraAPIError("Sora API response did not include a job id.")
result_payload = {
"job_id": job_result.job_id,
"status": job_result.status,
"downloaded_file": job_result.downloaded_file,
"raw_response": job_result.response_json,
}
return (
job_result.job_id or "",
job_result.status,
json.dumps(result_payload, ensure_ascii=False),
)
def _poll_until_complete(
self,
client: SoraClient,
job_id: str,
poll_interval: float,
max_wait_seconds: float,
download_path: str,
) -> SoraJobResult:
deadline = time.time() + max_wait_seconds
last_response: Dict[str, Any] = {}
downloaded_file: Optional[str] = None
while time.time() < deadline:
last_response = client.fetch_job(job_id)
status = str(last_response.get("status", "unknown")).lower()
if status in {"succeeded", "completed", "ready"}:
downloaded_file = self._maybe_download(last_response, client, download_path, job_id)
return SoraJobResult(
job_id=job_id,
status=status,
response_json=last_response,
downloaded_file=downloaded_file,
)
if status in {"failed", "error"}:
message = last_response.get("error") or last_response
raise SoraAPIError(f"Sora generation failed: {message}")
time.sleep(poll_interval)
raise TimeoutError(f"Sora job {job_id} did not finish within {max_wait_seconds} seconds.")
def _maybe_download(
self,
job_response: Dict[str, Any],
client: SoraClient,
download_path: str,
job_id: str,
) -> Optional[str]:
if not download_path:
return None
video_url = self._extract_video_url(job_response)
if not video_url:
return None
target = download_path
if os.path.isdir(download_path):
safe_job_id = job_id or "sora"
filename = f"{safe_job_id}.mp4"
target = os.path.join(download_path, filename)
return client.download_asset(video_url, target)
@staticmethod
def _extract_video_url(job_response: Dict[str, Any]) -> Optional[str]:
return SoraTextToVideo._extract_video_url(job_response)
NODE_CLASS_MAPPINGS = {
"SoraTextToVideo": SoraTextToVideo,
"SoraImageToVideo": SoraImageToVideo,
"SoraVideoToVideo": SoraVideoToVideo,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SoraTextToVideo": "Sora Text-To-Video",
"SoraImageToVideo": "Sora Image-To-Video",
"SoraVideoToVideo": "Sora Video-To-Video",
}