first commit
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
[](https://github.com/MrSamSeen)
|
||||

|
||||
|
||||
# ComfyUI SS Before After Node
|
||||
|
||||
This repository provides two powerful custom nodes for ComfyUI to create stunning before-and-after transition videos. These nodes are designed for visual comparisons, transformations, and creative effects, supporting both standard and depth map-based transitions.
|
||||
|
||||
## Features
|
||||
|
||||
- **SSBeforeAndAfterVideo**: Create videos with fade and wipe transitions between two images.
|
||||
- **SSBeforeAndAfterVideoWithDepthMap**: Create depth-aware transitions using a depth map for more immersive effects.
|
||||
- High-quality output with customizable resolution, frame rate, feathering, and looping.
|
||||
- Automatic handling of output directories and filenames.
|
||||
- Progress bar for frame generation.
|
||||
- Robust ffmpeg integration for video encoding.
|
||||
|
||||
---
|
||||
|
||||
## Node 1: SSBeforeAndAfterVideo
|
||||
|
||||
### Description
|
||||
Creates before-and-after transition videos using classic fade and wipe effects. Ideal for showcasing transformations, comparisons, or visual storytelling.
|
||||
|
||||
### Supported Transitions
|
||||
- **Fade**: Smoothly blends from the before image to the after image.
|
||||
- **Wipe**: Animated reveal from left, right, top, or bottom, with optional feathered (blurred) edge.
|
||||
|
||||
### Parameters
|
||||
- **before_image**: The starting image (tensor).
|
||||
- **after_image**: The ending image (tensor).
|
||||
- **transition_type**: Choose from `fade`, `wipe_from_left`, `wipe_from_top`, `wipe_from_right`, `wipe_from_bottom`.
|
||||
- **fps**: Frames per second (1-120).
|
||||
- **width/height**: Output video resolution (64-4096 px).
|
||||
- **output_path**: Output directory (default `/output/`).
|
||||
- **filename_prefix**: Prefix for output video files.
|
||||
- **transition_duration**: Duration of the transition (0.5-10.0 seconds).
|
||||
- **hold_duration**: Time to hold before and after images (0.0-5.0 seconds).
|
||||
- **feather**: Feathering amount for wipe transitions (0.0-1.0).
|
||||
- **loop_transition**: If enabled, the video will loop back to the start.
|
||||
|
||||
### How It Works
|
||||
1. **Resizes** both images to the specified resolution.
|
||||
2. **Generates frames** for the hold, transition, and (optionally) loop-back phases.
|
||||
3. **Applies the selected transition** (fade or wipe) with feathering.
|
||||
4. **Saves frames** to a temporary directory.
|
||||
5. **Encodes video** using ffmpeg.
|
||||
6. **Cleans up** temporary files and returns the video path and a preview frame.
|
||||
|
||||
---
|
||||
|
||||
## Node 2: SSBeforeAndAfterVideoWithDepthMap
|
||||
|
||||
### Description
|
||||
Creates before-and-after transition videos using a depth map to control the transition. This enables advanced effects like revealing the after image from back-to-front, front-to-back, or from the middle outwards, based on scene depth.
|
||||
|
||||
### Supported Transitions
|
||||
- **back_to_front**: Reveals the after image starting from the farthest (background) regions.
|
||||
- **front_to_back**: Reveals the after image starting from the closest (foreground) regions.
|
||||
- **middle_out**: Reveals the after image from the center depth outward.
|
||||
|
||||
### Parameters
|
||||
- **before_image**: The starting image (tensor).
|
||||
- **after_image**: The ending image (tensor).
|
||||
- **depth_map**: Depth map image (tensor, same size as input images).
|
||||
- **transition_type**: Choose from `back_to_front`, `front_to_back`, `middle_out`.
|
||||
- **fps**: Frames per second (1-120).
|
||||
- **width/height**: Output video resolution (64-4096 px).
|
||||
- **output_path**: Output directory (default `/output/`).
|
||||
- **filename_prefix**: Prefix for output video files.
|
||||
- **transition_duration**: Duration of the transition (0.5-10.0 seconds).
|
||||
- **hold_duration**: Time to hold before and after images (0.0-5.0 seconds).
|
||||
- **feather**: Feathering amount for the depth mask (0.0-1.0).
|
||||
- **loop_transition**: If enabled, the video will loop back to the start.
|
||||
- **easing_method**: Transition easing ("none", "ease-in", "ease-out", "both").
|
||||
|
||||
### How It Works
|
||||
1. **Resizes** all images to the specified resolution.
|
||||
2. **Normalizes the depth map** and creates a mask based on the selected transition type and progress.
|
||||
3. **Applies feathering** to the mask for smooth transitions.
|
||||
4. **Blends images** according to the mask for each frame.
|
||||
5. **Handles looping** and hold frames as configured.
|
||||
6. **Encodes video** using ffmpeg and cleans up temporary files.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Place this folder in your `ComfyUI/custom_nodes` directory.
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
3. Ensure ffmpeg is installed and available in your system PATH.
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
1. Restart ComfyUI after installing the node.
|
||||
2. Add the desired node (`SSBeforeAndAfterVideo` or `SSBeforeAndAfterVideoWithDepthMap`) to your workflow.
|
||||
3. Configure the parameters as needed.
|
||||
4. Run the workflow to generate your before-and-after transition video.
|
||||
5. The output video and a preview frame will be saved to your specified output directory.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
- **ffmpeg not found**: Make sure ffmpeg is installed and accessible from your system PATH.
|
||||
- **Missing dependencies**: Install all required Python packages listed in `requirements.txt`.
|
||||
- **Output not appearing**: Check the output directory path and permissions.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
MIT License
|
||||
|
||||
---
|
||||
|
||||
## Credits
|
||||
Developed by SamSeen. Inspired by creative visual storytelling needs in ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
## Contribution
|
||||
Pull requests and suggestions are welcome!
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
print("Initializing SS Before and After node for transition view creation...")
|
||||
|
||||
from .ss_before_after_video import SSBeforeAndAfterVideo
|
||||
from .ss_before_after_video_with_depth import SSBeforeAndAfterVideoWithDepthMap
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SSBeforeAndAfterVideo": SSBeforeAndAfterVideo,
|
||||
"SSBeforeAndAfterVideoWithDepthMap": SSBeforeAndAfterVideoWithDepthMap
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SSBeforeAndAfterVideo": "SS Before After Video",
|
||||
"SSBeforeAndAfterVideoWithDepthMap": "SS Before After Video (Depth Map)"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
torch
|
||||
numpy
|
||||
Pillow
|
||||
opencv-python
|
||||
imageio
|
||||
@@ -0,0 +1,275 @@
|
||||
# Robust imports with fallbacks for standalone or missing dependencies
|
||||
try:
|
||||
import torch
|
||||
except ImportError:
|
||||
raise ImportError('Please install torch to use this module.')
|
||||
try:
|
||||
import numpy as np
|
||||
except ImportError:
|
||||
raise ImportError('Please install numpy to use this module.')
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
raise ImportError('Please install pillow to use this module.')
|
||||
import cv2
|
||||
try:
|
||||
from comfy.utils import ProgressBar
|
||||
except ImportError:
|
||||
raise ImportError('Please ensure comfy.utils is available in your Python path.')
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
raise ImportError('Please ensure folder_paths is available in your Python path.')
|
||||
try:
|
||||
import imageio
|
||||
except ImportError:
|
||||
raise ImportError('Please install imageio to use this module.')
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
# Check for ffmpeg availability
|
||||
def get_ffmpeg_path():
|
||||
"""Get the path to ffmpeg executable"""
|
||||
# Try to find ffmpeg in the system path
|
||||
try:
|
||||
if os.name == 'nt': # Windows
|
||||
ffmpeg_command = "where ffmpeg"
|
||||
else: # Unix/Linux/Mac
|
||||
ffmpeg_command = "which ffmpeg"
|
||||
|
||||
result = subprocess.run(ffmpeg_command, shell=True, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if ffmpeg is in the current directory
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if os.name == 'nt': # Windows
|
||||
ffmpeg_path = os.path.join(current_dir, "ffmpeg.exe")
|
||||
else: # Unix/Linux/Mac
|
||||
ffmpeg_path = os.path.join(current_dir, "ffmpeg")
|
||||
|
||||
if os.path.exists(ffmpeg_path):
|
||||
return ffmpeg_path
|
||||
|
||||
# Try to use imageio's ffmpeg
|
||||
try:
|
||||
import imageio
|
||||
return imageio.plugins.ffmpeg.get_exe()
|
||||
except ImportError:
|
||||
raise ImportError('Please install imageio to use this module.')
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
ffmpeg_path = get_ffmpeg_path()
|
||||
|
||||
class SSBeforeAndAfterVideo:
|
||||
def __init__(self):
|
||||
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"before_image": ("IMAGE",),
|
||||
"after_image": ("IMAGE",),
|
||||
"transition_type": (["fade", "wipe_from_left", "wipe_from_top", "wipe_from_right", "wipe_from_bottom"], {"default": "fade"}),
|
||||
"fps": ("INT", {"default": 30, "min": 1, "max": 120, "step": 1}),
|
||||
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
|
||||
"height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
|
||||
"output_path": ("STRING", {"default": "/output/"}),
|
||||
"filename_prefix": ("STRING", {"default": "before_after_transition"}),
|
||||
"transition_duration": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1}),
|
||||
"hold_duration": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1}),
|
||||
"feather": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"loop_transition": ("BOOLEAN", {"default": False}),
|
||||
"loop_transition": ("BOOLEAN", {"default": False})
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "IMAGE")
|
||||
RETURN_NAMES = ("video_path", "preview_frame")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_transition_video"
|
||||
CATEGORY = "👀 SamSeen"
|
||||
DESCRIPTION = "Create stunning before and after transition videos with various wipe and fade effects. Perfect for showcasing transformations and comparisons!"
|
||||
|
||||
def tensor_to_pil(self, tensor):
|
||||
"""Convert tensor to PIL Image"""
|
||||
if tensor.dim() == 4:
|
||||
tensor = tensor.squeeze(0)
|
||||
|
||||
# Convert from tensor to numpy
|
||||
np_image = tensor.cpu().numpy()
|
||||
|
||||
# Scale to 0-255 and convert to uint8
|
||||
np_image = (np_image * 255).astype(np.uint8)
|
||||
|
||||
return Image.fromarray(np_image)
|
||||
|
||||
def pil_to_tensor(self, pil_image):
|
||||
"""Convert PIL Image to tensor"""
|
||||
np_image = np.array(pil_image).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(np_image).unsqueeze(0)
|
||||
|
||||
def resize_images(self, before_img, after_img, width, height):
|
||||
"""Resize both images to the specified dimensions"""
|
||||
before_pil = self.tensor_to_pil(before_img)
|
||||
after_pil = self.tensor_to_pil(after_img)
|
||||
|
||||
before_resized = before_pil.resize((width, height), Image.LANCZOS)
|
||||
after_resized = after_pil.resize((width, height), Image.LANCZOS)
|
||||
|
||||
return before_resized, after_resized
|
||||
|
||||
def create_fade_transition(self, before_img, after_img, progress):
|
||||
"""Create a fade transition between two images"""
|
||||
# Convert PIL images to numpy arrays
|
||||
before_np = np.array(before_img, dtype=np.float32)
|
||||
after_np = np.array(after_img, dtype=np.float32)
|
||||
|
||||
# Blend the images based on progress
|
||||
blended = before_np * (1 - progress) + after_np * progress
|
||||
|
||||
return Image.fromarray(blended.astype(np.uint8))
|
||||
|
||||
def create_wipe_transition(self, before_img, after_img, progress, direction, feather):
|
||||
"""Create a wipe transition between two images with feathering (blurred edge)"""
|
||||
width, height = before_img.size
|
||||
mask = np.zeros((height, width), dtype=np.float32)
|
||||
feather_pixels_x = max(1, int(width * feather))
|
||||
feather_pixels_y = max(1, int(height * feather))
|
||||
|
||||
if direction == "wipe_from_left":
|
||||
wipe_pos = int(progress * width)
|
||||
left = max(0, wipe_pos - feather_pixels_x // 2)
|
||||
right = min(width, wipe_pos + feather_pixels_x // 2)
|
||||
mask[:, :left] = 1.0
|
||||
mask[:, right:] = 0.0
|
||||
if right > left:
|
||||
feather = np.linspace(1.0, 0.0, right - left)
|
||||
mask[:, left:right] = feather
|
||||
elif direction == "wipe_from_top":
|
||||
wipe_pos = int(progress * height)
|
||||
top = max(0, wipe_pos - feather_pixels_y // 2)
|
||||
bottom = min(height, wipe_pos + feather_pixels_y // 2)
|
||||
mask[:top, :] = 1.0
|
||||
mask[bottom:, :] = 0.0
|
||||
if bottom > top:
|
||||
feather = np.linspace(1.0, 0.0, bottom - top)
|
||||
mask[top:bottom, :] = feather[:, None]
|
||||
elif direction == "wipe_from_right":
|
||||
wipe_pos = int((1.0 - progress) * width)
|
||||
left = max(0, wipe_pos - feather_pixels_x // 2)
|
||||
right = min(width, wipe_pos + feather_pixels_x // 2)
|
||||
mask[:, :left] = 0.0
|
||||
mask[:, right:] = 1.0
|
||||
if right > left:
|
||||
feather = np.linspace(0.0, 1.0, right - left)
|
||||
mask[:, left:right] = feather
|
||||
elif direction == "wipe_from_bottom":
|
||||
wipe_pos = int((1.0 - progress) * height)
|
||||
top = max(0, wipe_pos - feather_pixels_y // 2)
|
||||
bottom = min(height, wipe_pos + feather_pixels_y // 2)
|
||||
mask[:top, :] = 0.0
|
||||
mask[bottom:, :] = 1.0
|
||||
if bottom > top:
|
||||
feather = np.linspace(0.0, 1.0, bottom - top)
|
||||
mask[top:bottom, :] = feather[:, None]
|
||||
elif direction == "wipe_from_bottom":
|
||||
wipe_pos = int(height * (1 - progress))
|
||||
top = max(0, wipe_pos - feather_pixels_y // 2)
|
||||
bottom = min(height, wipe_pos + feather_pixels_y // 2)
|
||||
mask[:top, :] = 0.0
|
||||
mask[bottom:, :] = 1.0
|
||||
if bottom > top:
|
||||
alpha = np.linspace(1, 0, bottom - top)
|
||||
mask[top:bottom, :] = alpha[:, None]
|
||||
|
||||
# Blur the mask for a feathered (soft) edge
|
||||
blur_size = max(1, int(max(feather_pixels_x, feather_pixels_y) // 2) * 2 + 1)
|
||||
if blur_size > 1:
|
||||
mask = cv2.GaussianBlur(mask, (blur_size, blur_size), 0)
|
||||
mask = np.clip(mask, 0, 1)
|
||||
|
||||
before_np = np.array(before_img, dtype=np.float32)
|
||||
after_np = np.array(after_img, dtype=np.float32)
|
||||
mask_3d = np.stack([mask] * 3, axis=2)
|
||||
result = before_np * (1 - mask_3d) + after_np * mask_3d
|
||||
return Image.fromarray(result.astype(np.uint8))
|
||||
|
||||
def generate_transition_frames(self, before_img, after_img, transition_type, fps, transition_duration, hold_duration, feather, loop_transition):
|
||||
frames = []
|
||||
total_transition_frames = int(fps * transition_duration)
|
||||
total_hold_frames = int(fps * hold_duration)
|
||||
# Hold the before image
|
||||
for _ in range(total_hold_frames):
|
||||
frames.append(before_img.copy())
|
||||
# Transition frames
|
||||
for i in range(total_transition_frames):
|
||||
progress = i / max(1, total_transition_frames - 1)
|
||||
if transition_type == "fade":
|
||||
frame = self.create_fade_transition(before_img, after_img, progress)
|
||||
elif transition_type.startswith("wipe"):
|
||||
frame = self.create_wipe_transition(before_img, after_img, progress, transition_type, feather)
|
||||
else:
|
||||
frame = before_img.copy()
|
||||
frames.append(frame)
|
||||
# Hold the after image
|
||||
for _ in range(total_hold_frames):
|
||||
frames.append(after_img.copy())
|
||||
# Optionally loop back
|
||||
if loop_transition:
|
||||
for i in range(total_transition_frames):
|
||||
progress = 1 - (i / max(1, total_transition_frames - 1))
|
||||
if transition_type == "fade":
|
||||
frame = self.create_fade_transition(before_img, after_img, progress)
|
||||
elif transition_type.startswith("wipe"):
|
||||
frame = self.create_wipe_transition(before_img, after_img, progress, transition_type, feather)
|
||||
else:
|
||||
frame = after_img.copy()
|
||||
frames.append(frame)
|
||||
for _ in range(total_hold_frames):
|
||||
frames.append(before_img.copy())
|
||||
return frames
|
||||
|
||||
def create_transition_video(self, before_image, after_image, transition_type, fps, width, height, output_path, filename_prefix, transition_duration, hold_duration, feather, loop_transition=False):
|
||||
if ffmpeg_path is None:
|
||||
raise RuntimeError("ffmpeg not found. Please install ffmpeg to use video functionality.")
|
||||
before_img, after_img = self.resize_images(before_image, after_image, width, height)
|
||||
# Use the correct frame generation method for transitions
|
||||
frames = self.generate_transition_frames(
|
||||
before_img, after_img, transition_type, fps, transition_duration, hold_duration, feather, loop_transition
|
||||
)
|
||||
# Prepare output directory
|
||||
if output_path.startswith("/output/"):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
relative_path = output_path[8:]
|
||||
if relative_path:
|
||||
output_dir = os.path.join(output_dir, relative_path)
|
||||
else:
|
||||
output_dir = output_path
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
# Generate unique filename
|
||||
counter = 1
|
||||
while True:
|
||||
filename = f"{filename_prefix}_{counter:05d}.mp4"
|
||||
video_path = os.path.join(output_dir, filename)
|
||||
if not os.path.exists(video_path):
|
||||
break
|
||||
counter += 1
|
||||
temp_dir = os.path.join(output_dir, "temp_depth_frames")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
for idx, frame in enumerate(frames):
|
||||
frame.save(os.path.join(temp_dir, f"frame_{idx:04d}.png"))
|
||||
ffmpeg_cmd = [ffmpeg_path, "-y", "-framerate", str(fps), "-i", os.path.join(temp_dir, "frame_%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", video_path]
|
||||
subprocess.run(ffmpeg_cmd, check=True)
|
||||
for f in os.listdir(temp_dir):
|
||||
os.remove(os.path.join(temp_dir, f))
|
||||
os.rmdir(temp_dir)
|
||||
preview_frame = frames[len(frames) // 2]
|
||||
preview_tensor = torch.from_numpy(np.array(preview_frame).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (video_path, preview_tensor)
|
||||
@@ -0,0 +1,192 @@
|
||||
# Robust imports with fallbacks for standalone or missing dependencies
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import cv2
|
||||
from comfy.utils import ProgressBar
|
||||
import os
|
||||
import subprocess
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError:
|
||||
class FolderPaths:
|
||||
def get_input_directory(self):
|
||||
return "input"
|
||||
def get_output_directory(self):
|
||||
return "output"
|
||||
def get_temp_directory(self):
|
||||
return "temp"
|
||||
folder_paths = FolderPaths()
|
||||
# Check for ffmpeg availability
|
||||
def get_ffmpeg_path():
|
||||
"""Get the path to ffmpeg executable"""
|
||||
# Try to find ffmpeg in the system path
|
||||
try:
|
||||
if os.name == 'nt': # Windows
|
||||
ffmpeg_command = "where ffmpeg"
|
||||
else: # Unix/Linux/Mac
|
||||
ffmpeg_command = "which ffmpeg"
|
||||
|
||||
result = subprocess.run(ffmpeg_command, shell=True, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except:
|
||||
pass
|
||||
|
||||
# Check if ffmpeg is in the current directory
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if os.name == 'nt': # Windows
|
||||
ffmpeg_path = os.path.join(current_dir, "ffmpeg.exe")
|
||||
else: # Unix/Linux/Mac
|
||||
ffmpeg_path = os.path.join(current_dir, "ffmpeg")
|
||||
|
||||
if os.path.exists(ffmpeg_path):
|
||||
return ffmpeg_path
|
||||
|
||||
# Try to use imageio's ffmpeg
|
||||
try:
|
||||
import imageio
|
||||
return imageio.plugins.ffmpeg.get_exe()
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
ffmpeg_path = get_ffmpeg_path()
|
||||
|
||||
class SSBeforeAndAfterVideoWithDepthMap:
|
||||
def __init__(self):
|
||||
self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"before_image": ("IMAGE",),
|
||||
"after_image": ("IMAGE",),
|
||||
"depth_map": ("IMAGE",),
|
||||
"transition_type": (["back_to_front", "front_to_back", "middle_out"], {"default": "back_to_front"}),
|
||||
"fps": ("INT", {"default": 30, "min": 1, "max": 120, "step": 1}),
|
||||
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
|
||||
"height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
|
||||
"output_path": ("STRING", {"default": "/output/"}),
|
||||
"filename_prefix": ("STRING", {"default": "before_after_depth_transition"}),
|
||||
"transition_duration": ("FLOAT", {"default": 2.0, "min": 0.5, "max": 10.0, "step": 0.1}),
|
||||
"hold_duration": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1}),
|
||||
"feather": ("FLOAT", {"default": 0.1, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"loop_transition": ("BOOLEAN", {"default": False}),
|
||||
"easing_method": (["none", "ease-in", "ease-out", "both"], {"default": "none"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "IMAGE")
|
||||
RETURN_NAMES = ("video_path", "preview_frame")
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "create_transition_video"
|
||||
CATEGORY = "👀 SamSeen"
|
||||
DESCRIPTION = "Create before and after transition videos using a depth map for depth-based transitions."
|
||||
|
||||
def tensor_to_pil(self, tensor):
|
||||
if tensor.dim() == 4:
|
||||
tensor = tensor.squeeze(0)
|
||||
np_image = tensor.cpu().numpy()
|
||||
np_image = (np_image * 255).astype(np.uint8)
|
||||
return Image.fromarray(np_image)
|
||||
|
||||
def resize_images(self, before_img, after_img, depth_img, width, height):
|
||||
before_pil = self.tensor_to_pil(before_img)
|
||||
after_pil = self.tensor_to_pil(after_img)
|
||||
depth_pil = self.tensor_to_pil(depth_img)
|
||||
before_resized = before_pil.resize((width, height), Image.LANCZOS)
|
||||
after_resized = after_pil.resize((width, height), Image.LANCZOS)
|
||||
depth_resized = depth_pil.resize((width, height), Image.LANCZOS)
|
||||
return before_resized, after_resized, depth_resized
|
||||
|
||||
def create_depth_transition(self, before_img, after_img, depth_img, progress, transition_type, feather):
|
||||
before_np = np.array(before_img, dtype=np.float32)
|
||||
after_np = np.array(after_img, dtype=np.float32)
|
||||
depth_np = np.array(depth_img, dtype=np.float32)
|
||||
if depth_np.ndim == 3:
|
||||
depth_np = depth_np[..., 0]
|
||||
depth_norm = (depth_np - depth_np.min()) / (depth_np.ptp() + 1e-8)
|
||||
if transition_type == "back_to_front":
|
||||
threshold = 1.0 - progress
|
||||
mask = (depth_norm >= threshold).astype(np.float32)
|
||||
elif transition_type == "front_to_back":
|
||||
threshold = progress
|
||||
mask = (depth_norm <= threshold).astype(np.float32)
|
||||
elif transition_type == "middle_out":
|
||||
center = 0.5
|
||||
spread = progress * 0.5
|
||||
mask = ((depth_norm >= center - spread) & (depth_norm <= center + spread)).astype(np.float32)
|
||||
else:
|
||||
mask = np.zeros_like(depth_norm, dtype=np.float32)
|
||||
feather_px = max(1, int(feather * 50))
|
||||
if feather_px > 1:
|
||||
mask = cv2.GaussianBlur(mask, (feather_px | 1, feather_px | 1), 0)
|
||||
mask = np.clip(mask, 0, 1)
|
||||
mask_3d = np.stack([mask] * 3, axis=2)
|
||||
result = before_np * (1 - mask_3d) + after_np * mask_3d
|
||||
return Image.fromarray(result.astype(np.uint8))
|
||||
|
||||
def generate_transition_frames(self, before_img, after_img, depth_img, transition_type, fps, transition_duration, hold_duration, feather, loop_transition=False, easing_method="none"):
|
||||
frames = []
|
||||
hold_frames = int(fps * hold_duration)
|
||||
transition_frames = int(fps * transition_duration)
|
||||
pbar = ProgressBar(hold_frames + transition_frames + (transition_frames if loop_transition else 0) + hold_frames)
|
||||
for i in range(hold_frames):
|
||||
frames.append(before_img.copy())
|
||||
pbar.update(1)
|
||||
for i in range(transition_frames):
|
||||
progress = i / (transition_frames - 1) if transition_frames > 1 else 1.0
|
||||
frame = self.create_depth_transition(before_img, after_img, depth_img, progress, transition_type, feather)
|
||||
frames.append(frame)
|
||||
pbar.update(1)
|
||||
if loop_transition:
|
||||
for i in range(transition_frames):
|
||||
progress = i / (transition_frames - 1) if transition_frames > 1 else 1.0
|
||||
frame = self.create_depth_transition(after_img, before_img, depth_img, progress, transition_type, feather)
|
||||
frames.append(frame)
|
||||
pbar.update(1)
|
||||
for i in range(hold_frames):
|
||||
frames.append(before_img.copy())
|
||||
pbar.update(1)
|
||||
else:
|
||||
for i in range(hold_frames):
|
||||
frames.append(after_img.copy())
|
||||
pbar.update(1)
|
||||
return frames
|
||||
|
||||
def create_transition_video(self, before_image, after_image, depth_map, transition_type, fps, width, height, output_path, filename_prefix, transition_duration, hold_duration, feather, loop_transition=False, easing_method="none"):
|
||||
before_img, after_img, depth_img = self.resize_images(before_image, after_image, depth_map, width, height)
|
||||
frames = self.generate_transition_frames(before_img, after_img, depth_img, transition_type, fps, transition_duration, hold_duration, feather, loop_transition, easing_method)
|
||||
if output_path.startswith("/output/"):
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
relative_path = output_path[8:]
|
||||
if relative_path:
|
||||
output_dir = os.path.join(output_dir, relative_path)
|
||||
else:
|
||||
output_dir = output_path
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
counter = 1
|
||||
while True:
|
||||
filename = f"{filename_prefix}_{counter:05d}.mp4"
|
||||
video_path = os.path.join(output_dir, filename)
|
||||
if not os.path.exists(video_path):
|
||||
break
|
||||
counter += 1
|
||||
temp_dir = os.path.join(output_dir, "temp_depth_frames")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
for idx, frame in enumerate(frames):
|
||||
frame.save(os.path.join(temp_dir, f"frame_{idx:04d}.png"))
|
||||
if ffmpeg_path is None:
|
||||
raise RuntimeError("FFmpeg executable not found. Please ensure FFmpeg is installed and accessible.")
|
||||
ffmpeg_cmd = [ffmpeg_path, "-y", "-framerate", str(fps), "-i", os.path.join(temp_dir, "frame_%04d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", video_path]
|
||||
subprocess.run(ffmpeg_cmd, check=True)
|
||||
for f in os.listdir(temp_dir):
|
||||
os.remove(os.path.join(temp_dir, f))
|
||||
os.rmdir(temp_dir)
|
||||
preview_frame = frames[len(frames) // 2]
|
||||
preview_tensor = torch.from_numpy(np.array(preview_frame).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (video_path, preview_tensor)
|
||||
Reference in New Issue
Block a user