Compare commits

...

1 Commits

Author SHA1 Message Date
bigcat88
d25d14dfb6 dev: PhotaLabs API nodes
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-03-31 16:36:32 +03:00
5 changed files with 500 additions and 1 deletions

View File

@@ -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",

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)
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,

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 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