[Partner Nodes] feat(Recraft): add V4 Styles (#15903)

Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
This commit is contained in:
Alexander Piskun
2026-08-26 22:22:22 +04:00
committed by GitHub
parent b133e48368
commit 30d03fea0a
2 changed files with 296 additions and 16 deletions
+25 -1
View File
@@ -250,6 +250,26 @@ RECRAFT_V4_PRO_SIZES = [
"1536x2688",
]
RECRAFT_V4_STYLES_MODELS = frozenset(
{
"recraftv4_styles",
"recraftv4_styles_vector",
"recraftv4_styles_pro",
"recraftv4_styles_pro_vector",
}
)
RECRAFT_V4_VECTOR_MODEL_FOR_STYLE = {
"recraftv4": "recraftv4_vector",
"recraftv4_pro": "recraftv4_pro_vector",
}
RECRAFT_STYLE_MATCH_OPTIONS = ["precise", "flexible"]
RECRAFT_STYLE_REFERENCES_MAX = 10
RECRAFT_STYLE_REFERENCES_MAX_BYTES = 10 * 1024 * 1024
class RecraftColorObject(BaseModel):
rgb: list[int] = Field(..., description='An array of 3 integer values in range of 0...255 defining RGB Color Model')
@@ -272,6 +292,8 @@ class RecraftImageGenerationRequest(BaseModel):
substyle: str | None = Field(None, description='The substyle to apply to the generated image, depending on the style input')
controls: RecraftControlsObject | None = Field(None, description='A set of custom parameters to tweak generation process')
style_id: str | None = Field(None, description='Use a previously uploaded style as a reference; UUID')
style_match: str | None = Field(None, description='How closely to follow the referenced style: "precise" or "flexible" for V4 models')
style_reference_urls: list[str] | None = Field(None, description='URLs or data URLs of style reference images; a private style is created from them and returned as style_id')
strength: float | None = Field(None, description='Defines the difference with the original image, should lie in [0, 1], where 0 means almost identical, and 1 means miserable similarity')
random_seed: int | None = Field(None, description="Seed for video generation")
@@ -286,10 +308,12 @@ class RecraftImageGenerationResponse(BaseModel):
credits: int = Field(..., description='Number of credits used for the generation')
data: list[RecraftReturnedObject] | None = Field(None, description='Array of generated image information')
image: RecraftReturnedObject | None = Field(None, description='Single generated image')
style_id: str | None = Field(None, description='The style applied to the generation, including one auto-created from style references')
class RecraftCreateStyleRequest(BaseModel):
style: str = Field(..., description="realistic_image, digital_illustration, vector_illustration, or icon")
style: str = Field(..., description="any, realistic_image, digital_illustration, vector_illustration, or icon")
model: str | None = Field(None, description="The model family the style is created for, e.g. recraftv4_styles")
class RecraftCreateStyleResponse(BaseModel):
+271 -15
View File
@@ -1,3 +1,5 @@
import base64
import uuid
from io import BytesIO
import aiohttp
@@ -8,8 +10,13 @@ from typing_extensions import override
from comfy.utils import ProgressBar
from comfy_api.latest import IO, ComfyExtension
from comfy_api_nodes.apis.recraft import (
RECRAFT_STYLE_MATCH_OPTIONS,
RECRAFT_STYLE_REFERENCES_MAX,
RECRAFT_STYLE_REFERENCES_MAX_BYTES,
RECRAFT_V4_PRO_SIZES,
RECRAFT_V4_SIZES,
RECRAFT_V4_STYLES_MODELS,
RECRAFT_V4_VECTOR_MODEL_FOR_STYLE,
RecraftColor,
RecraftColorChain,
RecraftControls,
@@ -173,6 +180,60 @@ class handle_recraft_image_output:
)
def style_reference_images(images: IO.Autogrow.Type | None) -> list[torch.Tensor]:
references = []
for batch in (images or {}).values():
if batch is None:
continue
if batch.ndim == 3:
batch = batch.unsqueeze(0)
references.extend(batch[i] for i in range(batch.shape[0]))
return references
def encode_style_references(references: list[torch.Tensor]) -> list[bytes]:
if not references:
raise ValueError("At least one style reference image is required.")
if len(references) > RECRAFT_STYLE_REFERENCES_MAX:
raise ValueError(
f"At most {RECRAFT_STYLE_REFERENCES_MAX} style reference images are allowed; got {len(references)}."
)
encoded = []
total_size = 0
for reference in references:
data = tensor_to_bytesio(reference, total_pixels=2048 * 2048, mime_type="image/webp").read()
total_size += len(data)
if total_size > RECRAFT_STYLE_REFERENCES_MAX_BYTES:
raise ValueError("Total size of style reference images exceeds the 10 MB limit.")
encoded.append(data)
return encoded
def resolve_v4_style(
model: str, style_id: str, style_references: IO.Autogrow.Type | None
) -> tuple[str | None, list[str] | None]:
style_id = style_id.strip()
references = style_reference_images(style_references)
if style_id and references:
raise ValueError("Provide either a style_id or style reference images, not both.")
if style_id:
try:
uuid.UUID(style_id)
except ValueError:
raise ValueError(f"style_id must be a UUID; got '{style_id}'.") from None
return style_id, None
if references:
return None, [
f"data:image/webp;base64,{base64.b64encode(data).decode()}"
for data in encode_style_references(references)
]
if model in RECRAFT_V4_STYLES_MODELS:
raise ValueError(
f"Model '{model}' always requires a style: connect style reference images or provide a style_id."
)
return None, None
class RecraftColorRGBNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
@@ -272,9 +333,9 @@ class RecraftStyleV3VectorIllustrationNode(RecraftStyleV3RealisticImageNode):
def define_schema(cls):
return IO.Schema(
node_id="RecraftStyleV3VectorIllustrationNode",
display_name="Recraft Style - Realistic Image",
display_name="Recraft Style - Vector Illustration",
category="partner/image/Recraft",
description="Select realistic_image style and optional substyle.",
description="Select vector_illustration style and optional substyle.",
inputs=[
IO.Combo.Input("substyle", options=get_v3_substyles(cls.RECRAFT_STYLE)),
],
@@ -293,7 +354,7 @@ class RecraftStyleV3LogoRasterNode(RecraftStyleV3RealisticImageNode):
node_id="RecraftStyleV3LogoRaster",
display_name="Recraft Style - Logo Raster",
category="partner/image/Recraft",
description="Select realistic_image style and optional substyle.",
description="Select logo_raster style and substyle.",
inputs=[
IO.Combo.Input("substyle", options=get_v3_substyles(cls.RECRAFT_STYLE, include_none=False)),
],
@@ -395,6 +456,74 @@ class RecraftCreateStyleNode(IO.ComfyNode):
return IO.NodeOutput(response.id)
class RecraftV4CreateStyleNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="RecraftV4CreateStyleNode",
display_name="Recraft V4 Create Style",
category="partner/image/Recraft",
description="Create a reusable Recraft V4 style from 1-10 reference images. "
"The returned style_id works with every Recraft V4 and V4.1 model of the same output type. "
"Total size of all images is limited to 10 MB.",
inputs=[
IO.Combo.Input(
"model",
options=["recraftv4_styles", "recraftv4_styles_vector"],
tooltip="Output type the style is created for: recraftv4_styles for raster images, "
"recraftv4_styles_vector for SVG.",
),
IO.Autogrow.Input(
"images",
template=IO.Autogrow.TemplatePrefix(
IO.Image.Input("image"),
prefix="image",
min=1,
max=RECRAFT_STYLE_REFERENCES_MAX,
),
tooltip="Reference images defining the style. Similar references sharpen the match, "
"varied references widen it.",
),
],
outputs=[
IO.String.Output(display_name="style_id"),
],
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(
expr="""{"type":"usd","usd": 0.005}""",
),
)
@classmethod
async def execute(
cls,
model: str,
images: IO.Autogrow.Type,
) -> IO.NodeOutput:
files = [
(f"file{i + 1}", data)
for i, data in enumerate(encode_style_references(style_reference_images(images)))
]
response = await sync_op(
cls,
endpoint=ApiEndpoint(path="/proxy/recraft/styles", method="POST"),
response_model=RecraftCreateStyleResponse,
files=files,
data=RecraftCreateStyleRequest(
style="vector_illustration" if model.endswith("_vector") else "any",
model=model,
),
content_type="multipart/form-data",
max_retries=1,
)
return IO.NodeOutput(response.id)
class RecraftTextToImageNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
@@ -1170,8 +1299,31 @@ class RecraftV4TextToImageNode(IO.ComfyNode):
),
],
),
IO.DynamicCombo.Option(
"recraftv4_styles",
[
IO.Combo.Input(
"size",
options=RECRAFT_V4_SIZES,
default="1024x1024",
tooltip="The size of the generated image.",
),
],
),
IO.DynamicCombo.Option(
"recraftv4_styles_pro",
[
IO.Combo.Input(
"size",
options=RECRAFT_V4_PRO_SIZES,
default="2048x2048",
tooltip="The size of the generated image.",
),
],
),
],
tooltip="The model to use for generation.",
tooltip="The model to use for generation. The recraftv4_styles models are built for "
"style-consistent generation and always require a style_id or style_references.",
),
IO.Int.Input(
"n",
@@ -1194,9 +1346,36 @@ class RecraftV4TextToImageNode(IO.ComfyNode):
tooltip="Optional additional controls over the generation via the Recraft Controls node.",
optional=True,
),
IO.String.Input(
"style_id",
default="",
optional=True,
tooltip="UUID of a Recraft V4 style to apply, e.g. from the Recraft V4 Create Style node "
"or the style_id output of a previous run. Cannot be combined with style_references.",
),
IO.Combo.Input(
"style_match",
options=RECRAFT_STYLE_MATCH_OPTIONS,
optional=True,
tooltip="How closely to follow the style: precise reproduces it in detail, "
"flexible matches the general look. Only used when a style is provided.",
),
IO.Autogrow.Input(
"style_references",
template=IO.Autogrow.TemplatePrefix(
IO.Image.Input("style_reference"),
prefix="style_reference",
min=0,
max=RECRAFT_STYLE_REFERENCES_MAX,
),
optional=True,
tooltip="Reference images to create a style from on the fly, billed on top of the generation. "
"The created style is returned as style_id for reuse. Cannot be combined with style_id.",
),
],
outputs=[
IO.Image.Output(),
IO.String.Output(id="style_id", display_name="style_id"),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
@@ -1205,7 +1384,7 @@ class RecraftV4TextToImageNode(IO.ComfyNode):
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model", "n"]),
depends_on=IO.PriceBadgeDepends(widgets=["model", "n"], input_groups=["style_references"]),
expr="""
(
$prices := {
@@ -1214,9 +1393,13 @@ class RecraftV4TextToImageNode(IO.ComfyNode):
"recraftv4_1_pro": 0.21,
"recraftv4_1_utility_pro": 0.21,
"recraftv4": 0.04,
"recraftv4_pro": 0.25
"recraftv4_pro": 0.25,
"recraftv4_styles": 0.035,
"recraftv4_styles_pro": 0.10
};
{"type":"usd","usd": $lookup($prices, widgets.model) * widgets.n}
$references := $lookup(inputGroups, "style_references");
$style := ($references ? $references : 0) > 0 ? 0.005 : 0;
{"type":"usd","usd": $lookup($prices, widgets.model) * widgets.n + $style}
)
""",
),
@@ -1231,8 +1414,12 @@ class RecraftV4TextToImageNode(IO.ComfyNode):
n: int,
seed: int,
recraft_controls: RecraftControls | None = None,
style_id: str = "",
style_match: str = "precise",
style_references: IO.Autogrow.Type | None = None,
) -> IO.NodeOutput:
validate_string(prompt, strip_whitespace=True, min_length=1, max_length=10000)
style_id, style_reference_urls = resolve_v4_style(model["model"], style_id, style_references)
response = await sync_op(
cls,
ApiEndpoint(path="/proxy/recraft/image_generation", method="POST"),
@@ -1242,6 +1429,9 @@ class RecraftV4TextToImageNode(IO.ComfyNode):
model=model["model"],
size=model["size"],
n=n,
style_id=style_id,
style_match=style_match if style_id or style_reference_urls else None,
style_reference_urls=style_reference_urls,
controls=recraft_controls.create_api_model() if recraft_controls else None,
),
max_retries=1,
@@ -1253,7 +1443,7 @@ class RecraftV4TextToImageNode(IO.ComfyNode):
if len(image.shape) < 4:
image = image.unsqueeze(0)
images.append(image)
return IO.NodeOutput(torch.cat(images, dim=0))
return IO.NodeOutput(torch.cat(images, dim=0), response.style_id or "")
class RecraftV4TextToVectorNode(IO.ComfyNode):
@@ -1345,8 +1535,31 @@ class RecraftV4TextToVectorNode(IO.ComfyNode):
),
],
),
IO.DynamicCombo.Option(
"recraftv4_styles_vector",
[
IO.Combo.Input(
"size",
options=RECRAFT_V4_SIZES,
default="1024x1024",
tooltip="The size of the generated image.",
),
],
),
IO.DynamicCombo.Option(
"recraftv4_styles_pro_vector",
[
IO.Combo.Input(
"size",
options=RECRAFT_V4_PRO_SIZES,
default="2048x2048",
tooltip="The size of the generated image.",
),
],
),
],
tooltip="The model to use for generation.",
tooltip="The model to use for generation. The recraftv4_styles models are built for "
"style-consistent generation and always require a style_id or style_references.",
),
IO.Int.Input(
"n",
@@ -1369,9 +1582,36 @@ class RecraftV4TextToVectorNode(IO.ComfyNode):
tooltip="Optional additional controls over the generation via the Recraft Controls node.",
optional=True,
),
IO.String.Input(
"style_id",
default="",
optional=True,
tooltip="UUID of a Recraft V4 vector style to apply, e.g. from the Recraft V4 Create Style node "
"or the style_id output of a previous run. Cannot be combined with style_references.",
),
IO.Combo.Input(
"style_match",
options=RECRAFT_STYLE_MATCH_OPTIONS,
optional=True,
tooltip="How closely to follow the style: precise reproduces it in detail, "
"flexible matches the general look. Only used when a style is provided.",
),
IO.Autogrow.Input(
"style_references",
template=IO.Autogrow.TemplatePrefix(
IO.Image.Input("style_reference"),
prefix="style_reference",
min=0,
max=RECRAFT_STYLE_REFERENCES_MAX,
),
optional=True,
tooltip="Reference images to create a vector style from on the fly, billed on top of the generation. "
"The created style is returned as style_id for reuse. Cannot be combined with style_id.",
),
],
outputs=[
IO.SVG.Output(),
IO.String.Output(id="style_id", display_name="style_id"),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
@@ -1380,7 +1620,7 @@ class RecraftV4TextToVectorNode(IO.ComfyNode):
],
is_api_node=True,
price_badge=IO.PriceBadge(
depends_on=IO.PriceBadgeDepends(widgets=["model", "n"]),
depends_on=IO.PriceBadgeDepends(widgets=["model", "n"], input_groups=["style_references"]),
expr="""
(
$prices := {
@@ -1389,9 +1629,13 @@ class RecraftV4TextToVectorNode(IO.ComfyNode):
"recraftv4_1_pro_vector": 0.30,
"recraftv4_1_utility_pro_vector": 0.30,
"recraftv4": 0.08,
"recraftv4_pro": 0.30
"recraftv4_pro": 0.30,
"recraftv4_styles_vector": 0.05,
"recraftv4_styles_pro_vector": 0.12
};
{"type":"usd","usd": $lookup($prices, widgets.model) * widgets.n}
$references := $lookup(inputGroups, "style_references");
$style := ($references ? $references : 0) > 0 ? 0.005 : 0;
{"type":"usd","usd": $lookup($prices, widgets.model) * widgets.n + $style}
)
""",
),
@@ -1406,19 +1650,30 @@ class RecraftV4TextToVectorNode(IO.ComfyNode):
n: int,
seed: int,
recraft_controls: RecraftControls | None = None,
style_id: str = "",
style_match: str = "precise",
style_references: IO.Autogrow.Type | None = None,
) -> IO.NodeOutput:
validate_string(prompt, strip_whitespace=True, min_length=1, max_length=10000)
model_id = model["model"]
style_id, style_reference_urls = resolve_v4_style(model_id, style_id, style_references)
has_style = bool(style_id or style_reference_urls)
if has_style:
model_id = RECRAFT_V4_VECTOR_MODEL_FOR_STYLE.get(model_id, model_id)
response = await sync_op(
cls,
ApiEndpoint(path="/proxy/recraft/image_generation", method="POST"),
response_model=RecraftImageGenerationResponse,
data=RecraftImageGenerationRequest(
prompt=prompt,
model=model["model"],
model=model_id,
size=model["size"],
n=n,
style=None if model["model"].endswith("_vector") else "vector_illustration",
style=None if has_style or model_id.endswith("_vector") else "vector_illustration",
substyle=None,
style_id=style_id,
style_match=style_match if has_style else None,
style_reference_urls=style_reference_urls,
controls=recraft_controls.create_api_model() if recraft_controls else None,
),
max_retries=1,
@@ -1426,7 +1681,7 @@ class RecraftV4TextToVectorNode(IO.ComfyNode):
svg_data = []
for data in response.data:
svg_data.append(await download_url_as_bytesio(data.url, timeout=1024))
return IO.NodeOutput(SVG(svg_data))
return IO.NodeOutput(SVG(svg_data), response.style_id or "")
class RecraftExtension(ComfyExtension):
@@ -1451,6 +1706,7 @@ class RecraftExtension(ComfyExtension):
RecraftControlsNode,
RecraftV4TextToImageNode,
RecraftV4TextToVectorNode,
RecraftV4CreateStyleNode,
]