mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-04-05 16:26:48 +08:00
1668 lines
65 KiB
Python
1668 lines
65 KiB
Python
import re
|
||
|
||
from typing_extensions import override
|
||
|
||
from comfy_api.latest import IO, ComfyExtension, Input
|
||
from comfy_api_nodes.apis.wan import (
|
||
Image2ImageInputField,
|
||
Image2ImageParametersField,
|
||
Image2ImageTaskCreationRequest,
|
||
Image2VideoInputField,
|
||
Image2VideoParametersField,
|
||
Image2VideoTaskCreationRequest,
|
||
ImageTaskStatusResponse,
|
||
Reference2VideoInputField,
|
||
Reference2VideoParametersField,
|
||
Reference2VideoTaskCreationRequest,
|
||
TaskCreationResponse,
|
||
Text2ImageInputField,
|
||
Text2ImageTaskCreationRequest,
|
||
Text2VideoInputField,
|
||
Text2VideoParametersField,
|
||
Text2VideoTaskCreationRequest,
|
||
Txt2ImageParametersField,
|
||
VideoTaskStatusResponse,
|
||
Wan27ImageToVideoInputField,
|
||
Wan27ImageToVideoParametersField,
|
||
Wan27ImageToVideoTaskCreationRequest,
|
||
Wan27MediaItem,
|
||
Wan27ReferenceVideoInputField,
|
||
Wan27ReferenceVideoParametersField,
|
||
Wan27ReferenceVideoTaskCreationRequest,
|
||
Wan27Text2VideoParametersField,
|
||
Wan27Text2VideoTaskCreationRequest,
|
||
Wan27VideoEditInputField,
|
||
Wan27VideoEditParametersField,
|
||
Wan27VideoEditTaskCreationRequest,
|
||
)
|
||
from comfy_api_nodes.util import (
|
||
ApiEndpoint,
|
||
audio_to_base64_string,
|
||
download_url_to_image_tensor,
|
||
download_url_to_video_output,
|
||
get_number_of_images,
|
||
poll_op,
|
||
sync_op,
|
||
tensor_to_base64_string,
|
||
upload_audio_to_comfyapi,
|
||
upload_image_to_comfyapi,
|
||
upload_video_to_comfyapi,
|
||
validate_audio_duration,
|
||
validate_string,
|
||
validate_video_duration,
|
||
)
|
||
|
||
RES_IN_PARENS = re.compile(r"\((\d+)\s*[x×]\s*(\d+)\)")
|
||
|
||
|
||
class WanTextToImageApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="WanTextToImageApi",
|
||
display_name="Wan Text to Image",
|
||
category="api node/image/Wan",
|
||
description="Generates an image based on a text prompt.",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=["wan2.5-t2i-preview"],
|
||
tooltip="Model to use.",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. Supports English and Chinese.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"width",
|
||
default=1024,
|
||
min=768,
|
||
max=1440,
|
||
step=32,
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"height",
|
||
default=1024,
|
||
min=768,
|
||
max=1440,
|
||
step=32,
|
||
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(
|
||
"prompt_extend",
|
||
default=True,
|
||
tooltip="Whether to enhance the prompt with AI assistance.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
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}""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
negative_prompt: str = "",
|
||
width: int = 1024,
|
||
height: int = 1024,
|
||
seed: int = 0,
|
||
prompt_extend: bool = True,
|
||
watermark: bool = False,
|
||
):
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path="/proxy/wan/api/v1/services/aigc/text2image/image-synthesis", method="POST"),
|
||
response_model=TaskCreationResponse,
|
||
data=Text2ImageTaskCreationRequest(
|
||
model=model,
|
||
input=Text2ImageInputField(prompt=prompt, negative_prompt=negative_prompt),
|
||
parameters=Txt2ImageParametersField(
|
||
size=f"{width}*{height}",
|
||
seed=seed,
|
||
prompt_extend=prompt_extend,
|
||
watermark=watermark,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=ImageTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
estimated_duration=9,
|
||
poll_interval=3,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_image_tensor(str(response.output.results[0].url)))
|
||
|
||
|
||
class WanImageToImageApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="WanImageToImageApi",
|
||
display_name="Wan Image to Image",
|
||
category="api node/image/Wan",
|
||
description="Generates an image from one or two input images and a text prompt. "
|
||
"The output image is currently fixed at 1.6 MP, and its aspect ratio matches the input image(s).",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=["wan2.5-i2i-preview"],
|
||
default="wan2.5-i2i-preview",
|
||
tooltip="Model to use.",
|
||
),
|
||
IO.Image.Input(
|
||
"image",
|
||
tooltip="Single-image editing or multi-image fusion. Maximum 2 images.",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. Supports English and Chinese.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
optional=True,
|
||
),
|
||
# redo this later as an optional combo of recommended resolutions
|
||
# IO.Int.Input(
|
||
# "width",
|
||
# default=1280,
|
||
# min=384,
|
||
# max=1440,
|
||
# step=16,
|
||
# optional=True,
|
||
# ),
|
||
# IO.Int.Input(
|
||
# "height",
|
||
# default=1280,
|
||
# min=384,
|
||
# max=1440,
|
||
# step=16,
|
||
# 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 result.",
|
||
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}""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
image: Input.Image,
|
||
prompt: str,
|
||
negative_prompt: str = "",
|
||
# width: int = 1024,
|
||
# height: int = 1024,
|
||
seed: int = 0,
|
||
watermark: bool = False,
|
||
):
|
||
n_images = get_number_of_images(image)
|
||
if n_images not in (1, 2):
|
||
raise ValueError(f"Expected 1 or 2 input images, but got {n_images}.")
|
||
images = []
|
||
for i in image:
|
||
images.append("data:image/png;base64," + tensor_to_base64_string(i, total_pixels=4096 * 4096))
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path="/proxy/wan/api/v1/services/aigc/image2image/image-synthesis", method="POST"),
|
||
response_model=TaskCreationResponse,
|
||
data=Image2ImageTaskCreationRequest(
|
||
model=model,
|
||
input=Image2ImageInputField(prompt=prompt, negative_prompt=negative_prompt, images=images),
|
||
parameters=Image2ImageParametersField(
|
||
# size=f"{width}*{height}",
|
||
seed=seed,
|
||
watermark=watermark,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=ImageTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
estimated_duration=42,
|
||
poll_interval=4,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_image_tensor(str(response.output.results[0].url)))
|
||
|
||
|
||
class WanTextToVideoApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="WanTextToVideoApi",
|
||
display_name="Wan Text to Video",
|
||
category="api node/video/Wan",
|
||
description="Generates a video based on a text prompt.",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=["wan2.5-t2v-preview", "wan2.6-t2v"],
|
||
default="wan2.6-t2v",
|
||
tooltip="Model to use.",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. Supports English and Chinese.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
optional=True,
|
||
),
|
||
IO.Combo.Input(
|
||
"size",
|
||
options=[
|
||
"480p: 1:1 (624x624)",
|
||
"480p: 16:9 (832x480)",
|
||
"480p: 9:16 (480x832)",
|
||
"720p: 1:1 (960x960)",
|
||
"720p: 16:9 (1280x720)",
|
||
"720p: 9:16 (720x1280)",
|
||
"720p: 4:3 (1088x832)",
|
||
"720p: 3:4 (832x1088)",
|
||
"1080p: 1:1 (1440x1440)",
|
||
"1080p: 16:9 (1920x1080)",
|
||
"1080p: 9:16 (1080x1920)",
|
||
"1080p: 4:3 (1632x1248)",
|
||
"1080p: 3:4 (1248x1632)",
|
||
],
|
||
default="720p: 1:1 (960x960)",
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=5,
|
||
max=15,
|
||
step=5,
|
||
display_mode=IO.NumberDisplay.number,
|
||
tooltip="A 15-second duration is available only for the Wan 2.6 model.",
|
||
optional=True,
|
||
),
|
||
IO.Audio.Input(
|
||
"audio",
|
||
optional=True,
|
||
tooltip="Audio must contain a clear, loud voice, without extraneous noise or background music.",
|
||
),
|
||
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(
|
||
"generate_audio",
|
||
default=False,
|
||
optional=True,
|
||
tooltip="If no audio input is provided, generate audio automatically.",
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"prompt_extend",
|
||
default=True,
|
||
tooltip="Whether to enhance the prompt with AI assistance.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Combo.Input(
|
||
"shot_type",
|
||
options=["single", "multi"],
|
||
tooltip="Specifies the shot type for the generated video, that is, whether the video is a "
|
||
"single continuous shot or multiple shots with cuts. "
|
||
"This parameter takes effect only when prompt_extend is True.",
|
||
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=["duration", "size"]),
|
||
expr="""
|
||
(
|
||
$ppsTable := { "480p": 0.05, "720p": 0.1, "1080p": 0.15 };
|
||
$resKey := $substringBefore(widgets.size, ":");
|
||
$pps := $lookup($ppsTable, $resKey);
|
||
{ "type": "usd", "usd": $round($pps * widgets.duration, 2) }
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
negative_prompt: str = "",
|
||
size: str = "720p: 1:1 (960x960)",
|
||
duration: int = 5,
|
||
audio: Input.Audio | None = None,
|
||
seed: int = 0,
|
||
generate_audio: bool = False,
|
||
prompt_extend: bool = True,
|
||
watermark: bool = False,
|
||
shot_type: str = "single",
|
||
):
|
||
if "480p" in size and model == "wan2.6-t2v":
|
||
raise ValueError("The Wan 2.6 model does not support 480p.")
|
||
if duration == 15 and model == "wan2.5-t2v-preview":
|
||
raise ValueError("A 15-second duration is supported only by the Wan 2.6 model.")
|
||
width, height = RES_IN_PARENS.search(size).groups()
|
||
audio_url = None
|
||
if audio is not None:
|
||
validate_audio_duration(audio, 3.0, 29.0)
|
||
audio_url = "data:audio/mp3;base64," + audio_to_base64_string(audio, "mp3", "libmp3lame")
|
||
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis", method="POST"),
|
||
response_model=TaskCreationResponse,
|
||
data=Text2VideoTaskCreationRequest(
|
||
model=model,
|
||
input=Text2VideoInputField(prompt=prompt, negative_prompt=negative_prompt, audio_url=audio_url),
|
||
parameters=Text2VideoParametersField(
|
||
size=f"{width}*{height}",
|
||
duration=duration,
|
||
seed=seed,
|
||
audio=generate_audio,
|
||
prompt_extend=prompt_extend,
|
||
watermark=watermark,
|
||
shot_type=shot_type,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
estimated_duration=120 * int(duration / 5),
|
||
poll_interval=6,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class WanImageToVideoApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="WanImageToVideoApi",
|
||
display_name="Wan Image to Video",
|
||
category="api node/video/Wan",
|
||
description="Generates a video from the first frame and a text prompt.",
|
||
inputs=[
|
||
IO.Combo.Input(
|
||
"model",
|
||
options=["wan2.5-i2v-preview", "wan2.6-i2v"],
|
||
default="wan2.6-i2v",
|
||
tooltip="Model to use.",
|
||
),
|
||
IO.Image.Input(
|
||
"image",
|
||
),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. Supports English and Chinese.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
optional=True,
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=[
|
||
"480P",
|
||
"720P",
|
||
"1080P",
|
||
],
|
||
default="720P",
|
||
optional=True,
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=5,
|
||
max=15,
|
||
step=5,
|
||
display_mode=IO.NumberDisplay.number,
|
||
tooltip="Duration 15 available only for WAN2.6 model.",
|
||
optional=True,
|
||
),
|
||
IO.Audio.Input(
|
||
"audio",
|
||
optional=True,
|
||
tooltip="Audio must contain a clear, loud voice, without extraneous noise or background music.",
|
||
),
|
||
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(
|
||
"generate_audio",
|
||
default=False,
|
||
optional=True,
|
||
tooltip="If no audio input is provided, generate audio automatically.",
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"prompt_extend",
|
||
default=True,
|
||
tooltip="Whether to enhance the prompt with AI assistance.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
optional=True,
|
||
advanced=True,
|
||
),
|
||
IO.Combo.Input(
|
||
"shot_type",
|
||
options=["single", "multi"],
|
||
tooltip="Specifies the shot type for the generated video, that is, whether the video is a "
|
||
"single continuous shot or multiple shots with cuts. "
|
||
"This parameter takes effect only when prompt_extend is True.",
|
||
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=["duration", "resolution"]),
|
||
expr="""
|
||
(
|
||
$ppsTable := { "480p": 0.05, "720p": 0.1, "1080p": 0.15 };
|
||
$pps := $lookup($ppsTable, widgets.resolution);
|
||
{ "type": "usd", "usd": $round($pps * widgets.duration, 2) }
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
image: Input.Image,
|
||
prompt: str,
|
||
negative_prompt: str = "",
|
||
resolution: str = "720P",
|
||
duration: int = 5,
|
||
audio: Input.Audio | None = None,
|
||
seed: int = 0,
|
||
generate_audio: bool = False,
|
||
prompt_extend: bool = True,
|
||
watermark: bool = False,
|
||
shot_type: str = "single",
|
||
):
|
||
if get_number_of_images(image) != 1:
|
||
raise ValueError("Exactly one input image is required.")
|
||
if "480P" in resolution and model == "wan2.6-i2v":
|
||
raise ValueError("The Wan 2.6 model does not support 480P.")
|
||
if duration == 15 and model == "wan2.5-i2v-preview":
|
||
raise ValueError("A 15-second duration is supported only by the Wan 2.6 model.")
|
||
image_url = "data:image/png;base64," + tensor_to_base64_string(image, total_pixels=2000 * 2000)
|
||
audio_url = None
|
||
if audio is not None:
|
||
validate_audio_duration(audio, 3.0, 29.0)
|
||
audio_url = "data:audio/mp3;base64," + audio_to_base64_string(audio, "mp3", "libmp3lame")
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis", method="POST"),
|
||
response_model=TaskCreationResponse,
|
||
data=Image2VideoTaskCreationRequest(
|
||
model=model,
|
||
input=Image2VideoInputField(
|
||
prompt=prompt, negative_prompt=negative_prompt, img_url=image_url, audio_url=audio_url
|
||
),
|
||
parameters=Image2VideoParametersField(
|
||
resolution=resolution,
|
||
duration=duration,
|
||
seed=seed,
|
||
audio=generate_audio,
|
||
prompt_extend=prompt_extend,
|
||
watermark=watermark,
|
||
shot_type=shot_type,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
estimated_duration=120 * int(duration / 5),
|
||
poll_interval=6,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class WanReferenceVideoApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="WanReferenceVideoApi",
|
||
display_name="Wan Reference to Video",
|
||
category="api node/video/Wan",
|
||
description="Use the character and voice from input videos, combined with a prompt, "
|
||
"to generate a new video that maintains character consistency.",
|
||
inputs=[
|
||
IO.Combo.Input("model", options=["wan2.6-r2v"]),
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. Supports English and Chinese. "
|
||
"Use identifiers such as `character1` and `character2` to refer to the reference characters.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
),
|
||
IO.Autogrow.Input(
|
||
"reference_videos",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.Video.Input("reference_video"),
|
||
names=["character1", "character2", "character3"],
|
||
min=1,
|
||
),
|
||
),
|
||
IO.Combo.Input(
|
||
"size",
|
||
options=[
|
||
"720p: 1:1 (960x960)",
|
||
"720p: 16:9 (1280x720)",
|
||
"720p: 9:16 (720x1280)",
|
||
"720p: 4:3 (1088x832)",
|
||
"720p: 3:4 (832x1088)",
|
||
"1080p: 1:1 (1440x1440)",
|
||
"1080p: 16:9 (1920x1080)",
|
||
"1080p: 9:16 (1080x1920)",
|
||
"1080p: 4:3 (1632x1248)",
|
||
"1080p: 3:4 (1248x1632)",
|
||
],
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=5,
|
||
max=10,
|
||
step=5,
|
||
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,
|
||
),
|
||
IO.Combo.Input(
|
||
"shot_type",
|
||
options=["single", "multi"],
|
||
tooltip="Specifies the shot type for the generated video, that is, whether the video is a "
|
||
"single continuous shot or multiple shots with cuts.",
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
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=["size", "duration"]),
|
||
expr="""
|
||
(
|
||
$rate := $contains(widgets.size, "1080p") ? 0.15 : 0.10;
|
||
$inputMin := 2 * $rate;
|
||
$inputMax := 5 * $rate;
|
||
$outputPrice := widgets.duration * $rate;
|
||
{
|
||
"type": "range_usd",
|
||
"min_usd": $inputMin + $outputPrice,
|
||
"max_usd": $inputMax + $outputPrice
|
||
}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: str,
|
||
prompt: str,
|
||
negative_prompt: str,
|
||
reference_videos: IO.Autogrow.Type,
|
||
size: str,
|
||
duration: int,
|
||
seed: int,
|
||
shot_type: str,
|
||
watermark: bool,
|
||
):
|
||
reference_video_urls = []
|
||
for i in reference_videos:
|
||
validate_video_duration(reference_videos[i], min_duration=2, max_duration=30)
|
||
for i in reference_videos:
|
||
reference_video_urls.append(await upload_video_to_comfyapi(cls, reference_videos[i]))
|
||
width, height = RES_IN_PARENS.search(size).groups()
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis", method="POST"),
|
||
response_model=TaskCreationResponse,
|
||
data=Reference2VideoTaskCreationRequest(
|
||
model=model,
|
||
input=Reference2VideoInputField(
|
||
prompt=prompt, negative_prompt=negative_prompt, reference_video_urls=reference_video_urls
|
||
),
|
||
parameters=Reference2VideoParametersField(
|
||
size=f"{width}*{height}",
|
||
duration=duration,
|
||
shot_type=shot_type,
|
||
watermark=watermark,
|
||
seed=seed,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
poll_interval=6,
|
||
max_poll_attempts=280,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class Wan2TextToVideoApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="Wan2TextToVideoApi",
|
||
display_name="Wan 2.7 Text to Video",
|
||
category="api node/video/Wan",
|
||
description="Generates a video based on a text prompt using the Wan 2.7 model.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option(
|
||
"wan2.7-t2v",
|
||
[
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. "
|
||
"Supports English and Chinese.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["720P", "1080P"],
|
||
),
|
||
IO.Combo.Input(
|
||
"ratio",
|
||
options=["16:9", "9:16", "1:1", "4:3", "3:4"],
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=2,
|
||
max=15,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
IO.Audio.Input(
|
||
"audio",
|
||
optional=True,
|
||
tooltip="Audio for driving video generation (e.g., lip sync, beat-matched motion). "
|
||
"Duration: 3s-30s. If not provided, the model automatically generates matching "
|
||
"background music or sound effects.",
|
||
),
|
||
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.",
|
||
),
|
||
IO.Boolean.Input(
|
||
"prompt_extend",
|
||
default=True,
|
||
tooltip="Whether to enhance the prompt with AI assistance.",
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
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="""
|
||
(
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$ppsTable := { "720p": 0.1, "1080p": 0.15 };
|
||
$pps := $lookup($ppsTable, $res);
|
||
{ "type": "usd", "usd": $pps * $dur }
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
seed: int,
|
||
prompt_extend: bool,
|
||
watermark: bool,
|
||
audio: Input.Audio | None = None,
|
||
):
|
||
validate_string(model["prompt"], strip_whitespace=False, min_length=1)
|
||
audio_url = None
|
||
if audio is not None:
|
||
validate_audio_duration(audio, 1.5, 60.0)
|
||
audio_url = await upload_audio_to_comfyapi(
|
||
cls, audio, container_format="mp3", codec_name="libmp3lame", mime_type="audio/mpeg"
|
||
)
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(
|
||
path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis",
|
||
method="POST",
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
data=Wan27Text2VideoTaskCreationRequest(
|
||
model=model["model"],
|
||
input=Text2VideoInputField(
|
||
prompt=model["prompt"],
|
||
negative_prompt=model["negative_prompt"] or None,
|
||
audio_url=audio_url,
|
||
),
|
||
parameters=Wan27Text2VideoParametersField(
|
||
resolution=model["resolution"],
|
||
ratio=model["ratio"],
|
||
duration=model["duration"],
|
||
seed=seed,
|
||
prompt_extend=prompt_extend,
|
||
watermark=watermark,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
poll_interval=7,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class Wan2ImageToVideoApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="Wan2ImageToVideoApi",
|
||
display_name="Wan 2.7 Image to Video",
|
||
category="api node/video/Wan",
|
||
description="Generate a video from a first-frame image, with optional last-frame image and audio.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option(
|
||
"wan2.7-i2v",
|
||
[
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. "
|
||
"Supports English and Chinese.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["720P", "1080P"],
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=2,
|
||
max=15,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
IO.Image.Input(
|
||
"first_frame",
|
||
tooltip="First frame image. The output aspect ratio is derived from this image.",
|
||
),
|
||
IO.Image.Input(
|
||
"last_frame",
|
||
optional=True,
|
||
tooltip="Last frame image. The model generates a video transitioning from first to last frame.",
|
||
),
|
||
IO.Audio.Input(
|
||
"audio",
|
||
optional=True,
|
||
tooltip="Audio for driving video generation (e.g., lip sync, beat-matched motion). "
|
||
"Duration: 2s-30s. If not provided, the model automatically generates matching "
|
||
"background music or sound effects.",
|
||
),
|
||
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.",
|
||
),
|
||
IO.Boolean.Input(
|
||
"prompt_extend",
|
||
default=True,
|
||
tooltip="Whether to enhance the prompt with AI assistance.",
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
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="""
|
||
(
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$ppsTable := { "720p": 0.1, "1080p": 0.15 };
|
||
$pps := $lookup($ppsTable, $res);
|
||
{ "type": "usd", "usd": $pps * $dur }
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
first_frame: Input.Image,
|
||
seed: int,
|
||
prompt_extend: bool,
|
||
watermark: bool,
|
||
last_frame: Input.Image | None = None,
|
||
audio: Input.Audio | None = None,
|
||
):
|
||
media = [
|
||
Wan27MediaItem(
|
||
type="first_frame",
|
||
url=await upload_image_to_comfyapi(cls, image=first_frame),
|
||
)
|
||
]
|
||
if last_frame is not None:
|
||
media.append(
|
||
Wan27MediaItem(
|
||
type="last_frame",
|
||
url=await upload_image_to_comfyapi(cls, image=last_frame),
|
||
)
|
||
)
|
||
if audio is not None:
|
||
validate_audio_duration(audio, 2.0, 30.0)
|
||
audio_url = await upload_audio_to_comfyapi(
|
||
cls, audio, container_format="mp3", codec_name="libmp3lame", mime_type="audio/mpeg"
|
||
)
|
||
media.append(Wan27MediaItem(type="driving_audio", url=audio_url))
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(
|
||
path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis",
|
||
method="POST",
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
data=Wan27ImageToVideoTaskCreationRequest(
|
||
model=model["model"],
|
||
input=Wan27ImageToVideoInputField(
|
||
prompt=model["prompt"] or None,
|
||
negative_prompt=model["negative_prompt"] or None,
|
||
media=media,
|
||
),
|
||
parameters=Wan27ImageToVideoParametersField(
|
||
resolution=model["resolution"],
|
||
duration=model["duration"],
|
||
seed=seed,
|
||
prompt_extend=prompt_extend,
|
||
watermark=watermark,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
poll_interval=7,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class Wan2VideoContinuationApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="Wan2VideoContinuationApi",
|
||
display_name="Wan 2.7 Video Continuation",
|
||
category="api node/video/Wan",
|
||
description="Continue a video from where it left off, with optional last-frame control.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option(
|
||
"wan2.7-i2v",
|
||
[
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the elements and visual features. Supports English and Chinese.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["720P", "1080P"],
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=2,
|
||
max=15,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
tooltip="Total output duration in seconds. The model generates continuation "
|
||
"to fill the remaining time after the input clip.",
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
IO.Video.Input(
|
||
"first_clip",
|
||
tooltip="Input video to continue from. Duration: 2s-10s. "
|
||
"The output aspect ratio is derived from this video.",
|
||
),
|
||
IO.Image.Input(
|
||
"last_frame",
|
||
optional=True,
|
||
tooltip="Last frame image. The continuation will transition towards this frame.",
|
||
),
|
||
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.",
|
||
),
|
||
IO.Boolean.Input(
|
||
"prompt_extend",
|
||
default=True,
|
||
tooltip="Whether to enhance the prompt with AI assistance.",
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
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="""
|
||
(
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$ppsTable := { "720p": 0.1, "1080p": 0.15 };
|
||
$pps := $lookup($ppsTable, $res);
|
||
$outputPrice := $pps * $dur;
|
||
{
|
||
"type": "range_usd",
|
||
"min_usd": 2 * $pps + $outputPrice,
|
||
"max_usd": 5 * $pps + $outputPrice
|
||
}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
first_clip: Input.Video,
|
||
prompt: str = "",
|
||
negative_prompt: str = "",
|
||
last_frame: Input.Image | None = None,
|
||
seed: int = 0,
|
||
prompt_extend: bool = True,
|
||
watermark: bool = False,
|
||
):
|
||
validate_video_duration(first_clip, min_duration=2, max_duration=10)
|
||
media = [
|
||
Wan27MediaItem(
|
||
type="first_clip",
|
||
url=await upload_video_to_comfyapi(cls, first_clip),
|
||
)
|
||
]
|
||
if last_frame is not None:
|
||
media.append(
|
||
Wan27MediaItem(
|
||
type="last_frame",
|
||
url=await upload_image_to_comfyapi(cls, image=last_frame),
|
||
)
|
||
)
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(
|
||
path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis",
|
||
method="POST",
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
data=Wan27ImageToVideoTaskCreationRequest(
|
||
model=model["model"],
|
||
input=Wan27ImageToVideoInputField(
|
||
prompt=model["prompt"] or None,
|
||
negative_prompt=model["negative_prompt"] or None,
|
||
media=media,
|
||
),
|
||
parameters=Wan27ImageToVideoParametersField(
|
||
resolution=model["resolution"],
|
||
duration=model["duration"],
|
||
seed=seed,
|
||
prompt_extend=prompt_extend,
|
||
watermark=watermark,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
poll_interval=7,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class Wan2VideoEditApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="Wan2VideoEditApi",
|
||
display_name="Wan 2.7 Video Edit",
|
||
category="api node/video/Wan",
|
||
description="Edit a video using text instructions, reference images, or style transfer.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option(
|
||
"wan2.7-videoedit",
|
||
[
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Editing instructions or style transfer requirements.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["720P", "1080P"],
|
||
),
|
||
IO.Combo.Input(
|
||
"ratio",
|
||
options=["16:9", "9:16", "1:1", "4:3", "3:4"],
|
||
tooltip="Aspect ratio. If not changed, approximates the input video ratio.",
|
||
),
|
||
IO.Combo.Input(
|
||
"duration",
|
||
options=["auto", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
||
default="auto",
|
||
tooltip="Output duration in seconds. 'auto' matches the input video duration. "
|
||
"A specific value truncates from the start of the video.",
|
||
),
|
||
IO.Autogrow.Input(
|
||
"reference_images",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.Image.Input("reference_image"),
|
||
names=[
|
||
"image1",
|
||
"image2",
|
||
"image3",
|
||
"image4",
|
||
],
|
||
min=0,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
IO.Video.Input(
|
||
"video",
|
||
tooltip="The video to edit.",
|
||
),
|
||
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.",
|
||
),
|
||
IO.Combo.Input(
|
||
"audio_setting",
|
||
options=["auto", "origin"],
|
||
default="auto",
|
||
tooltip="'auto': model decides whether to regenerate audio based on the prompt. "
|
||
"'origin': preserve the original audio from the input video.",
|
||
advanced=True,
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
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="""
|
||
(
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$ppsTable := { "720p": 0.1, "1080p": 0.15 };
|
||
$pps := $lookup($ppsTable, $res);
|
||
{ "type": "usd", "usd": $pps, "format": { "suffix": "/second", "note": "(input + output)" } }
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
video: Input.Video,
|
||
seed: int,
|
||
audio_setting: str,
|
||
watermark: bool,
|
||
):
|
||
validate_string(model["prompt"], strip_whitespace=False, min_length=1)
|
||
validate_video_duration(video, min_duration=2, max_duration=10)
|
||
duration = 0 if model["duration"] == "auto" else int(model["duration"])
|
||
media = [Wan27MediaItem(type="video", url=await upload_video_to_comfyapi(cls, video))]
|
||
reference_images = model.get("reference_images", {})
|
||
for key in reference_images:
|
||
media.append(
|
||
Wan27MediaItem(
|
||
type="reference_image", url=await upload_image_to_comfyapi(cls, image=reference_images[key])
|
||
)
|
||
)
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(
|
||
path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis",
|
||
method="POST",
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
data=Wan27VideoEditTaskCreationRequest(
|
||
model=model["model"],
|
||
input=Wan27VideoEditInputField(prompt=model["prompt"], media=media),
|
||
parameters=Wan27VideoEditParametersField(
|
||
resolution=model["resolution"],
|
||
ratio=model["ratio"],
|
||
duration=duration,
|
||
audio_setting=audio_setting,
|
||
watermark=watermark,
|
||
seed=seed,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
poll_interval=7,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class Wan2ReferenceVideoApi(IO.ComfyNode):
|
||
@classmethod
|
||
def define_schema(cls):
|
||
return IO.Schema(
|
||
node_id="Wan2ReferenceVideoApi",
|
||
display_name="Wan 2.7 Reference to Video",
|
||
category="api node/video/Wan",
|
||
description="Generate a video featuring a person or object from reference materials. "
|
||
"Supports single-character performances and multi-character interactions.",
|
||
inputs=[
|
||
IO.DynamicCombo.Input(
|
||
"model",
|
||
options=[
|
||
IO.DynamicCombo.Option(
|
||
"wan2.7-r2v",
|
||
[
|
||
IO.String.Input(
|
||
"prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Prompt describing the video. Use identifiers such as 'character1' and "
|
||
"'character2' to refer to the reference characters.",
|
||
),
|
||
IO.String.Input(
|
||
"negative_prompt",
|
||
multiline=True,
|
||
default="",
|
||
tooltip="Negative prompt describing what to avoid.",
|
||
),
|
||
IO.Combo.Input(
|
||
"resolution",
|
||
options=["720P", "1080P"],
|
||
),
|
||
IO.Combo.Input(
|
||
"ratio",
|
||
options=["16:9", "9:16", "1:1", "4:3", "3:4"],
|
||
),
|
||
IO.Int.Input(
|
||
"duration",
|
||
default=5,
|
||
min=2,
|
||
max=10,
|
||
step=1,
|
||
display_mode=IO.NumberDisplay.number,
|
||
),
|
||
IO.Autogrow.Input(
|
||
"reference_videos",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.Video.Input("reference_video"),
|
||
names=["video1", "video2", "video3"],
|
||
min=0,
|
||
),
|
||
),
|
||
IO.Autogrow.Input(
|
||
"reference_images",
|
||
template=IO.Autogrow.TemplateNames(
|
||
IO.Image.Input("reference_image"),
|
||
names=["image1", "image2", "image3", "image4", "image5"],
|
||
min=0,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
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.",
|
||
),
|
||
IO.Boolean.Input(
|
||
"watermark",
|
||
default=False,
|
||
tooltip="Whether to add an AI-generated watermark to the result.",
|
||
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="""
|
||
(
|
||
$res := $lookup(widgets, "model.resolution");
|
||
$dur := $lookup(widgets, "model.duration");
|
||
$ppsTable := { "720p": 0.1, "1080p": 0.15 };
|
||
$pps := $lookup($ppsTable, $res);
|
||
$outputPrice := $pps * $dur;
|
||
{
|
||
"type": "range_usd",
|
||
"min_usd": $outputPrice,
|
||
"max_usd": 5 * $pps + $outputPrice
|
||
}
|
||
)
|
||
""",
|
||
),
|
||
)
|
||
|
||
@classmethod
|
||
async def execute(
|
||
cls,
|
||
model: dict,
|
||
seed: int,
|
||
watermark: bool,
|
||
):
|
||
validate_string(model["prompt"], strip_whitespace=False, min_length=1)
|
||
media = []
|
||
reference_videos = model.get("reference_videos", {})
|
||
for key in reference_videos:
|
||
media.append(
|
||
Wan27MediaItem(type="reference_video", url=await upload_video_to_comfyapi(cls, reference_videos[key]))
|
||
)
|
||
reference_images = model.get("reference_images", {})
|
||
for key in reference_images:
|
||
media.append(
|
||
Wan27MediaItem(
|
||
type="reference_image",
|
||
url=await upload_image_to_comfyapi(cls, image=reference_images[key]),
|
||
)
|
||
)
|
||
if not media:
|
||
raise ValueError("At least one reference video or reference image must be provided.")
|
||
if len(media) > 5:
|
||
raise ValueError(
|
||
f"Too many references ({len(media)}). The maximum total of reference videos and images is 5."
|
||
)
|
||
|
||
initial_response = await sync_op(
|
||
cls,
|
||
ApiEndpoint(
|
||
path="/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis",
|
||
method="POST",
|
||
),
|
||
response_model=TaskCreationResponse,
|
||
data=Wan27ReferenceVideoTaskCreationRequest(
|
||
model=model["model"],
|
||
input=Wan27ReferenceVideoInputField(
|
||
prompt=model["prompt"],
|
||
negative_prompt=model["negative_prompt"] or None,
|
||
media=media,
|
||
),
|
||
parameters=Wan27ReferenceVideoParametersField(
|
||
resolution=model["resolution"],
|
||
ratio=model["ratio"],
|
||
duration=model["duration"],
|
||
watermark=watermark,
|
||
seed=seed,
|
||
),
|
||
),
|
||
)
|
||
if not initial_response.output:
|
||
raise Exception(f"An unknown error occurred: {initial_response.code} - {initial_response.message}")
|
||
response = await poll_op(
|
||
cls,
|
||
ApiEndpoint(path=f"/proxy/wan/api/v1/tasks/{initial_response.output.task_id}"),
|
||
response_model=VideoTaskStatusResponse,
|
||
status_extractor=lambda x: x.output.task_status,
|
||
poll_interval=7,
|
||
)
|
||
return IO.NodeOutput(await download_url_to_video_output(response.output.video_url))
|
||
|
||
|
||
class WanApiExtension(ComfyExtension):
|
||
@override
|
||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||
return [
|
||
WanTextToImageApi,
|
||
WanImageToImageApi,
|
||
WanTextToVideoApi,
|
||
WanImageToVideoApi,
|
||
WanReferenceVideoApi,
|
||
Wan2TextToVideoApi,
|
||
Wan2ImageToVideoApi,
|
||
Wan2VideoContinuationApi,
|
||
Wan2VideoEditApi,
|
||
Wan2ReferenceVideoApi,
|
||
]
|
||
|
||
|
||
async def comfy_entrypoint() -> WanApiExtension:
|
||
return WanApiExtension()
|