diff --git a/README.md b/README.md index 165ef41..25741cc 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A custom node for ComfyUI that allows you to create iPhone-compatible Live Photo - Adjustable FPS and duration - Optional audio support - Standard output format compatible with iPhone +- Image transition effects with customizable parameters ![LivePhotoCreator](./example/Screenshot_2024-12-13_14-57-54.png) @@ -61,13 +62,40 @@ The LivePhotoPreview node allows you to preview the created Live Photo: - Input: Takes the video path from LivePhotoCreator - Output: Interactive preview with hover effect -## Example Workflow +### ImageCompareTransition Node +The ImageCompareTransition node creates smooth transition effects between two images: +- **Inputs**: + - **image1**: First input image (IMAGE type) + - **image2**: Second input image (IMAGE type) + - **frames**: Number of transition frames (2-120 frames) + - **fps**: Frame rate of the transition (1-60 fps) +- **Output**: + - **frames**: Generated transition frame sequence + +Features: +- Smooth left-to-right sliding transition effect +- Automatic image size matching +- Compatible with VHS video nodes for further processing +- Customizable frame count and frame rate + +![Transition Demo](./example/Screenity_video_Dec_14_2024.mp4) + +## Example Workflows + +### Basic Live Photo Creation 1. Load your video using the "Load Video" node 2. Connect the video output to LivePhotoCreator 3. Configure the desired key frame, duration, and FPS 4. Connect LivePhotoPreview to see the result -5. The output files will be saved in the ComfyUI output directory under a timestamp-based folder +5. The output files will be saved in the ComfyUI output directory + +### Image Transition Workflow +1. Load two images you want to transition between +2. Connect them to ImageCompareTransition node +3. Adjust frames and fps parameters +4. Use VHS nodes to save the transition as video +5. Check the example workflow in [haihyper_example.json](./example/haihyper_example.json) ## Output Format diff --git a/example/Screenity video - Dec 14, 2024.mp4 b/example/Screenity video - Dec 14, 2024.mp4 new file mode 100644 index 0000000..7bc5003 Binary files /dev/null and b/example/Screenity video - Dec 14, 2024.mp4 differ diff --git a/example/haihyper_example.json b/example/haihyper_example.json new file mode 100644 index 0000000..4b07035 --- /dev/null +++ b/example/haihyper_example.json @@ -0,0 +1,1005 @@ +{ + "last_node_id": 137, + "last_link_id": 213, + "nodes": [ + { + "id": 1, + "type": "CheckpointLoaderSimple", + "pos": [ + -74, + -3 + ], + "size": [ + 315, + 98 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "MODEL", + "type": "MODEL", + "links": [ + 83 + ], + "slot_index": 0, + "shape": 3, + "label": "MODEL" + }, + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 43, + 45, + 47 + ], + "slot_index": 1, + "shape": 3, + "label": "CLIP" + }, + { + "name": "VAE", + "type": "VAE", + "links": null, + "shape": 3, + "label": "VAE" + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple" + }, + "widgets_values": [ + "SDXL/dreamshaperXL_v21TurboDPMSDE.safetensors" + ] + }, + { + "id": 2, + "type": "VAELoader", + "pos": [ + -82, + 153 + ], + "size": [ + 315, + 58 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 44 + ], + "shape": 3, + "label": "VAE" + } + ], + "properties": { + "Node name for S&R": "VAELoader" + }, + "widgets_values": [ + "sdxl_vae.safetensors" + ] + }, + { + "id": 4, + "type": "LoadImage", + "pos": [ + -203, + 272 + ], + "size": [ + 304.088134765625, + 409.95361328125 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 194, + 195, + 196, + 212 + ], + "slot_index": 0, + "shape": 3, + "label": "IMAGE" + }, + { + "name": "MASK", + "type": "MASK", + "links": null, + "shape": 3, + "label": "MASK" + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "ComfyUI_temp_cfjvy_00005_.png", + "image" + ] + }, + { + "id": 48, + "type": "ToBasicPipe", + "pos": [ + 576, + 23 + ], + "size": [ + 198.71665954589844, + 106 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "model", + "type": "MODEL", + "link": 83, + "slot_index": 0, + "label": "model" + }, + { + "name": "clip", + "type": "CLIP", + "link": 43, + "slot_index": 1, + "label": "clip" + }, + { + "name": "vae", + "type": "VAE", + "link": 44, + "slot_index": 2, + "label": "vae" + }, + { + "name": "positive", + "type": "CONDITIONING", + "link": 46, + "label": "positive" + }, + { + "name": "negative", + "type": "CONDITIONING", + "link": 48, + "label": "negative" + } + ], + "outputs": [ + { + "name": "basic_pipe", + "type": "BASIC_PIPE", + "links": [ + 69 + ], + "slot_index": 0, + "shape": 3, + "label": "basic_pipe" + } + ], + "properties": { + "Node name for S&R": "ToBasicPipe" + }, + "widgets_values": [] + }, + { + "id": 49, + "type": "CLIPTextEncode", + "pos": [ + 335, + 18 + ], + "size": [ + 219.84222412109375, + 200.23219299316406 + ], + "flags": { + "collapsed": false + }, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 45, + "label": "clip" + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 46 + ], + "slot_index": 0, + "shape": 3, + "label": "CONDITIONING" + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "green hair color\n,high detail,shiny,high quality, uhd, 4k" + ] + }, + { + "id": 50, + "type": "CLIPTextEncode", + "pos": [ + 307, + 272 + ], + "size": [ + 210, + 76 + ], + "flags": { + "collapsed": false + }, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "clip", + "type": "CLIP", + "link": 47, + "slot_index": 0, + "label": "clip" + } + ], + "outputs": [ + { + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 48 + ], + "slot_index": 0, + "shape": 3, + "label": "CONDITIONING" + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "blurry, deformed" + ] + }, + { + "id": 51, + "type": "PreviewImage", + "pos": [ + 1824, + -400 + ], + "size": [ + 394.4882507324219, + 492.5002136230469 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 72, + "label": "images" + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 58, + "type": "DetailerForEachDebugPipe", + "pos": [ + 816, + 37 + ], + "size": [ + 371.20001220703125, + 824 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 196, + "label": "image" + }, + { + "name": "segs", + "type": "SEGS", + "link": 180, + "label": "segs" + }, + { + "name": "basic_pipe", + "type": "BASIC_PIPE", + "link": 69, + "label": "basic_pipe" + }, + { + "name": "detailer_hook", + "type": "DETAILER_HOOK", + "link": null, + "shape": 7, + "label": "detailer_hook" + }, + { + "name": "refiner_basic_pipe_opt", + "type": "BASIC_PIPE", + "link": null, + "shape": 7, + "label": "refiner_basic_pipe_opt" + }, + { + "name": "scheduler_func_opt", + "type": "SCHEDULER_FUNC", + "link": null, + "shape": 7, + "label": "scheduler_func_opt" + } + ], + "outputs": [ + { + "name": "image", + "type": "IMAGE", + "links": [ + 72, + 211 + ], + "slot_index": 0, + "shape": 3, + "label": "image" + }, + { + "name": "segs", + "type": "SEGS", + "links": null, + "shape": 3, + "label": "segs" + }, + { + "name": "basic_pipe", + "type": "BASIC_PIPE", + "links": null, + "shape": 3, + "label": "basic_pipe" + }, + { + "name": "cropped", + "type": "IMAGE", + "links": null, + "shape": 6, + "label": "cropped" + }, + { + "name": "cropped_refined", + "type": "IMAGE", + "links": null, + "shape": 6, + "label": "cropped_refined" + }, + { + "name": "cropped_refined_alpha", + "type": "IMAGE", + "links": null, + "shape": 6, + "label": "cropped_refined_alpha" + }, + { + "name": "cnet_images", + "type": "IMAGE", + "links": null, + "shape": 6, + "label": "cnet_images" + } + ], + "properties": { + "Node name for S&R": "DetailerForEachDebugPipe" + }, + "widgets_values": [ + 64, + true, + 1024, + 265822843035570, + "randomize", + 8, + 6, + "dpmpp_2m_sde_gpu", + "karras", + 0.6, + 10, + true, + true, + "", + 0.2, + 1, + false, + 20 + ] + }, + { + "id": 111, + "type": "UltralyticsDetectorProvider", + "pos": [ + 198, + 409 + ], + "size": [ + 324.06451416015625, + 139.77671813964844 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "BBOX_DETECTOR", + "type": "BBOX_DETECTOR", + "links": [], + "slot_index": 0, + "shape": 3, + "label": "BBOX_DETECTOR" + }, + { + "name": "SEGM_DETECTOR", + "type": "SEGM_DETECTOR", + "links": [ + 191 + ], + "slot_index": 1, + "shape": 3, + "label": "SEGM_DETECTOR" + } + ], + "properties": { + "Node name for S&R": "UltralyticsDetectorProvider" + }, + "widgets_values": [ + "segm/hair_yolov8n-seg_60.pt" + ] + }, + { + "id": 118, + "type": "UltralyticsDetectorProvider", + "pos": [ + 183, + 603 + ], + "size": [ + 346.69671630859375, + 100.40532684326172 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "BBOX_DETECTOR", + "type": "BBOX_DETECTOR", + "links": [], + "slot_index": 0, + "shape": 3, + "label": "BBOX_DETECTOR" + }, + { + "name": "SEGM_DETECTOR", + "type": "SEGM_DETECTOR", + "links": [ + 192 + ], + "slot_index": 1, + "shape": 3, + "label": "SEGM_DETECTOR" + } + ], + "properties": { + "Node name for S&R": "UltralyticsDetectorProvider" + }, + "widgets_values": [ + "segm/face_yolov8m-seg_60.pt" + ] + }, + { + "id": 120, + "type": "SubtractMaskForEach", + "pos": [ + 576, + 187 + ], + "size": [ + 210, + 46 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "base_segs", + "type": "SEGS", + "link": 189, + "label": "base_segs" + }, + { + "name": "mask_segs", + "type": "SEGS", + "link": 190, + "label": "mask_segs" + } + ], + "outputs": [ + { + "name": "SEGS", + "type": "SEGS", + "links": [ + 180 + ], + "slot_index": 0, + "shape": 3, + "label": "SEGS" + } + ], + "properties": { + "Node name for S&R": "SubtractMaskForEach" + }, + "widgets_values": [] + }, + { + "id": 126, + "type": "SegmDetectorSEGS", + "pos": [ + 558, + 535 + ], + "size": [ + 210, + 212 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "segm_detector", + "type": "SEGM_DETECTOR", + "link": 192, + "label": "segm_detector" + }, + { + "name": "image", + "type": "IMAGE", + "link": 194, + "label": "image" + }, + { + "name": "detailer_hook", + "type": "DETAILER_HOOK", + "link": null, + "shape": 7, + "label": "detailer_hook" + } + ], + "outputs": [ + { + "name": "SEGS", + "type": "SEGS", + "links": [ + 190 + ], + "slot_index": 0, + "shape": 3, + "label": "SEGS" + } + ], + "properties": { + "Node name for S&R": "SegmDetectorSEGS" + }, + "widgets_values": [ + 0.5, + 10, + 3, + 10, + "all" + ] + }, + { + "id": 127, + "type": "SegmDetectorSEGS", + "pos": [ + 559, + 281 + ], + "size": [ + 210, + 212 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "segm_detector", + "type": "SEGM_DETECTOR", + "link": 191, + "label": "segm_detector" + }, + { + "name": "image", + "type": "IMAGE", + "link": 195, + "label": "image" + }, + { + "name": "detailer_hook", + "type": "DETAILER_HOOK", + "link": null, + "shape": 7, + "label": "detailer_hook" + } + ], + "outputs": [ + { + "name": "SEGS", + "type": "SEGS", + "links": [ + 189 + ], + "slot_index": 0, + "shape": 3, + "label": "SEGS" + } + ], + "properties": { + "Node name for S&R": "SegmDetectorSEGS" + }, + "widgets_values": [ + 0.5, + 10, + 3, + 10, + "all" + ] + }, + { + "id": 136, + "type": "ImageCompareTransition", + "pos": [ + 1382, + 184 + ], + "size": [ + 315, + 102 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "name": "image1", + "type": "IMAGE", + "link": 211, + "label": "image1" + }, + { + "name": "image2", + "type": "IMAGE", + "link": 212, + "label": "image2" + } + ], + "outputs": [ + { + "name": "frames", + "type": "IMAGE", + "links": [ + 213 + ], + "slot_index": 0, + "label": "frames" + } + ], + "properties": { + "Node name for S&R": "ImageCompareTransition" + }, + "widgets_values": [ + 30, + 8 + ] + }, + { + "id": 137, + "type": "VHS_VideoCombine", + "pos": [ + 2029.8699951171875, + 315.24346923828125 + ], + "size": [ + 315, + 715.1956787109375 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 213, + "shape": 7, + "label": "images" + }, + { + "name": "audio", + "type": "AUDIO", + "link": null, + "shape": 7, + "label": "audio" + }, + { + "name": "meta_batch", + "type": "VHS_BatchManager", + "link": null, + "shape": 7, + "label": "meta_batch" + }, + { + "name": "vae", + "type": "VAE", + "link": null, + "shape": 7, + "label": "vae" + } + ], + "outputs": [ + { + "name": "Filenames", + "type": "VHS_FILENAMES", + "links": null, + "label": "Filenames" + } + ], + "properties": { + "Node name for S&R": "VHS_VideoCombine" + }, + "widgets_values": { + "frame_rate": 8, + "loop_count": 0, + "filename_prefix": "AnimateDiff", + "format": "video/h264-mp4", + "pix_fmt": "yuv420p", + "crf": 19, + "save_metadata": true, + "pingpong": false, + "save_output": true, + "videopreview": { + "hidden": false, + "paused": false, + "params": { + "filename": "AnimateDiff_00211.mp4", + "subfolder": "", + "type": "output", + "format": "video/h264-mp4", + "frame_rate": 8 + } + } + } + } + ], + "links": [ + [ + 43, + 1, + 1, + 48, + 1, + "CLIP" + ], + [ + 44, + 2, + 0, + 48, + 2, + "VAE" + ], + [ + 45, + 1, + 1, + 49, + 0, + "CLIP" + ], + [ + 46, + 49, + 0, + 48, + 3, + "CONDITIONING" + ], + [ + 47, + 1, + 1, + 50, + 0, + "CLIP" + ], + [ + 48, + 50, + 0, + 48, + 4, + "CONDITIONING" + ], + [ + 69, + 48, + 0, + 58, + 2, + "BASIC_PIPE" + ], + [ + 72, + 58, + 0, + 51, + 0, + "IMAGE" + ], + [ + 83, + 1, + 0, + 48, + 0, + "MODEL" + ], + [ + 180, + 120, + 0, + 58, + 1, + "SEGS" + ], + [ + 189, + 127, + 0, + 120, + 0, + "SEGS" + ], + [ + 190, + 126, + 0, + 120, + 1, + "SEGS" + ], + [ + 191, + 111, + 1, + 127, + 0, + "SEGM_DETECTOR" + ], + [ + 192, + 118, + 1, + 126, + 0, + "SEGM_DETECTOR" + ], + [ + 194, + 4, + 0, + 126, + 1, + "IMAGE" + ], + [ + 195, + 4, + 0, + 127, + 1, + "IMAGE" + ], + [ + 196, + 4, + 0, + 58, + 0, + "IMAGE" + ], + [ + 211, + 58, + 0, + 136, + 0, + "IMAGE" + ], + [ + 212, + 4, + 0, + 136, + 1, + "IMAGE" + ], + [ + 213, + 136, + 0, + 137, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.5989500000000019, + "offset": [ + 190.82726333325894, + 470.8488194436603 + ] + }, + "info": { + "name": "haircolorize", + "author": "", + "description": "", + "version": "1", + "created": "2024-03-19T15:59:44.300Z", + "modified": "2024-04-02T11:58:58.757Z", + "software": "ComfyUI" + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/livephoto_nodes.py b/livephoto_nodes.py index 84ae8b2..f9c35e2 100644 --- a/livephoto_nodes.py +++ b/livephoto_nodes.py @@ -224,12 +224,116 @@ class LivePhotoPreview: print(f"[LivePhotoPreview] Executing with video path: {video}") return {"ui": {"video": video}} +class ImageCompareTransition: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image1": ("IMAGE",), # 第一张图片 + "image2": ("IMAGE",), # 第二张图片 + "frames": ("INT", { + "default": 30, + "min": 2, + "max": 120, + "step": 1 + }), # 过渡帧数 + "fps": ("FLOAT", { + "default": 8.0, + "min": 1.0, + "max": 60.0, + "step": 1.0 + }) + } + } + + RETURN_TYPES = ("IMAGE",) # 只返回帧序列 + RETURN_NAMES = ("frames",) + FUNCTION = "create_transition" + CATEGORY = "image/animation" + + def resize_image(self, img, target_h, target_w): + """调整图片尺寸,保持长宽比""" + import torch + import torch.nn.functional as F + + # 计算缩放比例 + h, w = img.shape[1:3] + scale = min(target_h/h, target_w/w) + new_h = int(h * scale) + new_w = int(w * scale) + + # 使用双线性插值进行缩放 + img = img.unsqueeze(0) # 添加batch维度 + img = F.interpolate(img, size=(new_h, new_w), mode='bilinear', align_corners=False) + img = img.squeeze(0) # 移除batch维度 + + return img + + def create_transition(self, image1, image2, frames, fps): + import torch + import numpy as np + + # 确保输入图像维度正确 + if len(image1.shape) > 4: + image1 = image1.squeeze(0) + if len(image2.shape) > 4: + image2 = image2.squeeze(0) + + # 打印输入图像的形状 + print(f"Input image1 shape: {image1.shape}") + print(f"Input image2 shape: {image2.shape}") + + # 获取两张图片的尺寸 + h1, w1 = image1.shape[1:3] + h2, w2 = image2.shape[1:3] + + # 使用较小的尺寸作为目标尺寸 + target_h = min(h1, h2) + target_w = min(w1, w2) + + # 调整两张图片的尺寸 + if h1 != target_h or w1 != target_w: + image1 = self.resize_image(image1, target_h, target_w) + if h2 != target_h or w2 != target_w: + image2 = self.resize_image(image2, target_h, target_w) + + # 创建帧列表 + frame_list = [] + + # 生成过渡帧 + for i in range(frames): + # 计算当前过渡位置(从0到1) + progress = i / (frames - 1) + + # 计算分割点 + split_pos = int(progress * target_w) + + # 创建新帧 - 保持 [B, H, W, C] 格式 + new_frame = torch.clone(image1) + new_frame[0, :, split_pos:, :] = image2[0, :, split_pos:, :] + frame_list.append(new_frame[0]) # 只保留 [H, W, C] 部分 + + # 将所有帧堆叠成一个张量 + output_frames = torch.stack(frame_list) + + # 确保数据在正确的范围内 + output_frames = output_frames.clamp(0, 1) + + # 打印调试信息 + print(f"Single frame shape: {frame_list[0].shape}") + print(f"Output frames shape: {output_frames.shape}") + print(f"Output frames dtype: {output_frames.dtype}") + + return (output_frames,) + NODE_CLASS_MAPPINGS = { "LivePhotoCreator": LivePhotoCreator, - "LivePhotoPreview": LivePhotoPreview + "LivePhotoPreview": LivePhotoPreview, + "ImageCompareTransition": ImageCompareTransition } NODE_DISPLAY_NAME_MAPPINGS = { "LivePhotoCreator": "Create Live Photo", - "LivePhotoPreview": "Preview Live Photo" + "LivePhotoPreview": "Preview Live Photo", + "ImageCompareTransition": "Image Compare Transition" }