Rewrite each node to faithfully mirror the official comfy_api_nodes/
contract (apis/*.py + Comfy-Org/embedded-docs). The ComfyOrg proxy
forwards the body verbatim, so the official request/response models
ARE the real vendor API.
- Video nodes now return VIDEO (InputImpl.VideoFromFile), not file paths
- Audio nodes now return AUDIO ({waveform, sample_rate}), not file paths
- Request/response bodies match official pydantic models field-for-field
- Model ids, options, defaults, ranges match official + embedded docs
- Every node exposes api_key + base_url (overridable for relays/proxies)
- utils.py: add ComfyUI-native helpers — video_from_bytes,
download_video_output, audio_from_bytes, download_audio_output,
poll_until, validate_prompt, tensor_to_data_uri
- Rewrite nodes_minimax.py (submit -> poll -> retrieve -> download VIDEO)
- nodes_extra.py: base_url on all three, poll_until, validate_prompt
- Clean __init__.py (drop duplicate grok import/keys); 37 nodes total
- README: output-type alignment, node list, relay examples
Co-Authored-By: Claude <noreply@anthropic.com>
252 lines
9.6 KiB
Python
252 lines
9.6 KiB
Python
"""Utility functions for BYOKey custom nodes.
|
|
|
|
Provides ComfyUI-native output construction (IMAGE/VIDEO/AUDIO), tensor<->base64
|
|
conversion, async HTTP, and input validation so that BYOKey nodes produce the
|
|
exact same output types as the official ComfyUI API nodes.
|
|
"""
|
|
import asyncio
|
|
import base64
|
|
import logging
|
|
from io import BytesIO
|
|
|
|
import aiohttp
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
logger = logging.getLogger("comfyui_byokey")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Image conversion #
|
|
# --------------------------------------------------------------------------- #
|
|
def tensor_to_base64(image_tensor: torch.Tensor, mime_type: str = "image/png") -> str:
|
|
"""Convert a [B,H,W,C] or [H,W,C] float tensor to a base64-encoded string."""
|
|
if len(image_tensor.shape) == 4:
|
|
image_tensor = image_tensor[0]
|
|
image_np = (image_tensor.cpu().numpy() * 255).astype(np.uint8)
|
|
img = Image.fromarray(image_np)
|
|
if img.mode == "RGBA" and mime_type in ("image/jpeg", "image/jpg"):
|
|
img = img.convert("RGB")
|
|
buf = BytesIO()
|
|
fmt = mime_type.split("/")[-1].upper()
|
|
if fmt == "JPG":
|
|
fmt = "JPEG"
|
|
img.save(buf, format=fmt)
|
|
return base64.b64encode(buf.getvalue()).decode("utf-8")
|
|
|
|
|
|
def tensor_to_data_uri(image_tensor: torch.Tensor, mime_type: str = "image/png") -> str:
|
|
"""Convert an image tensor to a ``data:<mime>;base64,...`` URI."""
|
|
return f"data:{mime_type};base64,{tensor_to_base64(image_tensor, mime_type)}"
|
|
|
|
|
|
def bytes_to_tensor(image_bytes: bytes, mode: str = "RGBA") -> torch.Tensor:
|
|
"""Convert raw image bytes to a [1,H,W,C] float32 tensor."""
|
|
img = Image.open(BytesIO(image_bytes)).convert(mode)
|
|
arr = np.array(img).astype(np.float32) / 255.0
|
|
return torch.from_numpy(arr).unsqueeze(0)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Video output (ComfyUI VIDEO type) #
|
|
# --------------------------------------------------------------------------- #
|
|
def video_from_bytes(video_bytes: bytes):
|
|
"""Wrap raw video bytes (e.g. mp4) in a ComfyUI VIDEO output object.
|
|
|
|
Returns a ``comfy_api.latest.InputImpl.VideoFromFile`` so the node can declare
|
|
``RETURN_TYPES = ("VIDEO",)`` exactly like the official API nodes.
|
|
"""
|
|
from comfy_api.latest import InputImpl
|
|
return InputImpl.VideoFromFile(BytesIO(video_bytes))
|
|
|
|
|
|
async def download_video_output(url: str, headers: dict | None = None, timeout: float = 600.0):
|
|
"""Download a video from a URL and return it as a ComfyUI VIDEO output object."""
|
|
data = await download_bytes(url, headers=headers, timeout=timeout)
|
|
return video_from_bytes(data)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Audio output (ComfyUI AUDIO type) #
|
|
# --------------------------------------------------------------------------- #
|
|
def _f32_pcm(wav: torch.Tensor) -> torch.Tensor:
|
|
"""Convert integer PCM tensors to float32 in the range [-1, 1]."""
|
|
if wav.dtype.is_floating_point:
|
|
return wav
|
|
if wav.dtype == torch.int16:
|
|
return wav.float() / (2 ** 15)
|
|
if wav.dtype == torch.int32:
|
|
return wav.float() / (2 ** 31)
|
|
raise ValueError(f"Unsupported wav dtype: {wav.dtype}")
|
|
|
|
|
|
def audio_from_bytes(audio_bytes: bytes) -> dict:
|
|
"""Decode audio bytes (mp3/wav/etc) into a ComfyUI AUDIO dict.
|
|
|
|
Returns ``{"waveform": [1, C, T] float32, "sample_rate": int}`` so the node can
|
|
declare ``RETURN_TYPES = ("AUDIO",)`` exactly like the official API nodes.
|
|
"""
|
|
import av
|
|
|
|
with av.open(BytesIO(audio_bytes)) as af:
|
|
if not af.streams.audio:
|
|
raise ValueError("No audio stream found in response.")
|
|
stream = af.streams.audio[0]
|
|
sample_rate = int(stream.codec_context.sample_rate)
|
|
n_channels = stream.channels or 1
|
|
|
|
frames: list[torch.Tensor] = []
|
|
for frame in af.decode(streams=stream.index):
|
|
arr = frame.to_ndarray() # [C, T] | [T, C] | [T]
|
|
buf = torch.from_numpy(arr)
|
|
if buf.ndim == 1:
|
|
buf = buf.unsqueeze(0)
|
|
elif buf.shape[0] != n_channels and buf.shape[-1] == n_channels:
|
|
buf = buf.transpose(0, 1).contiguous()
|
|
elif buf.shape[0] != n_channels:
|
|
buf = buf.reshape(-1, n_channels).t().contiguous()
|
|
frames.append(buf)
|
|
|
|
if not frames:
|
|
raise ValueError("Decoded zero audio frames.")
|
|
|
|
wav = _f32_pcm(torch.cat(frames, dim=1)) # [C, T]
|
|
return {"waveform": wav.unsqueeze(0).contiguous(), "sample_rate": sample_rate}
|
|
|
|
|
|
async def download_audio_output(url: str, headers: dict | None = None, timeout: float = 300.0) -> dict:
|
|
"""Download audio from a URL and return it as a ComfyUI AUDIO dict."""
|
|
data = await download_bytes(url, headers=headers, timeout=timeout)
|
|
return audio_from_bytes(data)
|
|
|
|
|
|
def audio_to_bytes(audio: dict, container: str = "mp4", codec: str = "aac") -> bytes:
|
|
"""Encode a ComfyUI AUDIO dict ({"waveform","sample_rate"}) into container bytes."""
|
|
import av
|
|
|
|
waveform = audio["waveform"]
|
|
sample_rate = int(audio["sample_rate"])
|
|
if waveform.ndim == 3:
|
|
waveform = waveform[0] # [C, T]
|
|
samples = waveform.cpu().numpy()
|
|
n_channels = samples.shape[0]
|
|
|
|
out = BytesIO()
|
|
with av.open(out, mode="w", format=container) as oc:
|
|
ostream = oc.add_stream(codec, rate=sample_rate)
|
|
layout = "mono" if n_channels == 1 else "stereo"
|
|
frame = av.AudioFrame.from_ndarray(
|
|
(samples * (2 ** 15)).astype(np.int16), format="s16", layout=layout
|
|
)
|
|
frame.sample_rate = sample_rate
|
|
for packet in ostream.encode(frame):
|
|
oc.mux(packet)
|
|
for packet in ostream.encode(None):
|
|
oc.mux(packet)
|
|
return out.getvalue()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Validation #
|
|
# --------------------------------------------------------------------------- #
|
|
def validate_prompt(prompt: str, *, min_length: int = 1, strip: bool = True) -> str:
|
|
"""Validate a prompt string the way official nodes do; returns the prompt."""
|
|
check = prompt.strip() if strip else prompt
|
|
if len(check) < min_length:
|
|
raise ValueError(
|
|
f"Prompt must be at least {min_length} character(s) long after trimming whitespace."
|
|
)
|
|
return prompt
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Async + HTTP #
|
|
# --------------------------------------------------------------------------- #
|
|
def run_async(coro):
|
|
"""Run an async coroutine from a sync ComfyUI node execution context."""
|
|
try:
|
|
loop = asyncio.get_running_loop()
|
|
except RuntimeError:
|
|
loop = None
|
|
|
|
if loop and loop.is_running():
|
|
import concurrent.futures
|
|
with concurrent.futures.ThreadPoolExecutor(1) as pool:
|
|
return pool.submit(asyncio.run, coro).result()
|
|
return asyncio.run(coro)
|
|
|
|
|
|
async def http_request(
|
|
method: str,
|
|
url: str,
|
|
headers: dict,
|
|
json_data: dict | None = None,
|
|
*,
|
|
data=None,
|
|
timeout: float = 300.0,
|
|
) -> dict:
|
|
"""Make an HTTP request and return parsed JSON.
|
|
|
|
Pass ``json_data`` for a JSON body or ``data`` for a raw/multipart body
|
|
(e.g. ``aiohttp.FormData``).
|
|
"""
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.request(
|
|
method, url, headers=headers, json=json_data, data=data,
|
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
) as resp:
|
|
body = await resp.read()
|
|
if resp.status >= 400:
|
|
raise RuntimeError(f"API error (HTTP {resp.status}): {body[:2000].decode('utf-8', 'replace')}")
|
|
try:
|
|
import json as _json
|
|
return _json.loads(body)
|
|
except Exception:
|
|
return {"_raw": body, "_content_type": resp.headers.get("Content-Type", "")}
|
|
|
|
|
|
async def download_bytes(url: str, headers: dict | None = None, timeout: float = 120.0) -> bytes:
|
|
"""Download raw bytes from a URL (handles ``data:`` URIs too)."""
|
|
if url.startswith("data:"):
|
|
return base64.b64decode(url.split(",", 1)[1])
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.get(
|
|
url, headers=headers or {},
|
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
|
) as resp:
|
|
if resp.status >= 400:
|
|
raise RuntimeError(f"Download failed (HTTP {resp.status}) for {url[:200]}")
|
|
return await resp.read()
|
|
|
|
|
|
async def poll_until(
|
|
poll_fn,
|
|
*,
|
|
status_of,
|
|
done_statuses,
|
|
failed_statuses,
|
|
interval: float = 10.0,
|
|
max_wait: float = 600.0,
|
|
on_fail_message: str = "Generation failed",
|
|
):
|
|
"""Generic submit+poll loop.
|
|
|
|
``poll_fn`` is an async callable returning the latest task JSON; ``status_of`` maps
|
|
that JSON to a lowercase status string. Returns the final JSON on success.
|
|
"""
|
|
elapsed = 0.0
|
|
last = None
|
|
done = {s.lower() for s in done_statuses}
|
|
failed = {s.lower() for s in failed_statuses}
|
|
while elapsed < max_wait:
|
|
await asyncio.sleep(interval)
|
|
elapsed += interval
|
|
last = await poll_fn()
|
|
status = str(status_of(last) or "").lower()
|
|
if status in failed:
|
|
raise RuntimeError(f"{on_fail_message}. Status={status}. Response={last}")
|
|
if status in done:
|
|
return last
|
|
raise RuntimeError(f"Timed out after {max_wait:.0f}s. Last response={last}")
|