commit d367dc76af5aa9fa55bb856d8574f84d04de853a Author: ft Date: Sat Mar 28 14:27:40 2026 +0800 Prepare ComfyUI-VideoSlice for GitHub and RunPod (ib video slicer). Made-with: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..019b8f7 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8448017 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7fcda75 --- /dev/null +++ b/README.md @@ -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` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4d140eb --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .video_slice import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/install_deps.sh b/install_deps.sh new file mode 100755 index 0000000..6b85855 --- /dev/null +++ b/install_deps.sh @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..09eb3b9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +opencv-python>=4.8.0 diff --git a/video_slice.py b/video_slice.py new file mode 100644 index 0000000..8f2dd7c --- /dev/null +++ b/video_slice.py @@ -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", +}