mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-04-02 02:39:06 +00:00
Compare commits
1 Commits
worksplit-
...
dev/Combo-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d25d14dfb6 |
@@ -43,9 +43,55 @@ class UploadType(str, Enum):
|
||||
model = "file_upload"
|
||||
|
||||
|
||||
class RemoteItemSchema:
|
||||
"""Describes how to map API response objects to rich dropdown items.
|
||||
|
||||
All *_field parameters use dot-path notation (e.g. ``"labels.gender"``).
|
||||
``label_field`` additionally supports template strings with ``{field}``
|
||||
placeholders (e.g. ``"{name} ({labels.accent})"``).
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
value_field: str,
|
||||
label_field: str,
|
||||
preview_url_field: str | None = None,
|
||||
preview_type: Literal["image", "video", "audio"] = "image",
|
||||
description_field: str | None = None,
|
||||
search_fields: list[str] | None = None,
|
||||
filter_field: str | None = None,
|
||||
):
|
||||
self.value_field = value_field
|
||||
"""Dot-path to the unique identifier within each item. This value is stored in the widget and passed to execute()."""
|
||||
self.label_field = label_field
|
||||
"""Dot-path to the display name, or a template string with {field} placeholders."""
|
||||
self.preview_url_field = preview_url_field
|
||||
"""Dot-path to a preview media URL. If None, no preview is shown."""
|
||||
self.preview_type = preview_type
|
||||
"""How to render the preview: "image", "video", or "audio"."""
|
||||
self.description_field = description_field
|
||||
"""Optional dot-path or template for a subtitle line shown below the label."""
|
||||
self.search_fields = search_fields
|
||||
"""Dot-paths to fields included in the search index. Defaults to [label_field]."""
|
||||
self.filter_field = filter_field
|
||||
"""Optional dot-path to a categorical field for filter tabs."""
|
||||
|
||||
def as_dict(self):
|
||||
return prune_dict({
|
||||
"value_field": self.value_field,
|
||||
"label_field": self.label_field,
|
||||
"preview_url_field": self.preview_url_field,
|
||||
"preview_type": self.preview_type,
|
||||
"description_field": self.description_field,
|
||||
"search_fields": self.search_fields,
|
||||
"filter_field": self.filter_field,
|
||||
})
|
||||
|
||||
|
||||
class RemoteOptions:
|
||||
def __init__(self, route: str, refresh_button: bool, control_after_refresh: Literal["first", "last"]="first",
|
||||
timeout: int=None, max_retries: int=None, refresh: int=None):
|
||||
timeout: int=None, max_retries: int=None, refresh: int=None,
|
||||
response_key: str=None, query_params: dict[str, str]=None,
|
||||
item_schema: RemoteItemSchema=None):
|
||||
self.route = route
|
||||
"""The route to the remote source."""
|
||||
self.refresh_button = refresh_button
|
||||
@@ -58,6 +104,12 @@ class RemoteOptions:
|
||||
"""The maximum number of retries before aborting the request."""
|
||||
self.refresh = refresh
|
||||
"""The TTL of the remote input's value in milliseconds. Specifies the interval at which the remote input's value is refreshed."""
|
||||
self.response_key = response_key
|
||||
"""Dot-path to the items array in the response. If None, the entire response is used."""
|
||||
self.query_params = query_params
|
||||
"""Static query parameters appended to the request URL."""
|
||||
self.item_schema = item_schema
|
||||
"""When present, the frontend renders a rich dropdown with previews instead of a plain combo widget."""
|
||||
|
||||
def as_dict(self):
|
||||
return prune_dict({
|
||||
@@ -67,6 +119,9 @@ class RemoteOptions:
|
||||
"timeout": self.timeout,
|
||||
"max_retries": self.max_retries,
|
||||
"refresh": self.refresh,
|
||||
"response_key": self.response_key,
|
||||
"query_params": self.query_params,
|
||||
"item_schema": self.item_schema.as_dict() if self.item_schema else None,
|
||||
})
|
||||
|
||||
|
||||
@@ -2184,6 +2239,7 @@ class NodeReplace:
|
||||
__all__ = [
|
||||
"FolderType",
|
||||
"UploadType",
|
||||
"RemoteItemSchema",
|
||||
"RemoteOptions",
|
||||
"NumberDisplay",
|
||||
"ControlAfterGenerate",
|
||||
|
||||
49
comfy_api_nodes/apis/phota_labs.py
Normal file
49
comfy_api_nodes/apis/phota_labs.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PhotaGenerateRequest(BaseModel):
|
||||
prompt: str = Field(...)
|
||||
num_output_images: int = Field(1)
|
||||
aspect_ratio: str = Field(...)
|
||||
resolution: str = Field(...)
|
||||
profile_ids: list[str] | None = Field(None)
|
||||
|
||||
|
||||
class PhotaEditRequest(BaseModel):
|
||||
prompt: str = Field(...)
|
||||
images: list[str] = Field(...)
|
||||
num_output_images: int = Field(1)
|
||||
aspect_ratio: str = Field(...)
|
||||
resolution: str = Field(...)
|
||||
profile_ids: list[str] | None = Field(None)
|
||||
|
||||
|
||||
class PhotaEnhanceRequest(BaseModel):
|
||||
image: str = Field(...)
|
||||
num_output_images: int = Field(1)
|
||||
|
||||
|
||||
class PhotaKnownGeneratedSubjectCounts(BaseModel):
|
||||
counts: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PhotoStudioResponse(BaseModel):
|
||||
images: list[str] = Field(..., description="Base64-encoded PNG output images.")
|
||||
known_subjects: PhotaKnownGeneratedSubjectCounts = Field(default_factory=PhotaKnownGeneratedSubjectCounts)
|
||||
|
||||
|
||||
class PhotaAddProfileRequest(BaseModel):
|
||||
image_urls: list[str] = Field(...)
|
||||
|
||||
|
||||
class PhotaAddProfileResponse(BaseModel):
|
||||
profile_id: str = Field(...)
|
||||
|
||||
|
||||
class PhotaProfileStatusResponse(BaseModel):
|
||||
profile_id: str = Field(...)
|
||||
status: str = Field(
|
||||
...,
|
||||
description="Current profile status: VALIDATING, QUEUING, IN_PROGRESS, READY, ERROR, or INACTIVE.",
|
||||
)
|
||||
message: str | None = Field(default=None, description="Optional error or status message.")
|
||||
@@ -233,6 +233,45 @@ class ElevenLabsVoiceSelector(IO.ComfyNode):
|
||||
return IO.NodeOutput(voice_id)
|
||||
|
||||
|
||||
class ElevenLabsRichVoiceSelector(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> IO.Schema:
|
||||
return IO.Schema(
|
||||
node_id="ElevenLabsRichVoiceSelector",
|
||||
display_name="ElevenLabs Voice Selector (Rich)",
|
||||
category="api node/audio/ElevenLabs",
|
||||
description="Select an ElevenLabs voice with audio preview and rich metadata.",
|
||||
inputs=[
|
||||
IO.Combo.Input(
|
||||
"voice",
|
||||
options=ELEVENLABS_VOICE_OPTIONS,
|
||||
remote=IO.RemoteOptions(
|
||||
route="http://localhost:9000/elevenlabs/voices",
|
||||
refresh_button=True,
|
||||
item_schema=IO.RemoteItemSchema(
|
||||
value_field="voice_id",
|
||||
label_field="name",
|
||||
preview_url_field="preview_url",
|
||||
preview_type="audio",
|
||||
search_fields=["name", "labels.gender", "labels.accent"],
|
||||
filter_field="labels.use_case",
|
||||
),
|
||||
),
|
||||
tooltip="Choose a voice with audio preview.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Custom(ELEVENLABS_VOICE).Output(display_name="voice"),
|
||||
],
|
||||
is_api_node=False,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, voice: str) -> IO.NodeOutput:
|
||||
# voice is already the voice_id from item_schema.value_field
|
||||
return IO.NodeOutput(voice)
|
||||
|
||||
|
||||
class ElevenLabsTextToSpeech(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> IO.Schema:
|
||||
@@ -911,6 +950,7 @@ class ElevenLabsExtension(ComfyExtension):
|
||||
return [
|
||||
ElevenLabsSpeechToText,
|
||||
ElevenLabsVoiceSelector,
|
||||
ElevenLabsRichVoiceSelector,
|
||||
ElevenLabsTextToSpeech,
|
||||
ElevenLabsAudioIsolation,
|
||||
ElevenLabsTextToSoundEffects,
|
||||
|
||||
350
comfy_api_nodes/nodes_phota_labs.py
Normal file
350
comfy_api_nodes/nodes_phota_labs.py
Normal file
@@ -0,0 +1,350 @@
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import IO, ComfyExtension, Input
|
||||
from comfy_api_nodes.apis.phota_labs import (
|
||||
PhotaAddProfileRequest,
|
||||
PhotaAddProfileResponse,
|
||||
PhotaEditRequest,
|
||||
PhotaEnhanceRequest,
|
||||
PhotaGenerateRequest,
|
||||
PhotaProfileStatusResponse,
|
||||
PhotoStudioResponse,
|
||||
)
|
||||
from comfy_api_nodes.util import (
|
||||
ApiEndpoint,
|
||||
bytesio_to_image_tensor,
|
||||
poll_op,
|
||||
sync_op,
|
||||
upload_images_to_comfyapi,
|
||||
upload_image_to_comfyapi,
|
||||
validate_string,
|
||||
)
|
||||
|
||||
# Direct API endpoint (comment out this class to use proxy)
|
||||
class ApiEndpoint(ApiEndpoint):
|
||||
"""Temporary override to use direct API instead of proxy."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
method: str = "GET",
|
||||
*,
|
||||
query_params: dict | None = None,
|
||||
headers: dict | None = None,
|
||||
):
|
||||
self.path = path.replace("/proxy/phota/", "https://api.photalabs.com/")
|
||||
self.method = method
|
||||
self.query_params = query_params or {}
|
||||
self.headers = headers or {}
|
||||
if "api.photalabs.com" in self.path:
|
||||
self.headers["X-API-Key"] = "YOUR_PHOTA_API_KEY"
|
||||
|
||||
|
||||
PHOTA_LABS_PROFILE_ID = "PHOTA_LABS_PROFILE_ID"
|
||||
|
||||
|
||||
class PhotaLabsGenerate(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="PhotaLabsGenerate",
|
||||
display_name="Phota Labs Generate",
|
||||
category="api node/image/Phota Labs",
|
||||
description="Generate images from a text prompt using Phota Labs.",
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="Text prompt describing the desired image.",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"aspect_ratio",
|
||||
options=["auto", "1:1", "3:4", "4:3", "9:16", "16:9"],
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"resolution",
|
||||
options=["1K", "4K"],
|
||||
),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=False, min_length=1)
|
||||
pid_list = None # list(profile_ids.values()) if profile_ids else None
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/phota/v1/phota/generate", method="POST"),
|
||||
response_model=PhotoStudioResponse,
|
||||
data=PhotaGenerateRequest(
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
profile_ids=pid_list or None,
|
||||
),
|
||||
)
|
||||
return IO.NodeOutput(bytesio_to_image_tensor(BytesIO(base64.b64decode(response.images[0]))))
|
||||
|
||||
|
||||
class PhotaLabsEdit(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="PhotaLabsEdit",
|
||||
display_name="Phota Labs Edit",
|
||||
category="api node/image/Phota Labs",
|
||||
description="Edit images based on a text prompt using Phota Labs. "
|
||||
"Provide input images and a prompt describing the desired edit.",
|
||||
inputs=[
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplatePrefix(
|
||||
IO.Image.Input("image"),
|
||||
prefix="image",
|
||||
min=1,
|
||||
max=10,
|
||||
),
|
||||
),
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"aspect_ratio",
|
||||
options=["auto", "1:1", "3:4", "4:3", "9:16", "16:9"],
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"resolution",
|
||||
options=["1K", "4K"],
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"profile_ids",
|
||||
template=IO.Autogrow.TemplatePrefix(
|
||||
IO.Custom(PHOTA_LABS_PROFILE_ID).Input("profile_id"),
|
||||
prefix="profile_id",
|
||||
min=0,
|
||||
max=5,
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
images: IO.Autogrow.Type,
|
||||
prompt: str,
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
profile_ids: IO.Autogrow.Type = None,
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=False, min_length=1)
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/phota/v1/phota/edit", method="POST"),
|
||||
response_model=PhotoStudioResponse,
|
||||
data=PhotaEditRequest(
|
||||
prompt=prompt,
|
||||
images=await upload_images_to_comfyapi(cls, list(images.values()), max_images=10),
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
profile_ids=list(profile_ids.values()) if profile_ids else None,
|
||||
),
|
||||
)
|
||||
return IO.NodeOutput(bytesio_to_image_tensor(BytesIO(base64.b64decode(response.images[0]))))
|
||||
|
||||
|
||||
class PhotaLabsEnhance(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="PhotaLabsEnhance",
|
||||
display_name="Phota Labs Enhance",
|
||||
category="api node/image/Phota Labs",
|
||||
description="Automatically enhance a photo using Phota Labs. "
|
||||
"No text prompt is required — enhancement parameters are inferred automatically.",
|
||||
inputs=[
|
||||
IO.Image.Input(
|
||||
"image",
|
||||
tooltip="Input image to enhance.",
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"profile_ids",
|
||||
template=IO.Autogrow.TemplatePrefix(
|
||||
IO.Custom(PHOTA_LABS_PROFILE_ID).Input("profile_id"),
|
||||
prefix="profile_id",
|
||||
min=0,
|
||||
max=5,
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
image: Input.Image,
|
||||
profile_ids: IO.Autogrow.Type = None,
|
||||
) -> IO.NodeOutput:
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/phota/v1/phota/enhance", method="POST"),
|
||||
response_model=PhotoStudioResponse,
|
||||
data=PhotaEnhanceRequest(
|
||||
image=await upload_image_to_comfyapi(cls, image),
|
||||
),
|
||||
)
|
||||
return IO.NodeOutput(bytesio_to_image_tensor(BytesIO(base64.b64decode(response.images[0]))))
|
||||
|
||||
|
||||
class PhotaLabsSelectProfile(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="PhotaLabsSelectProfile",
|
||||
display_name="Phota Labs Select Profile",
|
||||
category="api node/image/Phota Labs",
|
||||
description="Select a trained Phota Labs profile for use in generation.",
|
||||
inputs=[
|
||||
IO.Combo.Input(
|
||||
"profile_id",
|
||||
options=[],
|
||||
remote=IO.RemoteOptions(
|
||||
route="http://localhost:9000/phota/profiles",
|
||||
refresh_button=True,
|
||||
item_schema=IO.RemoteItemSchema(
|
||||
value_field="profile_id",
|
||||
label_field="profile_id",
|
||||
preview_url_field="preview_url",
|
||||
preview_type="image",
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[IO.Custom(PHOTA_LABS_PROFILE_ID).Output(display_name="profile_id")],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, profile_id: str) -> IO.NodeOutput:
|
||||
return IO.NodeOutput(profile_id)
|
||||
|
||||
|
||||
class PhotaLabsAddProfile(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="PhotaLabsAddProfile",
|
||||
display_name="Phota Labs Add Profile",
|
||||
category="api node/image/Phota Labs",
|
||||
description="Create a training profile from 30-50 reference images using Phota Labs. "
|
||||
"Uploads images and starts asynchronous training, returning the profile ID once training is queued.",
|
||||
inputs=[
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplatePrefix(
|
||||
IO.Image.Input("image"),
|
||||
prefix="image",
|
||||
min=30,
|
||||
max=50,
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Custom(PHOTA_LABS_PROFILE_ID).Output(display_name="profile_id"),
|
||||
IO.String.Output(display_name="status"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
images: IO.Autogrow.Type,
|
||||
) -> IO.NodeOutput:
|
||||
image_urls = await upload_images_to_comfyapi(
|
||||
cls,
|
||||
list(images.values()),
|
||||
max_images=50,
|
||||
wait_label="Uploading training images",
|
||||
)
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/phota/v1/phota/profiles/add", method="POST"),
|
||||
response_model=PhotaAddProfileResponse,
|
||||
data=PhotaAddProfileRequest(image_urls=image_urls),
|
||||
)
|
||||
# Poll until validation passes and training is queued/in-progress/ready
|
||||
status_response = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(
|
||||
path=f"/proxy/phota/v1/phota/profiles/{response.profile_id}/status"
|
||||
),
|
||||
response_model=PhotaProfileStatusResponse,
|
||||
status_extractor=lambda r: r.status,
|
||||
completed_statuses=["QUEUING", "IN_PROGRESS", "READY"],
|
||||
failed_statuses=["ERROR", "INACTIVE"],
|
||||
)
|
||||
return IO.NodeOutput(response.profile_id, status_response.status)
|
||||
|
||||
|
||||
class PhotaLabsExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [
|
||||
PhotaLabsGenerate,
|
||||
PhotaLabsEdit,
|
||||
PhotaLabsEnhance,
|
||||
PhotaLabsSelectProfile,
|
||||
PhotaLabsAddProfile,
|
||||
]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> PhotaLabsExtension:
|
||||
return PhotaLabsExtension()
|
||||
@@ -991,6 +991,10 @@ async def validate_inputs(prompt_id, prompt, item, validated):
|
||||
|
||||
if isinstance(input_type, list) or input_type == io.Combo.io_type:
|
||||
if input_type == io.Combo.io_type:
|
||||
# Skip validation for combos with remote options — options
|
||||
# are fetched client-side and not available on the server.
|
||||
if extra_info.get("remote"):
|
||||
continue
|
||||
combo_options = extra_info.get("options", [])
|
||||
else:
|
||||
combo_options = input_type
|
||||
|
||||
Reference in New Issue
Block a user