chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:54 +08:00
commit ecb5ae4e59
153 changed files with 21551 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# rendering abstraction
from .protocols import ImageGenerator, VideoGenerator
from .render_backend import RenderBackend
# image generators
from .image_generator_doubao_seedream_yunwu_api import ImageGeneratorDoubaoSeedreamYunwuAPI
from .image_generator_nanobanana_google_api import ImageGeneratorNanobananaGoogleAPI
from .image_generator_nanobanana_yunwu_api import ImageGeneratorNanobananaYunwuAPI
# reranker for rag
from .reranker_bge_silicon_api import RerankerBgeSiliconapi
# video generators
from .video_generator_doubao_seedance_yunwu_api import VideoGeneratorDoubaoSeedanceYunwuAPI
from .video_generator_omni_yunwu_api import VideoGeneratorOmniYunwuAPI, VideoGeneratorOminiYunwuAPI
from .video_generator_openrouter_api import VideoGeneratorOpenRouterAPI
from .video_generator_veo_google_api import VideoGeneratorVeoGoogleAPI
from .video_generator_veo_yunwu_api import VideoGeneratorVeoYunwuAPI
__all__ = [
"ImageGenerator",
"VideoGenerator",
"RenderBackend",
"ImageGeneratorDoubaoSeedreamYunwuAPI",
"ImageGeneratorNanobananaGoogleAPI",
"ImageGeneratorNanobananaYunwuAPI",
"RerankerBgeSiliconapi",
"VideoGeneratorDoubaoSeedanceYunwuAPI",
"VideoGeneratorOmniYunwuAPI",
"VideoGeneratorOminiYunwuAPI",
"VideoGeneratorOpenRouterAPI",
"VideoGeneratorVeoGoogleAPI",
"VideoGeneratorVeoYunwuAPI",
]
@@ -0,0 +1,74 @@
# https://yunwu.apifox.cn/api-347960869
import asyncio
import logging
import aiohttp
from typing import List, Optional
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from utils.retry import after_func
from utils.image import image_path_to_b64
from interfaces.image_output import ImageOutput
class ImageGeneratorDoubaoSeedreamYunwuAPI:
def __init__(
self,
api_key: str,
model: str = "doubao-seedream-4-0-250828",
):
self.api_key = api_key
self.base_url = "https://yunwu.ai/v1/images/generations"
self.model = model
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, max=30),
retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)),
reraise=True,
after=after_func,
)
async def generate_single_image(
self,
prompt: str,
reference_image_paths: List[str] = [],
size: Optional[str] = None,
**kwargs,
) -> ImageOutput:
"""
size: [1024x1024, 4096x4096]
"""
logging.info(f"Calling {self.model} to generate image...")
image = [
image_path_to_b64(path, mime=True) for path in reference_image_paths
]
payload = {
"model": self.model,
"prompt": prompt,
"sequential_image_generation": "disabled", # "auto" or "disabled"
# "sequential_image_generation_options": {
# "max_images": 1
# },
"response_format": "url",
"size": size if size is not None else "1024x1024",
}
if len(image) > 0:
payload["image"] = image
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession() as session:
async with session.post(self.base_url, json=payload, headers=headers) as response:
response_json = await response.json()
if response.status >= 400:
raise RuntimeError(f"Image generation failed with HTTP {response.status}: {response_json}")
data = response_json['data'][0]['url']
return ImageOutput(fmt="url", ext="png", data=data)
@@ -0,0 +1,97 @@
# https://ai.google.dev/gemini-api/docs/image-generation
import logging
import asyncio
from PIL import Image
from typing import List, Optional
from google import genai
from google.genai import types
from google.genai.errors import ClientError
from tenacity import retry, stop_after_attempt, wait_exponential
from interfaces.image_output import ImageOutput
from tools.image_orientation import ensure_not_portrait, landscape_guard_requested
from tools.image_response import image_from_response_part
from utils.retry import after_func
from utils.rate_limiter import RateLimiter
class ImageGeneratorNanobananaGoogleAPI:
def __init__(
self,
api_key: str,
rate_limiter: Optional[RateLimiter] = None,
):
self.model = "gemini-2.5-flash-image"
self.rate_limiter = rate_limiter
self.client = genai.Client(
api_key=api_key,
)
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), after=after_func, reraise=True)
async def generate_single_image(
self,
prompt: str,
reference_image_paths: List[str] = [],
aspect_ratio: Optional[str] = "16:9",
**kwargs,
) -> ImageOutput:
"""
aspect_ratio: The aspect ratio of the image.
"""
logging.info(f"Calling {self.model} to generate image...")
# Apply rate limiting if configured
if self.rate_limiter:
await self.rate_limiter.acquire()
reference_images = [Image.open(path) for path in reference_image_paths]
# Retry logic for rate limit errors
max_retries = 3
retry_delay = 5
for attempt in range(max_retries):
try:
response = await self.client.aio.models.generate_content(
model=self.model,
contents=reference_images + [prompt],
config=types.GenerateContentConfig(
response_modalities=["IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
),
),
)
break
except ClientError as e:
if e.status_code == 429 and attempt < max_retries - 1:
wait_time = retry_delay * (2 ** attempt)
logging.warning(f"Rate limit hit (429), retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries})")
await asyncio.sleep(wait_time)
else:
raise
image = None
text = ""
for part in response.candidates[0].content.parts:
if part.text is not None:
text += part.text
elif part.inline_data is not None:
image = image_from_response_part(part)
if image is None:
logging.error(f"No image generated. The response text is: {text}")
raise ValueError("No image generated")
if landscape_guard_requested(
size=kwargs.get("size"),
aspect_ratio=aspect_ratio,
enforce_landscape=kwargs.get("enforce_landscape", True),
allow_portrait=kwargs.get("allow_portrait", False),
):
ensure_not_portrait(image)
return ImageOutput(fmt="pil", ext="png", data=image)
@@ -0,0 +1,79 @@
# https://ai.google.dev/gemini-api/docs/image-generation?hl=zh-cn
import logging
from PIL import Image
from typing import List, Optional
from google import genai
from google.genai import types
from tenacity import retry, stop_after_attempt, wait_exponential
from interfaces.image_output import ImageOutput
from tools.image_orientation import ensure_not_portrait, landscape_guard_requested
from tools.image_response import image_from_response_part
from utils.retry import after_func
class ImageGeneratorNanobananaYunwuAPI:
def __init__(
self,
api_key: str,
model: str = "gemini-2.5-flash-image-preview",
base_url: str = "https://yunwu.ai",
):
self.client = genai.Client(
api_key=api_key,
http_options=types.HttpOptions(
base_url=base_url.rstrip("/"),
api_version="v1beta",
),
)
self.model = model
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), after=after_func, reraise=True)
async def generate_single_image(
self,
prompt: str,
reference_image_paths: List[str] = [],
aspect_ratio: Optional[str] = "16:9",
**kwargs,
) -> ImageOutput:
"""
aspect_ratio: The aspect ratio of the image.
"""
logging.info(f"Calling {self.model} to generate image...")
reference_images = [Image.open(path) for path in reference_image_paths]
response = await self.client.aio.models.generate_content(
model=self.model,
contents=reference_images + [prompt],
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=aspect_ratio,
),
),
)
image = None
text = ""
for part in response.candidates[0].content.parts:
if part.text is not None:
text += part.text
elif part.inline_data is not None:
image = image_from_response_part(part)
if image is None:
logging.error(f"No image generated. The response text is: {text}")
raise ValueError(f"Error occurred while generating image.")
if landscape_guard_requested(
size=kwargs.get("size"),
aspect_ratio=aspect_ratio,
enforce_landscape=kwargs.get("enforce_landscape", True),
allow_portrait=kwargs.get("allow_portrait", False),
):
ensure_not_portrait(image)
return ImageOutput(fmt="pil", ext="png", data=image)
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
import os
from typing import Any
from PIL import Image
def landscape_guard_requested(*, size: Any = None, aspect_ratio: Any = None, enforce_landscape: Any = True, allow_portrait: Any = False) -> bool:
if bool(allow_portrait):
return False
if bool(enforce_landscape):
return True
parsed = _parse_size(size)
if parsed and parsed[0] > parsed[1]:
return True
parsed_ratio = _parse_size(aspect_ratio)
return bool(parsed_ratio and parsed_ratio[0] > parsed_ratio[1])
def ensure_not_portrait(image: Image.Image, *, tolerance: float | None = None) -> None:
width, height = image.size
if width <= 0 or height <= 0:
return
threshold = tolerance if tolerance is not None else _portrait_tolerance()
if height > width * threshold:
raise ValueError(f"Generated image is portrait-oriented ({width}x{height}); retrying for a landscape frame")
def _portrait_tolerance() -> float:
raw = os.environ.get("VIMAX_IMAGE_PORTRAIT_RETRY_TOLERANCE", "1.05")
try:
return max(1.0, float(raw))
except ValueError:
return 1.05
def _parse_size(size: Any) -> tuple[int, int] | None:
if not isinstance(size, str):
return None
normalized = size.lower()
separator = "x" if "x" in normalized else ":" if ":" in normalized else ""
if not separator:
return None
left, right = normalized.split(separator, 1)
try:
width = int(left.strip())
height = int(right.strip())
except ValueError:
return None
if width <= 0 or height <= 0:
return None
return width, height
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import base64
from io import BytesIO
from typing import Any
from PIL import Image
def image_from_response_part(part: Any) -> Image.Image | None:
inline_data = getattr(part, "inline_data", None)
if inline_data is None and isinstance(part, dict):
inline_data = part.get("inline_data")
if inline_data is None:
return None
as_image = getattr(part, "as_image", None)
if callable(as_image):
image = as_image()
if isinstance(image, Image.Image):
return image
data = _value(inline_data, "data")
if data is None:
return None
if isinstance(data, str):
if data.startswith("data:") and "," in data:
data = data.split(",", 1)[1]
data = base64.b64decode(data)
if isinstance(data, bytearray):
data = bytes(data)
if not isinstance(data, bytes):
return None
return Image.open(BytesIO(data)).convert("RGB")
def _value(obj: Any, key: str) -> Any:
if isinstance(obj, dict):
return obj.get(key)
return getattr(obj, key, None)
+35
View File
@@ -0,0 +1,35 @@
"""Structural typing contracts for rendering backends.
Any class that exposes the right method signatures satisfies these
protocols -- no inheritance required. Existing generators (Google,
Yunwu/Doubao, Yunwu/Veo) are already compliant by duck typing.
"""
from typing import List, Protocol, runtime_checkable
from interfaces.image_output import ImageOutput
from interfaces.video_output import VideoOutput
@runtime_checkable
class ImageGenerator(Protocol):
"""Generates a single image from a text prompt and optional reference images."""
async def generate_single_image(
self,
prompt: str,
reference_image_paths: List[str],
**kwargs,
) -> ImageOutput: ...
@runtime_checkable
class VideoGenerator(Protocol):
"""Generates a single video from a text prompt and optional reference images."""
async def generate_single_video(
self,
prompt: str,
reference_image_paths: List[str],
**kwargs,
) -> VideoOutput: ...
+62
View File
@@ -0,0 +1,62 @@
"""RenderBackend: config-driven factory for image and video generators.
Reads the ``image_generator`` and ``video_generator`` sections from a
ViMax YAML config, instantiates the concrete classes via *class_path*,
and wires up rate limiters.
Usage::
backend = RenderBackend.from_config(config)
image = await backend.image_generator.generate_single_image(...)
video = await backend.video_generator.generate_single_video(...)
"""
import importlib
import logging
from dataclasses import dataclass
from typing import Any, Dict
from utils.rate_limiter import RateLimiter
@dataclass
class RenderBackend:
"""Bundles an image generator and a video generator."""
image_generator: Any
video_generator: Any
@classmethod
def from_config(cls, config: Dict[str, Any]) -> "RenderBackend":
"""Build a RenderBackend from a parsed YAML config dict.
Rate limiters are created from ``max_requests_per_minute`` /
``max_requests_per_day`` if present in each generator section.
"""
img_cfg = config["image_generator"]
vid_cfg = config["video_generator"]
image_gen = _instantiate(img_cfg, _build_rate_limiter(img_cfg))
video_gen = _instantiate(vid_cfg, _build_rate_limiter(vid_cfg))
logging.info("RenderBackend: image=%s, video=%s",
img_cfg["class_path"], vid_cfg["class_path"])
return cls(image_generator=image_gen, video_generator=video_gen)
def _build_rate_limiter(section: Dict[str, Any]) -> RateLimiter | None:
rpm = section.get("max_requests_per_minute")
rpd = section.get("max_requests_per_day")
if rpm or rpd:
return RateLimiter(max_requests_per_minute=rpm, max_requests_per_day=rpd)
return None
def _instantiate(section: Dict[str, Any], rate_limiter: RateLimiter | None) -> Any:
module_path, cls_name = section["class_path"].rsplit(".", 1)
cls = getattr(importlib.import_module(module_path), cls_name)
init_args = dict(section.get("init_args", {}))
if rate_limiter is not None:
init_args["rate_limiter"] = rate_limiter
return cls(**init_args)
+83
View File
@@ -0,0 +1,83 @@
from typing import List
import aiohttp
import asyncio
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
import logging
class RerankerBgeSiliconapi:
def __init__(
self,
api_key: str,
base_url: str,
model: str = "BAAI/bge-reranker-v2-m3",
):
self.api_key = api_key
self.base_url = base_url
self.model = model
# return_documents: bool = True,
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, max=30),
retry=retry_if_exception_type((aiohttp.ClientError, asyncio.TimeoutError)),
reraise=True,
after=lambda retry_state: logging.warning(f"Retrying SiliconReranker due to error: {retry_state.outcome.exception()}"),
)
async def __call__(
self,
documents: List[str],
query: str,
top_n: int,
) -> List[str]:
url = f"{self.base_url}/rerank"
payload = {
"model": self.model,
"query": query,
"documents": documents,
"top_n": top_n,
"return_documents": True,
}
headers = {
'Accept': 'application/json',
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
async with aiohttp.ClientSession() as session:
async with session.post(url, json=payload, headers=headers) as resp:
response = await resp.json()
if resp.status >= 400:
raise RuntimeError(f"Rerank request failed with HTTP {resp.status}: {response}")
"""
{
"id": "<string>",
"results": [
{
"document": {
"text": "<string>"
},
"index": 123,
"relevance_score": 123
}
],
"tokens": {
"input_tokens": 123,
"output_tokens": 123
}
}
"""
results = []
for result in response["results"]:
results.append((result["document"]["text"], result["relevance_score"]))
return results
@@ -0,0 +1,212 @@
import logging
from typing import List, Literal
import asyncio
import aiohttp
from interfaces.video_output import VideoOutput
from utils.image import image_path_to_b64
class VideoGeneratorDoubaoSeedanceYunwuAPI:
def __init__(
self,
api_key: str,
t2v_model: str = "doubao-seedance-1-0-lite-t2v-250428",
ff2v_model: str = "doubao-seedance-1-0-lite-i2v-250428",
flf2v_model: str = "doubao-seedance-1-0-lite-i2v-250428",
max_create_attempts: int = 3,
poll_interval: int = 2,
max_poll_attempts: int = 300,
):
self.api_key = api_key
self.t2v_model = t2v_model
self.ff2v_model = ff2v_model
self.flf2v_model = flf2v_model
self.max_create_attempts = max_create_attempts
self.poll_interval = poll_interval
self.max_poll_attempts = max_poll_attempts
async def create_video_generation_task(
self,
prompt: str,
reference_image_paths: List[str],
resolution: Literal["480p", "720p", "1080p"] = "720p",
aspect_ratio: str = "16:9",
fps: Literal[16, 24] = 16,
duration: Literal[5, 10] = 5,
) -> str:
"""
Create a video generation task and return the task ID.
Args:
prompt: Text prompt for video generation
reference_image_paths: List of 1 or 2 reference images
Returns:
Task ID string
"""
if len(reference_image_paths) == 0:
model = self.t2v_model
elif len(reference_image_paths) == 1:
model = self.ff2v_model
elif len(reference_image_paths) == 2:
model = self.flf2v_model
else:
raise ValueError("reference_image_paths must contain 1 or 2 images.")
logging.info(f"Calling {model} to generate video...")
url = "https://yunwu.ai/volc/v1/contents/generations/tasks"
content = [
{
"type": "text",
"text": prompt + f" --rs {resolution} --rt {aspect_ratio} --dur {duration} --fps {fps} --wm false --seed -1 --cf false"
}
]
if len(reference_image_paths) >= 1:
content.append(
{
"type": "image_url",
"image_url": {
"url": image_path_to_b64(reference_image_paths[0])
},
"role": "first_frame",
}
)
if len(reference_image_paths) >= 2:
content.append(
{
"type": "image_url",
"image_url": {
"url": image_path_to_b64(reference_image_paths[1])
},
"role": "last_frame",
}
)
payload = {
"model": model,
"content": content
}
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
last_error = None
for attempt in range(1, self.max_create_attempts + 1):
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=payload) as response:
response_json = await response.json()
http_status = response.status
logging.debug(f"Response: {response_json}")
except Exception as e:
last_error = e
logging.error(f"Error occurred while creating video generation task (attempt {attempt}/{self.max_create_attempts}): {e}")
if attempt < self.max_create_attempts:
await asyncio.sleep(attempt)
continue
if http_status >= 400:
message = f"Video generation task creation failed with HTTP {http_status}: {response_json}"
if http_status < 500:
raise RuntimeError(message)
last_error = RuntimeError(message)
logging.error(f"{message} (attempt {attempt}/{self.max_create_attempts})")
if attempt < self.max_create_attempts:
await asyncio.sleep(attempt)
continue
task_id = response_json.get("id")
if not task_id:
raise RuntimeError(f"Video generation task creation returned no task id: {response_json}")
logging.info(f"Video generation task created successfully. Task ID: {task_id}")
return task_id
raise RuntimeError(f"Failed to create video generation task after {self.max_create_attempts} attempts.") from last_error
async def query_video_generation_task(
self,
task_id: str,
) -> str:
"""
Query the video generation task until completion and return the video URL.
Args:
task_id: Task ID to query
Returns:
Video URL string
"""
url = f"https://yunwu.ai/volc/v1/contents/generations/tasks/{task_id}"
headers = {
'Authorization': f'Bearer {self.api_key}',
}
attempts = 0
consecutive_errors = 0
while True:
if attempts >= self.max_poll_attempts:
raise TimeoutError(f"Video generation did not complete after {attempts} polls.")
attempts += 1
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
response_json = await response.json()
http_status = response.status
except Exception as e:
consecutive_errors += 1
if consecutive_errors >= 5:
raise RuntimeError(f"Querying video generation task failed {consecutive_errors} times in a row.") from e
logging.error(f"Error occurred while querying video generation task: {e}. Retrying in {self.poll_interval} seconds...")
await asyncio.sleep(self.poll_interval)
continue
consecutive_errors = 0
if http_status >= 400:
raise RuntimeError(f"Querying video generation task failed with HTTP {http_status}: {response_json}")
status = response_json.get("status")
if status == "succeeded":
video_url = response_json["content"]["video_url"]
logging.info(f"Video generation completed successfully. Video URL: {video_url}")
return video_url
elif status == "failed":
logging.error(f"Video generation failed. Response: {response_json}")
raise ValueError("Video generation failed.")
else:
logging.info(f"Video generation is still in progress. Checking again in {self.poll_interval} seconds...")
await asyncio.sleep(self.poll_interval)
async def generate_single_video(
self,
prompt: str,
reference_image_paths: List[str],
resolution: Literal["480p", "720p", "1080p"] = "720p",
aspect_ratio: str = "16:9",
fps: Literal[16, 24] = 16,
duration: Literal[5, 10] = 5,
**kwargs,
) -> VideoOutput:
"""
Generate a single video by creating a task and waiting for completion.
Args:
prompt: Text prompt for video generation
reference_image_paths: List of 1 or 2 reference images
resolution: Resolution of the video
aspect_ratio: Aspect ratio of the video
fps: Frames per second of the video
duration: Duration of the video
Returns:
VideoOutput containing the video URL
"""
task_id = await self.create_video_generation_task(prompt, reference_image_paths, resolution, aspect_ratio, fps, duration)
video_url = await self.query_video_generation_task(task_id)
return VideoOutput(fmt="url", ext="mp4", data=video_url)
+224
View File
@@ -0,0 +1,224 @@
import asyncio
import logging
from typing import List, Optional
import aiohttp
from interfaces.video_output import VideoOutput
from utils.image import image_path_to_b64
from utils.rate_limiter import RateLimiter
class VideoGeneratorOmniYunwuAPI:
def __init__(
self,
api_key: str,
t2v_model: str = "omni-flash",
i2v_model: str = "omni-flash",
base_url: str = "https://yunwu.ai",
seconds: int = 8,
enable_upsample: bool = False,
enable_sample: Optional[bool] = None,
poll_interval: int = 2,
max_poll_attempts: Optional[int] = 300,
max_create_attempts: int = 3,
rate_limiter: Optional[RateLimiter] = None,
):
self.api_key = api_key
self.t2v_model = t2v_model
self.i2v_model = i2v_model
self.base_url = base_url.rstrip("/")
self.seconds = seconds
self.enable_upsample = enable_upsample
self.enable_sample = enable_sample
self.poll_interval = poll_interval
self.max_poll_attempts = max_poll_attempts
self.max_create_attempts = max_create_attempts
self.rate_limiter = rate_limiter
def _headers(self) -> dict:
return {
"Accept": "application/json",
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
def _image_uri(self, image_path: str) -> str:
if image_path.startswith(("http://", "https://", "data:")):
return image_path
return image_path_to_b64(image_path, mime=True)
def _build_payload(
self,
prompt: str,
reference_image_paths: List[str],
aspect_ratio: str,
seconds: Optional[int],
size: Optional[str],
enable_upsample: Optional[bool],
enable_sample: Optional[bool],
) -> dict:
if len(reference_image_paths) > 3:
raise ValueError("The number of reference images must be no more than 3")
payload = {
"model": self.t2v_model if len(reference_image_paths) == 0 else self.i2v_model,
"prompt": prompt,
"seconds": str(seconds or self.seconds),
}
if len(reference_image_paths) == 0:
payload["type"] = 1
elif len(reference_image_paths) <= 2:
payload["type"] = 2
payload["images"] = [self._image_uri(path) for path in reference_image_paths]
else:
payload["type"] = 3
payload["images"] = [self._image_uri(path) for path in reference_image_paths]
if aspect_ratio:
payload["aspect_ratio"] = aspect_ratio
if size:
payload["size"] = size
if enable_upsample is not None:
payload["enable_upsample"] = enable_upsample
if enable_sample is not None:
payload["enable_sample"] = enable_sample
return payload
async def create_video_generation_task(
self,
prompt: str,
reference_image_paths: List[str],
aspect_ratio: str = "16:9",
seconds: Optional[int] = None,
size: Optional[str] = None,
enable_upsample: Optional[bool] = None,
enable_sample: Optional[bool] = None,
) -> tuple[str, str]:
payload = self._build_payload(
prompt=prompt,
reference_image_paths=reference_image_paths,
aspect_ratio=aspect_ratio,
seconds=seconds,
size=size,
enable_upsample=self.enable_upsample if enable_upsample is None else enable_upsample,
enable_sample=self.enable_sample if enable_sample is None else enable_sample,
)
logging.info("Calling %s to generate video...", payload["model"])
if self.rate_limiter:
await self.rate_limiter.acquire()
url = f"{self.base_url}/v1/video/create"
last_error = None
for attempt in range(1, self.max_create_attempts + 1):
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=self._headers(), json=payload) as response:
response_json = await response.json()
http_status = response.status
logging.debug("Response: %s", response_json)
except Exception as e:
last_error = e
logging.error(
"Error occurred while creating video generation task (attempt %s/%s): %s",
attempt,
self.max_create_attempts,
e,
)
if attempt < self.max_create_attempts:
await asyncio.sleep(attempt)
continue
if http_status >= 400:
message = f"Video generation task creation failed with HTTP {http_status}: {response_json}"
if http_status < 500:
raise RuntimeError(message)
last_error = RuntimeError(message)
logging.error("%s (attempt %s/%s)", message, attempt, self.max_create_attempts)
if attempt < self.max_create_attempts:
await asyncio.sleep(attempt)
continue
task_id = response_json.get("id")
if not task_id:
raise RuntimeError(f"Video generation task creation returned no task id: {response_json}")
logging.info("Video generation task created successfully. Task ID: %s", task_id)
return task_id, payload["model"]
raise RuntimeError(
f"Failed to create video generation task after {self.max_create_attempts} attempts."
) from last_error
async def query_video_generation_task(self, task_id: str, model: str) -> str:
url = f"{self.base_url}/v1/video/query"
params = {"id": task_id, "model": model}
attempts = 0
while True:
if self.max_poll_attempts is not None and attempts >= self.max_poll_attempts:
raise TimeoutError(f"Video generation did not complete after {attempts} polls.")
attempts += 1
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self._headers(), params=params) as response:
response_json = await response.json()
logging.debug("Response: %s", response_json)
except Exception as e:
logging.error(
"Error occurred while querying video generation task: %s. Retrying in %s seconds...",
e,
self.poll_interval,
)
await asyncio.sleep(self.poll_interval)
continue
status = response_json.get("status")
if status == "completed":
detail = response_json.get("detail") or {}
video_url = (
response_json.get("video_url")
or detail.get("upsample_video_url")
or detail.get("video_url")
)
if not video_url:
raise RuntimeError(f"Video generation completed without a video URL: {response_json}")
logging.info("Video generation completed successfully. Video URL: %s", video_url)
return video_url
if status in {"failed", "error"}:
raise RuntimeError(f"Video generation failed: {response_json}")
logging.info("Video generation status: %s, waiting %s seconds...", status, self.poll_interval)
await asyncio.sleep(self.poll_interval)
async def generate_single_video(
self,
prompt: str,
reference_image_paths: List[str],
aspect_ratio: str = "16:9",
seconds: Optional[int] = None,
size: Optional[str] = None,
enable_upsample: Optional[bool] = None,
enable_sample: Optional[bool] = None,
**kwargs,
) -> VideoOutput:
task_id, model = await self.create_video_generation_task(
prompt=prompt,
reference_image_paths=reference_image_paths,
aspect_ratio=aspect_ratio,
seconds=seconds,
size=size,
enable_upsample=enable_upsample,
enable_sample=enable_sample,
)
video_url = await self.query_video_generation_task(task_id, model)
return VideoOutput(fmt="url", ext="mp4", data=video_url)
class VideoGeneratorOminiYunwuAPI(VideoGeneratorOmniYunwuAPI):
"""Backward-compatible alias for the common "omini" spelling."""
+201
View File
@@ -0,0 +1,201 @@
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)
+116
View File
@@ -0,0 +1,116 @@
import logging
from typing import List, Optional
import asyncio
from google import genai
from google.genai import types
from google.genai.errors import ClientError
from interfaces.video_output import VideoOutput
from utils.rate_limiter import RateLimiter
# https://ai.google.dev/gemini-api/docs/video-generation?hl=zh-cn
class VideoGeneratorVeoGoogleAPI:
def __init__(
self,
api_key: str,
t2v_model: str = "veo-3.1-generate-preview",
ff2v_model: str = "veo-3.1-generate-preview",
flf2v_model: str = "veo-3.1-generate-preview",
rate_limiter: Optional[RateLimiter] = None,
):
self.api_key = api_key
self.t2v_model = t2v_model
self.ff2v_model = ff2v_model
self.flf2v_model = flf2v_model
self.rate_limiter = rate_limiter
self.client = genai.Client(
api_key=api_key,
)
async def generate_single_video(
self,
prompt: str,
reference_image_paths: List[str],
resolution: str = "1080p",
aspect_ratio: str = "16:9",
duration: int = 8,
**kwargs,
) -> VideoOutput:
params = {
"prompt": prompt,
}
config_params = {
"resolution": resolution,
"aspect_ratio": aspect_ratio,
"duration_seconds": duration,
}
if len(reference_image_paths) == 0:
params["model"] = self.t2v_model
elif len(reference_image_paths) == 1:
params["model"] = self.ff2v_model
params["image"] = types.Image.from_file(location=reference_image_paths[0])
elif len(reference_image_paths) == 2:
params["model"] = self.flf2v_model
params["image"] = types.Image.from_file(location=reference_image_paths[0])
config_params["last_frame"] = types.Image.from_file(location=reference_image_paths[1])
else:
raise ValueError("The number of reference images must be no more than 2")
logging.info(f"Calling {params['model']} to generate video...")
# Apply rate limiting if configured
if self.rate_limiter:
await self.rate_limiter.acquire()
# Retry logic for rate limit errors
max_retries = 3
retry_delay = 5
for attempt in range(max_retries):
try:
operation = self.client.models.generate_videos(
**params,
config=types.GenerateVideosConfig(**config_params),
)
break
except ClientError as e:
if e.status_code == 429 and attempt < max_retries - 1:
wait_time = retry_delay * (2 ** attempt)
logging.warning(f"Rate limit hit (429), retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries})")
await asyncio.sleep(wait_time)
else:
raise
while not operation.done:
await asyncio.sleep(2)
operation = self.client.operations.get(operation)
logging.info(f"Video generation not completed, waiting 2 seconds...")
# Check if operation completed successfully
if operation.error:
error_msg = f"Video generation failed: {operation.error}"
logging.error(error_msg)
raise RuntimeError(error_msg)
if not operation.response:
error_msg = "Video generation completed but no response received"
logging.error(error_msg)
raise RuntimeError(error_msg)
if not hasattr(operation.response, 'generated_videos') or not operation.response.generated_videos:
error_msg = "Video generation completed but no videos were generated"
logging.error(error_msg)
raise RuntimeError(error_msg)
generated_video = operation.response.generated_videos[0]
self.client.files.download(file=generated_video.video)
video_output = VideoOutput(
fmt="bytes",
ext="mp4",
data=generated_video.video.video_bytes,
)
return video_output
+177
View File
@@ -0,0 +1,177 @@
import logging
from typing import List, Optional
from PIL import Image
import asyncio
import aiohttp
import os
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 _emit_progress(progress, stage: str, message: str, metadata: dict | None = None) -> None:
if progress is not None:
progress(stage, message, metadata or {})
class VideoGeneratorVeoYunwuAPI:
def __init__(
self,
api_key: str,
t2v_model: str = "veo3.1-fast", # text to video
ff2v_model: str = "veo3.1-fast", # first frame to video
flf2v_model: str = "veo2-fast-frames", # first and last frame to video
base_url: str = "https://yunwu.ai",
):
"""
all models:
veo2
veo2-fast
veo2-fast-frames
veo2-fast-components
veo2-pro
veo3
veo3-fast
veo3-pro
veo3-pro-frames
veo3-fast-frames
veo3-frames
NOTE: veo3 does not support first and last frame to video generation.
"""
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.t2v_model = t2v_model
self.ff2v_model = ff2v_model
self.flf2v_model = flf2v_model
async def generate_single_video(
self,
prompt: str = "",
reference_image_paths: List[Image.Image] = [],
aspect_ratio: str = "16:9",
**kwargs,
) -> VideoOutput:
progress = kwargs.get("progress")
create_retries = _env_int("VIMAX_VIDEO_CREATE_RETRIES", 3)
query_timeout_seconds = _env_float("VIMAX_VIDEO_QUERY_TIMEOUT_SECONDS", 600.0)
request_timeout_seconds = _env_float("VIMAX_VIDEO_REQUEST_TIMEOUT_SECONDS", 60.0)
poll_interval_seconds = _env_float("VIMAX_VIDEO_POLL_INTERVAL_SECONDS", 5.0)
max_query_errors = _env_int("VIMAX_VIDEO_MAX_QUERY_ERRORS", 5)
if len(reference_image_paths) == 0:
model = self.t2v_model
elif len(reference_image_paths) == 1:
model = self.ff2v_model
elif len(reference_image_paths) == 2:
model = self.flf2v_model
else:
raise ValueError("The number of reference images must be no more than 2")
logging.info(f"Calling {model} to generate video...")
# 1. Create video generation task
payload = {
"prompt": prompt,
"model": model,
"images": [image_path_to_b64(image_path, mime=True) for image_path in reference_image_paths],
"enhance_prompt": True,
}
# only veo3 supports aspect ratio setting
if model.startswith("veo3"):
payload["aspect_ratio"] = aspect_ratio
headers = {
"Accept": "application/json",
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
url = f"{self.base_url}/v1/video/create"
task_id = None
last_create_error = None
timeout = aiohttp.ClientTimeout(total=request_timeout_seconds)
for attempt in range(1, create_retries + 1):
try:
_emit_progress(progress, "video_create", f"Creating video generation task with {model}", {"model": model, "attempt": attempt, "max_attempts": create_retries})
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, headers=headers, json=payload) as response:
response_payload = await response.json(content_type=None)
logging.debug(f"Response: {response_payload}")
if response.status >= 400:
raise RuntimeError(f"Video create failed with HTTP {response.status}: {response_payload}")
task_id = response_payload.get("id")
if not task_id:
raise RuntimeError(f"Video create response missing id: {response_payload}")
logging.info(f"Video generation task created successfully. Task ID: {task_id}")
_emit_progress(progress, "video_task_created", "Video generation task created", {"model": model, "task_id": task_id})
break
except Exception as e:
last_create_error = e
logging.error(f"Error occurred while creating video generation task: {e}.")
_emit_progress(progress, "video_create_error", f"Video create attempt {attempt} failed", {"model": model, "attempt": attempt, "error": str(e)})
if attempt < create_retries:
await asyncio.sleep(1)
if not task_id:
raise RuntimeError(f"Video create failed after {create_retries} attempts: {last_create_error}")
# 2. Query the video generation task until the video generation is completed
headers = {
'Accept': 'application/json',
'Authorization': f'Bearer {self.api_key}',
}
deadline = asyncio.get_running_loop().time() + query_timeout_seconds if query_timeout_seconds > 0 else None
query_errors = 0
last_status = None
while deadline is None or asyncio.get_running_loop().time() < deadline:
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(f"{self.base_url}/v1/video/query?id={task_id}", headers=headers) as response:
payload = await response.json(content_type=None)
logging.debug(f"Response: {payload}")
if response.status >= 400:
raise RuntimeError(f"Video query failed with HTTP {response.status}: {payload}")
status = payload.get("status")
if not status:
raise RuntimeError(f"Video query response missing status: {payload}")
query_errors = 0
except Exception as e:
query_errors += 1
logging.error(f"Error occurred while querying video generation task: {e}.")
_emit_progress(progress, "video_query_error", "Video query failed", {"model": model, "task_id": task_id, "error": str(e), "query_errors": query_errors, "max_query_errors": max_query_errors})
if query_errors >= max_query_errors:
raise RuntimeError(f"Video query failed {query_errors} times for task {task_id}: {e}")
await asyncio.sleep(poll_interval_seconds)
continue
if status == "completed":
logging.info(f"Video generation completed successfully")
video_url = payload.get("video_url")
if not video_url:
raise RuntimeError(f"Video task completed without video_url: {payload}")
_emit_progress(progress, "video_completed", "Video generation completed", {"model": model, "task_id": task_id})
return VideoOutput(fmt="url", ext="mp4", data=video_url)
elif status == "failed":
logging.error(f"Video generation failed: \n{payload}")
raise RuntimeError(f"Video generation failed for task {task_id}: {payload}")
else:
logging.info(f"Video generation status: {status}, waiting 1 second...")
last_status = status
_emit_progress(progress, "video_status", f"Video generation status: {status}", {"model": model, "task_id": task_id, "status": status})
await asyncio.sleep(poll_interval_seconds)
continue
raise RuntimeError(f"Video generation timed out after {query_timeout_seconds:g}s for task {task_id}; last_status={last_status}")