Files
hkuds--vimax/tools/video_generator_openrouter_api.py
2026-07-13 12:23:54 +08:00

202 lines
8.6 KiB
Python

import asyncio
import logging
import os
from typing import List
from urllib.parse import urljoin
import aiohttp
from interfaces.video_output import VideoOutput
from utils.image import image_path_to_b64
def _env_int(name: str, default: int) -> int:
try:
return max(0, int(os.environ.get(name, str(default))))
except ValueError:
return default
def _env_float(name: str, default: float) -> float:
try:
return max(0.0, float(os.environ.get(name, str(default))))
except ValueError:
return default
def _env_bool(name: str, default: bool) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _emit_progress(progress, stage: str, message: str, metadata: dict | None = None) -> None:
if progress is not None:
progress(stage, message, metadata or {})
class VideoGeneratorOpenRouterAPI:
def __init__(
self,
api_key: str,
model: str = "google/veo-3.1-lite",
base_url: str = "https://openrouter.ai/api/v1",
http_referer: str = "",
app_title: str = "ViMax",
):
self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/")
self.http_referer = http_referer
self.app_title = app_title
async def generate_single_video(
self,
prompt: str = "",
reference_image_paths: List[str] = [],
aspect_ratio: str = "16:9",
**kwargs,
) -> VideoOutput:
progress = kwargs.get("progress")
request_timeout_seconds = _env_float("VIMAX_VIDEO_REQUEST_TIMEOUT_SECONDS", 60.0)
query_timeout_seconds = _env_float("VIMAX_VIDEO_QUERY_TIMEOUT_SECONDS", 600.0)
poll_interval_seconds = _env_float("VIMAX_VIDEO_POLL_INTERVAL_SECONDS", 10.0)
duration = _env_int("VIMAX_OPENROUTER_VIDEO_DURATION", 8)
resolution = os.environ.get("VIMAX_OPENROUTER_VIDEO_RESOLUTION", "720p")
generate_audio = _env_bool("VIMAX_OPENROUTER_GENERATE_AUDIO", True)
payload = {
"model": self.model,
"prompt": prompt,
"aspect_ratio": aspect_ratio,
"duration": duration,
"resolution": resolution,
"generate_audio": generate_audio,
}
frame_images = _frame_images(reference_image_paths)
if frame_images:
payload["frame_images"] = frame_images
headers = self._headers()
timeout = aiohttp.ClientTimeout(total=request_timeout_seconds)
_emit_progress(progress, "video_create", f"Creating OpenRouter video generation task with {self.model}", {"model": self.model, "duration": duration, "resolution": resolution, "frame_count": len(frame_images)})
create_status, create_payload = await _post_json(
f"{self.base_url}/videos",
headers=headers,
payload=payload,
timeout=timeout,
hard_timeout_seconds=request_timeout_seconds,
)
if create_status >= 400:
raise RuntimeError(f"OpenRouter video create failed with HTTP {create_status}: {create_payload}")
job_id = create_payload.get("id")
polling_url = create_payload.get("polling_url")
if not job_id or not polling_url:
raise RuntimeError(f"OpenRouter video create response missing id or polling_url: {create_payload}")
_emit_progress(progress, "video_task_created", "OpenRouter video generation task created", {"model": self.model, "job_id": job_id, "status": create_payload.get("status")})
poll_url = _absolute_url(self.base_url, polling_url)
deadline = asyncio.get_running_loop().time() + query_timeout_seconds if query_timeout_seconds > 0 else None
last_status = create_payload.get("status")
last_payload = create_payload
while deadline is None or asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(poll_interval_seconds)
poll_status, poll_payload = await _get_json(
poll_url,
headers=headers,
timeout=timeout,
hard_timeout_seconds=request_timeout_seconds,
)
if poll_status >= 400:
raise RuntimeError(f"OpenRouter video poll failed with HTTP {poll_status}: {poll_payload}")
last_payload = poll_payload
status = poll_payload.get("status")
last_status = status
_emit_progress(progress, "video_status", f"OpenRouter video generation status: {status}", {"model": self.model, "job_id": job_id, "status": status})
if status == "completed":
urls = poll_payload.get("unsigned_urls") or []
if urls:
content_url = urls[0]
else:
content_url = f"{self.base_url}/videos/{job_id}/content?index=0"
_emit_progress(progress, "video_download_start", "Downloading OpenRouter video output", {"model": self.model, "job_id": job_id})
download_status, data = await _get_bytes(
content_url,
headers=headers if _needs_authorization(content_url) else {},
timeout=timeout,
hard_timeout_seconds=request_timeout_seconds,
)
if download_status >= 400:
raise RuntimeError(f"OpenRouter video content download failed with HTTP {download_status}: {data[:500]!r}")
_emit_progress(progress, "video_completed", "OpenRouter video generation completed and downloaded", {"model": self.model, "job_id": job_id})
return VideoOutput(fmt="bytes", ext="mp4", data=data)
if status in {"failed", "cancelled", "expired"}:
raise RuntimeError(f"OpenRouter video generation {status} for job {job_id}: {poll_payload.get('error') or poll_payload}")
raise RuntimeError(f"OpenRouter video generation timed out after {query_timeout_seconds:g}s for job {job_id}; last_status={last_status}; last_payload={last_payload}")
def _headers(self) -> dict[str, str]:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
if self.http_referer:
headers["HTTP-Referer"] = self.http_referer
if self.app_title:
headers["X-OpenRouter-Title"] = self.app_title
return headers
def _frame_images(reference_image_paths: List[str]) -> list[dict]:
if len(reference_image_paths) > 2:
raise ValueError("OpenRouter video generation supports at most first and last frame images")
frame_types = ["first_frame", "last_frame"]
return [
{
"type": "image_url",
"image_url": {"url": image_path_to_b64(path, mime=True)},
"frame_type": frame_types[index],
}
for index, path in enumerate(reference_image_paths)
]
def _absolute_url(base_url: str, url: str) -> str:
if url.startswith("http://") or url.startswith("https://"):
return url
return urljoin(f"{base_url.rstrip('/')}/", url.lstrip("/"))
def _needs_authorization(url: str) -> bool:
return url.startswith("https://openrouter.ai/api/")
async def _post_json(url: str, *, headers: dict[str, str], payload: dict, timeout: aiohttp.ClientTimeout, hard_timeout_seconds: float) -> tuple[int, dict]:
async def request() -> tuple[int, dict]:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, headers=headers, json=payload) as response:
return response.status, await response.json(content_type=None)
return await asyncio.wait_for(request(), timeout=hard_timeout_seconds + 5)
async def _get_json(url: str, *, headers: dict[str, str], timeout: aiohttp.ClientTimeout, hard_timeout_seconds: float) -> tuple[int, dict]:
async def request() -> tuple[int, dict]:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as response:
return response.status, await response.json(content_type=None)
return await asyncio.wait_for(request(), timeout=hard_timeout_seconds + 5)
async def _get_bytes(url: str, *, headers: dict[str, str], timeout: aiohttp.ClientTimeout, hard_timeout_seconds: float) -> tuple[int, bytes]:
async def request() -> tuple[int, bytes]:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers=headers) as response:
return response.status, await response.read()
return await asyncio.wait_for(request(), timeout=hard_timeout_seconds + 5)