diff --git a/comfy_api_nodes/apis/meshy.py b/comfy_api_nodes/apis/meshy.py index 7d72e6e9149..fe9dcb55dbc 100644 --- a/comfy_api_nodes/apis/meshy.py +++ b/comfy_api_nodes/apis/meshy.py @@ -14,6 +14,7 @@ class InputShouldRemesh(TypedDict): class InputShouldTexture(TypedDict): should_texture: str enable_pbr: bool + texture_resolution: str texture_prompt: str texture_image: Input.Image | None @@ -25,7 +26,7 @@ class MeshyTaskResponse(BaseModel): class MeshyTextToModelRequest(BaseModel): mode: str = Field("preview") prompt: str = Field(..., max_length=600) - art_style: str = Field(..., description="'realistic' or 'sculpture'") + art_style: str = Field(...) ai_model: str = Field(...) topology: str | None = Field(..., description="'quad' or 'triangle'") target_polycount: int | None = Field(..., ge=100, le=300000) @@ -35,6 +36,7 @@ class MeshyTextToModelRequest(BaseModel): ) symmetry_mode: str = Field(..., description="'auto', 'off' or 'on'") pose_mode: str = Field(...) + ultra_mode: bool = Field(False) seed: int = Field(...) moderation: bool = Field(False) @@ -43,6 +45,7 @@ class MeshyRefineTask(BaseModel): mode: str = Field("refine") preview_task_id: str = Field(...) enable_pbr: bool | None = Field(...) + texture_resolution: str = Field(...) texture_prompt: str | None = Field(...) texture_image_url: str | None = Field(...) ai_model: str = Field(...) @@ -61,7 +64,9 @@ class MeshyImageToModelRequest(BaseModel): ) should_texture: bool = Field(...) enable_pbr: bool | None = Field(...) + texture_resolution: str | None = Field(None) pose_mode: str = Field(...) + ultra_mode: bool = Field(False) texture_prompt: str | None = Field(None, max_length=600) texture_image_url: str | None = Field(None) seed: int = Field(...) @@ -80,6 +85,7 @@ class MeshyMultiImageToModelRequest(BaseModel): ) should_texture: bool = Field(...) enable_pbr: bool | None = Field(...) + texture_resolution: str | None = Field(None) pose_mode: str = Field(...) texture_prompt: str | None = Field(None, max_length=600) texture_image_url: str | None = Field(None) @@ -103,8 +109,10 @@ class MeshyTextureRequest(BaseModel): ai_model: str = Field(...) enable_original_uv: bool = Field(...) enable_pbr: bool = Field(...) - text_style_prompt: str | None = Field(...) - image_style_url: str | None = Field(...) + texture_resolution: str = Field(...) + text_style_prompt: str | None = Field(None) + image_style_url: str | None = Field(None) + multiview_image_urls: list[str] | None = Field(None) class MeshyModelsUrls(BaseModel): diff --git a/comfy_api_nodes/apis/wan.py b/comfy_api_nodes/apis/wan.py index c64acae972c..523ac68c13e 100644 --- a/comfy_api_nodes/apis/wan.py +++ b/comfy_api_nodes/apis/wan.py @@ -184,6 +184,32 @@ class Wan27Text2VideoTaskCreationRequest(BaseModel): parameters: Wan27Text2VideoParametersField = Field(...) +class Wan3MediaItem(BaseModel): + type: str = Field(...) + url: str = Field(...) + + +class Wan3InputField(BaseModel): + prompt: str | None = Field(None) + media: list[Wan3MediaItem] | None = Field(None) + + +class Wan3ParametersField(BaseModel): + resolution: str = Field(...) + ratio: str = Field(...) + duration: int = Field(..., ge=-1, le=30) + seed: int = Field(..., ge=0, le=2147483647) + audio: bool = Field(True) + prompt_extend: bool = Field(True) + watermark: bool = Field(False) + + +class Wan3TaskCreationRequest(BaseModel): + model: str = Field(...) + input: Wan3InputField = Field(...) + parameters: Wan3ParametersField = Field(...) + + class TaskCreationOutputField(BaseModel): task_id: str = Field(...) task_status: str = Field(...) diff --git a/comfy_api_nodes/nodes_meshy.py b/comfy_api_nodes/nodes_meshy.py index 3a24f109556..f4624c26fd8 100644 --- a/comfy_api_nodes/nodes_meshy.py +++ b/comfy_api_nodes/nodes_meshy.py @@ -35,9 +35,9 @@ def define_schema(cls): display_name="Meshy: Text to Model", category="partner/3d/Meshy", inputs=[ - IO.Combo.Input("model", options=["latest"]), + IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), IO.String.Input("prompt", multiline=True, default=""), - IO.Combo.Input("style", options=["realistic", "sculpture"]), + IO.Combo.Input("style", options=["realistic"]), IO.DynamicCombo.Input( "should_remesh", options=[ @@ -75,6 +75,11 @@ def define_schema(cls): tooltip="Seed controls whether the node should re-run; " "results are non-deterministic regardless of seed.", ), + IO.Boolean.Input( + "ultra_mode", + default=False, + tooltip="Run an extra refinement pass for higher-fidelity geometry with finer surface detail.", + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only @@ -90,7 +95,13 @@ def define_schema(cls): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.8}""", + depends_on=IO.PriceBadgeDepends(widgets=["model", "ultra_mode"]), + expr=""" + ( + $credits := 20 + ((widgets.ultra_mode and widgets.model in ["meshy-7", "latest"]) ? 5 : 0); + {"type":"usd","usd": $round($credits * 0.0572, 4)} + ) + """, ), ) @@ -104,8 +115,11 @@ async def execute( symmetry_mode: str, pose_mode: str, seed: int, + ultra_mode: bool, ) -> IO.NodeOutput: validate_string(prompt, field_name="prompt", min_length=1, max_length=600) + if ultra_mode and model not in ("meshy-7", "latest"): + raise ValueError("ultra_mode requires the meshy-7 or latest model") response = await sync_op( cls, ApiEndpoint(path="/proxy/meshy/openapi/v2/text-to-3d", method="POST"), @@ -119,6 +133,7 @@ async def execute( should_remesh=should_remesh["should_remesh"] == "true", symmetry_mode=symmetry_mode, pose_mode=pose_mode.lower(), + ultra_mode=ultra_mode, seed=seed, ), ) @@ -148,14 +163,12 @@ def define_schema(cls): category="partner/3d/Meshy", description="Refine a previously created draft model.", inputs=[ - IO.Combo.Input("model", options=["latest"]), + IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"), IO.Boolean.Input( "enable_pbr", default=False, - tooltip="Generate PBR Maps (metallic, roughness, normal) in addition to the base color. " - "Note: this should be set to false when using Sculpture style, " - "as Sculpture style generates its own set of PBR maps.", + tooltip="Generate PBR Maps (metallic, roughness, normal) in addition to the base color.", advanced=True, ), IO.String.Input( @@ -170,6 +183,11 @@ def define_schema(cls): tooltip="Only one of 'texture_image' or 'texture_prompt' may be used at the same time.", optional=True, ), + IO.Combo.Input( + "texture_resolution", + options=["2k", "4k", "8k"], + tooltip="Base color texture resolution. Higher resolutions capture more surface detail.", + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only @@ -185,7 +203,13 @@ def define_schema(cls): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.4}""", + depends_on=IO.PriceBadgeDepends(widgets=["texture_resolution"]), + expr=""" + ( + $credits := widgets.texture_resolution = "8k" ? 15 : 10; + {"type":"usd","usd": $round($credits * 0.0572, 4)} + ) + """, ), ) @@ -196,6 +220,7 @@ async def execute( meshy_task_id: str, enable_pbr: bool, texture_prompt: str, + texture_resolution: str, texture_image: Input.Image | None = None, ) -> IO.NodeOutput: if texture_prompt and texture_image is not None: @@ -212,6 +237,7 @@ async def execute( data=MeshyRefineTask( preview_task_id=meshy_task_id, enable_pbr=enable_pbr, + texture_resolution=texture_resolution, texture_prompt=texture_prompt if texture_prompt else None, texture_image_url=texture_image_url, ai_model=model, @@ -242,7 +268,7 @@ def define_schema(cls): display_name="Meshy: Image to Model", category="partner/3d/Meshy", inputs=[ - IO.Combo.Input("model", options=["latest"]), + IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), IO.Image.Input("image"), IO.DynamicCombo.Input( "should_remesh", @@ -290,6 +316,12 @@ def define_schema(cls): "may be used at the same time.", optional=True, ), + IO.Combo.Input( + "texture_resolution", + options=["2k", "4k", "8k"], + tooltip="Base color texture resolution. " + "Higher resolutions capture more surface detail.", + ), ], ), IO.DynamicCombo.Option("false", []), @@ -313,6 +345,11 @@ def define_schema(cls): tooltip="Seed controls whether the node should re-run; " "results are non-deterministic regardless of seed.", ), + IO.Boolean.Input( + "ultra_mode", + default=False, + tooltip="Run an extra refinement pass for higher-fidelity geometry with finer surface detail.", + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only @@ -328,11 +365,17 @@ def define_schema(cls): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]), + depends_on=IO.PriceBadgeDepends( + widgets=["model", "should_texture", "should_texture.texture_resolution", "ultra_mode"], + ), expr=""" ( - $prices := {"true": 1.2, "false": 0.8}; - {"type":"usd","usd": $lookup($prices, widgets.should_texture)} + $textured := widgets.should_texture = "true"; + $resolution := $textured ? $lookup(widgets, "should_texture.texture_resolution") : "2k"; + $credits := ($textured ? 30 : 20) + + ($resolution = "8k" ? 5 : 0) + + ((widgets.ultra_mode and widgets.model in ["meshy-7", "latest"]) ? 5 : 0); + {"type":"usd","usd": $round($credits * 0.0572, 4)} ) """, ), @@ -348,7 +391,10 @@ async def execute( should_texture: InputShouldTexture, pose_mode: str, seed: int, + ultra_mode: bool, ) -> IO.NodeOutput: + if ultra_mode and model not in ("meshy-7", "latest"): + raise ValueError("ultra_mode requires the meshy-7 or latest model") texture = should_texture["should_texture"] == "true" texture_image_url = texture_prompt = None if texture: @@ -376,7 +422,9 @@ async def execute( should_remesh=should_remesh["should_remesh"] == "true", should_texture=texture, enable_pbr=should_texture.get("enable_pbr", None), + texture_resolution=should_texture.get("texture_resolution", None), pose_mode=pose_mode.lower(), + ultra_mode=ultra_mode, texture_prompt=texture_prompt, texture_image_url=texture_image_url, seed=seed, @@ -407,7 +455,7 @@ def define_schema(cls): display_name="Meshy: Multi-Image to Model", category="partner/3d/Meshy", inputs=[ - IO.Combo.Input("model", options=["latest"]), + IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), IO.Autogrow.Input( "images", template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=2, max=4), @@ -458,6 +506,12 @@ def define_schema(cls): "may be used at the same time.", optional=True, ), + IO.Combo.Input( + "texture_resolution", + options=["2k", "4k", "8k"], + tooltip="Base color texture resolution. " + "Higher resolutions capture more surface detail.", + ), ], ), IO.DynamicCombo.Option("false", []), @@ -496,11 +550,15 @@ def define_schema(cls): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]), + depends_on=IO.PriceBadgeDepends( + widgets=["should_texture", "should_texture.texture_resolution"], + ), expr=""" ( - $prices := {"true": 0.6, "false": 0.2}; - {"type":"usd","usd": $lookup($prices, widgets.should_texture)} + $textured := widgets.should_texture = "true"; + $resolution := $textured ? $lookup(widgets, "should_texture.texture_resolution") : "2k"; + $credits := ($textured ? 30 : 20) + ($resolution = "8k" ? 5 : 0); + {"type":"usd","usd": $round($credits * 0.0572, 4)} ) """, ), @@ -546,6 +604,7 @@ async def execute( should_remesh=should_remesh["should_remesh"] == "true", should_texture=texture, enable_pbr=should_texture.get("enable_pbr", None), + texture_resolution=should_texture.get("texture_resolution", None), pose_mode=pose_mode.lower(), texture_prompt=texture_prompt, texture_image_url=texture_image_url, @@ -609,7 +668,7 @@ def define_schema(cls): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.2}""", + expr="""{"type":"usd","usd": 0.286}""", ), ) @@ -681,7 +740,7 @@ def define_schema(cls): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.12}""", + expr="""{"type":"usd","usd": 0.1716}""", ), ) @@ -724,7 +783,7 @@ def define_schema(cls): display_name="Meshy: Texture Model", category="partner/3d/Meshy", inputs=[ - IO.Combo.Input("model", options=["latest"]), + IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"), IO.Boolean.Input( "enable_original_uv", @@ -748,10 +807,15 @@ def define_schema(cls): tooltip="A 2d image to guide the texturing process. " "Can not be used at the same time with 'text_style_prompt'.", ), + IO.Combo.Input( + "texture_resolution", + options=["2k", "4k", "8k"], + tooltip="Base color texture resolution. Higher resolutions capture more surface detail.", + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only - IO.Custom("MODEL_TASK_ID").Output(display_name="meshy_task_id"), + IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), IO.File3DGLB.Output(display_name="GLB"), IO.File3DFBX.Output(display_name="FBX"), ], @@ -763,7 +827,13 @@ def define_schema(cls): is_api_node=True, is_output_node=True, price_badge=IO.PriceBadge( - expr="""{"type":"usd","usd":0.4}""", + depends_on=IO.PriceBadgeDepends(widgets=["texture_resolution"]), + expr=""" + ( + $credits := widgets.texture_resolution = "8k" ? 15 : 10; + {"type":"usd","usd": $round($credits * 0.0572, 4)} + ) + """, ), ) @@ -775,6 +845,7 @@ async def execute( enable_original_uv: bool, pbr: bool, text_style_prompt: str, + texture_resolution: str, image_style: Input.Image | None = None, ) -> IO.NodeOutput: if text_style_prompt and image_style is not None: @@ -793,6 +864,7 @@ async def execute( ai_model=model, enable_original_uv=enable_original_uv, enable_pbr=pbr, + texture_resolution=texture_resolution, text_style_prompt=text_style_prompt if text_style_prompt else None, image_style_url=image_style_url, ), @@ -813,6 +885,108 @@ async def execute( ) +class MeshyTextureMultiViewNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="MeshyTextureMultiViewNode", + display_name="Meshy: Texture Model (Multi-View)", + category="partner/3d/Meshy", + description="Texture a previously created model using 1 to 4 reference views of the same object.", + inputs=[ + IO.Combo.Input("model", options=["meshy-7"]), + IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"), + IO.Autogrow.Input( + "multiview_images", + template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=1, max=4), + tooltip="Reference views of the same object. The first image is the primary (front) view; " + "the order of the remaining views does not matter.", + ), + IO.Boolean.Input( + "enable_original_uv", + default=True, + tooltip="Use the original UV of the model instead of generating new UVs. " + "When enabled, Meshy preserves existing textures from the uploaded model. " + "If the model has no original UV, the quality of the output might not be as good.", + advanced=True, + ), + IO.Boolean.Input("pbr", default=False, advanced=True), + IO.Combo.Input( + "texture_resolution", + options=["2k", "4k", "8k"], + tooltip="Base color texture resolution. Higher resolutions capture more surface detail.", + ), + ], + outputs=[ + IO.String.Output(display_name="model_file"), # for backward compatibility only + IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), + IO.File3DGLB.Output(display_name="GLB"), + IO.File3DFBX.Output(display_name="FBX"), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + is_output_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["texture_resolution"]), + expr=""" + ( + $credits := widgets.texture_resolution = "8k" ? 15 : 10; + {"type":"usd","usd": $round($credits * 0.0572, 4)} + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + model: str, + meshy_task_id: str, + multiview_images: IO.Autogrow.Type, + enable_original_uv: bool, + pbr: bool, + texture_resolution: str, + ) -> IO.NodeOutput: + reference_views = list(multiview_images.values()) + view_count = sum(v.shape[0] if len(v.shape) > 3 else 1 for v in reference_views) + if not 1 <= view_count <= 4: + raise ValueError("multiview_images must contain 1 to 4 images") + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/retexture", method="POST"), + response_model=MeshyTaskResponse, + data=MeshyTextureRequest( + input_task_id=meshy_task_id, + ai_model=model, + enable_original_uv=enable_original_uv, + enable_pbr=pbr, + texture_resolution=texture_resolution, + multiview_image_urls=await upload_images_to_comfyapi( + cls, reference_views, max_images=4, wait_label="Uploading reference views" + ), + ), + ) + task_id = response.result + result = await poll_op( + cls, + ApiEndpoint(path=f"/proxy/meshy/openapi/v1/retexture/{task_id}"), + response_model=MeshyModelResult, + status_extractor=lambda r: r.status, + progress_extractor=lambda r: r.progress, + ) + return IO.NodeOutput( + f"{task_id}.glb", + task_id, + await download_url_to_file_3d(result.model_urls.glb, "glb", task_id=task_id), + await download_url_to_file_3d(result.model_urls.fbx, "fbx", task_id=task_id), + ) + + class MeshyExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -824,6 +998,7 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]: MeshyRigModelNode, MeshyAnimateModelNode, MeshyTextureNode, + MeshyTextureMultiViewNode, ] diff --git a/comfy_api_nodes/nodes_wan.py b/comfy_api_nodes/nodes_wan.py index 1782739fd7b..b11c528bcca 100644 --- a/comfy_api_nodes/nodes_wan.py +++ b/comfy_api_nodes/nodes_wan.py @@ -34,6 +34,10 @@ Wan27VideoEditInputField, Wan27VideoEditParametersField, Wan27VideoEditTaskCreationRequest, + Wan3InputField, + Wan3MediaItem, + Wan3ParametersField, + Wan3TaskCreationRequest, ) from comfy_api_nodes.util import ( ApiEndpoint, @@ -53,10 +57,41 @@ validate_string, validate_video_duration, ) +from comfy_api_nodes.util.client import FAILED_STATUSES, QUEUED_STATUSES RES_IN_PARENS = re.compile(r"\((\d+)\s*[x×]\s*(\d+)\)") +WAN3_QUEUED_STATUSES = [*QUEUED_STATUSES, "pending"] +WAN3_FAILED_STATUSES = [*FAILED_STATUSES, "unknown"] + +_WAN3_REF_TAG_RE = re.compile(r"@(image|video|audio)(?P\d*)(?!\w)", re.IGNORECASE | re.ASCII) + + +def _wan3_rewrite_reference_prompt(prompt: str, counts: dict[str, int]) -> str: + parts = [] + pos = 0 + prev_end = -1 + for match in _WAN3_REF_TAG_RE.finditer(prompt): + start = match.start() + before = prompt[start - 1] if start > 0 else "" + if (before.isascii() and (before.isalnum() or before == "_")) and start != prev_end: + continue + kind = match.group(1).lower() + idx = int(match.group("idx") or 1) + total = counts[kind] + if not 1 <= idx <= total: + raise ValueError( + f"The prompt references @{kind.capitalize()}{idx}, " + f"but only {total} reference {kind} inputs are connected." + ) + parts.append(prompt[pos:start]) + parts.append(f"{kind.capitalize()} {idx}") + pos = match.end() + prev_end = match.end() + parts.append(prompt[pos:]) + return "".join(parts) + class WanTextToImageApi(IO.ComfyNode): @classmethod @@ -1648,6 +1683,390 @@ async def execute( return IO.NodeOutput(await download_url_to_video_output(response.output.video_url)) +class Wan3ReferenceToVideoApi(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="Wan3ReferenceToVideoApi", + display_name="Wan 3.0 Reference to Video", + category="partner/video/Wan", + description="Generates a video from a text prompt and optional reference images, videos, and audio " + "using the Wan 3.0 model. Reference media can be combined freely and mentioned in the prompt " + "as @Image1, @Video1, @Audio1.", + inputs=[ + IO.DynamicCombo.Input( + "model", + options=[ + IO.DynamicCombo.Option( + "wan3.0-video", + [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Prompt describing the elements and visual features. " + "Supports English and Chinese. Refer to connected reference media " + "as @Image1, @Video1, @Audio1, numbered per type in input order.", + ), + IO.Combo.Input( + "resolution", + options=["1080P", "720P", "480P"], + ), + IO.Combo.Input( + "ratio", + options=["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4"], + tooltip="Aspect ratio of the output video. With 'adaptive', the output " + "dimensions are derived from the input media.", + ), + IO.Combo.Input( + "duration", + options=["auto", *(str(i) for i in range(2, 31))], + default="5", + tooltip="Output duration in seconds. With 'auto', the model chooses " + "a duration that fits the prompt and reference media. The combined " + "duration of reference videos and output must not exceed 30 seconds.", + ), + IO.Boolean.Input( + "audio", + default=True, + tooltip="Whether the output video contains an audio track.", + ), + IO.Boolean.Input( + "prompt_extend", + default=True, + tooltip="Whether to enhance the prompt with AI assistance.", + advanced=True, + ), + IO.Autogrow.Input( + "reference_images", + template=IO.Autogrow.TemplateNames( + IO.Image.Input("reference_image"), + names=[f"image{i}" for i in range(1, 11)], + min=0, + ), + ), + IO.Autogrow.Input( + "reference_videos", + template=IO.Autogrow.TemplateNames( + IO.Video.Input("reference_video"), + names=[f"video{i}" for i in range(1, 6)], + min=0, + ), + ), + IO.Autogrow.Input( + "reference_audios", + template=IO.Autogrow.TemplateNames( + IO.Audio.Input("reference_audio"), + names=[f"audio{i}" for i in range(1, 6)], + min=0, + ), + ), + ], + ), + ], + ), + IO.Int.Input( + "seed", + default=42, + 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=""" + ( + $ppsTable := { "480p": 0.0715, "720p": 0.143, "1080p": 0.286 }; + $pps := $lookup($ppsTable, $lookup(widgets, "model.resolution")); + $dur := $lookup(widgets, "model.duration"); + $dur = "auto" + ? { "type": "usd", "usd": $pps, "format": {"suffix": "/second"} } + : ( + $minUsd := $round($number($dur) * $pps, 2); + $maxUsd := $round($min([$number($dur) + 15, 30]) * $pps, 2); + $minUsd = $maxUsd + ? { "type": "usd", "usd": $minUsd } + : { "type": "range_usd", "min_usd": $minUsd, "max_usd": $maxUsd } + ) + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + model: dict, + seed: int, + watermark: bool, + ): + reference_images = model.get("reference_images", {}) + reference_videos = model.get("reference_videos", {}) + reference_audios = model.get("reference_audios", {}) + for key in reference_images: + if get_number_of_images(reference_images[key]) != 1: + raise ValueError(f"Reference image input '{key}' must contain exactly one image, not a batch.") + total_video_seconds = 0.0 + for key in reference_videos: + validate_video_duration(reference_videos[key], max_duration=15) + try: + total_video_seconds += reference_videos[key].get_duration() + except Exception: + pass + if total_video_seconds > 15.0001: + raise ValueError( + f"The total duration of reference videos ({total_video_seconds:.2f}s) exceeds the 15s limit." + ) + total_audio_seconds = 0.0 + for key in reference_audios: + validate_audio_duration(reference_audios[key], max_duration=15) + total_audio_seconds += reference_audios[key]["waveform"].shape[-1] / int( + reference_audios[key]["sample_rate"] + ) + if total_audio_seconds > 15.0001: + raise ValueError( + f"The total duration of reference audios ({total_audio_seconds:.2f}s) exceeds the 15s limit." + ) + duration = -1 if model["duration"] == "auto" else int(model["duration"]) + if duration != -1 and total_video_seconds + duration > 30.0001: + raise ValueError( + f"Reference video duration ({total_video_seconds:.2f}s) plus output duration ({duration}s) " + "exceeds the 30s combined limit." + ) + prompt = _wan3_rewrite_reference_prompt( + model["prompt"], + {"image": len(reference_images), "video": len(reference_videos), "audio": len(reference_audios)}, + ) + validate_string(prompt, strip_whitespace=False, max_length=20000) + if not prompt.strip() and not (reference_images or reference_videos or reference_audios): + raise ValueError("Provide a prompt or at least one reference input.") + media = [] + for key in reference_images: + media.append( + Wan3MediaItem(type="reference_image", url=await upload_image_to_comfyapi(cls, reference_images[key])) + ) + for key in reference_videos: + media.append( + Wan3MediaItem(type="reference_video", url=await upload_video_to_comfyapi(cls, reference_videos[key])) + ) + for key in reference_audios: + media.append( + Wan3MediaItem( + type="reference_audio", + url=await upload_audio_to_comfyapi( + cls, + reference_audios[key], + 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=Wan3TaskCreationRequest( + model=model["model"], + input=Wan3InputField(prompt=prompt or None, media=media or None), + parameters=Wan3ParametersField( + resolution=model["resolution"], + ratio=model["ratio"], + duration=duration, + seed=seed, + audio=model["audio"], + prompt_extend=model["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, + queued_statuses=WAN3_QUEUED_STATUSES, + failed_statuses=WAN3_FAILED_STATUSES, + poll_interval=10, + ) + return IO.NodeOutput(await download_url_to_video_output(response.output.video_url)) + + +class Wan3ImageToVideoApi(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="Wan3ImageToVideoApi", + display_name="Wan 3.0 Image to Video", + category="partner/video/Wan", + description="Generates a video from a first-frame image, with optional last-frame control, " + "using the Wan 3.0 model.", + inputs=[ + IO.DynamicCombo.Input( + "model", + options=[ + IO.DynamicCombo.Option( + "wan3.0-video", + [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Prompt describing the elements and visual features. " + "Supports English and Chinese.", + ), + IO.Combo.Input( + "resolution", + options=["1080P", "720P", "480P"], + ), + IO.Combo.Input( + "ratio", + options=["adaptive", "16:9", "9:16", "1:1", "4:3", "3:4"], + tooltip="Aspect ratio of the output video. With 'adaptive', the output " + "dimensions are derived from the first frame.", + ), + IO.Combo.Input( + "duration", + options=["auto", *(str(i) for i in range(2, 31))], + default="5", + tooltip="Output duration in seconds. With 'auto', the model chooses " + "a duration that fits the prompt.", + ), + IO.Boolean.Input( + "audio", + default=True, + tooltip="Whether the output video contains an audio track.", + ), + IO.Boolean.Input( + "prompt_extend", + default=True, + tooltip="Whether to enhance the prompt with AI assistance.", + advanced=True, + ), + ], + ), + ], + ), + IO.Image.Input( + "first_frame", + tooltip="First frame image.", + ), + IO.Image.Input( + "last_frame", + optional=True, + tooltip="Last frame image. The model generates a video transitioning from first to last frame.", + ), + IO.Int.Input( + "seed", + default=42, + 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=""" + ( + $ppsTable := { "480p": 0.0715, "720p": 0.143, "1080p": 0.286 }; + $pps := $lookup($ppsTable, $lookup(widgets, "model.resolution")); + $dur := $lookup(widgets, "model.duration"); + $dur = "auto" + ? { "type": "usd", "usd": $pps, "format": {"suffix": "/second"} } + : { "type": "usd", "usd": $round($number($dur) * $pps, 2) } + ) + """, + ), + ) + + @classmethod + async def execute( + cls, + model: dict, + first_frame: Input.Image, + seed: int, + watermark: bool, + last_frame: Input.Image | None = None, + ): + if get_number_of_images(first_frame) != 1: + raise ValueError("Exactly one first_frame image is required.") + if last_frame is not None and get_number_of_images(last_frame) != 1: + raise ValueError("Exactly one last_frame image is required.") + validate_string(model["prompt"], strip_whitespace=False, max_length=20000) + media = [Wan3MediaItem(type="first_frame", url=await upload_image_to_comfyapi(cls, first_frame))] + if last_frame is not None: + media.append(Wan3MediaItem(type="last_frame", url=await upload_image_to_comfyapi(cls, 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=Wan3TaskCreationRequest( + model=model["model"], + input=Wan3InputField(prompt=model["prompt"] or None, media=media), + parameters=Wan3ParametersField( + resolution=model["resolution"], + ratio=model["ratio"], + duration=-1 if model["duration"] == "auto" else int(model["duration"]), + seed=seed, + audio=model["audio"], + prompt_extend=model["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, + queued_statuses=WAN3_QUEUED_STATUSES, + failed_statuses=WAN3_FAILED_STATUSES, + poll_interval=10, + ) + return IO.NodeOutput(await download_url_to_video_output(response.output.video_url)) + + class HappyHorseTextToVideoApi(IO.ComfyNode): @classmethod def define_schema(cls): @@ -2342,6 +2761,8 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]: Wan2VideoContinuationApi, Wan2VideoEditApi, Wan2ReferenceVideoApi, + Wan3ReferenceToVideoApi, + Wan3ImageToVideoApi, HappyHorseTextToVideoApi, HappyHorseImageToVideoApi, HappyHorseVideoEditApi, diff --git a/comfy_extras/nodes_image_compare.py b/comfy_extras/nodes_image_compare.py index 58af9ae82df..64c3505c0fc 100644 --- a/comfy_extras/nodes_image_compare.py +++ b/comfy_extras/nodes_image_compare.py @@ -15,7 +15,6 @@ def define_schema(cls): description="Compares two images side by side with a slider.", category="image", essentials_category="Image Tools", - is_experimental=True, is_output_node=True, inputs=[ IO.Image.Input("image_a", optional=True), diff --git a/requirements.txt b/requirements.txt index 32ec9488965..ee068cb5e9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ comfyui-frontend-package==1.49.6 -comfyui-workflow-templates==0.11.44 +comfyui-workflow-templates==0.11.46 comfyui-embedded-docs==0.5.10 torch torchsde