mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-08 08:12:34 +08:00
feat(api-nodes): add Luma UNI-1 models (#13614)
Signed-off-by: bigcat88 <bigcat88@icloud.com> Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
This commit is contained in:
parent
8d75211300
commit
c55ff85243
@ -1,15 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
import torch
|
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
import torch
|
||||||
from pydantic import BaseModel, Field, confloat
|
from pydantic import BaseModel, Field, confloat
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LumaIO:
|
class LumaIO:
|
||||||
LUMA_REF = "LUMA_REF"
|
LUMA_REF = "LUMA_REF"
|
||||||
LUMA_CONCEPTS = "LUMA_CONCEPTS"
|
LUMA_CONCEPTS = "LUMA_CONCEPTS"
|
||||||
@ -183,13 +180,13 @@ class LumaAssets(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class LumaImageRef(BaseModel):
|
class LumaImageRef(BaseModel):
|
||||||
'''Used for image gen'''
|
"""Used for image gen"""
|
||||||
url: str = Field(..., description='The URL of the image reference')
|
url: str = Field(..., description='The URL of the image reference')
|
||||||
weight: confloat(ge=0.0, le=1.0) = Field(..., description='The weight of the image reference')
|
weight: confloat(ge=0.0, le=1.0) = Field(..., description='The weight of the image reference')
|
||||||
|
|
||||||
|
|
||||||
class LumaImageReference(BaseModel):
|
class LumaImageReference(BaseModel):
|
||||||
'''Used for video gen'''
|
"""Used for video gen"""
|
||||||
type: Optional[str] = Field('image', description='Input type, defaults to image')
|
type: Optional[str] = Field('image', description='Input type, defaults to image')
|
||||||
url: str = Field(..., description='The URL of the image')
|
url: str = Field(..., description='The URL of the image')
|
||||||
|
|
||||||
@ -251,3 +248,32 @@ class LumaGeneration(BaseModel):
|
|||||||
assets: Optional[LumaAssets] = Field(None, description='The assets of the generation')
|
assets: Optional[LumaAssets] = Field(None, description='The assets of the generation')
|
||||||
model: str = Field(..., description='The model used for the generation')
|
model: str = Field(..., description='The model used for the generation')
|
||||||
request: Union[LumaGenerationRequest, LumaImageGenerationRequest] = Field(..., description="The request used for the generation")
|
request: Union[LumaGenerationRequest, LumaImageGenerationRequest] = Field(..., description="The request used for the generation")
|
||||||
|
|
||||||
|
|
||||||
|
class Luma2ImageRef(BaseModel):
|
||||||
|
url: str | None = None
|
||||||
|
data: str | None = None
|
||||||
|
media_type: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Luma2GenerationRequest(BaseModel):
|
||||||
|
prompt: str = Field(..., min_length=1, max_length=6000)
|
||||||
|
model: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
aspect_ratio: str | None = None
|
||||||
|
style: str | None = None
|
||||||
|
output_format: str | None = None
|
||||||
|
web_search: bool | None = None
|
||||||
|
image_ref: list[Luma2ImageRef] | None = None
|
||||||
|
source: Luma2ImageRef | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Luma2Generation(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
type: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
output: list[LumaImageReference] | None = None
|
||||||
|
failure_reason: str | None = None
|
||||||
|
failure_code: str | None = None
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
|
|
||||||
from comfy_api.latest import IO, ComfyExtension
|
from comfy_api.latest import IO, ComfyExtension, Input
|
||||||
from comfy_api_nodes.apis.luma import (
|
from comfy_api_nodes.apis.luma import (
|
||||||
|
Luma2Generation,
|
||||||
|
Luma2GenerationRequest,
|
||||||
|
Luma2ImageRef,
|
||||||
LumaAspectRatio,
|
LumaAspectRatio,
|
||||||
LumaCharacterRef,
|
LumaCharacterRef,
|
||||||
LumaConceptChain,
|
LumaConceptChain,
|
||||||
@ -30,6 +31,7 @@ from comfy_api_nodes.util import (
|
|||||||
download_url_to_video_output,
|
download_url_to_video_output,
|
||||||
poll_op,
|
poll_op,
|
||||||
sync_op,
|
sync_op,
|
||||||
|
upload_image_to_comfyapi,
|
||||||
upload_images_to_comfyapi,
|
upload_images_to_comfyapi,
|
||||||
validate_string,
|
validate_string,
|
||||||
)
|
)
|
||||||
@ -212,9 +214,9 @@ class LumaImageGenerationNode(IO.ComfyNode):
|
|||||||
aspect_ratio: str,
|
aspect_ratio: str,
|
||||||
seed,
|
seed,
|
||||||
style_image_weight: float,
|
style_image_weight: float,
|
||||||
image_luma_ref: Optional[LumaReferenceChain] = None,
|
image_luma_ref: LumaReferenceChain | None = None,
|
||||||
style_image: Optional[torch.Tensor] = None,
|
style_image: torch.Tensor | None = None,
|
||||||
character_image: Optional[torch.Tensor] = None,
|
character_image: torch.Tensor | None = None,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
validate_string(prompt, strip_whitespace=True, min_length=3)
|
validate_string(prompt, strip_whitespace=True, min_length=3)
|
||||||
# handle image_luma_ref
|
# handle image_luma_ref
|
||||||
@ -434,7 +436,7 @@ class LumaTextToVideoGenerationNode(IO.ComfyNode):
|
|||||||
duration: str,
|
duration: str,
|
||||||
loop: bool,
|
loop: bool,
|
||||||
seed,
|
seed,
|
||||||
luma_concepts: Optional[LumaConceptChain] = None,
|
luma_concepts: LumaConceptChain | None = None,
|
||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
validate_string(prompt, strip_whitespace=False, min_length=3)
|
validate_string(prompt, strip_whitespace=False, min_length=3)
|
||||||
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
duration = duration if model != LumaVideoModel.ray_1_6 else None
|
||||||
@ -533,7 +535,6 @@ class LumaImageToVideoGenerationNode(IO.ComfyNode):
|
|||||||
],
|
],
|
||||||
is_api_node=True,
|
is_api_node=True,
|
||||||
price_badge=PRICE_BADGE_VIDEO,
|
price_badge=PRICE_BADGE_VIDEO,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -644,6 +645,293 @@ PRICE_BADGE_VIDEO = IO.PriceBadge(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _luma2_uni1_common_inputs(max_image_refs: int) -> list:
|
||||||
|
return [
|
||||||
|
IO.Combo.Input(
|
||||||
|
"style",
|
||||||
|
options=["auto", "manga"],
|
||||||
|
default="auto",
|
||||||
|
tooltip="Style preset. 'auto' picks based on the prompt; "
|
||||||
|
"'manga' applies a manga/anime aesthetic and requires a portrait "
|
||||||
|
"aspect ratio (2:3, 9:16, 1:2, 1:3).",
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"web_search",
|
||||||
|
default=False,
|
||||||
|
tooltip="Search the web for visual references before generating.",
|
||||||
|
),
|
||||||
|
IO.Autogrow.Input(
|
||||||
|
"image_ref",
|
||||||
|
template=IO.Autogrow.TemplateNames(
|
||||||
|
IO.Image.Input("image"),
|
||||||
|
names=[f"image_{i}" for i in range(1, max_image_refs + 1)],
|
||||||
|
min=0,
|
||||||
|
),
|
||||||
|
optional=True,
|
||||||
|
tooltip=f"Up to {max_image_refs} reference images for style/content guidance.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _luma2_upload_image_refs(
|
||||||
|
cls: type[IO.ComfyNode],
|
||||||
|
refs: dict | None,
|
||||||
|
max_count: int,
|
||||||
|
) -> list[Luma2ImageRef] | None:
|
||||||
|
if not refs:
|
||||||
|
return None
|
||||||
|
out: list[Luma2ImageRef] = []
|
||||||
|
for key in refs:
|
||||||
|
url = await upload_image_to_comfyapi(cls, refs[key])
|
||||||
|
out.append(Luma2ImageRef(url=url))
|
||||||
|
if len(out) > max_count:
|
||||||
|
raise ValueError(f"Maximum {max_count} reference images are allowed.")
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _luma2_submit_and_poll(
|
||||||
|
cls: type[IO.ComfyNode],
|
||||||
|
request: Luma2GenerationRequest,
|
||||||
|
) -> Input.Image:
|
||||||
|
initial = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path="/proxy/luma_2/generations", method="POST"),
|
||||||
|
response_model=Luma2Generation,
|
||||||
|
data=request,
|
||||||
|
)
|
||||||
|
if not initial.id:
|
||||||
|
raise RuntimeError("Luma 2 API did not return a generation id.")
|
||||||
|
final = await poll_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path=f"/proxy/luma_2/generations/{initial.id}", method="GET"),
|
||||||
|
response_model=Luma2Generation,
|
||||||
|
status_extractor=lambda r: r.state,
|
||||||
|
progress_extractor=lambda r: None,
|
||||||
|
)
|
||||||
|
if not final.output:
|
||||||
|
msg = final.failure_reason or "no output returned"
|
||||||
|
raise RuntimeError(f"Luma 2 generation failed: {msg}")
|
||||||
|
url = final.output[0].url
|
||||||
|
if not url:
|
||||||
|
raise RuntimeError("Luma 2 generation completed without an output URL.")
|
||||||
|
return await download_url_to_image_tensor(url)
|
||||||
|
|
||||||
|
|
||||||
|
class LumaImageNode(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls) -> IO.Schema:
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="LumaImageNode2",
|
||||||
|
display_name="Luma UNI-1 Image",
|
||||||
|
category="api node/image/Luma",
|
||||||
|
description="Generate images from text using the Luma UNI-1 model.",
|
||||||
|
inputs=[
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Text description of the desired image. 1–6000 characters.",
|
||||||
|
),
|
||||||
|
IO.DynamicCombo.Input(
|
||||||
|
"model",
|
||||||
|
options=[
|
||||||
|
IO.DynamicCombo.Option(
|
||||||
|
"uni-1",
|
||||||
|
[
|
||||||
|
IO.Combo.Input(
|
||||||
|
"aspect_ratio",
|
||||||
|
options=[
|
||||||
|
"auto",
|
||||||
|
"3:1",
|
||||||
|
"2:1",
|
||||||
|
"16:9",
|
||||||
|
"3:2",
|
||||||
|
"1:1",
|
||||||
|
"2:3",
|
||||||
|
"9:16",
|
||||||
|
"1:2",
|
||||||
|
"1:3",
|
||||||
|
],
|
||||||
|
default="auto",
|
||||||
|
tooltip="Output image aspect ratio. 'auto' lets "
|
||||||
|
"the model pick based on the prompt.",
|
||||||
|
),
|
||||||
|
*_luma2_uni1_common_inputs(max_image_refs=9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IO.DynamicCombo.Option(
|
||||||
|
"uni-1-max",
|
||||||
|
[
|
||||||
|
IO.Combo.Input(
|
||||||
|
"aspect_ratio",
|
||||||
|
options=[
|
||||||
|
"auto",
|
||||||
|
"3:1",
|
||||||
|
"2:1",
|
||||||
|
"16:9",
|
||||||
|
"3:2",
|
||||||
|
"1:1",
|
||||||
|
"2:3",
|
||||||
|
"9:16",
|
||||||
|
"1:2",
|
||||||
|
"1:3",
|
||||||
|
],
|
||||||
|
default="auto",
|
||||||
|
tooltip="Output image aspect ratio. 'auto' lets "
|
||||||
|
"the model pick based on the prompt.",
|
||||||
|
),
|
||||||
|
*_luma2_uni1_common_inputs(max_image_refs=9),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tooltip="Model to use for generation.",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
control_after_generate=True,
|
||||||
|
tooltip="Seed controls whether the node should re-run; "
|
||||||
|
"results are non-deterministic regardless of seed.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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=IO.PriceBadge(
|
||||||
|
depends_on=IO.PriceBadgeDepends(widgets=["model"], input_groups=["model.image_ref"]),
|
||||||
|
expr="""
|
||||||
|
(
|
||||||
|
$m := widgets.model;
|
||||||
|
$refs := $lookup(inputGroups, "model.image_ref");
|
||||||
|
$base := $m = "uni-1-max" ? 0.1 : 0.0404;
|
||||||
|
{"type":"usd","usd": $round($base + 0.003 * $refs, 4)}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
prompt: str,
|
||||||
|
model: dict,
|
||||||
|
seed: int,
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
validate_string(prompt, min_length=1, max_length=6000)
|
||||||
|
aspect_ratio = model["aspect_ratio"]
|
||||||
|
style = model["style"]
|
||||||
|
allowed_manga_ratios = {"2:3", "9:16", "1:2", "1:3"}
|
||||||
|
if style == "manga" and aspect_ratio != "auto" and aspect_ratio not in allowed_manga_ratios:
|
||||||
|
raise ValueError(
|
||||||
|
f"'manga' style requires a portrait aspect ratio "
|
||||||
|
f"({', '.join(sorted(allowed_manga_ratios))}) or 'auto'; got '{aspect_ratio}'."
|
||||||
|
)
|
||||||
|
request = Luma2GenerationRequest(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model["model"],
|
||||||
|
type="image",
|
||||||
|
aspect_ratio=aspect_ratio if aspect_ratio != "auto" else None,
|
||||||
|
style=style if style != "auto" else None,
|
||||||
|
output_format="png",
|
||||||
|
web_search=model["web_search"],
|
||||||
|
image_ref=await _luma2_upload_image_refs(cls, model.get("image_ref"), max_count=9),
|
||||||
|
)
|
||||||
|
return IO.NodeOutput(await _luma2_submit_and_poll(cls, request))
|
||||||
|
|
||||||
|
|
||||||
|
class LumaImageEditNode(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls) -> IO.Schema:
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="LumaImageEditNode2",
|
||||||
|
display_name="Luma UNI-1 Image Edit",
|
||||||
|
category="api node/image/Luma",
|
||||||
|
description="Edit an existing image with a text prompt using the Luma UNI-1 model.",
|
||||||
|
inputs=[
|
||||||
|
IO.Image.Input(
|
||||||
|
"source",
|
||||||
|
tooltip="Source image to edit.",
|
||||||
|
),
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Description of the desired edit. 1–6000 characters.",
|
||||||
|
),
|
||||||
|
IO.DynamicCombo.Input(
|
||||||
|
"model",
|
||||||
|
options=[
|
||||||
|
IO.DynamicCombo.Option(
|
||||||
|
"uni-1",
|
||||||
|
_luma2_uni1_common_inputs(max_image_refs=8),
|
||||||
|
),
|
||||||
|
IO.DynamicCombo.Option(
|
||||||
|
"uni-1-max",
|
||||||
|
_luma2_uni1_common_inputs(max_image_refs=8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tooltip="Model to use for editing.",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
control_after_generate=True,
|
||||||
|
tooltip="Seed controls whether the node should re-run; "
|
||||||
|
"results are non-deterministic regardless of seed.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
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=IO.PriceBadge(
|
||||||
|
depends_on=IO.PriceBadgeDepends(widgets=["model"], input_groups=["model.image_ref"]),
|
||||||
|
expr="""
|
||||||
|
(
|
||||||
|
$m := widgets.model;
|
||||||
|
$refs := $lookup(inputGroups, "model.image_ref");
|
||||||
|
$base := $m = "uni-1-max" ? 0.103 : 0.0434;
|
||||||
|
{"type":"usd","usd": $round($base + 0.003 * $refs, 4)}
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
source: Input.Image,
|
||||||
|
prompt: str,
|
||||||
|
model: dict,
|
||||||
|
seed: int,
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
validate_string(prompt, min_length=1, max_length=6000)
|
||||||
|
request = Luma2GenerationRequest(
|
||||||
|
prompt=prompt,
|
||||||
|
model=model["model"],
|
||||||
|
type="image_edit",
|
||||||
|
source=Luma2ImageRef(url=await upload_image_to_comfyapi(cls, source)),
|
||||||
|
style=model["style"] if model["style"] != "auto" else None,
|
||||||
|
output_format="png",
|
||||||
|
web_search=model["web_search"],
|
||||||
|
image_ref=await _luma2_upload_image_refs(cls, model.get("image_ref"), max_count=8),
|
||||||
|
)
|
||||||
|
return IO.NodeOutput(await _luma2_submit_and_poll(cls, request))
|
||||||
|
|
||||||
|
|
||||||
class LumaExtension(ComfyExtension):
|
class LumaExtension(ComfyExtension):
|
||||||
@override
|
@override
|
||||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||||
@ -654,6 +942,8 @@ class LumaExtension(ComfyExtension):
|
|||||||
LumaImageToVideoGenerationNode,
|
LumaImageToVideoGenerationNode,
|
||||||
LumaReferenceNode,
|
LumaReferenceNode,
|
||||||
LumaConceptsNode,
|
LumaConceptsNode,
|
||||||
|
LumaImageNode,
|
||||||
|
LumaImageEditNode,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user