first commit

This commit is contained in:
samseen
2025-05-24 09:58:24 +05:30
commit e75b5f54bf
5 changed files with 611 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
[![SamSeen Logo](https://img.shields.io/badge/ComfyUI SS_Before_After_Transition-by_SamSeen_Solutions-orange?style=for-the-badge)](https://github.com/MrSamSeen)
![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)
# 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
View File
@@ -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)"
}
+5
View File
@@ -0,0 +1,5 @@
torch
numpy
Pillow
opencv-python
imageio
+275
View File
@@ -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)
+192
View File
@@ -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)