Prepare ComfyUI-VideoSlice for GitHub and RunPod (ib video slicer).

Made-with: Cursor
This commit is contained in:
ft
2026-03-28 14:27:40 +08:00
commit d367dc76af
7 changed files with 557 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
.venv/
venv/
ENV/
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo
# Packaging / test
*.egg-info/
dist/
build/
.pytest_cache/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ComfyUI-VideoSlice authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+156
View File
@@ -0,0 +1,156 @@
# ComfyUI-VideoSlice — **ib video slicer**
Custom ComfyUI node that reads `.mov` / `.mp4` and outputs **one frame** as an **IMAGE** (plus sizes, counters, and a text readout).
---
## Install from GitHub (any machine)
1. Clone into ComfyUI’s `custom_nodes` folder (name must stay `ComfyUI-VideoSlice` or Comfy will still load it if the folder contains `__init__.py`):
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/YOUR_USERNAME/ComfyUI-VideoSlice.git
```
2. Install **opencv-python** with the **same Python / pip** that runs ComfyUI:
```bash
cd ComfyUI-VideoSlice
pip install -r requirements.txt
```
Or run the helper (tries common RunPod venv paths; see [RunPod](#runpod-comfyui) below):
```bash
bash install_deps.sh
```
3. **Restart ComfyUI** (full process restart, not only refresh).
---
## Publish this repo on GitHub
From the folder that contains this README:
```bash
git init
git add .
git commit -m "Initial commit: ib video slicer custom node"
```
On [github.com/new](https://github.com/new): create a repository named **`ComfyUI-VideoSlice`** (no template). Then:
```bash
git branch -M main
git remote add origin https://github.com/YOUR_USERNAME/ComfyUI-VideoSlice.git
git push -u origin main
```
Replace **`YOUR_USERNAME`** in the clone URL in this README when you share the project.
---
## RunPod ComfyUI
RunPod templates differ slightly; the idea is always: **node code in `custom_nodes/`**, **deps in Comfy’s Python**, **videos in `ComfyUI/input/`** or **`path_override`**.
### A. One-time setup on the pod
1. Open **Terminal** (or SSH) on the pod.
2. Find ComfyUI root (common cases):
```bash
ls /workspace/ComfyUI/custom_nodes 2>/dev/null || ls /workspace/comfyui/custom_nodes 2>/dev/null
```
3. Clone **this repo** into `custom_nodes`:
```bash
cd /workspace/ComfyUI/custom_nodes # adjust if your template uses another path
git clone https://github.com/YOUR_USERNAME/ComfyUI-VideoSlice.git
```
4. Install dependencies:
```bash
cd ComfyUI-VideoSlice
chmod +x install_deps.sh
bash install_deps.sh
```
If the script cannot find pip, locate Comfy’s venv and set **`COMFYUI_PIP`**:
```bash
export COMFYUI_PIP=/workspace/ComfyUI/venv/bin/pip # example
bash install_deps.sh
```
5. **Restart** the ComfyUI service / pod (per your template’s docs).
### B. Persistence
- Ephemeral disks: reinstall or clone into a **RunPod volume** mounted at e.g. **`/workspace`** so `custom_nodes` survives stop/start.
- Put large videos on the same volume; reference them with **`path_override`** or copy into **`ComfyUI/input/`**.
### C. Sharing with teammates
They use the **same `git clone` URL** into their pod’s `custom_nodes`, run **`install_deps.sh`** (or `pip install -r requirements.txt`), restart Comfy. No drag-and-drop install for the node itself—only media goes through the Comfy **Input** UI.
## Node: **ib video slicer**
**Category:** `video`
**Type IDs:** `IBVideoSlicer` (new) or `VideoSliceFrame` (legacy saves — same node).
### Select / upload video (no separate button)
ComfyUI custom nodes cannot add a real “Upload” button in the graph. Use this workflow:
1. Open the **Input** sidebar in ComfyUI (or put files in `ComfyUI/input/` on the server).
2. **Drag-and-drop** your `.mp4` / `.mov` there (same as “upload”).
3. In the node, choose the file from the **video** dropdown.
4. Optional: **path_override** = full server path (e.g. RunPod `/workspace/...`) to bypass the list.
After adding files, reload the page or run once; **IS_CHANGED** uses the input folder fingerprint so new files are noticed.
### Inputs
| Input | Description |
|--------|-------------|
| **video** | Dropdown of videos under ComfyUI `input/` (when any exist). |
| **path_override** | Full path overrides the dropdown. |
| **video_path** | Used when no videos are in `input/` (remote / empty input). |
| **random_frame** | On = pick a random frame from the slice list (ignores **current_frame_index** for selection). |
| **random_seed** | If random is on: **0** = different frame each run; **>0** = stable random for that seed. |
| **loop_on** | On = wrap **current_frame_index** with modulo (see **loop_every_n_frames**). |
| **loop_every_n_frames** | With loop on: **0** = wrap over the **full** slice list; **N > 0** = wrap only over the **first N** entries in that list. |
| **start_frame** / **end_frame** | Range in the file ( **end_frame** = **-1** = last frame ). |
| **skip_first_n_frames** | Added on top of **start_frame** before building the slice list. |
| **skip_every_n_frames** | **1** = every frame in range; **2** = every 2nd; **3** = every 3rd, … |
| **current_frame_index** | 0-based index into the slice list (ignored when random is on). |
### Outputs
| Output | Type | Description |
|--------|------|-------------|
| **image** | IMAGE | Selected frame (batch 1). |
| **image_width** / **image_height** | INT | Pixel size. |
| **slice_index** | INT | 0-based index into the slice list for this run. |
| **video_frame** | INT | Actual frame index inside the video file. |
| **frame_readout** | STRING | Human-readable status, e.g. `slice 3/120 | video frame 42 | sequential loop`. |
### Selection order
1. Build indices from **start** + **skip_first_n_frames** through **end**, stepping by **skip_every_n_frames**.
2. If **random_frame** → random **slice_index** (seeded if **random_seed** > 0).
3. Else if **loop_on** → `current_frame_index % window` (window from **loop_every_n_frames** as above).
4. Else → clamp **current_frame_index** to `[0, len(indices)-1]`.
### Migrating old workflows
Older graphs used **every_nth_frame**; the widget is now **skip_every_n_frames** (same meaning: 1 = all, 2 = every second, …). Re-open the node and set **skip_every_n_frames** to match the old value if you load an old JSON.
## Supported formats
- `.mp4`, `.mov`
+3
View File
@@ -0,0 +1,3 @@
from .video_slice import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Install OpenCV into the same Python environment ComfyUI uses (local or RunPod).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REQ="${ROOT}/requirements.txt"
run_pip() {
local pip_exe="$1"
echo "Using: ${pip_exe}"
"${pip_exe}" install -r "${REQ}"
}
# 1) Explicit override (recommended on RunPod if auto-detect fails)
if [[ -n "${COMFYUI_PIP:-}" && -x "${COMFYUI_PIP}" ]]; then
run_pip "${COMFYUI_PIP}"
exit 0
fi
# 2) Common RunPod / cloud ComfyUI venv locations (try first match)
for candidate in \
"/workspace/ComfyUI/venv/bin/pip" \
"/workspace/comfyui/venv/bin/pip" \
"/opt/ComfyUI/venv/bin/pip" \
"${HOME}/ComfyUI/venv/bin/pip"
do
if [[ -x "${candidate}" ]]; then
run_pip "${candidate}"
exit 0
fi
done
# 3) Fallback: whatever `pip` is on PATH (works if you activated Comfy's venv first)
if command -v pip >/dev/null 2>&1; then
run_pip "$(command -v pip)"
exit 0
fi
echo "No pip found. On RunPod: open a terminal, find Comfy's venv pip, then run:" >&2
echo " export COMFYUI_PIP=/path/to/ComfyUI/venv/bin/pip" >&2
echo " bash ${ROOT}/install_deps.sh" >&2
exit 1
+1
View File
@@ -0,0 +1 @@
opencv-python>=4.8.0
+312
View File
@@ -0,0 +1,312 @@
"""
ComfyUI custom node: slice .mov / .mp4 and output one frame as IMAGE.
Upload videos via ComfyUI's Input sidebar (drag-drop into input/), then pick from the list or set path override.
"""
import os
import random
import time
import numpy as np
import torch
try:
import cv2
HAS_CV2 = True
except ImportError:
HAS_CV2 = False
try:
import folder_paths
HAS_FOLDER_PATHS = True
except ImportError:
HAS_FOLDER_PATHS = False
VIDEO_EXTENSIONS = (".mp4", ".mov")
def _get_video_list_from_input():
if not HAS_FOLDER_PATHS:
return []
try:
input_dir = folder_paths.get_input_directory()
if not os.path.isdir(input_dir):
return []
out = []
for root, _dirs, files in os.walk(input_dir, followlinks=True):
rel = os.path.relpath(root, input_dir)
for f in files:
if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS:
if rel == ".":
out.append(f)
else:
out.append(os.path.join(rel, f).replace("\\", "/"))
return sorted(out)
except Exception:
return []
def _input_dir_fingerprint():
"""Rough fingerprint of input folder so IS_CHANGED picks up new uploads."""
if not HAS_FOLDER_PATHS:
return 0
try:
input_dir = folder_paths.get_input_directory()
if not os.path.isdir(input_dir):
return 0
mtimes = []
for root, _dirs, files in os.walk(input_dir, followlinks=True):
for f in files:
if os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS:
p = os.path.join(root, f)
try:
mtimes.append(os.path.getmtime(p))
except OSError:
pass
return (len(mtimes), round(max(mtimes, default=0), 3))
except Exception:
return 0
def _resolve_video_path(**kwargs):
path_override = (kwargs.get("path_override") or "").strip()
if path_override and os.path.isfile(path_override):
return path_override
if "video" in kwargs:
video_name = kwargs["video"]
if HAS_FOLDER_PATHS and video_name:
input_dir = folder_paths.get_input_directory()
return os.path.join(input_dir, video_name)
return ""
return (kwargs.get("video_path") or "").strip()
class IBVideoSlicer:
"""
Select a video from ComfyUI input (upload via Input tab) or path, output one frame.
Optional random pick, loop / loop window, and readable frame counter outputs.
"""
@classmethod
def INPUT_TYPES(cls):
video_choices = _get_video_list_from_input()
common = {
"random_frame": ("BOOLEAN", {"default": False}),
"random_seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"display": "number",
"tooltip": "When random is on: 0 = different frame each run; >0 = fixed pick for that seed.",
},
),
"loop_on": ("BOOLEAN", {"default": False}),
"loop_every_n_frames": (
"INT",
{
"default": 0,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"display": "number",
"tooltip": "When loop is on: 0 = wrap over full clip; N>0 = wrap over first N slice indices only.",
},
),
"start_frame": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1, "step": 1, "display": "number"}),
"end_frame": ("INT", {"default": -1, "min": -1, "max": 2**31 - 1, "step": 1, "display": "number"}),
"skip_first_n_frames": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1, "step": 1, "display": "number"}),
"skip_every_n_frames": (
"INT",
{
"default": 1,
"min": 1,
"max": 10000,
"step": 1,
"display": "number",
"tooltip": "1 = use every frame in range; 2 = every 2nd; 3 = every 3rd, etc.",
},
),
"current_frame_index": (
"INT",
{
"default": 0,
"min": 0,
"max": 2**31 - 1,
"step": 1,
"display": "number",
"tooltip": "Index into the slice list (after skip/every-nth). Ignored when random is on.",
},
),
}
if HAS_FOLDER_PATHS and video_choices:
return {
"required": {
"video": (
video_choices,
{
"default": video_choices[0],
"tooltip": "Files from ComfyUI input/. Upload: sidebar → Input → drag .mp4 / .mov here, then refresh if needed.",
},
),
**common,
},
"optional": {
"path_override": (
"STRING",
{
"default": "",
"multiline": False,
"placeholder": "Full path overrides dropdown (server path, e.g. RunPod /workspace/...)",
},
),
},
}
return {
"required": {
"video_path": (
"STRING",
{
"default": "",
"multiline": False,
"placeholder": "Full path to .mp4 or .mov (or upload to input/ and restart to get dropdown)",
},
),
**common,
},
"optional": {},
}
RETURN_TYPES = ("IMAGE", "INT", "INT", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "image_width", "image_height", "slice_index", "video_frame", "frame_readout")
FUNCTION = "slice_frame"
CATEGORY = "video"
@classmethod
def IS_CHANGED(
cls,
random_frame,
random_seed,
loop_on,
loop_every_n_frames,
start_frame,
end_frame,
skip_first_n_frames,
skip_every_n_frames,
current_frame_index,
**kwargs,
):
path = _resolve_video_path(**kwargs)
fp = _input_dir_fingerprint()
base = (path, fp, start_frame, end_frame, skip_first_n_frames, skip_every_n_frames, loop_on, loop_every_n_frames)
if random_frame:
if random_seed != 0:
return base + ("rand", random_seed)
return base + ("rand", time.time_ns())
return base + ("seq", current_frame_index)
def slice_frame(
self,
random_frame,
random_seed,
loop_on,
loop_every_n_frames,
start_frame,
end_frame,
skip_first_n_frames,
skip_every_n_frames,
current_frame_index,
**kwargs,
):
if not HAS_CV2:
raise ModuleNotFoundError(
"OpenCV (opencv-python) is required. Install: pip install opencv-python"
)
# Backward compat: old workflows may pass every_nth_frame
if "every_nth_frame" in kwargs and kwargs["every_nth_frame"] is not None:
skip_every_n_frames = int(kwargs["every_nth_frame"])
path = _resolve_video_path(**kwargs)
if not path or not os.path.isfile(path):
raise FileNotFoundError(f"Video file not found: {path!r}")
lower = path.lower()
if not (lower.endswith(".mp4") or lower.endswith(".mov")):
raise ValueError("Only .mp4 and .mov are supported.")
cap = cv2.VideoCapture(path)
if not cap.isOpened():
raise RuntimeError(f"Could not open video: {path}")
try:
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
if total_frames <= 0:
raise RuntimeError("Video has no frames or frame count unknown.")
effective_start = start_frame + skip_first_n_frames
effective_end = end_frame if end_frame >= 0 else (total_frames - 1)
effective_end = min(effective_end, total_frames - 1)
if effective_start > effective_end:
raise ValueError(
f"Effective start frame ({effective_start}) is after effective end ({effective_end})."
)
indices = []
i = effective_start
while i <= effective_end:
indices.append(i)
i += skip_every_n_frames
if not indices:
raise ValueError("No frames in selected range with given skip_every_n_frames.")
n = len(indices)
if random_frame:
if random_seed > 0:
rng = random.Random(random_seed)
idx = rng.randint(0, n - 1)
else:
idx = random.randint(0, n - 1)
elif loop_on:
window = loop_every_n_frames if loop_every_n_frames > 0 else n
window = max(1, min(window, n))
idx = current_frame_index % window
else:
idx = min(max(0, current_frame_index), n - 1)
video_frame_number = indices[idx]
cap.set(cv2.CAP_PROP_POS_FRAMES, video_frame_number)
ret, frame_bgr = cap.read()
if not ret or frame_bgr is None:
raise RuntimeError(f"Failed to read frame {video_frame_number} from {path}.")
frame_rgb = frame_bgr[:, :, ::-1].copy()
img_float = np.array(frame_rgb, dtype=np.float32) / 255.0
image = torch.from_numpy(img_float)[None, ...]
height, width = image.shape[1], image.shape[2]
readout = (
f"slice {idx + 1}/{n} | video frame {video_frame_number} | "
f"{'random' if random_frame else 'sequential'}{'' if random_frame else (' loop' if loop_on else '')}"
)
return (image, width, height, idx, video_frame_number, readout)
finally:
cap.release()
# Legacy class name: same implementation (saved workflows may reference either)
VideoSliceFrame = IBVideoSlicer
NODE_CLASS_MAPPINGS = {
"IBVideoSlicer": IBVideoSlicer,
"VideoSliceFrame": IBVideoSlicer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"IBVideoSlicer": "ib video slicer",
"VideoSliceFrame": "ib video slicer",
}