mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-04-24 09:22:32 +08:00
2005 lines
75 KiB
Python
2005 lines
75 KiB
Python
import logging
|
||
import math
|
||
import re
|
||
|
||
import torch
|
||
from typing_extensions import override
|
||
|
||
from comfy_api.latest import IO, ComfyExtension, Input
|
||
from comfy_api_nodes.apis.bytedance import (
|
||
RECOMMENDED_PRESETS,
|
||
RECOMMENDED_PRESETS_SEEDREAM_4,
|
||
SEEDANCE2_PRICE_PER_1K_TOKENS,
|
||
SEEDANCE2_REF_VIDEO_PIXEL_LIMITS,
|
||
VIDEO_TASKS_EXECUTION_TIME,
|
||
GetAssetResponse,
|
||
Image2VideoTaskCreationRequest,
|
||
ImageTaskCreationResponse,
|
||
Seedance2TaskCreationRequest,
|
||
SeedanceCreateAssetRequest,
|
||
SeedanceCreateAssetResponse,
|
||
SeedanceCreateVisualValidateSessionResponse,
|
||
SeedanceGetVisualValidateSessionResponse,
|
||
Seedream4Options,
|
||
Seedream4TaskCreationRequest,
|
||
TaskAudioContent,
|
||
TaskAudioContentUrl,
|
||
TaskCreationResponse,
|
||
TaskImageContent,
|
||
TaskImageContentUrl,
|
||
TaskStatusResponse,
|
||
TaskTextContent,
|
||
TaskVideoContent,
|
||
TaskVideoContentUrl,
|
||
Text2ImageTaskCreationRequest,
|
||
Text2VideoTaskCreationRequest,
|
||
)
|
||
from comfy_api_nodes.util import (
|
||
ApiEndpoint,
|
||
download_url_to_image_tensor,
|
||
download_url_to_video_output,
|
||
get_number_of_images,
|
||
image_tensor_pair_to_batch,
|
||
poll_op,
|
||
resize_video_to_pixel_budget,
|
||
sync_op,
|
||
upload_audio_to_comfyapi,
|
||
upload_image_to_comfyapi,
|
||
upload_images_to_comfyapi,
|
||
upload_video_to_comfyapi,
|
||
validate_image_aspect_ratio,
|
||
validate_image_dimensions,
|
||
validate_string,
|
||
validate_video_dimensions,
|
||
validate_video_duration,
|
||
)
|
||
from server import PromptServer
|
||
|
||
BYTEPLUS_IMAGE_ENDPOINT = "/proxy/byteplus/api/v3/images/generations"
|
||
|
||
_VERIFICATION_POLL_TIMEOUT_SEC = 120
|
||
_VERIFICATION_POLL_INTERVAL_SEC = 3
|
||
|
||
SEEDREAM_MODELS = {
|
||
"seedream 5.0 lite": "seedream-5-0-260128",
|
||
"seedream-4-5-251128": "seedream-4-5-251128",
|
||
"seedream-4-0-250828": "seedream-4-0-250828",
|
||
}
|
||
|
||
# Long-running tasks endpoints(e.g., video)
|
||
BYTEPLUS_TASK_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks"
|
||
BYTEPLUS_TASK_STATUS_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks" # + /{task_id}
|
||
BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT = "/proxy/byteplus-seedance2/api/v3/contents/generations/tasks" # + /{task_id}
|
||
|
||
SEEDANCE_MODELS = {
|
||
"Seedance 2.0": "dreamina-seedance-2-0-260128",
|
||
"Seedance 2.0 Fast": "dreamina-seedance-2-0-fast-260128",
|
||
}
|
||
|
||
DEPRECATED_MODELS = {"seedance-1-0-lite-t2v-250428", "seedance-1-0-lite-i2v-250428"}
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _validate_ref_video_pixels(video: Input.Video, model_id: str, resolution: str, index: int) -> None:
|
||
"""Validate reference video pixel count against Seedance 2.0 model limits for the selected resolution."""
|
||
model_limits = SEEDANCE2_REF_VIDEO_PIXEL_LIMITS.get(model_id)
|
||
if not model_limits:
|
||
return
|
||
limits = model_limits.get(resolution)
|
||
if not limits:
|
||
return
|
||
try:
|
||
w, h = video.get_dimensions()
|
||
except Exception:
|
||
return
|
||
pixels = w * h
|
||
min_px = limits.get("min")
|
||
max_px = limits.get("max")
|
||
if min_px and pixels < min_px:
|
||
raise ValueError(
|
||
f"Reference video {index} is too small: {w}x{h} = {pixels:,}px. " f"Minimum is {min_px:,}px for this model."
|
||
)
|
||
if max_px and pixels > max_px:
|
||
raise ValueError(
|
||
f"Reference video {index} is too large: {w}x{h} = {pixels:,}px. "
|
||
f"Maximum is {max_px:,}px for this model. Try downscaling the video."
|
||
)
|
||
|
||
|
||
async def _resolve_reference_assets(
|
||
cls: type[IO.ComfyNode],
|
||
asset_ids: list[str],
|
||
) -> tuple[dict[str, str], dict[str, str], dict[str, str]]:
|
||
"""Look up each asset, validate Active status, group by asset_type.
|
||
|
||
Returns (image_assets, video_assets, audio_assets), each mapping asset_id -> "asset://<asset_id>".
|
||
"""
|
||
image_assets: dict[str, str] = {}
|
||
video_assets: dict[str, str] = {}
|
||
audio_assets: dict[str, str] = {}
|
||
for i, raw_id in enumerate(asset_ids, 1):
|
||
asset_id = (raw_id or "").strip()
|
||
if not asset_id:
|
||
continue
|
||
result = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/seedance/assets/{asset_id}"),
|
||
response_model=GetAssetResponse,
|
||
)
|
||
if result.status != "Active":
|
||
extra = f" {result.error.code}: {result.error.message}" if result.error else ""
|
||
raise ValueError(f"Reference asset {i} (Id={asset_id}) is not Active (Status={result.status}).{extra}")
|
||
asset_uri = f"asset://{asset_id}"
|
||
if result.asset_type == "Image":
|
||
image_assets[asset_id] = asset_uri
|
||
elif result.asset_type == "Video":
|
||
video_assets[asset_id] = asset_uri
|
||
elif result.asset_type == "Audio":
|
||
audio_assets[asset_id] = asset_uri
|
||
return image_assets, video_assets, audio_assets
|
||
|
||
|
||
_ASSET_REF_RE = re.compile(r"\basset ?(\d{1,2})\b", re.IGNORECASE)
|
||
|
||
|
||
def _build_asset_labels(
|
||
reference_assets: dict[str, str],
|
||
image_asset_uris: dict[str, str],
|
||
video_asset_uris: dict[str, str],
|
||
audio_asset_uris: dict[str, str],
|
||
n_reference_images: int,
|
||
n_reference_videos: int,
|
||
n_reference_audios: int,
|
||
) -> dict[int, str]:
|
||
"""Map asset slot number (from 'asset_N' keys) to its positional label.
|
||
|
||
Asset entries are appended to `content` after the reference_images/videos/audios,
|
||
so their 1-indexed labels continue from the count of existing same-type refs:
|
||
one reference_images entry + one Image-type asset -> asset labelled "Image 2".
|
||
"""
|
||
image_n = n_reference_images
|
||
video_n = n_reference_videos
|
||
audio_n = n_reference_audios
|
||
labels: dict[int, str] = {}
|
||
for slot_key, raw_id in reference_assets.items():
|
||
asset_id = (raw_id or "").strip()
|
||
if not asset_id:
|
||
continue
|
||
try:
|
||
slot_num = int(slot_key.rsplit("_", 1)[-1])
|
||
except ValueError:
|
||
continue
|
||
if asset_id in image_asset_uris:
|
||
image_n += 1
|
||
labels[slot_num] = f"Image {image_n}"
|
||
elif asset_id in video_asset_uris:
|
||
video_n += 1
|
||
labels[slot_num] = f"Video {video_n}"
|
||
elif asset_id in audio_asset_uris:
|
||
audio_n += 1
|
||
labels[slot_num] = f"Audio {audio_n}"
|
||
return labels
|
||
|
||
|
||
def _rewrite_asset_refs(prompt: str, labels: dict[int, str]) -> str:
|
||
"""Case-insensitively replace 'assetNN' (1-2 digit) tokens with their labels."""
|
||
if not labels:
|
||
return prompt
|
||
|
||
def _sub(m: "re.Match[str]") -> str:
|
||
return labels.get(int(m.group(1)), m.group(0))
|
||
|
||
return _ASSET_REF_RE.sub(_sub, prompt)
|
||
|
||
|
||
async def _obtain_group_id_via_h5_auth(cls: type[IO.ComfyNode]) -> str:
|
||
session = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path="/proxy/seedance/visual-validate/sessions", method="POST"),
|
||
response_model=SeedanceCreateVisualValidateSessionResponse,
|
||
)
|
||
logger.warning("Seedance authentication required. Open link: %s", session.h5_link)
|
||
|
||
h5_text = f"Open this link in your browser and complete face verification:\n\n{session.h5_link}"
|
||
|
||
result = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/seedance/visual-validate/sessions/{session.session_id}"),
|
||
response_model=SeedanceGetVisualValidateSessionResponse,
|
||
status_extractor=lambda r: r.status,
|
||
completed_statuses=["completed"],
|
||
failed_statuses=["failed"],
|
||
poll_interval=_VERIFICATION_POLL_INTERVAL_SEC,
|
||
max_poll_attempts=(_VERIFICATION_POLL_TIMEOUT_SEC // _VERIFICATION_POLL_INTERVAL_SEC) - 1,
|
||
estimated_duration=_VERIFICATION_POLL_TIMEOUT_SEC - 1,
|
||
extra_text=h5_text,
|
||
)
|
||
|
||
if not result.group_id:
|
||
raise RuntimeError(f"Seedance session {session.session_id} completed without a group_id")
|
||
|
||
logger.warning("Seedance authentication complete. New GroupId: %s", result.group_id)
|
||
PromptServer.instance.send_progress_text(
|
||
f"Authentication complete. New GroupId: {result.group_id}", cls.hidden.unique_id
|
||
)
|
||
return result.group_id
|
||
|
||
|
||
async def _resolve_group_id(cls: type[IO.ComfyNode], group_id: str) -> str:
|
||
if group_id and group_id.strip():
|
||
return group_id.strip()
|
||
return await _obtain_group_id_via_h5_auth(cls)
|
||
|
||
|
||
async def _create_seedance_asset(
|
||
cls: type[IO.ComfyNode],
|
||
*,
|
||
group_id: str,
|
||
url: str,
|
||
name: str,
|
||
asset_type: str,
|
||
) -> str:
|
||
req = SeedanceCreateAssetRequest(
|
||
group_id=group_id,
|
||
url=url,
|
||
asset_type=asset_type,
|
||
name=name or None,
|
||
)
|
||
result = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path="/proxy/seedance/assets", method="POST"),
|
||
response_model=SeedanceCreateAssetResponse,
|
||
data=req,
|
||
)
|
||
return result.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))
|
||
if rate is None:
|
||
return None
|
||
|
||
def extractor(response: TaskStatusResponse) -> float | None:
|
||
if response.usage is None:
|
||
return None
|
||
return response.usage.total_tokens * 1.43 * rate / 1_000.0
|
||
|
||
return extractor
|
||
|
||
|
||
def get_image_url_from_response(response: ImageTaskCreationResponse) -> str:
|
||
if response.error:
|
||
error_msg = f"ByteDance request failed. Code: {response.error['code']}, message: {response.error['message']}"
|
||
logging.info(error_msg)
|
||
raise RuntimeError(error_msg)
|
||
logging.info("ByteDance task succeeded, image URL: %s", response.data[0]["url"])
|
||
return response.data[0]["url"]
|
||
|
||
|
||
class ByteDanceImageNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDanceImageNode",
|
||
display_name="ByteDance Image",
|
||
category="api node/image/ByteDance",
|
||
description="Generate images using ByteDance models via api based on prompt",
|
||
inputs=[
|
||
IO.Combo.Input("model", options=["seedream-3-0-t2i-250415"]),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
tooltip="The text prompt used to generate the image",
|
||
),
|
||
IO.Combo.Input(
|
||
"size_preset",
|
||
options=[label for label, _, _ in RECOMMENDED_PRESETS],
|
||
tooltip="Pick a recommended size. Select Custom to use the width and height below",
|
||
),
|
||
IO.Int.Input(
|
||
"width",
|
||
default=1024,
|
||
min=512,
|
||
max=2048,
|
||
step=64,
|
||
tooltip="Custom width for image. Value is working only if `size_preset` is set to `Custom`",
|
||
),
|
||
IO.Int.Input(
|
||
"height",
|
||
default=1024,
|
||
min=512,
|
||
max=2048,
|
||
step=64,
|
||
tooltip="Custom height for image. Value is working only if `size_preset` is set to `Custom`",
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed to use for generation",
|
||
optional=True,
|
||
),
|
||
IO.Float.Input(
|
||
"guidance_scale",
|
||
default=2.5,
|
||
min=1.0,
|
||
max=10.0,
|
||
step=0.01,
|
||
display_mode=IO.NumberDisplay.number,
|
||
tooltip="Higher value makes the image follow the prompt more closely",
|
||
optional=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip='Whether to add an "AI generated" watermark to the image',
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
],
|
||
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(
|
||
expr="""{"type":"usd","usd":0.03}""",
|
||
),
|
||
is_deprecated=True,
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
size_preset: str,
|
||
width: int,
|
||
height: int,
|
||
seed: int,
|
||
guidance_scale: float,
|
||
watermark: bool,
|
||
) -> IO.NodeOutput:
|
||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||
w = h = None
|
||
for label, tw, th in RECOMMENDED_PRESETS:
|
||
if label == size_preset:
|
||
w, h = tw, th
|
||
break
|
||
|
||
if w is None or h is None:
|
||
w, h = width, height
|
||
if not (512 <= w <= 2048) or not (512 <= h <= 2048):
|
||
raise ValueError(
|
||
f"Custom size out of range: {w}x{h}. " "Both width and height must be between 512 and 2048 pixels."
|
||
)
|
||
|
||
payload = Text2ImageTaskCreationRequest(
|
||
model=model,
|
||
prompt=prompt,
|
||
size=f"{w}x{h}",
|
||
seed=seed,
|
||
guidance_scale=guidance_scale,
|
||
watermark=watermark,
|
||
)
|
||
response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path=BYTEPLUS_IMAGE_ENDPOINT, method="POST"),
|
||
data=payload,
|
||
response_model=ImageTaskCreationResponse,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_image_tensor(get_image_url_from_response(response)))
|
||
|
||
|
||
class ByteDanceSeedreamNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDanceSeedreamNode",
|
||
display_name="ByteDance Seedream 4.5 & 5.0",
|
||
category="api node/image/ByteDance",
|
||
description="Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=list(SEEDREAM_MODELS.keys()),
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Text prompt for creating or editing an image.",
|
||
),
|
||
IO.Image.Input(
|
||
"image",
|
||
tooltip="Input image(s) for image-to-image generation. "
|
||
"Reference image(s) for single or multi-reference generation.",
|
||
optional=True,
|
||
),
|
||
IO.Combo.Input(
|
||
"size_preset",
|
||
options=[label for label, _, _ in RECOMMENDED_PRESETS_SEEDREAM_4],
|
||
tooltip="Pick a recommended size. Select Custom to use the width and height below.",
|
||
),
|
||
IO.Int.Input(
|
||
"width",
|
||
default=2048,
|
||
min=1024,
|
||
max=6240,
|
||
step=2,
|
||
tooltip="Custom width for image. Value is working only if `size_preset` is set to `Custom`",
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"height",
|
||
default=2048,
|
||
min=1024,
|
||
max=4992,
|
||
step=2,
|
||
tooltip="Custom height for image. Value is working only if `size_preset` is set to `Custom`",
|
||
optional=True,
|
||
),
|
||
IO.Combo.Input(
|
||
"sequential_image_generation",
|
||
options=["disabled", "auto"],
|
||
tooltip="Group image generation mode. "
|
||
"'disabled' generates a single image. "
|
||
"'auto' lets the model decide whether to generate multiple related images "
|
||
"(e.g., story scenes, character variations).",
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"max_images",
|
||
default=1,
|
||
min=1,
|
||
max=15,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
tooltip="Maximum number of images to generate when sequential_image_generation='auto'. "
|
||
"Total images (input + generated) cannot exceed 15.",
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed to use for generation.",
|
||
optional=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip='Whether to add an "AI generated" watermark to the image.',
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"fail_on_partial",
|
||
default=True,
|
||
tooltip="If enabled, abort execution if any requested images are missing or return an error.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
],
|
||
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"]),
|
||
expr="""
|
||
(
|
||
$price := $contains(widgets.model, "5.0 lite") ? 0.035 :
|
||
$contains(widgets.model, "4-5") ? 0.04 : 0.03;
|
||
{
|
||
"type":"usd",
|
||
"usd": $price,
|
||
"format": { "suffix":" x images/Run", "approximate": true }
|
||
}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
image: Input.Image | None = None,
|
||
size_preset: str = RECOMMENDED_PRESETS_SEEDREAM_4[0][0],
|
||
width: int = 2048,
|
||
height: int = 2048,
|
||
sequential_image_generation: str = "disabled",
|
||
max_images: int = 1,
|
||
seed: int = 0,
|
||
watermark: bool = False,
|
||
fail_on_partial: bool = True,
|
||
) -> IO.NodeOutput:
|
||
model = SEEDREAM_MODELS[model]
|
||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||
w = h = None
|
||
for label, tw, th in RECOMMENDED_PRESETS_SEEDREAM_4:
|
||
if label == size_preset:
|
||
w, h = tw, th
|
||
break
|
||
|
||
if w is None or h is None:
|
||
w, h = width, height
|
||
|
||
out_num_pixels = w * h
|
||
mp_provided = out_num_pixels / 1_000_000.0
|
||
if ("seedream-4-5" in model or "seedream-5-0" in model) and out_num_pixels < 3686400:
|
||
raise ValueError(
|
||
f"Minimum image resolution for the selected model is 3.68MP, " f"but {mp_provided:.2f}MP provided."
|
||
)
|
||
if "seedream-4-0" in model and out_num_pixels < 921600:
|
||
raise ValueError(
|
||
f"Minimum image resolution that the selected model can generate is 0.92MP, "
|
||
f"but {mp_provided:.2f}MP provided."
|
||
)
|
||
max_pixels = 10_404_496 if "seedream-5-0" in model else 16_777_216
|
||
if out_num_pixels > max_pixels:
|
||
raise ValueError(
|
||
f"Maximum image resolution for the selected model is {max_pixels / 1_000_000:.2f}MP, "
|
||
f"but {mp_provided:.2f}MP provided."
|
||
)
|
||
n_input_images = get_number_of_images(image) if image is not None else 0
|
||
max_num_of_images = 14 if model == "seedream-5-0-260128" else 10
|
||
if n_input_images > max_num_of_images:
|
||
raise ValueError(
|
||
f"Maximum of {max_num_of_images} reference images are supported, but {n_input_images} received."
|
||
)
|
||
if sequential_image_generation == "auto" and n_input_images + max_images > 15:
|
||
raise ValueError(
|
||
"The maximum number of generated images plus the number of reference images cannot exceed 15."
|
||
)
|
||
reference_images_urls = []
|
||
if n_input_images:
|
||
for i in image:
|
||
validate_image_aspect_ratio(i, (1, 3), (3, 1))
|
||
reference_images_urls = await upload_images_to_comfyapi(
|
||
cls,
|
||
image,
|
||
max_images=n_input_images,
|
||
mime_type="image/png",
|
||
)
|
||
response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path=BYTEPLUS_IMAGE_ENDPOINT, method="POST"),
|
||
response_model=ImageTaskCreationResponse,
|
||
data=Seedream4TaskCreationRequest(
|
||
model=model,
|
||
prompt=prompt,
|
||
image=reference_images_urls,
|
||
size=f"{w}x{h}",
|
||
seed=seed,
|
||
sequential_image_generation=sequential_image_generation,
|
||
sequential_image_generation_options=Seedream4Options(max_images=max_images),
|
||
watermark=watermark,
|
||
output_format="png" if model == "seedream-5-0-260128" else None,
|
||
),
|
||
)
|
||
if len(response.data) == 1:
|
||
return IO.NodeOutput(await download_url_to_image_tensor(get_image_url_from_response(response)))
|
||
urls = [str(d["url"]) for d in response.data if isinstance(d, dict) and "url" in d]
|
||
if fail_on_partial and len(urls) < len(response.data):
|
||
raise RuntimeError(f"Only {len(urls)} of {len(response.data)} images were generated before error.")
|
||
return IO.NodeOutput(torch.cat([await download_url_to_image_tensor(i) for i in urls]))
|
||
|
||
|
||
class ByteDanceTextToVideoNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDanceTextToVideoNode",
|
||
display_name="ByteDance Text to Video",
|
||
category="api node/video/ByteDance",
|
||
description="Generate video using ByteDance models via api based on prompt",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=[
|
||
"seedance-1-5-pro-251215",
|
||
"seedance-1-0-pro-250528",
|
||
"seedance-1-0-lite-t2v-250428",
|
||
"seedance-1-0-pro-fast-251015",
|
||
],
|
||
default="seedance-1-0-pro-fast-251015",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
tooltip="The text prompt used to generate the video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["480p", "720p", "1080p"],
|
||
tooltip="The resolution of the output video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"aspect_ratio",
|
||
options=["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
|
||
tooltip="The aspect ratio of the output video.",
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=3,
|
||
max=12,
|
||
step=1,
|
||
tooltip="The duration of the output video in seconds.",
|
||
display_mode=IO.NumberDisplay.slider,
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed to use for generation.",
|
||
optional=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"camera_fixed",
|
||
default=False,
|
||
tooltip="Specifies whether to fix the camera. The platform appends an instruction "
|
||
"to fix the camera to your prompt, but does not guarantee the actual effect.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip='Whether to add an "AI generated" watermark to the video.',
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"generate_audio",
|
||
default=False,
|
||
tooltip="This parameter is ignored for any model except seedance-1-5-pro.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
],
|
||
outputs=[
|
||
IO.Video.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_VIDEO,
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
resolution: str,
|
||
aspect_ratio: str,
|
||
duration: int,
|
||
seed: int,
|
||
camera_fixed: bool,
|
||
watermark: bool,
|
||
generate_audio: bool = False,
|
||
) -> IO.NodeOutput:
|
||
if model == "seedance-1-5-pro-251215" and duration < 4:
|
||
raise ValueError("Minimum supported duration for Seedance 1.5 Pro is 4 seconds.")
|
||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||
raise_if_text_params(prompt, ["resolution", "ratio", "duration", "seed", "camerafixed", "watermark"])
|
||
|
||
prompt = (
|
||
f"{prompt} "
|
||
f"--resolution {resolution} "
|
||
f"--ratio {aspect_ratio} "
|
||
f"--duration {duration} "
|
||
f"--seed {seed} "
|
||
f"--camerafixed {str(camera_fixed).lower()} "
|
||
f"--watermark {str(watermark).lower()}"
|
||
)
|
||
return await process_video_task(
|
||
cls,
|
||
payload=Text2VideoTaskCreationRequest(
|
||
model=model,
|
||
content=[TaskTextContent(text=prompt)],
|
||
generate_audio=generate_audio if model == "seedance-1-5-pro-251215" else None,
|
||
),
|
||
estimated_duration=max(1, math.ceil(VIDEO_TASKS_EXECUTION_TIME[model][resolution] * (duration / 10.0))),
|
||
)
|
||
|
||
|
||
class ByteDanceImageToVideoNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDanceImageToVideoNode",
|
||
display_name="ByteDance Image to Video",
|
||
category="api node/video/ByteDance",
|
||
description="Generate video using ByteDance models via api based on image and prompt",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=[
|
||
"seedance-1-5-pro-251215",
|
||
"seedance-1-0-pro-250528",
|
||
"seedance-1-0-lite-i2v-250428",
|
||
"seedance-1-0-pro-fast-251015",
|
||
],
|
||
default="seedance-1-0-pro-fast-251015",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
tooltip="The text prompt used to generate the video.",
|
||
),
|
||
IO.Image.Input(
|
||
"image",
|
||
tooltip="First frame to be used for the video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["480p", "720p", "1080p"],
|
||
tooltip="The resolution of the output video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"aspect_ratio",
|
||
options=["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
|
||
tooltip="The aspect ratio of the output video.",
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=3,
|
||
max=12,
|
||
step=1,
|
||
tooltip="The duration of the output video in seconds.",
|
||
display_mode=IO.NumberDisplay.slider,
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed to use for generation.",
|
||
optional=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"camera_fixed",
|
||
default=False,
|
||
tooltip="Specifies whether to fix the camera. The platform appends an instruction "
|
||
"to fix the camera to your prompt, but does not guarantee the actual effect.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip='Whether to add an "AI generated" watermark to the video.',
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"generate_audio",
|
||
default=False,
|
||
tooltip="This parameter is ignored for any model except seedance-1-5-pro.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
],
|
||
outputs=[
|
||
IO.Video.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_VIDEO,
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
image: Input.Image,
|
||
resolution: str,
|
||
aspect_ratio: str,
|
||
duration: int,
|
||
seed: int,
|
||
camera_fixed: bool,
|
||
watermark: bool,
|
||
generate_audio: bool = False,
|
||
) -> IO.NodeOutput:
|
||
if model == "seedance-1-5-pro-251215" and duration < 4:
|
||
raise ValueError("Minimum supported duration for Seedance 1.5 Pro is 4 seconds.")
|
||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||
raise_if_text_params(prompt, ["resolution", "ratio", "duration", "seed", "camerafixed", "watermark"])
|
||
validate_image_dimensions(image, min_width=300, min_height=300, max_width=6000, max_height=6000)
|
||
validate_image_aspect_ratio(image, (2, 5), (5, 2), strict=False) # 0.4 to 2.5
|
||
|
||
image_url = (await upload_images_to_comfyapi(cls, image, max_images=1))[0]
|
||
prompt = (
|
||
f"{prompt} "
|
||
f"--resolution {resolution} "
|
||
f"--ratio {aspect_ratio} "
|
||
f"--duration {duration} "
|
||
f"--seed {seed} "
|
||
f"--camerafixed {str(camera_fixed).lower()} "
|
||
f"--watermark {str(watermark).lower()}"
|
||
)
|
||
|
||
return await process_video_task(
|
||
cls,
|
||
payload=Image2VideoTaskCreationRequest(
|
||
model=model,
|
||
content=[TaskTextContent(text=prompt), TaskImageContent(image_url=TaskImageContentUrl(url=image_url))],
|
||
generate_audio=generate_audio if model == "seedance-1-5-pro-251215" else None,
|
||
),
|
||
estimated_duration=max(1, math.ceil(VIDEO_TASKS_EXECUTION_TIME[model][resolution] * (duration / 10.0))),
|
||
)
|
||
|
||
|
||
class ByteDanceFirstLastFrameNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDanceFirstLastFrameNode",
|
||
display_name="ByteDance First-Last-Frame to Video",
|
||
category="api node/video/ByteDance",
|
||
description="Generate video using prompt and first and last frames.",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=["seedance-1-5-pro-251215", "seedance-1-0-pro-250528", "seedance-1-0-lite-i2v-250428"],
|
||
default="seedance-1-0-lite-i2v-250428",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
tooltip="The text prompt used to generate the video.",
|
||
),
|
||
IO.Image.Input(
|
||
"first_frame",
|
||
tooltip="First frame to be used for the video.",
|
||
),
|
||
IO.Image.Input(
|
||
"last_frame",
|
||
tooltip="Last frame to be used for the video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["480p", "720p", "1080p"],
|
||
tooltip="The resolution of the output video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"aspect_ratio",
|
||
options=["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
|
||
tooltip="The aspect ratio of the output video.",
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=3,
|
||
max=12,
|
||
step=1,
|
||
tooltip="The duration of the output video in seconds.",
|
||
display_mode=IO.NumberDisplay.slider,
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed to use for generation.",
|
||
optional=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"camera_fixed",
|
||
default=False,
|
||
tooltip="Specifies whether to fix the camera. The platform appends an instruction "
|
||
"to fix the camera to your prompt, but does not guarantee the actual effect.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip='Whether to add an "AI generated" watermark to the video.',
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"generate_audio",
|
||
default=False,
|
||
tooltip="This parameter is ignored for any model except seedance-1-5-pro.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
],
|
||
outputs=[
|
||
IO.Video.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_VIDEO,
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
first_frame: Input.Image,
|
||
last_frame: Input.Image,
|
||
resolution: str,
|
||
aspect_ratio: str,
|
||
duration: int,
|
||
seed: int,
|
||
camera_fixed: bool,
|
||
watermark: bool,
|
||
generate_audio: bool = False,
|
||
) -> IO.NodeOutput:
|
||
if model == "seedance-1-5-pro-251215" and duration < 4:
|
||
raise ValueError("Minimum supported duration for Seedance 1.5 Pro is 4 seconds.")
|
||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||
raise_if_text_params(prompt, ["resolution", "ratio", "duration", "seed", "camerafixed", "watermark"])
|
||
for i in (first_frame, last_frame):
|
||
validate_image_dimensions(i, min_width=300, min_height=300, max_width=6000, max_height=6000)
|
||
validate_image_aspect_ratio(i, (2, 5), (5, 2), strict=False) # 0.4 to 2.5
|
||
|
||
download_urls = await upload_images_to_comfyapi(
|
||
cls,
|
||
image_tensor_pair_to_batch(first_frame, last_frame),
|
||
max_images=2,
|
||
mime_type="image/png",
|
||
)
|
||
|
||
prompt = (
|
||
f"{prompt} "
|
||
f"--resolution {resolution} "
|
||
f"--ratio {aspect_ratio} "
|
||
f"--duration {duration} "
|
||
f"--seed {seed} "
|
||
f"--camerafixed {str(camera_fixed).lower()} "
|
||
f"--watermark {str(watermark).lower()}"
|
||
)
|
||
|
||
return await process_video_task(
|
||
cls,
|
||
payload=Image2VideoTaskCreationRequest(
|
||
model=model,
|
||
content=[
|
||
TaskTextContent(text=prompt),
|
||
TaskImageContent(image_url=TaskImageContentUrl(url=str(download_urls[0])), role="first_frame"),
|
||
TaskImageContent(image_url=TaskImageContentUrl(url=str(download_urls[1])), role="last_frame"),
|
||
],
|
||
generate_audio=generate_audio if model == "seedance-1-5-pro-251215" else None,
|
||
),
|
||
estimated_duration=max(1, math.ceil(VIDEO_TASKS_EXECUTION_TIME[model][resolution] * (duration / 10.0))),
|
||
)
|
||
|
||
|
||
class ByteDanceImageReferenceNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDanceImageReferenceNode",
|
||
display_name="ByteDance Reference Images to Video",
|
||
category="api node/video/ByteDance",
|
||
description="Generate video using prompt and reference images.",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=["seedance-1-0-pro-250528", "seedance-1-0-lite-i2v-250428"],
|
||
default="seedance-1-0-lite-i2v-250428",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
tooltip="The text prompt used to generate the video.",
|
||
),
|
||
IO.Image.Input(
|
||
"images",
|
||
tooltip="One to four images.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["480p", "720p"],
|
||
tooltip="The resolution of the output video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"aspect_ratio",
|
||
options=["adaptive", "16:9", "4:3", "1:1", "3:4", "9:16", "21:9"],
|
||
tooltip="The aspect ratio of the output video.",
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=3,
|
||
max=12,
|
||
step=1,
|
||
tooltip="The duration of the output video in seconds.",
|
||
display_mode=IO.NumberDisplay.slider,
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed to use for generation.",
|
||
optional=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip='Whether to add an "AI generated" watermark to the video.',
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
],
|
||
outputs=[
|
||
IO.Video.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", "duration", "resolution"]),
|
||
expr="""
|
||
(
|
||
$priceByModel := {
|
||
"seedance-1-0-pro": {
|
||
"480p":[0.23,0.24],
|
||
"720p":[0.51,0.56]
|
||
},
|
||
"seedance-1-0-lite": {
|
||
"480p":[0.17,0.18],
|
||
"720p":[0.37,0.41]
|
||
}
|
||
};
|
||
$model := widgets.model;
|
||
$modelKey :=
|
||
$contains($model, "seedance-1-0-pro") ? "seedance-1-0-pro" :
|
||
"seedance-1-0-lite";
|
||
$resolution := widgets.resolution;
|
||
$resKey :=
|
||
$contains($resolution, "720") ? "720p" :
|
||
"480p";
|
||
$modelPrices := $lookup($priceByModel, $modelKey);
|
||
$baseRange := $lookup($modelPrices, $resKey);
|
||
$min10s := $baseRange[0];
|
||
$max10s := $baseRange[1];
|
||
$scale := widgets.duration / 10;
|
||
$minCost := $min10s * $scale;
|
||
$maxCost := $max10s * $scale;
|
||
($minCost = $maxCost)
|
||
? {"type":"usd","usd": $minCost}
|
||
: {"type":"range_usd","min_usd": $minCost, "max_usd": $maxCost}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
images: Input.Image,
|
||
resolution: str,
|
||
aspect_ratio: str,
|
||
duration: int,
|
||
seed: int,
|
||
watermark: bool,
|
||
) -> IO.NodeOutput:
|
||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||
raise_if_text_params(prompt, ["resolution", "ratio", "duration", "seed", "watermark"])
|
||
for image in images:
|
||
validate_image_dimensions(image, min_width=300, min_height=300, max_width=6000, max_height=6000)
|
||
validate_image_aspect_ratio(image, (2, 5), (5, 2), strict=False) # 0.4 to 2.5
|
||
|
||
image_urls = await upload_images_to_comfyapi(cls, images, max_images=4, mime_type="image/png")
|
||
prompt = (
|
||
f"{prompt} "
|
||
f"--resolution {resolution} "
|
||
f"--ratio {aspect_ratio} "
|
||
f"--duration {duration} "
|
||
f"--seed {seed} "
|
||
f"--watermark {str(watermark).lower()}"
|
||
)
|
||
x = [
|
||
TaskTextContent(text=prompt),
|
||
*[TaskImageContent(image_url=TaskImageContentUrl(url=str(i)), role="reference_image") for i in image_urls],
|
||
]
|
||
return await process_video_task(
|
||
cls,
|
||
payload=Image2VideoTaskCreationRequest(model=model, content=x, generate_audio=None),
|
||
estimated_duration=max(1, math.ceil(VIDEO_TASKS_EXECUTION_TIME[model][resolution] * (duration / 10.0))),
|
||
)
|
||
|
||
|
||
def raise_if_text_params(prompt: str, text_params: list[str]) -> None:
|
||
for i in text_params:
|
||
if f"--{i} " in prompt:
|
||
raise ValueError(
|
||
f"--{i} is not allowed in the prompt, use the appropriated widget input to change this value."
|
||
)
|
||
|
||
|
||
PRICE_BADGE_VIDEO = IO.PriceBadge(
|
||
depends_on=IO.PriceBadgeDepends(widgets=["model", "duration", "resolution", "generate_audio"]),
|
||
expr="""
|
||
(
|
||
$priceByModel := {
|
||
"seedance-1-5-pro": {
|
||
"480p":[0.12,0.12],
|
||
"720p":[0.26,0.26],
|
||
"1080p":[0.58,0.59]
|
||
},
|
||
"seedance-1-0-pro": {
|
||
"480p":[0.23,0.24],
|
||
"720p":[0.51,0.56],
|
||
"1080p":[1.18,1.22]
|
||
},
|
||
"seedance-1-0-pro-fast": {
|
||
"480p":[0.09,0.1],
|
||
"720p":[0.21,0.23],
|
||
"1080p":[0.47,0.49]
|
||
},
|
||
"seedance-1-0-lite": {
|
||
"480p":[0.17,0.18],
|
||
"720p":[0.37,0.41],
|
||
"1080p":[0.85,0.88]
|
||
}
|
||
};
|
||
$model := widgets.model;
|
||
$modelKey :=
|
||
$contains($model, "seedance-1-5-pro") ? "seedance-1-5-pro" :
|
||
$contains($model, "seedance-1-0-pro-fast") ? "seedance-1-0-pro-fast" :
|
||
$contains($model, "seedance-1-0-pro") ? "seedance-1-0-pro" :
|
||
"seedance-1-0-lite";
|
||
$resolution := widgets.resolution;
|
||
$resKey :=
|
||
$contains($resolution, "1080") ? "1080p" :
|
||
$contains($resolution, "720") ? "720p" :
|
||
"480p";
|
||
$modelPrices := $lookup($priceByModel, $modelKey);
|
||
$baseRange := $lookup($modelPrices, $resKey);
|
||
$min10s := $baseRange[0];
|
||
$max10s := $baseRange[1];
|
||
$scale := widgets.duration / 10;
|
||
$audioMultiplier := ($modelKey = "seedance-1-5-pro" and widgets.generate_audio) ? 2 : 1;
|
||
$minCost := $min10s * $scale * $audioMultiplier;
|
||
$maxCost := $max10s * $scale * $audioMultiplier;
|
||
($minCost = $maxCost)
|
||
? {"type":"usd","usd": $minCost, "format": { "approximate": true }}
|
||
: {"type":"range_usd","min_usd": $minCost, "max_usd": $maxCost, "format": { "approximate": true }}
|
||
)
|
||
""",
|
||
)
|
||
|
||
|
||
def _seedance2_text_inputs(resolutions: list[str]):
|
||
return [
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Text prompt for video generation.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=resolutions,
|
||
tooltip="Resolution of the output video.",
|
||
),
|
||
IO.Combo.Input(
|
||
"ratio",
|
||
options=["16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"],
|
||
tooltip="Aspect ratio of the output video.",
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=7,
|
||
min=4,
|
||
max=15,
|
||
step=1,
|
||
tooltip="Duration of the output video in seconds (4-15).",
|
||
display_mode=IO.NumberDisplay.slider,
|
||
),
|
||
IO.Boolean.Input(
|
||
"generate_audio",
|
||
default=True,
|
||
tooltip="Enable audio generation for the output video.",
|
||
),
|
||
]
|
||
|
||
|
||
class ByteDance2TextToVideoNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDance2TextToVideoNode",
|
||
display_name="ByteDance Seedance 2.0 Text to Video",
|
||
category="api node/video/ByteDance",
|
||
description="Generate video using Seedance 2.0 models based on a text prompt.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option("Seedance 2.0", _seedance2_text_inputs(["480p", "720p", "1080p"])),
|
||
IO.DynamicCombo.Option("Seedance 2.0 Fast", _seedance2_text_inputs(["480p", "720p"])),
|
||
],
|
||
tooltip="Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization.",
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed controls whether the node should re-run; "
|
||
"results are non-deterministic regardless of seed.",
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add a watermark to the video.",
|
||
advanced=True,
|
||
),
|
||
],
|
||
outputs=[
|
||
IO.Video.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.resolution", "model.duration"]),
|
||
expr="""
|
||
(
|
||
$rate480 := 10044;
|
||
$rate720 := 21600;
|
||
$rate1080 := 48800;
|
||
$m := widgets.model;
|
||
$pricePer1K := $contains($m, "fast") ? 0.008008 : 0.01001;
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$rate := $res = "1080p" ? $rate1080 :
|
||
$res = "720p" ? $rate720 :
|
||
$rate480;
|
||
$cost := $dur * $rate * $pricePer1K / 1000;
|
||
{"type": "usd", "usd": $cost, "format": {"approximate": true}}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
seed: int,
|
||
watermark: bool,
|
||
) -> IO.NodeOutput:
|
||
validate_string(model["prompt"], strip_whitespace=True, min_length=1)
|
||
model_id = SEEDANCE_MODELS[model["model"]]
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path=BYTEPLUS_TASK_ENDPOINT, method="POST"),
|
||
data=Seedance2TaskCreationRequest(
|
||
model=model_id,
|
||
content=[TaskTextContent(text=model["prompt"])],
|
||
generate_audio=model["generate_audio"],
|
||
resolution=model["resolution"],
|
||
ratio=model["ratio"],
|
||
duration=model["duration"],
|
||
seed=seed,
|
||
watermark=watermark,
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
)
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"{BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT}/{initial_response.id}"),
|
||
response_model=TaskStatusResponse,
|
||
status_extractor=lambda r: r.status,
|
||
price_extractor=_seedance2_price_extractor(model_id, has_video_input=False),
|
||
poll_interval=9,
|
||
max_poll_attempts=180,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.content.video_url))
|
||
|
||
|
||
class ByteDance2FirstLastFrameNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDance2FirstLastFrameNode",
|
||
display_name="ByteDance Seedance 2.0 First-Last-Frame to Video",
|
||
category="api node/video/ByteDance",
|
||
description="Generate video using Seedance 2.0 from a first frame image and optional last frame image.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option("Seedance 2.0", _seedance2_text_inputs(["480p", "720p", "1080p"])),
|
||
IO.DynamicCombo.Option("Seedance 2.0 Fast", _seedance2_text_inputs(["480p", "720p"])),
|
||
],
|
||
tooltip="Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization.",
|
||
),
|
||
IO.Image.Input(
|
||
"first_frame",
|
||
tooltip="First frame image for the video.",
|
||
),
|
||
IO.Image.Input(
|
||
"last_frame",
|
||
tooltip="Last frame image for the video.",
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed controls whether the node should re-run; "
|
||
"results are non-deterministic regardless of seed.",
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add a watermark to the video.",
|
||
advanced=True,
|
||
),
|
||
],
|
||
outputs=[
|
||
IO.Video.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.resolution", "model.duration"]),
|
||
expr="""
|
||
(
|
||
$rate480 := 10044;
|
||
$rate720 := 21600;
|
||
$rate1080 := 48800;
|
||
$m := widgets.model;
|
||
$pricePer1K := $contains($m, "fast") ? 0.008008 : 0.01001;
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$rate := $res = "1080p" ? $rate1080 :
|
||
$res = "720p" ? $rate720 :
|
||
$rate480;
|
||
$cost := $dur * $rate * $pricePer1K / 1000;
|
||
{"type": "usd", "usd": $cost, "format": {"approximate": true}}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
first_frame: Input.Image,
|
||
seed: int,
|
||
watermark: bool,
|
||
last_frame: Input.Image | None = None,
|
||
) -> IO.NodeOutput:
|
||
validate_string(model["prompt"], strip_whitespace=True, min_length=1)
|
||
model_id = SEEDANCE_MODELS[model["model"]]
|
||
|
||
content: list[TaskTextContent | TaskImageContent] = [
|
||
TaskTextContent(text=model["prompt"]),
|
||
TaskImageContent(
|
||
image_url=TaskImageContentUrl(
|
||
url=await upload_image_to_comfyapi(cls, first_frame, wait_label="Uploading first frame.")
|
||
),
|
||
role="first_frame",
|
||
),
|
||
]
|
||
if last_frame is not None:
|
||
content.append(
|
||
TaskImageContent(
|
||
image_url=TaskImageContentUrl(
|
||
url=await upload_image_to_comfyapi(cls, last_frame, wait_label="Uploading last frame.")
|
||
),
|
||
role="last_frame",
|
||
),
|
||
)
|
||
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path=BYTEPLUS_TASK_ENDPOINT, method="POST"),
|
||
data=Seedance2TaskCreationRequest(
|
||
model=model_id,
|
||
content=content,
|
||
generate_audio=model["generate_audio"],
|
||
resolution=model["resolution"],
|
||
ratio=model["ratio"],
|
||
duration=model["duration"],
|
||
seed=seed,
|
||
watermark=watermark,
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
)
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"{BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT}/{initial_response.id}"),
|
||
response_model=TaskStatusResponse,
|
||
status_extractor=lambda r: r.status,
|
||
price_extractor=_seedance2_price_extractor(model_id, has_video_input=False),
|
||
poll_interval=9,
|
||
max_poll_attempts=180,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.content.video_url))
|
||
|
||
|
||
def _seedance2_reference_inputs(resolutions: list[str]):
|
||
return [
|
||
*_seedance2_text_inputs(resolutions),
|
||
IO.Autogrow.Input(
|
||
"reference_images",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.Image.Input("reference_image"),
|
||
names=[
|
||
"image_1",
|
||
"image_2",
|
||
"image_3",
|
||
"image_4",
|
||
"image_5",
|
||
"image_6",
|
||
"image_7",
|
||
"image_8",
|
||
"image_9",
|
||
],
|
||
min=0,
|
||
),
|
||
),
|
||
IO.Autogrow.Input(
|
||
"reference_videos",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.Video.Input("reference_video"),
|
||
names=["video_1", "video_2", "video_3"],
|
||
min=0,
|
||
),
|
||
),
|
||
IO.Autogrow.Input(
|
||
"reference_audios",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.Audio.Input("reference_audio"),
|
||
names=["audio_1", "audio_2", "audio_3"],
|
||
min=0,
|
||
),
|
||
),
|
||
IO.Boolean.Input(
|
||
"auto_downscale",
|
||
default=False,
|
||
advanced=True,
|
||
optional=True,
|
||
tooltip="Automatically downscale reference videos that exceed the model's pixel budget "
|
||
"for the selected resolution. Aspect ratio is preserved; videos already within limits are untouched.",
|
||
),
|
||
IO.Autogrow.Input(
|
||
"reference_assets",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.String.Input("reference_asset"),
|
||
names=[
|
||
"asset_1",
|
||
"asset_2",
|
||
"asset_3",
|
||
"asset_4",
|
||
"asset_5",
|
||
"asset_6",
|
||
"asset_7",
|
||
"asset_8",
|
||
"asset_9",
|
||
],
|
||
min=0,
|
||
),
|
||
),
|
||
]
|
||
|
||
|
||
class ByteDance2ReferenceNode(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="ByteDance2ReferenceNode",
|
||
display_name="ByteDance Seedance 2.0 Reference to Video",
|
||
category="api node/video/ByteDance",
|
||
description="Generate, edit, or extend video using Seedance 2.0 with reference images, "
|
||
"videos, and audio. Supports multimodal reference, video editing, and video extension.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option("Seedance 2.0", _seedance2_reference_inputs(["480p", "720p", "1080p"])),
|
||
IO.DynamicCombo.Option("Seedance 2.0 Fast", _seedance2_reference_inputs(["480p", "720p"])),
|
||
],
|
||
tooltip="Seedance 2.0 for maximum quality; Seedance 2.0 Fast for speed optimization.",
|
||
),
|
||
IO.Int.Input(
|
||
"seed",
|
||
default=0,
|
||
min=0,
|
||
max=2147483647,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
control_after_generate=True,
|
||
tooltip="Seed controls whether the node should re-run; "
|
||
"results are non-deterministic regardless of seed.",
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add a watermark to the video.",
|
||
advanced=True,
|
||
),
|
||
],
|
||
outputs=[
|
||
IO.Video.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.resolution", "model.duration"],
|
||
input_groups=["model.reference_videos"],
|
||
),
|
||
expr="""
|
||
(
|
||
$rate480 := 10044;
|
||
$rate720 := 21600;
|
||
$rate1080 := 48800;
|
||
$m := widgets.model;
|
||
$hasVideo := $lookup(inputGroups, "model.reference_videos") > 0;
|
||
$noVideoPricePer1K := $contains($m, "fast") ? 0.008008 : 0.01001;
|
||
$videoPricePer1K := $contains($m, "fast") ? 0.004719 : 0.006149;
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$rate := $res = "1080p" ? $rate1080 :
|
||
$res = "720p" ? $rate720 :
|
||
$rate480;
|
||
$noVideoCost := $dur * $rate * $noVideoPricePer1K / 1000;
|
||
$minVideoFactor := $ceil($dur * 5 / 3);
|
||
$minVideoCost := $minVideoFactor * $rate * $videoPricePer1K / 1000;
|
||
$maxVideoCost := (15 + $dur) * $rate * $videoPricePer1K / 1000;
|
||
$hasVideo
|
||
? {
|
||
"type": "range_usd",
|
||
"min_usd": $minVideoCost,
|
||
"max_usd": $maxVideoCost,
|
||
"format": {"approximate": true}
|
||
}
|
||
: {
|
||
"type": "usd",
|
||
"usd": $noVideoCost,
|
||
"format": {"approximate": true}
|
||
}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
seed: int,
|
||
watermark: bool,
|
||
) -> IO.NodeOutput:
|
||
validate_string(model["prompt"], strip_whitespace=True, min_length=1)
|
||
|
||
reference_images = model.get("reference_images", {})
|
||
reference_videos = model.get("reference_videos", {})
|
||
reference_audios = model.get("reference_audios", {})
|
||
reference_assets = model.get("reference_assets", {})
|
||
|
||
reference_image_assets, reference_video_assets, reference_audio_assets = await _resolve_reference_assets(
|
||
cls, list(reference_assets.values())
|
||
)
|
||
|
||
if not reference_images and not reference_videos and not reference_image_assets and not reference_video_assets:
|
||
raise ValueError("At least one reference image or video or asset is required.")
|
||
|
||
model_id = SEEDANCE_MODELS[model["model"]]
|
||
has_video_input = len(reference_videos) > 0
|
||
|
||
if model.get("auto_downscale") and reference_videos:
|
||
max_px = SEEDANCE2_REF_VIDEO_PIXEL_LIMITS.get(model_id, {}).get(model["resolution"], {}).get("max")
|
||
if max_px:
|
||
for key in reference_videos:
|
||
reference_videos[key] = resize_video_to_pixel_budget(reference_videos[key], max_px)
|
||
|
||
total_video_duration = 0.0
|
||
for i, key in enumerate(reference_videos, 1):
|
||
video = reference_videos[key]
|
||
_validate_ref_video_pixels(video, model_id, model["resolution"], i)
|
||
try:
|
||
dur = video.get_duration()
|
||
if dur < 1.8:
|
||
raise ValueError(f"Reference video {i} is too short: {dur:.1f}s. Minimum duration is 1.8 seconds.")
|
||
total_video_duration += dur
|
||
except ValueError:
|
||
raise
|
||
except Exception:
|
||
pass
|
||
if total_video_duration > 15.1:
|
||
raise ValueError(f"Total reference video duration is {total_video_duration:.1f}s. Maximum is 15.1 seconds.")
|
||
|
||
total_audio_duration = 0.0
|
||
for i, key in enumerate(reference_audios, 1):
|
||
audio = reference_audios[key]
|
||
dur = int(audio["waveform"].shape[-1]) / int(audio["sample_rate"])
|
||
if dur < 1.8:
|
||
raise ValueError(f"Reference audio {i} is too short: {dur:.1f}s. Minimum duration is 1.8 seconds.")
|
||
total_audio_duration += dur
|
||
if total_audio_duration > 15.1:
|
||
raise ValueError(f"Total reference audio duration is {total_audio_duration:.1f}s. Maximum is 15.1 seconds.")
|
||
|
||
asset_labels = _build_asset_labels(
|
||
reference_assets,
|
||
reference_image_assets,
|
||
reference_video_assets,
|
||
reference_audio_assets,
|
||
len(reference_images),
|
||
len(reference_videos),
|
||
len(reference_audios),
|
||
)
|
||
prompt_text = _rewrite_asset_refs(model["prompt"], asset_labels)
|
||
|
||
content: list[TaskTextContent | TaskImageContent | TaskVideoContent | TaskAudioContent] = [
|
||
TaskTextContent(text=prompt_text),
|
||
]
|
||
for i, key in enumerate(reference_images, 1):
|
||
content.append(
|
||
TaskImageContent(
|
||
image_url=TaskImageContentUrl(
|
||
url=await upload_image_to_comfyapi(
|
||
cls,
|
||
image=reference_images[key],
|
||
wait_label=f"Uploading image {i}",
|
||
),
|
||
),
|
||
role="reference_image",
|
||
),
|
||
)
|
||
for i, key in enumerate(reference_videos, 1):
|
||
content.append(
|
||
TaskVideoContent(
|
||
video_url=TaskVideoContentUrl(
|
||
url=await upload_video_to_comfyapi(
|
||
cls,
|
||
reference_videos[key],
|
||
wait_label=f"Uploading video {i}",
|
||
),
|
||
),
|
||
),
|
||
)
|
||
for key in reference_audios:
|
||
content.append(
|
||
TaskAudioContent(
|
||
audio_url=TaskAudioContentUrl(
|
||
url=await upload_audio_to_comfyapi(
|
||
cls,
|
||
reference_audios[key],
|
||
container_format="mp3",
|
||
codec_name="libmp3lame",
|
||
mime_type="audio/mpeg",
|
||
),
|
||
),
|
||
),
|
||
)
|
||
for url in reference_image_assets.values():
|
||
content.append(
|
||
TaskImageContent(
|
||
image_url=TaskImageContentUrl(url=url),
|
||
role="reference_image",
|
||
),
|
||
)
|
||
for url in reference_video_assets.values():
|
||
content.append(
|
||
TaskVideoContent(video_url=TaskVideoContentUrl(url=url)),
|
||
)
|
||
for url in reference_audio_assets.values():
|
||
content.append(
|
||
TaskAudioContent(audio_url=TaskAudioContentUrl(url=url)),
|
||
)
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path=BYTEPLUS_TASK_ENDPOINT, method="POST"),
|
||
data=Seedance2TaskCreationRequest(
|
||
model=model_id,
|
||
content=content,
|
||
generate_audio=model["generate_audio"],
|
||
resolution=model["resolution"],
|
||
ratio=model["ratio"],
|
||
duration=model["duration"],
|
||
seed=seed,
|
||
watermark=watermark,
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
)
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"{BYTEPLUS_SEEDANCE2_TASK_STATUS_ENDPOINT}/{initial_response.id}"),
|
||
response_model=TaskStatusResponse,
|
||
status_extractor=lambda r: r.status,
|
||
price_extractor=_seedance2_price_extractor(model_id, has_video_input=has_video_input),
|
||
poll_interval=9,
|
||
max_poll_attempts=180,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.content.video_url))
|
||
|
||
|
||
async def process_video_task(
|
||
cls: type[IO.ComfyNode],
|
||
payload: Text2VideoTaskCreationRequest | Image2VideoTaskCreationRequest,
|
||
estimated_duration: int | None,
|
||
) -> IO.NodeOutput:
|
||
if payload.model in DEPRECATED_MODELS:
|
||
logger.warning(
|
||
"Model '%s' is deprecated and will be deactivated on May 13, 2026. "
|
||
"Please switch to a newer model. Recommended: seedance-1-0-pro-fast-251015.",
|
||
payload.model,
|
||
)
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path=BYTEPLUS_TASK_ENDPOINT, method="POST"),
|
||
data=payload,
|
||
response_model=TaskCreationResponse,
|
||
)
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"{BYTEPLUS_TASK_STATUS_ENDPOINT}/{initial_response.id}"),
|
||
status_extractor=lambda r: r.status,
|
||
estimated_duration=estimated_duration,
|
||
response_model=TaskStatusResponse,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.content.video_url))
|
||
|
||
|
||
class ByteDanceCreateImageAsset(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls) -> IO.Schema:
|
||
return IO.Schema(
|
||
node_id="ByteDanceCreateImageAsset",
|
||
display_name="ByteDance Create Image Asset",
|
||
category="api node/image/ByteDance",
|
||
description=(
|
||
"Create a Seedance 2.0 personal image asset. Uploads the input image and "
|
||
"registers it in the given asset group. If group_id is empty, runs a real-person "
|
||
"H5 authentication flow to create a new group before adding the asset."
|
||
),
|
||
inputs=[
|
||
IO.Image.Input("image", tooltip="Image to register as a personal asset."),
|
||
IO.String.Input(
|
||
"group_id",
|
||
default="",
|
||
tooltip=(
|
||
"Existing Seedance asset group ID. Leave empty to run real-person "
|
||
"authentication in the browser and create a new group."
|
||
),
|
||
),
|
||
# IO.String.Input(
|
||
# "name",
|
||
# default="",
|
||
# tooltip="Asset name (up to 64 characters).",
|
||
# ),
|
||
],
|
||
outputs=[
|
||
IO.String.Output(display_name="asset_id"),
|
||
IO.String.Output(display_name="group_id"),
|
||
],
|
||
hidden=[
|
||
IO.Hidden.auth_token_comfy_org,
|
||
IO.Hidden.api_key_comfy_org,
|
||
IO.Hidden.unique_id,
|
||
],
|
||
# is_api_node=True,
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
image: Input.Image,
|
||
group_id: str = "",
|
||
# name: str = "",
|
||
) -> IO.NodeOutput:
|
||
# if len(name) > 64:
|
||
# raise ValueError("Name of asset can not be greater then 64 symbols")
|
||
validate_image_dimensions(image, min_width=300, max_width=6000, min_height=300, max_height=6000)
|
||
validate_image_aspect_ratio(image, min_ratio=(0.4, 1), max_ratio=(2.5, 1))
|
||
resolved_group = await _resolve_group_id(cls, group_id)
|
||
asset_id = await _create_seedance_asset(
|
||
cls,
|
||
group_id=resolved_group,
|
||
url=await upload_image_to_comfyapi(cls, image),
|
||
name="",
|
||
asset_type="Image",
|
||
)
|
||
PromptServer.instance.send_progress_text(
|
||
f"Please save the asset_id and group_id for reuse.\n\nasset_id: {asset_id}\n\n"
|
||
f"group_id: {resolved_group}",
|
||
cls.hidden.unique_id,
|
||
)
|
||
return IO.NodeOutput(asset_id, resolved_group)
|
||
|
||
|
||
class ByteDanceCreateVideoAsset(IO.ComfyNode):
|
||
|
||
@classmethod
|
||
def define_schema(cls) -> IO.Schema:
|
||
return IO.Schema(
|
||
node_id="ByteDanceCreateVideoAsset",
|
||
display_name="ByteDance Create Video Asset",
|
||
category="api node/video/ByteDance",
|
||
description=(
|
||
"Create a Seedance 2.0 personal video asset. Uploads the input video and "
|
||
"registers it in the given asset group. If group_id is empty, runs a real-person "
|
||
"H5 authentication flow to create a new group before adding the asset."
|
||
),
|
||
inputs=[
|
||
IO.Video.Input("video", tooltip="Video to register as a personal asset."),
|
||
IO.String.Input(
|
||
"group_id",
|
||
default="",
|
||
tooltip=(
|
||
"Existing Seedance asset group ID. Leave empty to run real-person "
|
||
"authentication in the browser and create a new group."
|
||
),
|
||
),
|
||
# IO.String.Input(
|
||
# "name",
|
||
# default="",
|
||
# tooltip="Asset name (up to 64 characters).",
|
||
# ),
|
||
],
|
||
outputs=[
|
||
IO.String.Output(display_name="asset_id"),
|
||
IO.String.Output(display_name="group_id"),
|
||
],
|
||
hidden=[
|
||
IO.Hidden.auth_token_comfy_org,
|
||
IO.Hidden.api_key_comfy_org,
|
||
IO.Hidden.unique_id,
|
||
],
|
||
# is_api_node=True,
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
video: Input.Video,
|
||
group_id: str = "",
|
||
# name: str = "",
|
||
) -> IO.NodeOutput:
|
||
# if len(name) > 64:
|
||
# raise ValueError("Name of asset can not be greater then 64 symbols")
|
||
validate_video_duration(video, min_duration=2, max_duration=15)
|
||
validate_video_dimensions(video, min_width=300, max_width=6000, min_height=300, max_height=6000)
|
||
|
||
w, h = video.get_dimensions()
|
||
if h > 0:
|
||
ratio = w / h
|
||
if not (0.4 <= ratio <= 2.5):
|
||
raise ValueError(f"Asset video aspect ratio (W/H) must be in [0.4, 2.5], got {ratio:.3f} ({w}x{h}).")
|
||
pixels = w * h
|
||
if not (409_600 <= pixels <= 927_408):
|
||
raise ValueError(
|
||
f"Asset video total pixels (W×H) must be in [409600, 927408], " f"got {pixels:,} ({w}x{h})."
|
||
)
|
||
|
||
fps = float(video.get_frame_rate())
|
||
if not (24 <= fps <= 60):
|
||
raise ValueError(f"Asset video FPS must be in [24, 60], got {fps:.2f}.")
|
||
|
||
resolved_group = await _resolve_group_id(cls, group_id)
|
||
asset_id = await _create_seedance_asset(
|
||
cls,
|
||
group_id=resolved_group,
|
||
url=await upload_video_to_comfyapi(cls, video),
|
||
name="",
|
||
asset_type="Video",
|
||
)
|
||
PromptServer.instance.send_progress_text(
|
||
f"Please save the asset_id and group_id for reuse.\n\nasset_id: {asset_id}\n\n"
|
||
f"group_id: {resolved_group}",
|
||
cls.hidden.unique_id,
|
||
)
|
||
return IO.NodeOutput(asset_id, resolved_group)
|
||
|
||
|
||
class ByteDanceExtension(ComfyExtension):
|
||
@override
|
||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||
return [
|
||
ByteDanceImageNode,
|
||
ByteDanceSeedreamNode,
|
||
ByteDanceTextToVideoNode,
|
||
ByteDanceImageToVideoNode,
|
||
ByteDanceFirstLastFrameNode,
|
||
ByteDanceImageReferenceNode,
|
||
ByteDance2TextToVideoNode,
|
||
ByteDance2FirstLastFrameNode,
|
||
ByteDance2ReferenceNode,
|
||
ByteDanceCreateImageAsset,
|
||
ByteDanceCreateVideoAsset,
|
||
]
|
||
|
||
|
||
async def comfy_entrypoint() -> ByteDanceExtension:
|
||
return ByteDanceExtension()
|