Files

306 lines
10 KiB
Python
Raw Permalink Normal View History

import base64
import re
from io import BytesIO
import torch
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension
from comfy_api_nodes.apis.meta import (
MuseImageEditRequest,
MuseImageInput,
MuseImageRequest,
MuseImageResponse,
MuseImageToolEnablement,
)
from comfy_api_nodes.util import (
ApiEndpoint,
bytesio_to_image_tensor,
sync_op,
upload_images_to_comfyapi,
validate_string,
)
GENERATIONS_PATH = "/proxy/meta/v1/images/generations"
EDITS_PATH = "/proxy/meta/v1/images/edits"
MUSE_IMAGE_MODELS = ["muse-image-1.0"]
MAX_INPUT_IMAGES = 10
ASPECT_RATIOS = ["auto", "1:1", "3:2", "2:3", "4:3", "3:4", "5:4", "4:5", "16:9", "9:16", "21:9", "9:21", "2:1", "1:2"]
REASONING_STRENGTHS = ["high", "low"]
_IMAGE_REF_RE = re.compile(r"@image(?P<idx>\d*)(?!\w)", re.IGNORECASE | re.ASCII)
def _resolve_image_refs(prompt: str, total_images: int) -> str:
parts = []
pos = 0
prev_end = -1
for match in _IMAGE_REF_RE.finditer(prompt):
start = match.start()
if start > 0 and start != prev_end and (prompt[start - 1].isalnum() or prompt[start - 1] == "_"):
continue
idx = int(match.group("idx") or 1)
if not 1 <= idx <= total_images:
raise ValueError(
f"The prompt references @Image{idx}, but only {total_images} reference images "
f"are connected (a batched input counts once per image)."
)
parts.append(prompt[pos:start])
parts.append(f"image {idx}")
pos = match.end()
prev_end = match.end()
parts.append(prompt[pos:])
return "".join(parts)
def _size(aspect_ratio: str) -> str | None:
return None if aspect_ratio == "auto" else aspect_ratio.replace(":", "x")
def _decode_images(response: MuseImageResponse) -> torch.Tensor:
images = [
bytesio_to_image_tensor(BytesIO(base64.b64decode(item.b64_json)))
for item in response.data
if item.b64_json
]
if not images:
raise Exception("The response contains no images.")
return torch.cat(images)
def _reasoning_strength_input() -> IO.Combo.Input:
return IO.Combo.Input(
"reasoning_strength",
options=REASONING_STRENGTHS,
tooltip="How much the model thinks, plans and self-refines before rendering.",
)
def _t2i_model_option(model_id: str) -> IO.DynamicCombo.Option:
return IO.DynamicCombo.Option(
model_id,
[
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Prompt describing the image. The model reasons about the prompt, and may use "
"its built-in web and image search, before rendering.",
),
IO.Combo.Input(
"aspect_ratio",
options=ASPECT_RATIOS,
tooltip="Aspect ratio of the output. Images are rendered at about 2.5 megapixels "
"(1:1 is 1600x1600, 16:9 is 2048x1152); 'auto' lets the model choose from the prompt.",
),
_reasoning_strength_input(),
*_tool_toggle_inputs(),
_seed_input(),
],
)
def _edit_model_option(model_id: str) -> IO.DynamicCombo.Option:
return IO.DynamicCombo.Option(
model_id,
[
IO.Autogrow.Input(
"images",
template=IO.Autogrow.TemplateNames(
IO.Image.Input("image"),
names=[f"image_{i}" for i in range(1, MAX_INPUT_IMAGES + 1)],
min=1,
),
tooltip=f"1-{MAX_INPUT_IMAGES} reference images to edit or combine. Refer to them in the prompt "
"as @Image1, @Image2, ..., numbered in input order; a batched input counts once per image.",
),
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Editing instructions. Supports @Image1-style references to the input images.",
),
IO.Combo.Input(
"aspect_ratio",
options=ASPECT_RATIOS,
tooltip="Aspect ratio of the output. Images are rendered at about 2.5 megapixels "
"(1:1 is 1600x1600, 16:9 is 2048x1152); 'auto' keeps the aspect ratio of the input.",
),
_reasoning_strength_input(),
*_tool_toggle_inputs(),
_seed_input(),
],
)
def _tool_toggle_inputs() -> list[IO.Boolean.Input]:
return [
IO.Boolean.Input(
"enable_web_search",
default=True,
advanced=True,
tooltip="Lets the model search the web for facts and live information while planning the image.",
),
IO.Boolean.Input(
"enable_image_search",
default=True,
advanced=True,
tooltip="Lets the model search for reference images while planning the image.",
),
IO.Boolean.Input(
"enable_shell",
default=True,
advanced=True,
tooltip="Lets the model run code while planning, for precise layouts, charts and diagrams; "
"when off, quantities and alignment are approximated.",
),
]
def _tool_enablement(model: dict) -> MuseImageToolEnablement | None:
if model["enable_web_search"] and model["enable_image_search"] and model["enable_shell"]:
return None
return MuseImageToolEnablement(
enable_image_search=model["enable_image_search"],
enable_web_search=model["enable_web_search"],
enable_shell=model["enable_shell"],
)
def _seed_input() -> IO.Int.Input:
return IO.Int.Input(
"seed",
default=42,
min=0,
max=2147483647,
step=1,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
tooltip="Seed to determine if node should re-run; the API has no seed, "
"so actual results are nondeterministic regardless of this value.",
)
def _price_badge() -> IO.PriceBadge:
return IO.PriceBadge(expr="""{"type":"usd","usd":0.0143}""")
class MetaMuseImageTextToImageApi(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="MetaMuseImageTextToImageApi",
display_name="Meta Muse Image Text to Image",
category="partner/image/Meta",
description="Generates images from a text prompt using Meta's Muse Image model, "
"which reasons about the prompt before rendering.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[_t2i_model_option(model_id) for model_id in MUSE_IMAGE_MODELS],
tooltip="Model to use.",
),
],
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,
price_badge=_price_badge(),
)
@classmethod
async def execute(cls, model: dict):
validate_string(model["prompt"], min_length=1)
response = await sync_op(
cls,
ApiEndpoint(path=GENERATIONS_PATH, method="POST"),
response_model=MuseImageResponse,
data=MuseImageRequest(
model=model["model"],
prompt=model["prompt"],
size=_size(model["aspect_ratio"]),
reasoning_strength=model["reasoning_strength"],
tool_enablement=_tool_enablement(model),
),
)
return IO.NodeOutput(_decode_images(response))
class MetaMuseImageEditApi(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="MetaMuseImageEditApi",
display_name="Meta Muse Image Edit",
category="partner/image/Meta",
description=f"Edits or combines up to {MAX_INPUT_IMAGES} reference images guided by a text prompt "
"using Meta's Muse Image model.",
inputs=[
IO.DynamicCombo.Input(
"model",
options=[_edit_model_option(model_id) for model_id in MUSE_IMAGE_MODELS],
tooltip="Model to use.",
),
],
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,
price_badge=_price_badge(),
)
@classmethod
async def execute(cls, model: dict):
validate_string(model["prompt"], min_length=1)
reference_images = [image for key in model["images"] for image in model["images"][key]]
if len(reference_images) > MAX_INPUT_IMAGES:
raise ValueError(
f"A maximum of {MAX_INPUT_IMAGES} reference images is supported; got {len(reference_images)} "
f"(a batched input counts once per image)."
)
prompt = _resolve_image_refs(model["prompt"], len(reference_images))
urls = await upload_images_to_comfyapi(
cls,
[image[..., :3] for image in reference_images],
max_images=MAX_INPUT_IMAGES,
mime_type="image/png",
wait_label="Uploading reference images",
)
response = await sync_op(
cls,
ApiEndpoint(path=EDITS_PATH, method="POST"),
response_model=MuseImageResponse,
data=MuseImageEditRequest(
model=model["model"],
prompt=prompt,
size=_size(model["aspect_ratio"]),
reasoning_strength=model["reasoning_strength"],
tool_enablement=_tool_enablement(model),
images=[MuseImageInput(image_url=url) for url in urls],
),
)
return IO.NodeOutput(_decode_images(response))
class MetaApiExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
MetaMuseImageTextToImageApi,
MetaMuseImageEditApi,
]
async def comfy_entrypoint() -> MetaApiExtension:
return MetaApiExtension()