dev: PhotaLabs API nodes

Signed-off-by: bigcat88 <bigcat88@icloud.com>
This commit is contained in:
bigcat88 2026-03-30 19:48:53 +03:00
parent 55e6478526
commit d25d14dfb6
No known key found for this signature in database
GPG Key ID: 1F0BF0EC3CF22721
5 changed files with 500 additions and 1 deletions

View File

@ -43,9 +43,55 @@ class UploadType(str, Enum):
model = "file_upload" 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: class RemoteOptions:
def __init__(self, route: str, refresh_button: bool, control_after_refresh: Literal["first", "last"]="first", 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 self.route = route
"""The route to the remote source.""" """The route to the remote source."""
self.refresh_button = refresh_button self.refresh_button = refresh_button
@ -58,6 +104,12 @@ class RemoteOptions:
"""The maximum number of retries before aborting the request.""" """The maximum number of retries before aborting the request."""
self.refresh = refresh 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.""" """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): def as_dict(self):
return prune_dict({ return prune_dict({
@ -67,6 +119,9 @@ class RemoteOptions:
"timeout": self.timeout, "timeout": self.timeout,
"max_retries": self.max_retries, "max_retries": self.max_retries,
"refresh": self.refresh, "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__ = [ __all__ = [
"FolderType", "FolderType",
"UploadType", "UploadType",
"RemoteItemSchema",
"RemoteOptions", "RemoteOptions",
"NumberDisplay", "NumberDisplay",
"ControlAfterGenerate", "ControlAfterGenerate",

View 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.")

View File

@ -233,6 +233,45 @@ class ElevenLabsVoiceSelector(IO.ComfyNode):
return IO.NodeOutput(voice_id) 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): class ElevenLabsTextToSpeech(IO.ComfyNode):
@classmethod @classmethod
def define_schema(cls) -> IO.Schema: def define_schema(cls) -> IO.Schema:
@ -911,6 +950,7 @@ class ElevenLabsExtension(ComfyExtension):
return [ return [
ElevenLabsSpeechToText, ElevenLabsSpeechToText,
ElevenLabsVoiceSelector, ElevenLabsVoiceSelector,
ElevenLabsRichVoiceSelector,
ElevenLabsTextToSpeech, ElevenLabsTextToSpeech,
ElevenLabsAudioIsolation, ElevenLabsAudioIsolation,
ElevenLabsTextToSoundEffects, ElevenLabsTextToSoundEffects,

View 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()

View File

@ -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 isinstance(input_type, list) or input_type == io.Combo.io_type:
if 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", []) combo_options = extra_info.get("options", [])
else: else:
combo_options = input_type combo_options = input_type