Skip to content

Commit 175e854

Browse files
authored
[Partner Nodes] feat: add Krea2 nodes (Comfy-Org#14130)
1 parent 53eba22 commit 175e854

2 files changed

Lines changed: 336 additions & 0 deletions

File tree

comfy_api_nodes/apis/krea.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Pydantic models for the Krea image-generation API."""
2+
3+
from pydantic import BaseModel, Field
4+
5+
6+
class KreaMoodboard(BaseModel):
7+
id: str = Field(...)
8+
strength: float = Field(default=0.35, ge=-0.5, le=1.5)
9+
10+
11+
class KreaImageStyleReference(BaseModel):
12+
strength: float = Field(..., ge=-2.0, le=2.0)
13+
url: str | None = Field(default=None)
14+
15+
16+
class KreaGenerateImageRequest(BaseModel):
17+
prompt: str = Field(...)
18+
aspect_ratio: str = Field(...)
19+
resolution: str = Field(...)
20+
seed: int | None = Field(default=None)
21+
creativity: str = Field(default="medium")
22+
moodboards: list[KreaMoodboard] | None = Field(default=None)
23+
image_style_references: list[KreaImageStyleReference] | None = Field(default=None)
24+
25+
26+
class KreaJobResult(BaseModel):
27+
urls: list[str] | None = Field(default=None)
28+
style_id: str | None = Field(default=None)
29+
30+
31+
class KreaJob(BaseModel):
32+
job_id: str = Field(...)
33+
status: str = Field(...)
34+
created_at: str = Field(...)
35+
completed_at: str | None = Field(default=None)
36+
result: KreaJobResult | None = Field(default=None)
37+
38+
39+
class KreaAssetResponse(BaseModel):
40+
id: str = Field(...)
41+
image_url: str = Field(...)
42+
uploaded_at: str = Field(...)
43+
width: float | None = Field(default=None)
44+
height: float | None = Field(default=None)
45+
size_bytes: float | None = Field(default=None)
46+
mime_type: str | None = Field(default=None)

comfy_api_nodes/nodes_krea.py

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
"""Krea image-generation nodes."""
2+
3+
import re
4+
5+
from typing_extensions import override
6+
7+
from comfy_api.latest import IO, ComfyExtension, Input
8+
from comfy_api_nodes.apis.krea import (
9+
KreaAssetResponse,
10+
KreaGenerateImageRequest,
11+
KreaImageStyleReference,
12+
KreaJob,
13+
KreaMoodboard,
14+
)
15+
from comfy_api_nodes.util import (
16+
ApiEndpoint,
17+
download_url_to_image_tensor,
18+
poll_op,
19+
sync_op,
20+
tensor_to_bytesio,
21+
validate_string,
22+
)
23+
24+
25+
class KreaIO:
26+
STYLE_REF = "KREA_STYLE_REF"
27+
28+
29+
async def _upload_image_to_krea_assets(cls: type[IO.ComfyNode], image: Input.Image) -> str:
30+
"""Upload an image to Krea's /assets endpoint and return the Krea-hosted image URL."""
31+
img_io = tensor_to_bytesio(image, total_pixels=2048 * 2048, mime_type="image/png")
32+
response = await sync_op(
33+
cls,
34+
endpoint=ApiEndpoint(path="/proxy/krea/assets", method="POST"),
35+
response_model=KreaAssetResponse,
36+
files=[("file", (img_io.name, img_io, "image/png"))],
37+
content_type="multipart/form-data",
38+
max_retries=1,
39+
wait_label="Uploading reference",
40+
)
41+
return response.image_url
42+
43+
44+
_MODEL_MEDIUM = "Krea 2 Medium"
45+
_MODEL_LARGE = "Krea 2 Large"
46+
_MODEL_ENDPOINTS: dict[str, str] = {
47+
_MODEL_MEDIUM: "/proxy/krea/generate/image/krea/krea-2/medium",
48+
_MODEL_LARGE: "/proxy/krea/generate/image/krea/krea-2/large",
49+
}
50+
51+
_ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "2.35:1", "4:5", "2:3", "9:16"]
52+
_RESOLUTIONS = ["1K"]
53+
_CREATIVITY_LEVELS = ["raw", "low", "medium", "high"]
54+
_KREA_QUEUED_STATUSES = ["backlogged", "queued", "scheduled"]
55+
56+
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
57+
58+
59+
def _krea_model_inputs() -> list:
60+
"""Nested inputs shared by both Krea 2 Medium and Large under the DynamicCombo."""
61+
return [
62+
IO.Combo.Input(
63+
"aspect_ratio",
64+
options=_ASPECT_RATIOS,
65+
tooltip="Output aspect ratio.",
66+
),
67+
IO.Combo.Input(
68+
"resolution",
69+
options=_RESOLUTIONS,
70+
tooltip="Resolution scale.",
71+
),
72+
IO.Combo.Input(
73+
"creativity",
74+
options=_CREATIVITY_LEVELS,
75+
default="medium",
76+
tooltip="Prompt interpretation strength: raw stays closest to the prompt; high is most creative.",
77+
),
78+
IO.String.Input(
79+
"moodboard_id",
80+
default="",
81+
tooltip="Optional Krea moodboard UUID (e.g. from the Krea website). "
82+
"Leave empty to disable. Only one moodboard is supported per request.",
83+
optional=True,
84+
),
85+
IO.Float.Input(
86+
"moodboard_strength",
87+
default=0.35,
88+
min=-0.5,
89+
max=1.5,
90+
step=0.05,
91+
tooltip="Moodboard influence; ignored when moodboard_id is empty.",
92+
optional=True,
93+
),
94+
IO.Custom(KreaIO.STYLE_REF).Input(
95+
"style_reference",
96+
optional=True,
97+
tooltip="Optional chain of style references (max 10) from Krea 2 Style Reference nodes.",
98+
),
99+
]
100+
101+
102+
class Krea2ImageNode(IO.ComfyNode):
103+
104+
@classmethod
105+
def define_schema(cls) -> IO.Schema:
106+
return IO.Schema(
107+
node_id="Krea2ImageNode",
108+
display_name="Krea 2 Image",
109+
category="api node/image/Krea",
110+
description=(
111+
"Generate images via Krea 2 — pick Medium (expressive illustrations) or "
112+
"Large (expressive photorealism). Supports an optional moodboard and up "
113+
"to 10 chained image style references."
114+
),
115+
inputs=[
116+
IO.String.Input(
117+
"prompt",
118+
multiline=True,
119+
default="",
120+
tooltip="Text prompt for the image.",
121+
),
122+
IO.DynamicCombo.Input(
123+
"model",
124+
options=[
125+
IO.DynamicCombo.Option(_MODEL_MEDIUM, _krea_model_inputs()),
126+
IO.DynamicCombo.Option(_MODEL_LARGE, _krea_model_inputs()),
127+
],
128+
tooltip="Krea 2 Medium is best for expressive illustrations; "
129+
"Krea 2 Large is best for expressive photorealism.",
130+
),
131+
IO.Int.Input(
132+
"seed",
133+
default=0,
134+
min=0,
135+
max=2147483647,
136+
control_after_generate=True,
137+
tooltip="Random seed for reproducibility.",
138+
),
139+
],
140+
outputs=[IO.Image.Output()],
141+
hidden=[
142+
IO.Hidden.auth_token_comfy_org,
143+
IO.Hidden.api_key_comfy_org,
144+
IO.Hidden.unique_id,
145+
],
146+
is_api_node=True,
147+
price_badge=IO.PriceBadge(
148+
depends_on=IO.PriceBadgeDepends(
149+
widgets=["model", "model.moodboard_id"],
150+
inputs=["model.style_reference"],
151+
),
152+
expr="""
153+
(
154+
$isLarge := widgets.model = "krea 2 large";
155+
$hasMoodboard := $length($lookup(widgets, "model.moodboard_id")) > 0;
156+
$hasStyle := $lookup(inputs, "model.style_reference").connected;
157+
$usd := $hasMoodboard
158+
? ($isLarge ? 0.07 : 0.04)
159+
: ($hasStyle
160+
? ($isLarge ? 0.065 : 0.035)
161+
: ($isLarge ? 0.06 : 0.03));
162+
{"type":"usd","usd": $usd}
163+
)
164+
""",
165+
),
166+
)
167+
168+
@classmethod
169+
async def execute(
170+
cls,
171+
prompt: str,
172+
model: dict,
173+
seed: int,
174+
) -> IO.NodeOutput:
175+
validate_string(prompt, strip_whitespace=False, min_length=1)
176+
177+
model_choice = model["model"]
178+
endpoint_path = _MODEL_ENDPOINTS.get(model_choice)
179+
if endpoint_path is None:
180+
raise ValueError(f"Unknown Krea 2 model: {model_choice!r}")
181+
182+
moodboards: list[KreaMoodboard] | None = None
183+
mb_id = (model.get("moodboard_id") or "").strip()
184+
if mb_id:
185+
if not _UUID_RE.match(mb_id):
186+
raise ValueError(f"moodboard_id must be a UUID (received {mb_id!r}); copy it from the Krea website.")
187+
mb_strength = model.get("moodboard_strength")
188+
moodboards = [KreaMoodboard(id=mb_id, strength=0.35 if mb_strength is None else float(mb_strength))]
189+
190+
style_reference = model.get("style_reference")
191+
image_style_references: list[KreaImageStyleReference] | None = None
192+
if style_reference:
193+
if len(style_reference) > 10:
194+
raise ValueError(f"Krea 2 accepts at most 10 image_style_references; received {len(style_reference)}.")
195+
image_style_references = [
196+
KreaImageStyleReference(url=ref["url"], strength=float(ref["strength"])) for ref in style_reference
197+
]
198+
initial = await sync_op(
199+
cls,
200+
ApiEndpoint(path=endpoint_path, method="POST"),
201+
response_model=KreaJob,
202+
data=KreaGenerateImageRequest(
203+
prompt=prompt,
204+
aspect_ratio=model["aspect_ratio"],
205+
resolution=model["resolution"],
206+
seed=seed,
207+
creativity=model["creativity"],
208+
moodboards=moodboards,
209+
image_style_references=image_style_references,
210+
),
211+
)
212+
job = await poll_op(
213+
cls,
214+
ApiEndpoint(path=f"/proxy/krea/jobs/{initial.job_id}", method="GET"),
215+
response_model=KreaJob,
216+
status_extractor=lambda r: r.status,
217+
queued_statuses=_KREA_QUEUED_STATUSES,
218+
)
219+
if not job.result or not job.result.urls:
220+
raise RuntimeError(f"Krea 2 job {job.job_id} completed without any image URLs.")
221+
image = await download_url_to_image_tensor(job.result.urls[0])
222+
return IO.NodeOutput(image)
223+
224+
225+
class Krea2StyleReferenceNode(IO.ComfyNode):
226+
227+
@classmethod
228+
def define_schema(cls) -> IO.Schema:
229+
return IO.Schema(
230+
node_id="Krea2StyleReferenceNode",
231+
display_name="Krea 2 Style Reference",
232+
category="api node/image/Krea",
233+
description=(
234+
"Add an image style reference to a Krea 2 generation. Chain multiple Krea 2 "
235+
"Style Reference nodes (max 10) and feed the final `style_reference` output "
236+
"into Krea 2 Image. Each image is uploaded to ComfyAPI storage and passed as URL."
237+
),
238+
inputs=[
239+
IO.Image.Input(
240+
"image",
241+
tooltip="Reference image whose style influences the generation.",
242+
),
243+
IO.Float.Input(
244+
"strength",
245+
default=1.0,
246+
min=-2.0,
247+
max=2.0,
248+
step=0.05,
249+
tooltip="Reference strength; negative values invert the style influence.",
250+
),
251+
IO.Custom(KreaIO.STYLE_REF).Input(
252+
"style_reference",
253+
optional=True,
254+
tooltip="Optional incoming chain of style references; this node appends one more.",
255+
),
256+
],
257+
outputs=[IO.Custom(KreaIO.STYLE_REF).Output(display_name="style_reference")],
258+
hidden=[
259+
IO.Hidden.auth_token_comfy_org,
260+
IO.Hidden.api_key_comfy_org,
261+
IO.Hidden.unique_id,
262+
],
263+
)
264+
265+
@classmethod
266+
async def execute(
267+
cls,
268+
image: Input.Image,
269+
strength: float,
270+
style_reference: list[dict] | None = None,
271+
) -> IO.NodeOutput:
272+
chain: list[dict] = list(style_reference) if style_reference else []
273+
if len(chain) >= 10:
274+
raise ValueError("Krea 2 accepts at most 10 image_style_references in one generation.")
275+
url = await _upload_image_to_krea_assets(cls, image)
276+
chain.append({"url": url, "strength": float(strength)})
277+
return IO.NodeOutput(chain)
278+
279+
280+
class KreaExtension(ComfyExtension):
281+
@override
282+
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
283+
return [
284+
Krea2ImageNode,
285+
Krea2StyleReferenceNode,
286+
]
287+
288+
289+
async def comfy_entrypoint() -> KreaExtension:
290+
return KreaExtension()

0 commit comments

Comments
 (0)