Files
375f8e4229 ComfyUI-SayMaker 0.1.0
via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 23:58:28 +08:00

307 lines
9.9 KiB
Python

"""SayMaker client for the ComfyUI nodes (a copy of the `saymaker` package, kept stdlib-only so the node pack has no install step).
from saymaker import generate, generate_video
url = generate("a paper-cut layered mountain range at dusk")
clip = generate_video("a paper boat drifting down a rain gutter, low angle")
One API key runs the whole SayMaker shelf: Nano Banana 2, GPT Image 2.5,
Seedream 5.0, Veo 3.1, Kling 3.0, Seedance 2.0, MiniMax H3 and more, on your
own credits. Create a key at https://saymaker.ai/settings/apikeys and pass it
as ``api_key=`` or set ``SAYMAKER_API_KEY``.
Standard library only.
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from typing import Callable, Optional
__version__ = "0.1.1"
__all__ = [
"generate",
"generate_video",
"SayMakerError",
"AuthError",
"QuotaError",
"PlanError",
"BusyError",
"RejectedError",
"MODELS",
"BASE",
]
BASE = "https://saymaker.ai"
KEYS_URL = BASE + "/settings/apikeys"
#: Model id -> the routing field the API expects next to it. Ids not listed
#: here are sent with "kie"; the full list with inputs is on saymaker.ai/models.
MODELS = {
# image
"saymaker-image-v1": "kie",
"nano-banana-2-lite": "kie",
"nano-banana-2": "poyo",
"nano-banana-pro": "poyo",
"gpt-image-2": "poyo",
"gpt-image-2-5-flare": "kie",
"seedream-5-lite": "poyo",
"seedream-5-pro": "kie",
"qwen-image-3-pro": "kie",
"grok-imagine-image-2-0": "poyo",
# video
"minimax-h3-fast": "vgenv",
"minimax-h3": "poyo",
"seedance-2": "kie",
"seedance-2-fast": "kie",
"seedance-2-5": "kie",
"veo-3-1": "kie",
"kling-3-0": "kie",
"kling-3-0-turbo": "kie",
"wan-3-0": "kie",
"ltx-2-5-fast": "replicate",
}
#: The cheapest text-to-image model; the edit default takes an input image.
DEFAULT_IMAGE_MODEL = "saymaker-image-v1"
DEFAULT_EDIT_MODEL = "nano-banana-2-lite"
#: The video model a free account can run (480p or 768p, 4 to 15 seconds).
DEFAULT_VIDEO_MODEL = "minimax-h3-fast"
class SayMakerError(RuntimeError):
"""Base class. Catch a subclass to decide what to do next."""
class AuthError(SayMakerError):
"""No API key, or the key is invalid or deleted."""
class QuotaError(SayMakerError):
"""The account is out of credits."""
class PlanError(SayMakerError):
"""This model needs a paid plan or a credit pack."""
class BusyError(SayMakerError):
"""Another run on this account is still rendering. Free accounts run one at a time."""
class RejectedError(SayMakerError):
"""The content filter or the model refused the prompt. Reword it."""
Transport = Callable[[str, str, Optional[dict], dict], dict]
def _http(method: str, url: str, body: Optional[dict], headers: dict) -> dict:
headers = {**headers, "User-Agent": f"saymaker-python/{__version__}"}
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
return json.loads(e.read() or b"{}")
def _parse_submit(env: dict) -> str:
# Refusals arrive as HTTP 200 with code -1; the status code alone reads as success.
if env.get("code") != 0:
msg = str(env.get("message") or "request refused")
low = msg.lower()
if (env.get("data") or {}).get("busy") or "is rendering" in low or "at once" in low:
raise BusyError(msg)
if "api key" in low or "no auth" in low:
raise AuthError(msg)
if "insufficient credits" in low:
raise QuotaError(msg)
if "subscription" in low or "plan" in low or "sign in" in low:
raise PlanError(msg)
raise SayMakerError(msg)
data = env.get("data") or {}
# A signed-out request is also a 200 with code 0 — only `wall` tells it apart.
if data.get("wall"):
raise AuthError(f"SayMaker needs an API key; create one at {KEYS_URL}")
task = data.get("id")
if not task:
raise SayMakerError("the service returned no task id")
return str(task)
def _run(
media: str,
scene: str,
model: str,
prompt: str,
options: dict,
api_key: Optional[str],
timeout: float,
poll_every: float,
base_url: str,
transport: Optional[Transport],
) -> str:
if not str(prompt or "").strip():
raise ValueError("prompt is required")
key = api_key or os.environ.get("SAYMAKER_API_KEY") or None
if not key:
raise AuthError(f"pass api_key= or set SAYMAKER_API_KEY; create a key at {KEYS_URL}")
http = transport or _http
headers = {"Authorization": f"Bearer {key}"}
task = _parse_submit(
http(
"POST",
base_url + "/api/ai/generate",
{
"provider": MODELS.get(model, "kie"),
"mediaType": media,
"model": model,
"scene": scene,
"prompt": prompt,
"options": options,
},
headers,
)
)
# Free-account runs wait in a queue and only reach the model on the poll
# that crosses the end of the wait, so polling is what starts the work.
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
time.sleep(poll_every)
try:
env = http("POST", base_url + "/api/ai/query", {"taskId": task}, headers)
except (OSError, ValueError):
continue # a dropped poll is not a failed generation
if env.get("code") != 0:
continue
data = env.get("data") or {}
for field in ("cleanImages", "images", "videos"):
if data.get(field):
return data[field][0]
if str(data.get("status") or "").lower() in {"failed", "error"}:
raise RejectedError("the run failed; reword the prompt rather than retrying the same text")
raise SayMakerError(f"deadline passed after {timeout:g}s; the run may still finish in your SayMaker history")
def _check_url(image_url: Optional[str]) -> None:
if image_url is not None and not image_url.startswith(("http://", "https://")):
raise ValueError("image_url must be a public http(s) url")
def generate(
prompt: str,
*,
image_url: Optional[str] = None,
model: Optional[str] = None,
aspect_ratio: Optional[str] = None,
api_key: Optional[str] = None,
timeout: float = 4500.0,
poll_every: float = 5.0,
base_url: str = BASE,
transport: Optional[Transport] = None,
) -> str:
"""Generate one image — or edit ``image_url`` — and return its URL.
For an edit the prompt describes the change and should name what must stay
("keep the same face and lighting"). Raises a :class:`SayMakerError`
subclass on a refusal, an empty balance or a timeout.
"""
_check_url(image_url)
chosen = model or (DEFAULT_EDIT_MODEL if image_url else DEFAULT_IMAGE_MODEL)
# `size` carries the aspect ratio — the site's own field name. An edit
# defaults to "auto", which keeps the source framing.
options: dict = {"size": aspect_ratio or ("auto" if image_url else "1:1"), "resolution": "1K", "n": 1}
if image_url:
options["image_input"] = [image_url]
return _run(
"image",
"image-to-image" if image_url else "text-to-image",
chosen,
prompt,
options,
api_key,
timeout,
poll_every,
base_url,
transport,
)
def generate_video(
prompt: str,
*,
image_url: Optional[str] = None,
model: str = DEFAULT_VIDEO_MODEL,
duration: Optional[int] = None,
resolution: Optional[str] = None,
aspect_ratio: str = "16:9",
sound: bool = True,
api_key: Optional[str] = None,
timeout: float = 5400.0,
poll_every: float = 10.0,
base_url: str = BASE,
transport: Optional[Transport] = None,
) -> str:
"""Generate one clip — or animate ``image_url`` as the first frame — and return its URL.
Video takes minutes. ``model`` defaults to MiniMax H3 Fast, the one a free
account can run; Veo 3.1, Kling 3.0 and Seedance 2.0 need a plan or a pack.
"""
_check_url(image_url)
options: dict = {"aspect_ratio": aspect_ratio, "sound": sound}
if image_url:
options["image_input"] = [image_url]
if duration:
options["duration"] = duration
if resolution:
options["resolution"] = resolution
return _run(
"video",
"image-to-video" if image_url else "text-to-video",
model,
prompt,
options,
api_key,
timeout,
poll_every,
base_url,
transport,
)
def upload_png(data: bytes, base_url: str = BASE) -> str:
"""Put a PNG on saymaker.ai's storage and return its public URL (edits and
image-to-video take the source image by URL)."""
boundary = "----saymaker" + os.urandom(8).hex()
body = (
f"--{boundary}\r\nContent-Disposition: form-data; name=\"files\"; filename=\"input.png\"\r\n"
"Content-Type: image/png\r\n\r\n"
).encode() + data + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
base_url + "/api/storage/upload-image",
data=body,
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}", "User-Agent": f"comfyui-saymaker/{__version__}"},
)
with urllib.request.urlopen(req, timeout=60) as r:
env = json.loads(r.read())
urls = (env.get("data") or {}).get("urls") or []
if env.get("code") != 0 or not urls:
raise SayMakerError(f"upload failed: {env.get('message') or 'no url'}")
return urls[0]
def download(url: str) -> bytes:
req = urllib.request.Request(url, headers={"User-Agent": f"comfyui-saymaker/{__version__}"})
with urllib.request.urlopen(req, timeout=120) as r:
return r.read()