Files
comfyui_o1key/clients/seedream_image_client.py
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

410 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Seedream image client for O1Key's asynchronous image API."""
from __future__ import annotations
import os
import time
from typing import Any, Awaitable, Callable, Optional, Sequence
from PIL import Image
from ..utils.nano_banana_async import (
extract_async_image_result_urls,
image_to_upload_payload,
parse_completed_async_image_task,
poll_async_image_task,
submit_async_image_task,
upload_images_to_temp_urls,
)
from ..utils.o1key_image_catalog import (
MAX_UNIFIED_REFERENCE_IMAGES,
SEEDREAM_MODEL_OPTIONS,
SEEDREAM_LAYER_RESOLUTION_OPTIONS,
SEEDREAM_OUTPUT_FORMAT_OPTIONS,
SEEDREAM_SIZE_MATRIX,
UNIFIED_IMAGE_ROUTE_OPTIONS,
)
SEEDREAM_API_MODEL_ID = "dola-seedream-5-0-pro-260628-ep"
SEEDREAM_REFERENCE_MAX_BYTES = 30 * 1024 * 1024
SEEDREAM_REFERENCE_MAX_PIXELS = 6000 * 6000
SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE = 14
SEEDREAM_REFERENCE_MIN_ASPECT_RATIO = 1 / 16
SEEDREAM_REFERENCE_MAX_ASPECT_RATIO = 16
SEEDREAM_LAYER_REFERENCE_MIN_PIXELS = 512 * 512
def validate_seedream_reference_dimensions(
width: int,
height: int,
*,
label: str = "Seedream 参考图",
layer_decomposition: bool = False,
) -> None:
"""Validate the current Volcengine per-image reference-size contract."""
width = int(width)
height = int(height)
if width <= 0 or height <= 0:
raise ValueError(f"{label}尺寸无效:{width}×{height}")
pixels = width * height
if (
not layer_decomposition
and (
width <= SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE
or height <= SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE
)
):
raise ValueError(
f"{label}宽和高都必须大于 {SEEDREAM_REFERENCE_MIN_SIDE_EXCLUSIVE}px"
f"当前为 {width}×{height}"
)
ratio = width / height
if (
ratio < SEEDREAM_REFERENCE_MIN_ASPECT_RATIO
or ratio > SEEDREAM_REFERENCE_MAX_ASPECT_RATIO
):
raise ValueError(
f"{label}宽高比必须在 1:16~16:1,当前为 {width}:{height}"
)
if layer_decomposition:
if not (
SEEDREAM_LAYER_REFERENCE_MIN_PIXELS
<= pixels
<= SEEDREAM_REFERENCE_MAX_PIXELS
):
raise ValueError(
f"{label}总像素必须在 512×512262144)~6000×600036000000)之间,"
f"当前为 {width}×{height}{pixels}"
)
return
if pixels > SEEDREAM_REFERENCE_MAX_PIXELS:
raise ValueError(
f"{label}总像素不能超过 6000×600036000000),"
f"当前为 {width}×{height}{pixels}"
)
def validate_seedream_reference_image(
image: Image.Image,
*,
label: str = "Seedream 参考图",
layer_decomposition: bool = False,
) -> None:
"""Validate reference dimensions and the exact bytes sent to the uploader."""
validate_seedream_reference_dimensions(
image.width,
image.height,
label=label,
layer_decomposition=layer_decomposition,
)
payload, _extension, _content_type = image_to_upload_payload(image)
try:
payload_size = (
os.path.getsize(payload)
if isinstance(payload, (str, os.PathLike))
else len(payload)
)
except (OSError, TypeError) as exc:
raise ValueError(f"无法读取{label}文件大小") from exc
if payload_size > SEEDREAM_REFERENCE_MAX_BYTES:
raise ValueError(
f"{label}文件不能超过 30MB,当前为 {payload_size / 1024 / 1024:.2f}MB"
)
def validate_seedream_reference_images(
images: Sequence[Image.Image],
*,
layer_decomposition: bool = False,
) -> None:
for index, image in enumerate(images, start=1):
validate_seedream_reference_image(
image,
label=f"Seedream 参考图{index}",
layer_decomposition=layer_decomposition,
)
def resolve_seedream_model(model_name: str, route: str) -> str:
"""Map the stable workflow value to Seedream's API model identifier."""
if model_name == SEEDREAM_API_MODEL_ID:
return model_name
if model_name not in SEEDREAM_MODEL_OPTIONS:
raise ValueError(f"Seedream 模型无效:{model_name}")
if route not in UNIFIED_IMAGE_ROUTE_OPTIONS:
raise ValueError(f"Seedream 模型线路无效:{route}")
# O1Key currently exposes one Seedream endpoint for every displayed route.
return SEEDREAM_API_MODEL_ID
def build_seedream_submit_body(
*,
model: str,
prompt: str,
size: Optional[str],
output_format: str,
image_urls: Optional[Sequence[str]] = None,
layer_decomposition: bool = False,
) -> dict[str, Any]:
"""Build and validate the paid Seedream request without logging URLs."""
normalized_prompt = str(prompt or "").strip()
if not normalized_prompt and not layer_decomposition:
raise ValueError("请输入提示词")
if model != SEEDREAM_API_MODEL_ID:
raise ValueError(f"Seedream API 模型无效:{model}")
normalized_format = str(output_format or "").strip().lower()
if normalized_format not in SEEDREAM_OUTPUT_FORMAT_OPTIONS:
raise ValueError("Seedream 输出格式仅支持 png 或 jpeg")
if layer_decomposition and normalized_format != "png":
raise ValueError("Seedream 图层拆分仅支持 png 输出格式")
normalized_size = str(size or "").strip().lower().replace("*", "x").replace("×", "x")
if normalized_size:
if layer_decomposition:
normalized_size = "auto" if normalized_size == "auto" else normalized_size.upper()
if normalized_size not in SEEDREAM_LAYER_RESOLUTION_OPTIONS:
raise ValueError(f"Seedream 图层拆分分辨率无效:{size}")
elif normalized_size not in set(SEEDREAM_SIZE_MATRIX.values()):
raise ValueError(f"Seedream 图片尺寸无效:{size}")
urls = [str(url or "").strip() for url in (image_urls or ())]
if len(urls) > MAX_UNIFIED_REFERENCE_IMAGES:
raise ValueError(f"Seedream 参考图最多支持 {MAX_UNIFIED_REFERENCE_IMAGES} 张")
if any(not url.startswith("https://") for url in urls):
raise ValueError("Seedream 参考图必须使用临时素材 HTTPS URL")
if layer_decomposition and len(urls) != 1:
raise ValueError("Seedream 图层拆分必须且只能提供1张参考图")
body: dict[str, Any] = {
"model": model,
"n": 1,
"output_format": normalized_format,
"watermark": False,
}
if normalized_size:
body["size"] = normalized_size
if normalized_prompt:
body["prompt"] = normalized_prompt
if urls:
body["images"] = urls
if layer_decomposition:
body["layer_decomposition"] = True
return body
def _seedream_result_items(payload: Any) -> list[dict[str, Any]]:
"""Return the first documented image-item list without exposing its URLs."""
pending = [payload]
seen: set[int] = set()
while pending:
value = pending.pop(0)
if not isinstance(value, dict) or id(value) in seen:
continue
seen.add(id(value))
images = value.get("images")
if isinstance(images, list) and all(isinstance(item, dict) for item in images):
return images
for key in ("data", "result", "output"):
nested = value.get(key)
if isinstance(nested, dict):
pending.append(nested)
return []
def _bounded_int_list(value: Any, *, length: int) -> list[int] | None:
if not isinstance(value, (list, tuple)) or len(value) != length:
return None
try:
return [int(item) for item in value]
except (TypeError, ValueError):
return None
def extract_seedream_layer_metadata(payload: Any) -> list[dict[str, Any]]:
"""Sanitize layer metadata; result URLs are deliberately excluded."""
metadata: list[dict[str, Any]] = []
for offset, item in enumerate(_seedream_result_items(payload)):
try:
z_index = max(0, min(16, int(item.get("z_index", offset))))
except (TypeError, ValueError):
z_index = offset
safe: dict[str, Any] = {"z_index": z_index}
for key, limit in (("name", 200), ("description", 1000), ("size", 64), ("output_format", 16)):
value = item.get(key)
if isinstance(value, str) and value.strip():
safe[key] = value.strip()[:limit]
bounding_box = item.get("bounding_box")
if isinstance(bounding_box, dict):
absolute = _bounded_int_list(bounding_box.get("absolute"), length=4)
normalized = _bounded_int_list(bounding_box.get("normalized"), length=4)
safe_box = {}
if absolute is not None:
safe_box["absolute"] = absolute
if normalized is not None:
safe_box["normalized"] = normalized
if safe_box:
safe["bounding_box"] = safe_box
metadata.append(safe)
return metadata
class SeedreamImageClient:
"""Upload references, submit one Seedream task, poll it, and decode results."""
def __init__(self, *, base_url: str, api_key: str):
self.base_url = str(base_url).rstrip("/")
self.api_key = api_key
async def generate_async(
self,
*,
session: Any,
prompt: str,
model: str,
size: Optional[str],
output_format: str,
images: Optional[Sequence[Image.Image]] = None,
layer_decomposition: bool = False,
upload_cache: Optional[dict[int, Awaitable[str]]] = None,
check_interrupt: Optional[Callable[[], None]] = None,
progress_callback: Optional[Callable[[float], None]] = None,
result_url_callback: Optional[Callable[[str], None]] = None,
log_downloads: bool = True,
log_task_success: bool = True,
task_completed_callback: Optional[
Callable[[str, int, float, list[str]], None]
] = None,
) -> tuple[list[Image.Image], dict[str, Any]]:
if check_interrupt:
check_interrupt()
reference_images = list(images or ())
validate_seedream_reference_images(
reference_images,
layer_decomposition=layer_decomposition,
)
task_started = time.time()
image_urls = await upload_images_to_temp_urls(
session=session,
base_url=self.base_url,
api_key=self.api_key,
images=reference_images,
node_label="Seedream",
check_interrupt=check_interrupt,
upload_cache=upload_cache,
log_success=log_task_success,
)
body = build_seedream_submit_body(
model=model,
prompt=prompt,
size=size,
output_format=output_format,
image_urls=image_urls,
layer_decomposition=layer_decomposition,
)
task_id = await submit_async_image_task(
session,
self.base_url,
self.api_key,
body,
"Seedream",
log_body_enabled=False,
log_success=log_task_success,
)
task_payload = await poll_async_image_task(
session,
self.base_url,
self.api_key,
task_id,
"Seedream",
check_interrupt=check_interrupt,
log_body_enabled=False,
progress_callback=progress_callback,
log_success=log_task_success,
)
task_done = time.time()
parse_started = time.time()
task_payload, parsed = await parse_completed_async_image_task(
task_payload,
session,
self.base_url,
self.api_key,
task_id,
"Seedream",
check_interrupt=check_interrupt,
result_url_callback=None,
log_downloads=log_downloads,
)
if isinstance(parsed, tuple) and len(parsed) == 2:
result_images, metrics = parsed
else:
result_images = parsed
metrics = {
"download_bytes": 0,
"download_seconds": 0.0,
"download_wall_seconds": 0.0,
"inline_images": 0,
}
result_urls = extract_async_image_result_urls(task_payload)
result_metadata = extract_seedream_layer_metadata(task_payload)
if layer_decomposition:
paired = []
for index, image in enumerate(result_images):
metadata = (
result_metadata[index]
if index < len(result_metadata)
else {"z_index": index}
)
setattr(image, "_o1key_seedream_layer", metadata)
paired.append((metadata.get("z_index", index), index, image))
paired.sort(key=lambda item: (item[0], item[1]))
result_images = [item[2] for item in paired]
result_metadata = [
getattr(image, "_o1key_seedream_layer", {"z_index": index})
for index, image in enumerate(result_images)
]
if result_url_callback:
for url in result_urls:
result_url_callback(url)
if task_completed_callback:
task_completed_callback(
task_id,
len(result_images),
time.time() - task_started,
result_urls,
)
return result_images, {
"task_id": task_id,
"task_ids": [task_id],
"task_ms": (task_done - task_started) * 1000,
"parse_ms": (time.time() - parse_started) * 1000,
"download_ms": metrics["download_wall_seconds"] * 1000,
"download_total_ms": metrics["download_seconds"] * 1000,
"download_bytes": metrics["download_bytes"],
"inline_images": metrics["inline_images"],
"result_metadata": result_metadata,
}
__all__ = [
"SEEDREAM_API_MODEL_ID",
"SEEDREAM_LAYER_REFERENCE_MIN_PIXELS",
"SEEDREAM_REFERENCE_MAX_BYTES",
"SEEDREAM_REFERENCE_MAX_PIXELS",
"SeedreamImageClient",
"build_seedream_submit_body",
"extract_seedream_layer_metadata",
"resolve_seedream_model",
"validate_seedream_reference_dimensions",
"validate_seedream_reference_image",
"validate_seedream_reference_images",
]