""" 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", }