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()