From d25d14dfb624db890cc360a55d40a559ce1c85c2 Mon Sep 17 00:00:00 2001 From: bigcat88 Date: Mon, 30 Mar 2026 19:48:53 +0300 Subject: [PATCH] dev: PhotaLabs API nodes Signed-off-by: bigcat88 --- comfy_api/latest/_io.py | 58 ++++- comfy_api_nodes/apis/phota_labs.py | 49 ++++ comfy_api_nodes/nodes_elevenlabs.py | 40 ++++ comfy_api_nodes/nodes_phota_labs.py | 350 ++++++++++++++++++++++++++++ execution.py | 4 + 5 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 comfy_api_nodes/apis/phota_labs.py create mode 100644 comfy_api_nodes/nodes_phota_labs.py diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index fdeffea2d..a0abc988e 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -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", diff --git a/comfy_api_nodes/apis/phota_labs.py b/comfy_api_nodes/apis/phota_labs.py new file mode 100644 index 000000000..24d136cb9 --- /dev/null +++ b/comfy_api_nodes/apis/phota_labs.py @@ -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.") diff --git a/comfy_api_nodes/nodes_elevenlabs.py b/comfy_api_nodes/nodes_elevenlabs.py index e452daf77..c473bc95c 100644 --- a/comfy_api_nodes/nodes_elevenlabs.py +++ b/comfy_api_nodes/nodes_elevenlabs.py @@ -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, diff --git a/comfy_api_nodes/nodes_phota_labs.py b/comfy_api_nodes/nodes_phota_labs.py new file mode 100644 index 000000000..ff78f99e7 --- /dev/null +++ b/comfy_api_nodes/nodes_phota_labs.py @@ -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() diff --git a/execution.py b/execution.py index 5e02dffb2..fad002e58 100644 --- a/execution.py +++ b/execution.py @@ -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