diff --git a/README.md b/README.md index ac4190f..115af43 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,9 @@ This node pack focuses on **image / video / edit** — see the full **[node cata | AtlasCloud Seedance 2.0 Mini Reference-to-Video | bytedance/seedance-2.0-mini/reference-to-video | | AtlasCloud Avatar Omni Human 1.5 | bytedance/avatar-omni-human-v1.5 | | AtlasCloud Image Upscaler | atlascloud/image-upscaler | +| AtlasCloud Face Swap (Image) | atlascloud/face-swap-image | +| AtlasCloud Photo Cleanup | atlascloud/photo-cleanup | +| AtlasCloud Face Swap (Video) | atlascloud/face-swap-video | | AtlasCloud Seedance 2.0 Reference-to-Video Upscaled | bytedance/seedance-2.0/reference-to-video-upscaled | | AtlasCloud Seedance 2.0 Fast Reference-to-Video Upscaled | bytedance/seedance-2.0-fast/reference-to-video-upscaled | | AtlasCloud Vidu Q3 Reference-to-Video | vidu/q3/reference-to-video | diff --git a/src/atlascloud_comfyui/nodes/image/atlascloud_face_swap_image.py b/src/atlascloud_comfyui/nodes/image/atlascloud_face_swap_image.py new file mode 100644 index 0000000..256a333 --- /dev/null +++ b/src/atlascloud_comfyui/nodes/image/atlascloud_face_swap_image.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any, Dict, Tuple + +from ..auth.atlas_client_node import AtlasClientHandle + + +class AtlasFaceSwapImage: + CATEGORY = "AtlasCloud/Image" + FUNCTION = "run" + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("image_url", "prediction_id") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "atlas_client": ("ATLAS_CLIENT",), + "source_face": ("STRING", {"default": "", "tooltip": "Source Face — the face to transfer (clear front-facing photo)"}), + "target_image": ("STRING", {"default": "", "tooltip": "Target Image — the photo whose face gets replaced (pose/clothing/background preserved)"}), + }, + "optional": { + "size": ( + ["auto", "2048*2048", "2304*1728", "1728*2304", "2720*1530", "1530*2720", "2496*1664", "1664*2496", "1024*1024", "1536*1536", "1776*1328", "1328*1776", "2048*1152", "1152*2048"], + {"default": "auto", "tooltip": "Output resolution/size"}, + ), + "output_format": (["jpeg", "png"], {"default": "jpeg", "tooltip": "Output image format"}), + "poll_interval_sec": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 10.0, "tooltip": "Polling interval (seconds)"}), + "timeout_sec": ("INT", {"default": 300, "min": 30, "max": 7200, "tooltip": "Timeout (seconds)"}), + }, + } + + def run( + self, + atlas_client: AtlasClientHandle, + source_face: str, + target_image: str, + size: str = "auto", + output_format: str = "jpeg", + poll_interval_sec: float = 2.0, + timeout_sec: int = 300, + ) -> Tuple[str, str]: + face = (source_face or "").strip() + if not face: + raise RuntimeError("source_face is required for AtlasCloud Face Swap (Image)") + + target = (target_image or "").strip() + if not target: + raise RuntimeError("target_image is required for AtlasCloud Face Swap (Image)") + + client = atlas_client.client + + payload: Dict[str, Any] = { + "model": "atlascloud/face-swap-image", + "image": face, + "Image": target, + "size": size, + "output_format": output_format, + } + + prediction_id = client.generate_image(payload) + result = client.poll_prediction( + prediction_id, + poll_interval_sec=float(poll_interval_sec), + timeout_sec=float(timeout_sec), + ) + + outputs = (result.get("data") or {}).get("outputs") or [] + if not outputs: + raise RuntimeError(f"No outputs returned for prediction {prediction_id}: {result}") + + first = outputs[0] + if isinstance(first, dict): + url = first.get("url") or first.get("image") or first.get("output") + if isinstance(url, str) and url.strip(): + return (url, prediction_id) + raise RuntimeError(f"Unexpected output object for prediction {prediction_id}: {first}") + + if not isinstance(first, str): + raise RuntimeError(f"Unexpected output type for prediction {prediction_id}: {type(first).__name__} {first!r}") + + return (first, prediction_id) diff --git a/src/atlascloud_comfyui/nodes/image/atlascloud_photo_cleanup.py b/src/atlascloud_comfyui/nodes/image/atlascloud_photo_cleanup.py new file mode 100644 index 0000000..7812915 --- /dev/null +++ b/src/atlascloud_comfyui/nodes/image/atlascloud_photo_cleanup.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from typing import Any, Dict, Tuple + +from ..auth.atlas_client_node import AtlasClientHandle + + +class AtlasPhotoCleanup: + CATEGORY = "AtlasCloud/Image" + FUNCTION = "run" + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("image_url", "prediction_id") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "atlas_client": ("ATLAS_CLIENT",), + "image": ("STRING", {"default": "", "tooltip": "Input image URL to clean up (dust/scratches/noise removal)"}), + }, + "optional": { + "output_format": (["png", "jpeg", "webp", "jpg"], {"default": "png", "tooltip": "Output image format"}), + "poll_interval_sec": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 10.0, "tooltip": "Polling interval (seconds)"}), + "timeout_sec": ("INT", {"default": 300, "min": 30, "max": 7200, "tooltip": "Timeout (seconds)"}), + }, + } + + def run( + self, + atlas_client: AtlasClientHandle, + image: str, + output_format: str = "png", + poll_interval_sec: float = 2.0, + timeout_sec: int = 300, + ) -> Tuple[str, str]: + img = (image or "").strip() + if not img: + raise RuntimeError("image is required for AtlasCloud Photo Cleanup") + + client = atlas_client.client + + payload: Dict[str, Any] = { + "model": "atlascloud/photo-cleanup", + "image": img, + "output_format": output_format, + } + + prediction_id = client.generate_image(payload) + result = client.poll_prediction( + prediction_id, + poll_interval_sec=float(poll_interval_sec), + timeout_sec=float(timeout_sec), + ) + + outputs = (result.get("data") or {}).get("outputs") or [] + if not outputs: + raise RuntimeError(f"No outputs returned for prediction {prediction_id}: {result}") + + first = outputs[0] + if isinstance(first, dict): + url = first.get("url") or first.get("image") or first.get("output") + if isinstance(url, str) and url.strip(): + return (url, prediction_id) + raise RuntimeError(f"Unexpected output object for prediction {prediction_id}: {first}") + + if not isinstance(first, str): + raise RuntimeError(f"Unexpected output type for prediction {prediction_id}: {type(first).__name__} {first!r}") + + return (first, prediction_id) diff --git a/src/atlascloud_comfyui/nodes/video/atlascloud_face_swap_video.py b/src/atlascloud_comfyui/nodes/video/atlascloud_face_swap_video.py new file mode 100644 index 0000000..dc9c677 --- /dev/null +++ b/src/atlascloud_comfyui/nodes/video/atlascloud_face_swap_video.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any, Dict, Tuple + +from ..auth.atlas_client_node import AtlasClientHandle + + +class AtlasFaceSwapVideo: + CATEGORY = "AtlasCloud/Video" + FUNCTION = "run" + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("video_url", "prediction_id") + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "atlas_client": ("ATLAS_CLIENT",), + "source_face": ("STRING", {"default": "", "tooltip": "Source Face — clear front-facing photo of the face to place into the video"}), + "video": ("STRING", {"default": "", "tooltip": "Target Video — the video whose person will be replaced (MP4/MOV, 2-10s, <=100MB)"}), + }, + "optional": { + "resolution": (["720P", "1080P"], {"default": "1080P", "tooltip": "Output video resolution"}), + "poll_interval_sec": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 10.0, "tooltip": "Polling interval (seconds)"}), + "timeout_sec": ("INT", {"default": 900, "min": 30, "max": 7200, "tooltip": "Timeout (seconds)"}), + }, + } + + def run( + self, + atlas_client: AtlasClientHandle, + source_face: str, + video: str, + resolution: str = "1080P", + poll_interval_sec: float = 2.0, + timeout_sec: int = 900, + ) -> Tuple[str, str]: + face = (source_face or "").strip() + if not face: + raise RuntimeError("source_face is required for AtlasCloud Face Swap (Video)") + + vid = (video or "").strip() + if not vid: + raise RuntimeError("video is required for AtlasCloud Face Swap (Video)") + + client = atlas_client.client + + payload: Dict[str, Any] = { + "model": "atlascloud/face-swap-video", + "image": face, + "video": vid, + "resolution": resolution, + } + + prediction_id = client.generate_video(payload) + result = client.poll_prediction( + prediction_id, + poll_interval_sec=float(poll_interval_sec), + timeout_sec=float(timeout_sec), + ) + + outputs = (result.get("data") or {}).get("outputs") or [] + if not outputs: + raise RuntimeError(f"No outputs returned for prediction {prediction_id}: {result}") + + return (outputs[0], prediction_id) diff --git a/src/atlascloud_comfyui/registry.py b/src/atlascloud_comfyui/registry.py index 3d4fda7..f1b47ec 100644 --- a/src/atlascloud_comfyui/registry.py +++ b/src/atlascloud_comfyui/registry.py @@ -298,6 +298,9 @@ from atlascloud_comfyui.nodes.video.bytedance_seedance_2_0_mini_r2v import AtlasSeedance20MiniReferenceToVideo from atlascloud_comfyui.nodes.video.bytedance_avatar_omni_human_v15 import AtlasAvatarOmniHumanV15 from atlascloud_comfyui.nodes.image.atlascloud_image_upscaler import AtlasImageUpscaler +from atlascloud_comfyui.nodes.image.atlascloud_face_swap_image import AtlasFaceSwapImage +from atlascloud_comfyui.nodes.image.atlascloud_photo_cleanup import AtlasPhotoCleanup +from atlascloud_comfyui.nodes.video.atlascloud_face_swap_video import AtlasFaceSwapVideo from atlascloud_comfyui.nodes.deprecated.video.bytedance_seedance_2_0_t2v_upscaled import AtlasSeedance20TextToVideoUpscaled from atlascloud_comfyui.nodes.deprecated.video.bytedance_seedance_2_0_i2v_upscaled import AtlasSeedance20ImageToVideoUpscaled from atlascloud_comfyui.nodes.deprecated.video.bytedance_seedance_2_0_r2v_upscaled import AtlasSeedance20ReferenceToVideoUpscaled @@ -450,6 +453,9 @@ "AtlasCloud Seedance 2.0 Mini Reference-to-Video": AtlasSeedance20MiniReferenceToVideo, "AtlasCloud Avatar Omni Human 1.5": AtlasAvatarOmniHumanV15, "AtlasCloud Image Upscaler": AtlasImageUpscaler, + "AtlasCloud Face Swap (Image)": AtlasFaceSwapImage, + "AtlasCloud Photo Cleanup": AtlasPhotoCleanup, + "AtlasCloud Face Swap (Video)": AtlasFaceSwapVideo, "AtlasCloud Seedance 2.0 Text-to-Video Upscaled": AtlasSeedance20TextToVideoUpscaled, "AtlasCloud Seedance 2.0 Image-to-Video Upscaled": AtlasSeedance20ImageToVideoUpscaled, "AtlasCloud Seedance 2.0 Reference-to-Video Upscaled": AtlasSeedance20ReferenceToVideoUpscaled, @@ -795,6 +801,9 @@ "AtlasCloud Seedance 2.0 Mini Reference-to-Video": "AtlasCloud Seedance 2.0 Mini Reference-to-Video", "AtlasCloud Avatar Omni Human 1.5": "AtlasCloud Avatar Omni Human 1.5", "AtlasCloud Image Upscaler": "AtlasCloud Image Upscaler", + "AtlasCloud Face Swap (Image)": "AtlasCloud Face Swap (Image)", + "AtlasCloud Photo Cleanup": "AtlasCloud Photo Cleanup", + "AtlasCloud Face Swap (Video)": "AtlasCloud Face Swap (Video)", "AtlasCloud Seedance 2.0 Text-to-Video Upscaled": "AtlasCloud Seedance 2.0 Text-to-Video Upscaled", "AtlasCloud Seedance 2.0 Image-to-Video Upscaled": "AtlasCloud Seedance 2.0 Image-to-Video Upscaled", "AtlasCloud Seedance 2.0 Reference-to-Video Upscaled": "AtlasCloud Seedance 2.0 Reference-to-Video Upscaled", diff --git a/tests/test_new_nodes_2026_07_16.py b/tests/test_new_nodes_2026_07_16.py new file mode 100644 index 0000000..7f100d2 --- /dev/null +++ b/tests/test_new_nodes_2026_07_16.py @@ -0,0 +1,75 @@ +"""Metadata-only tests for newly added nodes (2026-07-16). + +AtlasCloud tools: Face Swap (Image), Photo Cleanup, Face Swap (Video). +These tests MUST NOT require ATLASCLOUD_API_KEY. +""" + + +def test_face_swap_image_metadata(): + from src.atlascloud_comfyui.nodes.image.atlascloud_face_swap_image import ( + AtlasFaceSwapImage, + ) + + required = AtlasFaceSwapImage.INPUT_TYPES()["required"] + assert "atlas_client" in required + assert "source_face" in required + assert "target_image" in required + assert AtlasFaceSwapImage.RETURN_TYPES == ("STRING", "STRING") + assert AtlasFaceSwapImage.CATEGORY == "AtlasCloud/Image" + + +def test_photo_cleanup_metadata(): + from src.atlascloud_comfyui.nodes.image.atlascloud_photo_cleanup import ( + AtlasPhotoCleanup, + ) + + required = AtlasPhotoCleanup.INPUT_TYPES()["required"] + assert "atlas_client" in required + assert "image" in required + assert AtlasPhotoCleanup.RETURN_TYPES == ("STRING", "STRING") + assert AtlasPhotoCleanup.CATEGORY == "AtlasCloud/Image" + + +def test_face_swap_video_metadata(): + from src.atlascloud_comfyui.nodes.video.atlascloud_face_swap_video import ( + AtlasFaceSwapVideo, + ) + + required = AtlasFaceSwapVideo.INPUT_TYPES()["required"] + assert "atlas_client" in required + assert "source_face" in required + assert "video" in required + assert AtlasFaceSwapVideo.RETURN_TYPES == ("STRING", "STRING") + assert AtlasFaceSwapVideo.CATEGORY == "AtlasCloud/Video" + + +def test_new_nodes_2026_07_16_registered(): + from src.atlascloud_comfyui.registry import ( + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS, + ) + + for key in ( + "AtlasCloud Face Swap (Image)", + "AtlasCloud Photo Cleanup", + "AtlasCloud Face Swap (Video)", + ): + assert key in NODE_CLASS_MAPPINGS + assert key in NODE_DISPLAY_NAME_MAPPINGS + + +def test_new_nodes_2026_07_16_model_ids(): + import inspect + + from src.atlascloud_comfyui.nodes.image.atlascloud_face_swap_image import AtlasFaceSwapImage + from src.atlascloud_comfyui.nodes.image.atlascloud_photo_cleanup import AtlasPhotoCleanup + from src.atlascloud_comfyui.nodes.video.atlascloud_face_swap_video import AtlasFaceSwapVideo + + expected = { + AtlasFaceSwapImage: "atlascloud/face-swap-image", + AtlasPhotoCleanup: "atlascloud/photo-cleanup", + AtlasFaceSwapVideo: "atlascloud/face-swap-video", + } + for cls, model_id in expected.items(): + src = inspect.getsource(cls.run) + assert f'"model": "{model_id}"' in src