separate out nodes per openai model

This commit is contained in:
thot-experiment 2025-04-23 08:59:02 -07:00
parent 7c41723ef3
commit 8d414a99de
4 changed files with 396 additions and 111 deletions

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: http://localhost:8080/openapi # filename: https://api.comfy.org/openapi
# timestamp: 2025-04-22T20:42:39+00:00 # timestamp: 2025-04-23T15:56:33+00:00
from __future__ import annotations from __future__ import annotations

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: http://localhost:8080/openapi # filename: https://api.comfy.org/openapi
# timestamp: 2025-04-22T20:42:39+00:00 # timestamp: 2025-04-23T15:56:33+00:00
from __future__ import annotations from __future__ import annotations

View File

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: http://localhost:8080/openapi # filename: https://api.comfy.org/openapi
# timestamp: 2025-04-22T20:42:39+00:00 # timestamp: 2025-04-23T15:56:33+00:00
from __future__ import annotations from __future__ import annotations
@ -874,9 +874,8 @@ class GenerationType3(str, Enum):
class LumaVideoModel(str, Enum): class LumaVideoModel(str, Enum):
ray_1_6 = 'ray-1-6'
ray_2 = 'ray-2' ray_2 = 'ray-2'
ray_flash_2 = 'ray-flash-2' ray_2_flash = 'ray-2-flash'
class LumaVideoModelOutputDuration1(str, Enum): class LumaVideoModelOutputDuration1(str, Enum):
@ -1058,34 +1057,6 @@ class NodeVersionUpdateRequest(BaseModel):
) )
class Quality(str, Enum):
low = 'low'
medium = 'medium'
high = 'high'
class OpenAIImageEditRequest(BaseModel):
image: Union[bytes_aliased, List[bytes_aliased]] = Field(
...,
description='Image(s) to edit. For DALL-E 2, only a single image is supported. For gpt-image-1, multiple images can be provided using image[] notation in form data.',
)
mask: Optional[bytes_aliased] = Field(
None,
description='An additional image whose fully transparent areas (e.g. where alpha is zero) indicate where image should be edited',
)
model: str = Field(
..., description='The model to use for image editing', examples=['gpt-image-1']
)
prompt: str = Field(
...,
description='A text description of the desired edit',
examples=['Give the rocketship rainbow coloring'],
)
quality: Optional[Quality] = Field(
None, description='The quality of the edited image', examples=['low']
)
class Background(str, Enum): class Background(str, Enum):
transparent = 'transparent' transparent = 'transparent'
opaque = 'opaque' opaque = 'opaque'
@ -1102,6 +1073,57 @@ class OutputFormat(str, Enum):
jpeg = 'jpeg' jpeg = 'jpeg'
class Quality(str, Enum):
low = 'low'
medium = 'medium'
high = 'high'
class OpenAIImageEditRequest(BaseModel):
background: Optional[Background] = Field(
None, description='Background transparency', examples=['opaque']
)
model: str = Field(
..., description='The model to use for image editing', examples=['gpt-image-1']
)
moderation: Optional[Moderation] = Field(
None, description='Content moderation setting', examples=['auto']
)
n: Optional[int] = Field(
None, description='The number of images to generate', examples=[1]
)
output_compression: Optional[int] = Field(
None, description='Compression level for JPEG or WebP (0-100)', examples=[100]
)
output_format: Optional[OutputFormat] = Field(
None, description='Format of the output image', examples=['png']
)
prompt: str = Field(
...,
description='A text description of the desired edit',
examples=['Give the rocketship rainbow coloring'],
)
quality: Optional[Quality] = Field(
None, description='The quality of the edited image', examples=['low']
)
size: Optional[str] = Field(
None, description='Size of the output image', examples=['1024x1024']
)
user: Optional[str] = Field(
None,
description='A unique identifier for end-user monitoring',
examples=['user-1234'],
)
class Quality1(str, Enum):
low = 'low'
medium = 'medium'
high = 'high'
standard = 'standard'
hd = 'hd'
class ResponseFormat(str, Enum): class ResponseFormat(str, Enum):
url = 'url' url = 'url'
b64_json = 'b64_json' b64_json = 'b64_json'
@ -1117,9 +1139,7 @@ class OpenAIImageGenerationRequest(BaseModel):
None, description='Background transparency', examples=['opaque'] None, description='Background transparency', examples=['opaque']
) )
model: Optional[str] = Field( model: Optional[str] = Field(
None, None, description='The model to use for image generation', examples=['dall-e-3']
description='The model to use for image generation',
examples=['gpt-image-1'],
) )
moderation: Optional[Moderation] = Field( moderation: Optional[Moderation] = Field(
None, description='Content moderation setting', examples=['auto'] None, description='Content moderation setting', examples=['auto']
@ -1140,7 +1160,7 @@ class OpenAIImageGenerationRequest(BaseModel):
description='A text description of the desired image', description='A text description of the desired image',
examples=['Draw a rocket in front of a blackhole in deep space'], examples=['Draw a rocket in front of a blackhole in deep space'],
) )
quality: Optional[Quality] = Field( quality: Optional[Quality1] = Field(
None, description='The quality of the generated image', examples=['high'] None, description='The quality of the generated image', examples=['high']
) )
response_format: Optional[ResponseFormat] = Field( response_format: Optional[ResponseFormat] = Field(
@ -1500,18 +1520,18 @@ class KlingAuthenticationError(KlingErrorResponse):
class LumaGenerationRequest(BaseModel): class LumaGenerationRequest(BaseModel):
aspect_ratio: Optional[LumaAspectRatio] = '16:9' aspect_ratio: LumaAspectRatio
callback_url: Optional[AnyUrl] = Field( callback_url: Optional[AnyUrl] = Field(
None, None,
description='The callback URL of the generation, a POST request with Generation object will be sent to the callback URL when the generation is dreaming, completed, or failed', description='The callback URL of the generation, a POST request with Generation object will be sent to the callback URL when the generation is dreaming, completed, or failed',
) )
duration: Optional[LumaVideoModelOutputDuration] = None duration: LumaVideoModelOutputDuration
generation_type: Optional[GenerationType1] = 'video' generation_type: Optional[GenerationType1] = 'video'
keyframes: Optional[LumaKeyframes] = None keyframes: Optional[LumaKeyframes] = None
loop: Optional[bool] = Field(None, description='Whether to loop the video') loop: Optional[bool] = Field(None, description='Whether to loop the video')
model: Optional[LumaVideoModel] = 'ray-1-6' model: LumaVideoModel
prompt: Optional[str] = Field(None, description='The prompt of the generation') prompt: str = Field(..., description='The prompt of the generation')
resolution: Optional[LumaVideoModelOutputResolution] = None resolution: LumaVideoModelOutputResolution
class CharacterRef(BaseModel): class CharacterRef(BaseModel):

View File

@ -1,16 +1,60 @@
import io import io
from inspect import cleandoc from inspect import cleandoc
from comfy.utils import common_upscale
from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict from comfy.comfy_types.node_typing import IO, ComfyNodeABC, InputTypeDict
from comfy_api_nodes.apis import ( from comfy_api_nodes.apis import (
IdeogramGenerateRequest, IdeogramGenerateRequest,
IdeogramGenerateResponse, IdeogramGenerateResponse,
ImageRequest, ImageRequest,
OpenAIImageGenerationRequest, OpenAIImageGenerationRequest,
OpenAIImageEditRequest,
OpenAIImageGenerationResponse OpenAIImageGenerationResponse
) )
from comfy_api_nodes.apis.client import ApiEndpoint, HttpMethod, SynchronousOperation from comfy_api_nodes.apis.client import ApiEndpoint, HttpMethod, SynchronousOperation
import numpy as np
from PIL import Image
import requests
import torch
import math
def downscale_input(image):
samples = image.movedim(-1,1)
#downscaling input images to roughly the same size as the outputs
total = int(1024 * 1024)
scale_by = math.sqrt(total / (samples.shape[3] * samples.shape[2]))
if scale_by >= 1:
return (image,)
width = round(samples.shape[3] * scale_by)
height = round(samples.shape[2] * scale_by)
s = common_upscale(samples, width, height, "lanczos", "disabled")
s = s.movedim(1,-1)
return s
def validate_and_cast_response (response):
# validate raw JSON response
data = response.data
if not data or len(data) == 0:
raise Exception("No images returned from API endpoint")
# Get base64 image data
image_url = data[0].url
if not image_url:
raise Exception("No image URL was generated in the response")
img_response = requests.get(image_url)
if img_response.status_code != 200:
raise Exception("Failed to download the image")
img = Image.open(io.BytesIO(img_response.content))
img = img.convert("RGB") # Ensure RGB format
# Convert to numpy array, normalize to float32 between 0 and 1
img_array = np.array(img).astype(np.float32) / 255.0
# Convert to torch tensor and add batch dimension
return torch.from_numpy(img_array)[None,]
class IdeogramTextToImage(ComfyNodeABC): class IdeogramTextToImage(ComfyNodeABC):
""" """
@ -165,11 +209,11 @@ class IdeogramTextToImage(ComfyNodeABC):
#def IS_CHANGED(s, image, string_field, int_field, float_field, print_to_screen): #def IS_CHANGED(s, image, string_field, int_field, float_field, print_to_screen):
# return "" # return ""
class OpenAITextToImage(ComfyNodeABC): class OpenAIDalle2(ComfyNodeABC):
""" """
Generates images synchronously via OpenAI's DALL·E 3 endpoint. Generates images synchronously via OpenAI's DALL·E 2 endpoint.
Uses the proxy at /proxy/dalle-3/generate. Returned URLs are shortlived, Uses the proxy at /proxy/openai/images/generations. Returned URLs are shortlived,
so download or cache results if you need to keep them. so download or cache results if you need to keep them.
""" """
def __init__(self): def __init__(self):
@ -184,14 +228,21 @@ class OpenAITextToImage(ComfyNodeABC):
"default": "", "default": "",
"tooltip": "Text prompt for DALL·E", "tooltip": "Text prompt for DALL·E",
}), }),
# TODO: add NEW MODEL
"model": (IO.COMBO, {
"options": ["dall-e-3", "dall-e-2"],
"default": "dall-e-3",
"tooltip": "OpenAI model name",
}),
}, },
"optional": { "optional": {
"seed": (IO.INT, {
"default": 0,
"min": 0,
"max": 2**31-1,
"step": 1,
"display": "number",
"tooltip": "not implemented yet in backend",
}),
"size": (IO.COMBO, {
"options": ["256x256", "512x512", "1024x1024"],
"default": "1024x1024",
"tooltip": "Image size",
}),
"n": (IO.INT, { "n": (IO.INT, {
"default": 1, "default": 1,
"min": 1, "min": 1,
@ -200,18 +251,13 @@ class OpenAITextToImage(ComfyNodeABC):
"display": "number", "display": "number",
"tooltip": "How many images to generate", "tooltip": "How many images to generate",
}), }),
"size": (IO.COMBO, { "image": (IO.IMAGE, {
"options": ["256x256", "512x512", "1024x1792", "1792x1024", "1024x1024", "1536x1024", "1024x1536", "auto"], "default": None,
"default": "auto", "tooltip": "Optional reference image for image editing.",
"tooltip": "Image size",
}), }),
"seed": (IO.INT, { "mask": (IO.MASK, {
"default": 0, "default": None,
"min": 0, "tooltip": "Optional mask for inpainting (white areas will be replaced)",
"max": 2**31-1,
"step": 1,
"display": "number",
"tooltip": "Optional random seed",
}), }),
}, },
"hidden": { "hidden": {
@ -225,28 +271,121 @@ class OpenAITextToImage(ComfyNodeABC):
DESCRIPTION = cleandoc(__doc__ or "") DESCRIPTION = cleandoc(__doc__ or "")
API_NODE = True API_NODE = True
def api_call(self, prompt, model, n=1, size="1024x1024", seed=0, auth_token=None): def api_call(self, prompt, seed=0, image=None, mask=None, n=1, size="1024x1024", auth_token=None):
# Validate size based on model model = "dall-e-2"
if model == "dall-e-2": path = "/proxy/openai/images/generations"
if size == "auto": request_class = OpenAIImageGenerationRequest
size = "1024x1024" img_binary = None
valid_sizes = ["256x256", "512x512", "1024x1024"]
if size not in valid_sizes:
raise ValueError(f"Size {size} not valid for dall-e-2. Must be one of: {', '.join(valid_sizes)}")
elif model == "dall-e-3":
if size == "auto":
size = "1024x1024"
valid_sizes = ["1024x1024", "1792x1024", "1024x1792"]
if size not in valid_sizes:
raise ValueError(f"Size {size} not valid for dall-e-3. Must be one of: {', '.join(valid_sizes)}")
# TODO: add NEW MODEL
if image is not None and mask is not None:
path = "/proxy/openai/images/edits"
request_class = OpenAIImageEditRequest
input_tensor = image.squeeze().cpu()
height, width, channels = input_tensor.shape
rgba_tensor = torch.ones(height, width, 4, device="cpu")
rgba_tensor[:, :, :channels] = input_tensor
import numpy as np if mask.shape[1:] != image.shape[1:-1]:
import torch raise Exception("Mask and Image must be the same size")
from PIL import Image rgba_tensor[:,:,3] = (1-mask.squeeze().cpu())
import requests
rgba_tensor = downscale_input(rgba_tensor.unsqueeze(0)).squeeze()
image_np = (rgba_tensor.numpy() * 255).astype(np.uint8)
img = Image.fromarray(image_np)
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0)
img_binary = img_byte_arr#.getvalue()
img_binary.name = "image.png"
elif image is not None or mask is not None:
raise Exception("Dall-E 2 image editing requires an image AND a mask")
# Build the operation
operation = SynchronousOperation(
endpoint=ApiEndpoint(
path=path,
method=HttpMethod.POST,
request_model=request_class,
response_model=OpenAIImageGenerationResponse
),
request=request_class(
model=model,
prompt=prompt,
n=n,
size=size,
seed=seed,
),
files={
"image": img_binary,
} if img_binary else None,
auth_token=auth_token
)
response = operation.execute()
img_tensor = validate_and_cast_response(response)
return (img_tensor,)
class OpenAIDalle3(ComfyNodeABC):
"""
Generates images synchronously via OpenAI's DALL·E 3 endpoint.
Uses the proxy at /proxy/openai/images/generations. Returned URLs are shortlived,
so download or cache results if you need to keep them.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
return {
"required": {
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt for DALL·E",
}),
},
"optional": {
"seed": (IO.INT, {
"default": 0,
"min": 0,
"max": 2**31-1,
"step": 1,
"display": "number",
"tooltip": "not implemented yet in backend",
}),
"quality" : (IO.COMBO, {
"options": ["standard","hd"],
"default": "standard",
"tooltip": "Image quality",
}),
"style": (IO.COMBO, {
"options": ["natural","vivid"],
"default": "natural",
"tooltip": "Vivid causes the model to lean towards generating hyper-real and dramatic images. Natural causes the model to produce more natural, less hyper-real looking images.",
}),
"size": (IO.COMBO, {
"options": ["1024x1024", "1024x1792", "1792x1024"],
"default": "1024x1024",
"tooltip": "Image size",
}),
},
"hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG"
}
}
RETURN_TYPES = (IO.IMAGE,)
FUNCTION = "api_call"
CATEGORY = "Example"
DESCRIPTION = cleandoc(__doc__ or "")
API_NODE = True
def api_call(self, prompt, seed=0, style="natural", quality="standard", size="1024x1024", auth_token=None):
model = "dall-e-3"
# build the operation # build the operation
operation = SynchronousOperation( operation = SynchronousOperation(
@ -259,49 +398,175 @@ class OpenAITextToImage(ComfyNodeABC):
request=OpenAIImageGenerationRequest( request=OpenAIImageGenerationRequest(
model=model, model=model,
prompt=prompt, prompt=prompt,
n=n, quality=quality,
size=size, size=size,
seed=seed if seed != 0 else None style=style,
seed=seed,
), ),
auth_token=auth_token auth_token=auth_token
) )
response = operation.execute() response = operation.execute()
# validate raw JSON response img_tensor = validate_and_cast_response(response)
data = response.data
if not data or len(data) == 0:
raise Exception("No images returned from OpenAI endpoint")
# Get base64 image data
image_url = data[0].url
if not image_url:
raise Exception("No image URL was generated in the response")
img_response = requests.get(image_url)
if img_response.status_code != 200:
raise Exception("Failed to download the image")
img = Image.open(io.BytesIO(img_response.content))
img = img.convert("RGB") # Ensure RGB format
# Convert to numpy array, normalize to float32 between 0 and 1
img_array = np.array(img).astype(np.float32) / 255.0
# Convert to torch tensor and add batch dimension
img_tensor = torch.from_numpy(img_array)[None,]
return (img_tensor,) return (img_tensor,)
class OpenAIXXX(ComfyNodeABC):
"""
Generates images synchronously via OpenAI's DALL·E 2 endpoint.
Uses the proxy at /proxy/openai/images/generations. Returned URLs are shortlived,
so download or cache results if you need to keep them.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls) -> InputTypeDict:
return {
"required": {
"prompt": (IO.STRING, {
"multiline": True,
"default": "",
"tooltip": "Text prompt for XXX",
}),
},
"optional": {
"seed": (IO.INT, {
"default": 0,
"min": 0,
"max": 2**31-1,
"step": 1,
"display": "number",
"tooltip": "not implemented yet in backend",
}),
"quality": (IO.COMBO, {
"options": ["low","medium","high"],
"default": "low",
"tooltip": "Image quality, affects cost and generation time.",
}),
"background": (IO.COMBO, {
"options": ["opaque","transparent"],
"default": "opaque",
"tooltip": "Return image with or without background",
}),
"size": (IO.COMBO, {
"options": ["auto", "1024x1024", "1024x1536", "1536x1024"],
"default": "auto",
"tooltip": "Image size",
}),
"n": (IO.INT, {
"default": 1,
"min": 1,
"max": 8,
"step": 1,
"display": "number",
"tooltip": "How many images to generate",
}),
"image": (IO.IMAGE, {
"default": None,
"tooltip": "Optional reference image for image editing.",
}),
"mask": (IO.MASK, {
"default": None,
"tooltip": "Optional mask for inpainting (white areas will be replaced)",
}),
},
"hidden": {
"auth_token": "AUTH_TOKEN_COMFY_ORG"
}
}
RETURN_TYPES = (IO.IMAGE,)
FUNCTION = "api_call"
CATEGORY = "Example"
DESCRIPTION = cleandoc(__doc__ or "")
API_NODE = True
def api_call(self, prompt, seed=0, quality="low", background="opaque", image=None, mask=None, n=1, size="1024x1024", auth_token=None):
model = "xxx"
path = "/proxy/openai/images/generations"
request_class = OpenAIImageGenerationRequest
img_binary = None
mask_binary = None
if image is not None:
path = "/proxy/openai/images/edits"
request_class = OpenAIImageEditRequest
scaled_image = downscale_input(image).squeeze()
image_np = (scaled_image.numpy() * 255).astype(np.uint8)
img = Image.fromarray(image_np)
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0)
img_binary = img_byte_arr#.getvalue()
img_binary.name = "image.png"
if mask is not None:
if image is None:
raise Exception("Cannot use a mask without an input image")
if mask.shape[1:] != image.shape[1:-1]:
raise Exception("Mask and Image must be the same size")
batch, height, width = mask.shape
rgba_mask = torch.zeros(height, width, 4, device="cpu")
rgba_mask[:,:,3] = (1-mask.squeeze().cpu())
mask_np = (rgba_mask.numpy() * 255).astype(np.uint8)
mask_img = Image.fromarray(mask_np)
mask_img_byte_arr = io.BytesIO()
mask_img.save(mask_img_byte_arr, format='PNG')
mask_img_byte_arr.seek(0)
mask_binary = mask_img_byte_arr#.getvalue()
mask_binary.name = "mask.png"
files = {}
if img_binary:
files["image"] = img_binary
if mask_binary:
files["mask"] = mask_binary
# Build the operation
operation = SynchronousOperation(
endpoint=ApiEndpoint(
path=path,
method=HttpMethod.POST,
request_model=request_class,
response_model=OpenAIImageGenerationResponse
),
request=request_class(
model=model,
prompt=prompt,
quality=quality,
background=background,
n=n,
seed=seed,
size=size,
),
files=files if files else None,
auth_token=auth_token
)
response = operation.execute()
img_tensor = validate_and_cast_response(response)
return (img_tensor,)
# A dictionary that contains all nodes you want to export with their names # A dictionary that contains all nodes you want to export with their names
# NOTE: names should be globally unique # NOTE: names should be globally unique
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"IdeogramTextToImage": IdeogramTextToImage, "IdeogramTextToImage": IdeogramTextToImage,
"OpenAIDalleTextToImage": OpenAITextToImage, "OpenAIDalle2": OpenAIDalle2,
"OpenAIDalle3": OpenAIDalle3,
"OpenAIXXX": OpenAIXXX,
} }
# A dictionary that contains the friendly/humanly readable titles for the nodes # A dictionary that contains the friendly/humanly readable titles for the nodes
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
"IdeogramTextToImage": "Ideogram Text to Image", "IdeogramTextToImage": "Ideogram Text to Image",
"OpenAIDalleTextToImage": "OpenAI DALL·E 3 Text to Image", "OpenAIDalle2": "OpenAI DALL·E 2",
"OpenAIDalle3": "OpenAI DALL·E 3",
"OpenAIXXX": "XXX",
} }