Files
df18925e8d Repair OpenRouter image, video and audio integration
Preserve existing workflows while adding capability-based catalogs, dedicated
image requests, resumable native video, validated audio and explicit chat tiers.
Cover compatibility, billing-sensitive retries, credentials and cancellation.

Co-authored-by: Arthur Reboul Salze <22814805+ArthurReboulSalze@users.noreply.github.com>
Co-authored-by: Dookie <me@djdookie.de>
2026-10-01 22:22:39 -05:00

425 lines
20 KiB
Python

"""OpenRouter video jobs, with resumable failures and native ComfyUI output."""
import base64
import binascii
import io
import math
import re
import time
from urllib.parse import quote, urljoin, urlsplit
import requests
API_ORIGIN = "https://openrouter.ai"
API_BASE = API_ORIGIN + "/api/v1/videos"
MAX_VIDEO_BYTES = 512 * 1024 * 1024
POLL_INTERVAL = 5.0
MODES = {"text_to_video", "image_to_video", "start_end_frame_to_video", "reference_to_video"}
MODE_ALIASES = {"first_frame": "image_to_video", "first_last_frame": "start_end_frame_to_video", "reference_images": "reference_to_video"}
class VideoJobError(RuntimeError):
"""An error that retains the already-submitted job for explicit resumption."""
def __init__(self, message, job_id=""):
self.job_id = job_id
self.openrouter_job_id = job_id
if job_id:
message += f" Video job: {job_id}. Set video_job_id to this ID to resume without submitting again."
super().__init__(message)
def _safe_detail(value, api_key):
text = str(value)
if isinstance(api_key, str) and api_key:
text = text.replace(api_key, "[redacted]")
text = re.sub(r"data:[^\s\"']+", "[media omitted]", text)
return text[:500]
def _job_id(value):
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,256}", value):
raise ValueError("OpenRouter returned an invalid video job ID.")
return value
def _url(value, base=API_ORIGIN, *, require_origin=True):
if not isinstance(value, str) or not value.strip():
raise ValueError("OpenRouter returned an empty video URL.")
result = urljoin(base, value)
parsed = urlsplit(result)
try:
port = parsed.port
except ValueError as exc:
raise ValueError("OpenRouter returned an invalid video URL.") from exc
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
raise ValueError("Video URLs must use HTTPS without credentials or fragments.")
if require_origin and (parsed.hostname.lower() != "openrouter.ai" or port not in (None, 443)):
raise ValueError("Refusing to send OpenRouter credentials to another origin.")
return result
def _native_video_support():
try:
from comfy_api.input_impl import VideoFromFile
import av
except ImportError as exc:
raise ValueError("Video output requires ComfyUI 0.3.31 or newer with native VideoFromFile and compatible PyAV support. Update ComfyUI and its requirements before submitting a video.") from exc
def validate_media(buffer):
buffer.seek(0)
try:
with av.open(buffer, mode="r") as container:
streams = container.streams.video
if not streams or streams[0].width <= 0 or streams[0].height <= 0:
raise ValueError("Downloaded content has no valid video stream.")
if next(container.decode(video=0), None) is None:
raise ValueError("Downloaded content has no decodable video frame.")
finally:
buffer.seek(0)
return VideoFromFile, validate_media
def _interrupt():
from comfy.model_management import throw_exception_if_processing_interrupted
throw_exception_if_processing_interrupted()
def _require_model(model):
if __package__:
from .openrouter_catalog import require_model
else:
from openrouter_catalog import require_model
return require_model("video", model)
def _supported_model(metadata):
if __package__:
from .openrouter_catalog import is_supported_video_model
else:
from openrouter_catalog import is_supported_video_model
return is_supported_video_model(metadata)
def _payload(model, prompt, reference_urls, mode, duration, resolution, aspect_ratio, generate_audio, seed):
mode = MODE_ALIASES.get(mode, mode)
if mode not in MODES:
raise ValueError(f"Unsupported video mode: {mode}.")
if not isinstance(prompt, str):
raise ValueError("The video prompt must be text.")
if not isinstance(generate_audio, bool):
raise ValueError("The generated-audio setting must be true or false.")
images = list(reference_urls or [])
expected = {"text_to_video": 0, "image_to_video": 1, "start_end_frame_to_video": 2}.get(mode)
if expected is not None and len(images) != expected:
raise ValueError(f"{mode} requires exactly {expected} connected image(s); received {len(images)}.")
if mode == "reference_to_video" and not images:
raise ValueError("reference_to_video requires at least one connected image.")
if mode == "text_to_video" and not prompt.strip():
raise ValueError("A prompt is required for text-to-video generation.")
for index, image in enumerate(images):
if not isinstance(image, str):
raise ValueError("Video image references must be image data URLs or HTTPS URLs.")
image = image.strip()
if image.startswith("data:"):
match = re.fullmatch(r"data:image/(?:png|jpeg|jpg|webp|gif);base64,(.+)", image, re.DOTALL)
try:
if not match or not base64.b64decode(match[1], validate=True):
raise ValueError
except (ValueError, binascii.Error) as exc:
raise ValueError("Video image references contain an invalid image data URL.") from exc
else:
parsed = urlsplit(image)
if parsed.scheme != "https" or not parsed.netloc:
raise ValueError("Video image references must use absolute HTTPS URLs.")
image = _url(image, require_origin=False)
images[index] = image
try:
if isinstance(seed, bool) or not isinstance(seed, (int, str)):
raise ValueError
seed_value = int(seed)
if seed_value < 0:
raise ValueError
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError("Video seed must be a nonnegative whole number.") from exc
metadata = _require_model(model)
if not _supported_model(dict(metadata, id=model)):
raise ValueError("This model requires video editing, upscaling, or avatar inputs that this node does not support.")
frames = set(metadata.get("supported_frame_images") or [])
if mode in {"image_to_video", "start_end_frame_to_video"} and "first_frame" not in frames:
raise ValueError(f"{model} does not advertise first-frame input support.")
if mode == "start_end_frame_to_video" and "last_frame" not in frames:
raise ValueError(f"{model} does not advertise last-frame input support.")
# Reference support and limits vary by provider. Do not invent a universal
# limit or infer support from marketing prose; honor explicit metadata.
params = metadata.get("supported_parameters") or {}
refs = params.get("input_references", {}) if isinstance(params, dict) else {}
if mode == "reference_to_video" and isinstance(refs, dict):
if refs.get("max") is not None and len(images) > refs["max"]:
raise ValueError(f"{model} accepts at most {refs['max']} reference images.")
if refs.get("min") is not None and len(images) < refs["min"]:
raise ValueError(f"{model} requires at least {refs['min']} reference images.")
payload = {"model": model}
if prompt.strip():
payload["prompt"] = prompt.strip()
if duration not in (None, "", "auto", 0, "0"):
try:
seconds = int(duration)
if isinstance(duration, bool) or float(duration) != seconds or seconds < 1:
raise ValueError
except (TypeError, ValueError, OverflowError) as exc:
raise ValueError("Video duration must be a positive whole number of seconds or auto.") from exc
supported = metadata.get("supported_durations") or []
if supported and seconds not in supported:
raise ValueError(f"{model} does not support duration {seconds}; supported values: {supported}.")
payload["duration"] = seconds
for field, value, catalog_field in (
("resolution", resolution, "supported_resolutions"),
("aspect_ratio", aspect_ratio, "supported_aspect_ratios"),
):
if value in (None, "", "auto"):
continue
supported = metadata.get(catalog_field) or []
if supported and value not in supported:
raise ValueError(f"{model} does not support {field} {value}; supported values: {supported}.")
payload[field] = value
if generate_audio and metadata.get("generate_audio") is not True:
raise ValueError(f"{model} does not advertise generated audio support.")
if metadata.get("generate_audio") is True:
payload["generate_audio"] = bool(generate_audio)
if metadata.get("seed") is True:
payload["seed"] = seed_value
elif seed_value != 0:
raise ValueError(f"{model} does not advertise seed support; use seed 0 to leave it unspecified.")
if mode in {"image_to_video", "start_end_frame_to_video"}:
payload["frame_images"] = [
{"type": "image_url", "image_url": {"url": image}, "frame_type": frame}
for image, frame in zip(images, ("first_frame", "last_frame"))
]
elif mode == "reference_to_video":
payload["input_references"] = [
{"type": "image_url", "image_url": {"url": image}} for image in images
]
return payload
class _Runtime:
def __init__(self, api_key, request_timeout, wait_timeout, http, clock, sleep, interrupt):
self.api_key = api_key
self.request_timeout = float(request_timeout)
timeout = float(wait_timeout)
if not math.isfinite(self.request_timeout) or self.request_timeout <= 0 or not math.isfinite(timeout) or timeout <= 0:
raise ValueError("Video request and wait timeouts must be positive finite numbers.")
self.http = http
self.clock = clock
self.sleep = sleep
self.interrupt = interrupt
self.deadline = clock() + timeout
self.job_id = ""
def check(self):
self.interrupt()
remaining = self.deadline - self.clock()
if remaining <= 0:
raise TimeoutError("Timed out waiting for the video. The remote job may still be running.")
return remaining
def wait(self, seconds):
end = self.clock() + max(0, seconds)
while self.clock() < end:
remaining = self.check()
interval = min(0.25, remaining, end - self.clock())
if interval > 0:
self.sleep(interval)
self.check()
def request(self, method, url, *, body=None, stream=False, media=False):
url = _url(url, require_origin=not media)
retries = 0
redirects = 0
while True:
remaining = self.check()
# Poll/download inactivity is bounded so interruption remains useful.
connect_timeout = min(5, remaining / 2)
read_timeout = min(self.request_timeout, remaining - connect_timeout, 15 if method == "GET" else self.request_timeout)
parts = urlsplit(url)
same_origin = parts.hostname == "openrouter.ai" and parts.port in (None, 443)
headers = {"Authorization": f"Bearer {self.api_key}"} if same_origin else {}
if body is not None:
headers["Content-Type"] = "application/json"
try:
response = self.http.request(
method, url, headers=headers, json=body, stream=stream,
timeout=(connect_timeout, read_timeout), allow_redirects=False,
)
except Exception as exc:
if method != "GET" or retries >= 3:
if method == "POST":
raise RuntimeError("Video submission did not return a job ID; its outcome is unknown. Check OpenRouter activity before submitting again. " + _safe_detail(exc, self.api_key)) from exc
raise RuntimeError("Video request failed: " + _safe_detail(exc, self.api_key)) from exc
retries += 1
self.wait(min(2 ** (retries - 1), 5))
continue
keep_open = False
try:
# A successful submit may already have created a billed job.
# Deliver its ID to the caller before honoring an interruption.
if method != "POST":
self.check()
status = response.status_code
if method == "GET" and status in {301, 302, 303, 307, 308}:
redirects += 1
if redirects > 5:
raise RuntimeError("Too many video download redirects.")
location = response.headers.get("Location", "")
url = _url(location, url, require_origin=not media)
continue
if method == "GET" and (status == 429 or status >= 500 or media and status in {404, 409}) and retries < 3:
retries += 1
try:
delay = float(response.headers.get("Retry-After", 2 ** (retries - 1)))
except (TypeError, ValueError):
delay = 2 ** (retries - 1)
self.wait(min(max(delay, 0.25), 30))
continue
if not 200 <= status < 300:
try:
detail = response.json().get("error", f"HTTP {status}")
except Exception:
detail = f"HTTP {status}"
if method == "POST" and (status >= 500 or 300 <= status < 400 or status == 408):
raise RuntimeError(f"Video submission failed ({status}); its outcome is unknown. Check OpenRouter activity before submitting again. Details: {_safe_detail(detail, self.api_key)}")
raise RuntimeError(f"OpenRouter video request failed ({status}): {_safe_detail(detail, self.api_key)}")
if stream:
keep_open = True
return response
try:
result = response.json()
if not isinstance(result, dict):
raise ValueError("OpenRouter returned an invalid video response.")
except Exception as exc:
if method == "POST":
raise RuntimeError("Video submission returned an invalid response; its outcome is unknown. Check OpenRouter activity before submitting again.") from exc
raise
return result
finally:
if not keep_open:
response.close()
def download(self):
response = self.request("GET", f"{API_BASE}/{quote(self.job_id, safe='')}/content?index=0", stream=True, media=True)
buffer = io.BytesIO()
try:
content_type = response.headers.get("Content-Type", "").lower()
if "json" in content_type or "html" in content_type:
raise ValueError("The video download returned a document instead of video content.")
length = response.headers.get("Content-Length")
if length and int(length) > MAX_VIDEO_BYTES:
raise ValueError("Video exceeds the 512 MiB download limit.")
# read1 returns after one buffered/socket read rather than waiting
# to fill a large chunk, allowing deadline/cancellation checks even
# when the provider trickles the content slowly.
read_once = getattr(getattr(response, "raw", None), "read1", None)
chunks = iter(lambda: read_once(64 * 1024, decode_content=True), b"") if callable(read_once) else response.iter_content(chunk_size=1024)
for chunk in chunks:
self.check()
if chunk:
if buffer.tell() + len(chunk) > MAX_VIDEO_BYTES:
raise ValueError("Video exceeds the 512 MiB download limit.")
buffer.write(chunk)
self.check()
if not buffer.tell():
raise ValueError("OpenRouter returned an empty video download.")
buffer.seek(0)
return buffer
finally:
response.close()
def generate_video(api_key, model, prompt, reference_urls=None, mode="text_to_video",
duration="auto", resolution="auto", aspect_ratio="auto",
generate_audio=False, seed=0, request_timeout=120, wait_timeout=900,
job_id="", *, on_job=None, _http=None, _clock=None, _sleep=None,
_interrupt_check=None):
"""Submit once, or resume ``job_id``; return one native VIDEO and actual usage.
``on_job(id)`` runs immediately after a valid ID is available, before polling.
On local cancellation the original ComfyUI BaseException is re-raised with
``openrouter_job_id`` attached; no remote cancellation is implied.
"""
runtime = None
active_id = ""
try:
if job_id:
active_id = _job_id(job_id.strip())
if not isinstance(api_key, str) or not api_key.strip():
raise ValueError("An OpenRouter API key is required for video.")
factory, validate_media = _native_video_support()
runtime = _Runtime(api_key.strip(), request_timeout, wait_timeout,
_http or requests, _clock or time.monotonic,
_sleep or time.sleep, _interrupt_check or _interrupt)
if job_id:
runtime.job_id = active_id
if on_job:
on_job(active_id)
polling_url = f"{API_BASE}/{quote(active_id, safe='')}"
result = runtime.request("GET", polling_url)
else:
payload = _payload(model, prompt, reference_urls, mode, duration, resolution,
aspect_ratio, generate_audio, seed)
result = runtime.request("POST", API_BASE, body=payload)
try:
active_id = _job_id(result.get("id"))
except ValueError as exc:
raise RuntimeError("Video submission returned no usable job ID; its outcome is unknown. Check OpenRouter activity before submitting again.") from exc
runtime.job_id = active_id
if on_job:
on_job(active_id)
polling_url = _url(result.get("polling_url") or f"{API_BASE}/{quote(active_id, safe='')}")
while True:
runtime.check()
if result.get("id") not in (None, active_id):
raise ValueError("OpenRouter returned a different video job ID while polling.")
status = result.get("status")
if status == "completed":
break
if status in {"failed", "cancelled", "expired"}:
detail = _safe_detail(result.get("error") or status, runtime.api_key)
raise RuntimeError(f"OpenRouter video job {status}: {detail}")
if status not in {"pending", "in_progress"}:
raise ValueError(f"OpenRouter returned an unknown video job status: {status!r}.")
runtime.wait(POLL_INTERVAL)
result = runtime.request("GET", polling_url)
buffer = runtime.download()
validate_media(buffer)
runtime.check()
video = factory(buffer)
usage = result.get("usage") if isinstance(result.get("usage"), dict) else {}
cost = usage.get("cost")
if isinstance(cost, bool) or not isinstance(cost, (int, float)) or not math.isfinite(cost):
cost = None
text = f"completed | job_id={active_id}"
if cost is not None:
text += f" | cost=${cost:.6f}"
return {"video": video, "text": text, "usage": usage, "cost": cost, "job_id": active_id}
except BaseException as exc:
if active_id:
# Comfy's InterruptProcessingException is a BaseException, not an
# Exception. Keep its type so ComfyUI still recognizes cancellation.
exc.openrouter_job_id = active_id
if not isinstance(exc, Exception):
if not isinstance(exc, (KeyboardInterrupt, SystemExit)):
exc.args = (f"Video wait interrupted. Remote job {active_id} may continue; resume with video_job_id={active_id}.",)
raise
if not isinstance(exc, Exception):
raise
if isinstance(exc, VideoJobError):
raise
raise VideoJobError(_safe_detail(exc, api_key), active_id) from exc