Compare commits

..

3 Commits

Author SHA1 Message Date
69586f6a62 Merge branch 'master' into feat/api-nodes/Krea2-Image 2026-05-26 23:44:39 -07:00
0cce76d402 [Partner Nodes] feat: improve video references uploading for SeeDance 2 (#14098)
* [Partner Nodes] feat: improve video references uploading for SeeDance 2

Signed-off-by: bigcat88 <bigcat88@icloud.com>

* [Partner Nodes] hash video via memoryview to avoid memory copy

Signed-off-by: bigcat88 <bigcat88@icloud.com>

---------

Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-05-26 23:44:27 -07:00
da18688198 [Partner Nodes] feat: add Krea2 nodes
Signed-off-by: bigcat88 <bigcat88@icloud.com>
2026-05-27 08:16:51 +03:00
5 changed files with 363 additions and 184 deletions

View File

@ -158,8 +158,9 @@ class SeedanceCreateAssetResponse(BaseModel):
class SeedanceVirtualLibraryCreateAssetRequest(BaseModel):
url: str = Field(..., description="Publicly accessible URL of the image asset to upload.")
url: str = Field(..., description="Publicly accessible URL of the asset to upload.")
hash: str = Field(..., description="Dedup key. Re-submitting the same hash returns the existing asset id.")
asset_type: str | None = Field(None, description="BytePlus asset type. Defaults to Image server-side when omitted.")
# Dollars per 1K tokens, keyed by (model_id, has_video_input).

View File

@ -0,0 +1,46 @@
"""Pydantic models for the Krea image-generation API."""
from pydantic import BaseModel, Field
class KreaMoodboard(BaseModel):
id: str = Field(...)
strength: float = Field(default=0.35, ge=-0.5, le=1.5)
class KreaImageStyleReference(BaseModel):
strength: float = Field(..., ge=-2.0, le=2.0)
url: str | None = Field(default=None)
class KreaGenerateImageRequest(BaseModel):
prompt: str = Field(...)
aspect_ratio: str = Field(...)
resolution: str = Field(...)
seed: int | None = Field(default=None)
creativity: str = Field(default="medium")
moodboards: list[KreaMoodboard] | None = Field(default=None)
image_style_references: list[KreaImageStyleReference] | None = Field(default=None)
class KreaJobResult(BaseModel):
urls: list[str] | None = Field(default=None)
style_id: str | None = Field(default=None)
class KreaJob(BaseModel):
job_id: str = Field(...)
status: str = Field(...)
created_at: str = Field(...)
completed_at: str | None = Field(default=None)
result: KreaJobResult | None = Field(default=None)
class KreaAssetResponse(BaseModel):
id: str = Field(...)
image_url: str = Field(...)
uploaded_at: str = Field(...)
width: float | None = Field(default=None)
height: float | None = Field(default=None)
size_bytes: float | None = Field(default=None)
mime_type: str | None = Field(default=None)

View File

@ -2,11 +2,12 @@ import hashlib
import logging
import math
import re
from io import BytesIO
import torch
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input
from comfy_api.latest import IO, ComfyExtension, Input, Types
from comfy_api_nodes.apis.bytedance import (
RECOMMENDED_PRESETS,
RECOMMENDED_PRESETS_SEEDREAM_4,
@ -308,6 +309,26 @@ async def _seedance_virtual_library_upload_image_asset(
return f"asset://{create_resp.asset_id}"
async def _seedance_virtual_library_upload_video_asset(
cls: type[IO.ComfyNode],
video: Input.Video,
*,
wait_label: str = "Uploading video",
) -> str:
buf = BytesIO()
video.save_to(buf, format=Types.VideoContainer.MP4, codec=Types.VideoCodec.H264)
video_hash = hashlib.sha256(buf.getbuffer()).hexdigest()
public_url = await upload_video_to_comfyapi(cls, video, wait_label=wait_label)
create_resp = await sync_op(
cls,
ApiEndpoint(path="/proxy/seedance/virtual-library/assets", method="POST"),
response_model=SeedanceCreateAssetResponse,
data=SeedanceVirtualLibraryCreateAssetRequest(url=public_url, hash=video_hash, asset_type="Video"),
)
await _wait_for_asset_active(cls, create_resp.asset_id, group_id="virtual-library")
return f"asset://{create_resp.asset_id}"
def _seedance2_price_extractor(model_id: str, has_video_input: bool):
"""Returns a price_extractor closure for Seedance 2.0 poll_op."""
rate = SEEDANCE2_PRICE_PER_1K_TOKENS.get((model_id, has_video_input))
@ -2106,7 +2127,7 @@ class ByteDance2ReferenceNode(IO.ComfyNode):
content.append(
TaskVideoContent(
video_url=TaskVideoContentUrl(
url=await upload_video_to_comfyapi(
url=await _seedance_virtual_library_upload_video_asset(
cls,
reference_videos[key],
wait_label=f"Uploading video {i}",

View File

@ -0,0 +1,290 @@
"""Krea image-generation nodes."""
import re
from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input
from comfy_api_nodes.apis.krea import (
KreaAssetResponse,
KreaGenerateImageRequest,
KreaImageStyleReference,
KreaJob,
KreaMoodboard,
)
from comfy_api_nodes.util import (
ApiEndpoint,
download_url_to_image_tensor,
poll_op,
sync_op,
tensor_to_bytesio,
validate_string,
)
class KreaIO:
STYLE_REF = "KREA_STYLE_REF"
async def _upload_image_to_krea_assets(cls: type[IO.ComfyNode], image: Input.Image) -> str:
"""Upload an image to Krea's /assets endpoint and return the Krea-hosted image URL."""
img_io = tensor_to_bytesio(image, total_pixels=2048 * 2048, mime_type="image/png")
response = await sync_op(
cls,
endpoint=ApiEndpoint(path="/proxy/krea/assets", method="POST"),
response_model=KreaAssetResponse,
files=[("file", (img_io.name, img_io, "image/png"))],
content_type="multipart/form-data",
max_retries=1,
wait_label="Uploading reference",
)
return response.image_url
_MODEL_MEDIUM = "Krea 2 Medium"
_MODEL_LARGE = "Krea 2 Large"
_MODEL_ENDPOINTS: dict[str, str] = {
_MODEL_MEDIUM: "/proxy/krea/generate/image/krea/krea-2/medium",
_MODEL_LARGE: "/proxy/krea/generate/image/krea/krea-2/large",
}
_ASPECT_RATIOS = ["1:1", "4:3", "3:2", "16:9", "2.35:1", "4:5", "2:3", "9:16"]
_RESOLUTIONS = ["1K"]
_CREATIVITY_LEVELS = ["raw", "low", "medium", "high"]
_KREA_QUEUED_STATUSES = ["backlogged", "queued", "scheduled"]
_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}$")
def _krea_model_inputs() -> list:
"""Nested inputs shared by both Krea 2 Medium and Large under the DynamicCombo."""
return [
IO.Combo.Input(
"aspect_ratio",
options=_ASPECT_RATIOS,
tooltip="Output aspect ratio.",
),
IO.Combo.Input(
"resolution",
options=_RESOLUTIONS,
tooltip="Resolution scale.",
),
IO.Combo.Input(
"creativity",
options=_CREATIVITY_LEVELS,
default="medium",
tooltip="Prompt interpretation strength: raw stays closest to the prompt; high is most creative.",
),
IO.String.Input(
"moodboard_id",
default="",
tooltip="Optional Krea moodboard UUID (e.g. from the Krea website). "
"Leave empty to disable. Only one moodboard is supported per request.",
optional=True,
),
IO.Float.Input(
"moodboard_strength",
default=0.35,
min=-0.5,
max=1.5,
step=0.05,
tooltip="Moodboard influence; ignored when moodboard_id is empty.",
optional=True,
),
IO.Custom(KreaIO.STYLE_REF).Input(
"style_reference",
optional=True,
tooltip="Optional chain of style references (max 10) from Krea 2 Style Reference nodes.",
),
]
class Krea2ImageNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="Krea2ImageNode",
display_name="Krea 2 Image",
category="api node/image/Krea",
description=(
"Generate images via Krea 2 — pick Medium (expressive illustrations) or "
"Large (expressive photorealism). Supports an optional moodboard and up "
"to 10 chained image style references."
),
inputs=[
IO.String.Input(
"prompt",
multiline=True,
default="",
tooltip="Text prompt for the image.",
),
IO.DynamicCombo.Input(
"model",
options=[
IO.DynamicCombo.Option(_MODEL_MEDIUM, _krea_model_inputs()),
IO.DynamicCombo.Option(_MODEL_LARGE, _krea_model_inputs()),
],
tooltip="Krea 2 Medium is best for expressive illustrations; "
"Krea 2 Large is best for expressive photorealism.",
),
IO.Int.Input(
"seed",
default=0,
min=0,
max=2147483647,
control_after_generate=True,
tooltip="Random seed for reproducibility.",
),
],
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", "model.moodboard_id"],
inputs=["model.style_reference"],
),
expr="""
(
$isLarge := widgets.model = "krea 2 large";
$hasMoodboard := $length($lookup(widgets, "model.moodboard_id")) > 0;
$hasStyle := $lookup(inputs, "model.style_reference").connected;
$usd := $hasMoodboard
? ($isLarge ? 0.07 : 0.04)
: ($hasStyle
? ($isLarge ? 0.065 : 0.035)
: ($isLarge ? 0.06 : 0.03));
{"type":"usd","usd": $usd}
)
""",
),
)
@classmethod
async def execute(
cls,
prompt: str,
model: dict,
seed: int,
) -> IO.NodeOutput:
validate_string(prompt, strip_whitespace=False, min_length=1)
model_choice = model["model"]
endpoint_path = _MODEL_ENDPOINTS.get(model_choice)
if endpoint_path is None:
raise ValueError(f"Unknown Krea 2 model: {model_choice!r}")
moodboards: list[KreaMoodboard] | None = None
mb_id = (model.get("moodboard_id") or "").strip()
if mb_id:
if not _UUID_RE.match(mb_id):
raise ValueError(f"moodboard_id must be a UUID (received {mb_id!r}); copy it from the Krea website.")
mb_strength = model.get("moodboard_strength")
moodboards = [KreaMoodboard(id=mb_id, strength=0.35 if mb_strength is None else float(mb_strength))]
style_reference = model.get("style_reference")
image_style_references: list[KreaImageStyleReference] | None = None
if style_reference:
if len(style_reference) > 10:
raise ValueError(f"Krea 2 accepts at most 10 image_style_references; received {len(style_reference)}.")
image_style_references = [
KreaImageStyleReference(url=ref["url"], strength=float(ref["strength"])) for ref in style_reference
]
initial = await sync_op(
cls,
ApiEndpoint(path=endpoint_path, method="POST"),
response_model=KreaJob,
data=KreaGenerateImageRequest(
prompt=prompt,
aspect_ratio=model["aspect_ratio"],
resolution=model["resolution"],
seed=seed,
creativity=model["creativity"],
moodboards=moodboards,
image_style_references=image_style_references,
),
)
job = await poll_op(
cls,
ApiEndpoint(path=f"/proxy/krea/jobs/{initial.job_id}", method="GET"),
response_model=KreaJob,
status_extractor=lambda r: r.status,
queued_statuses=_KREA_QUEUED_STATUSES,
)
if not job.result or not job.result.urls:
raise RuntimeError(f"Krea 2 job {job.job_id} completed without any image URLs.")
image = await download_url_to_image_tensor(job.result.urls[0])
return IO.NodeOutput(image)
class Krea2StyleReferenceNode(IO.ComfyNode):
@classmethod
def define_schema(cls) -> IO.Schema:
return IO.Schema(
node_id="Krea2StyleReferenceNode",
display_name="Krea 2 Style Reference",
category="api node/image/Krea",
description=(
"Add an image style reference to a Krea 2 generation. Chain multiple Krea 2 "
"Style Reference nodes (max 10) and feed the final `style_reference` output "
"into Krea 2 Image. Each image is uploaded to ComfyAPI storage and passed as URL."
),
inputs=[
IO.Image.Input(
"image",
tooltip="Reference image whose style influences the generation.",
),
IO.Float.Input(
"strength",
default=1.0,
min=-2.0,
max=2.0,
step=0.05,
tooltip="Reference strength; negative values invert the style influence.",
),
IO.Custom(KreaIO.STYLE_REF).Input(
"style_reference",
optional=True,
tooltip="Optional incoming chain of style references; this node appends one more.",
),
],
outputs=[IO.Custom(KreaIO.STYLE_REF).Output(display_name="style_reference")],
hidden=[
IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org,
IO.Hidden.unique_id,
],
)
@classmethod
async def execute(
cls,
image: Input.Image,
strength: float,
style_reference: list[dict] | None = None,
) -> IO.NodeOutput:
chain: list[dict] = list(style_reference) if style_reference else []
if len(chain) >= 10:
raise ValueError("Krea 2 accepts at most 10 image_style_references in one generation.")
url = await _upload_image_to_krea_assets(cls, image)
chain.append({"url": url, "strength": float(strength)})
return IO.NodeOutput(chain)
class KreaExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
Krea2ImageNode,
Krea2StyleReferenceNode,
]
async def comfy_entrypoint() -> KreaExtension:
return KreaExtension()

View File

@ -887,9 +887,9 @@ paths:
x-runtime: [cloud]
description: "[cloud-only] Version identifier for the workflow templates bundle. Local ComfyUI returns null."
workflow_templates_source:
type: string
nullable: true
allOf:
- $ref: "#/components/schemas/WorkflowTemplatesSource"
enum: [dynamic_config_override, workflow_templates_version_json]
x-runtime: [cloud]
description: "[cloud-only] How the templates version was resolved. Local ComfyUI returns null."
@ -5628,7 +5628,6 @@ paths:
required: true
schema:
type: string
format: uuid
description: The API key ID.
responses:
"204":
@ -6759,7 +6758,6 @@ paths:
required: true
schema:
type: string
format: uuid
description: The secret ID.
responses:
"204":
@ -7527,39 +7525,6 @@ components:
type: object
description: Metadata about the execution and nodes
additionalProperties: true
create_time:
type: integer
format: int64
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Unix-ms timestamp when the history entry was created."
extra_data:
type: object
additionalProperties: true
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Workflow-attached extra_data passed through with the prompt."
priority:
type: number
format: double
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Scheduling priority assigned to the history entry."
prompt_id:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] ID of the prompt this history entry belongs to."
workflow_id:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] ID of the workflow associated with the history entry."
HistoryManageRequest:
type: object
@ -7682,12 +7647,6 @@ components:
execution_meta:
type: object
additionalProperties: true
workflow_id:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] ID of the workflow associated with this job."
ExecutionError:
type: object
@ -7867,24 +7826,6 @@ components:
# -------------------------------------------------------------------
# Node / Object Info
# -------------------------------------------------------------------
cloud_version:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Version identifier of the cloud control plane."
comfyui_frontend_version:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Pinned ComfyUI frontend version served by the cloud."
workflow_templates_version:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Pinned workflow-templates version served by the cloud."
NodeInfo:
type: object
description: 'Definition of a registered node class: its inputs, outputs, category, and display metadata.'
@ -8158,12 +8099,6 @@ components:
name:
type: string
description: Name of the asset file
display_name:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Human-friendly display name for the asset. Cloud-populated; null in local ComfyUI."
hash:
type: string
nullable: true
@ -8286,12 +8221,6 @@ components:
updated_at:
type: string
format: date-time
display_name:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Human-friendly display name for the updated asset."
ListAssetsResponse:
type: object
@ -8309,12 +8238,6 @@ components:
type: integer
has_more:
type: boolean
next_cursor:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Opaque cursor for fetching the next page of assets; absent when no more pages."
TagInfo:
type: object
@ -9043,20 +8966,6 @@ components:
created_at:
type: string
format: date-time
description:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Profile description / bio."
website_urls:
type: array
items:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] List of websites/social links associated with the profile."
HubWorkflow:
type: object
@ -9648,25 +9557,6 @@ components:
created_at:
type: string
format: date-time
event_id:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Unique identifier of the billing event."
event_type:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Type/category of the billing event."
params:
type: object
additionalProperties: true
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Event-specific parameters (free-form map)."
BillingEventList:
type: object
@ -10166,42 +10056,6 @@ components:
size_bytes:
type: integer
format: int64
in_library:
type: boolean
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Whether the asset is present in the user's library (vs. a discovery/search result)."
name:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Display name of the asset."
model:
type: boolean
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Whether the asset is a model file (vs. a generic input/output)."
public:
type: boolean
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Whether the asset is publicly visible (vs. private to the user)."
preview_url:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] URL of a preview/thumbnail rendition for the asset."
storage_url:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] URL to fetch the asset's stored content."
BulkRevokeAPIKeysResponse:
type: object
@ -10348,12 +10202,6 @@ components:
format: date-time
error:
type: string
error_message:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Human-readable error message when the task fails."
TasksListResponse:
type: object
@ -11899,30 +11747,3 @@ components:
message:
type: string
description: User-provided feedback message
content:
type: string
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Free-form feedback text body."
metadata:
type: object
additionalProperties: true
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Submitter-supplied metadata attached to the feedback."
rating:
type: integer
nullable: true
x-runtime:
- cloud
description: "[cloud-only] Numeric rating (e.g. 1-5 stars) associated with the feedback."
WorkflowTemplatesSource:
type: string
x-runtime: [cloud]
enum:
- dynamic_config_override
- workflow_templates_version_json
description: "[cloud-only] How the workflow templates version was resolved (config override vs. bundled JSON)."