commit de638169dc37c1d591abb74a04a9d296d5f7288c Author: Arman Jangmiri Date: Mon Jul 13 10:20:18 2026 +0330 Initial release: First & Last Frame Extractor for ComfyUI A lightweight utility node that extracts the first and last frame from any video file or image batch. Supports dual input modes (file path + image batch), fallback sequential seeking, and is compatible with all ComfyUI versions. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c78b882 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.egg + +# Virtual environments +.venv/ +venv/ +ENV/ + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +.project +.settings/ + +# OS files +.DS_Store +Thumbs.db +Desktop.ini +ehthumbs.db + +# Logs +*.log + +# Test artifacts +.pytest_cache/ +htmlcov/ +.coverage + +# ComfyUI runtime (don't ship user data) +output/ +temp/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b77bf2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +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..c7cefef --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# ComfyUI-FirstframeLastframeExtractor ๐ŸŽฌ + +> Extract the **first frame** and **last frame** from any video โ€” directly in your ComfyUI workflow. + +A lightweight, zero-config utility node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that takes any video input and returns the first and last frames as standard `IMAGE` outputs. Works with direct file paths **and** image batches from other nodes like [VideoHelperSuite](https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite). + +--- + +## โœจ Features + +- ๐ŸŽฏ **Dual Input Mode** โ€” Accepts a video file path (`STRING`) or a pre-loaded image batch (`IMAGE`) from other nodes +- ๐Ÿ”„ **Auto Detection** โ€” Connect whichever input you have; the node handles the rest +- ๐ŸŽฅ **Broad Codec Support** โ€” `.mp4`, `.avi`, `.mkv`, `.mov`, `.webm`, `.gif` and more via OpenCV +- ๐Ÿ›ก๏ธ **Robust Fallback** โ€” If the codec doesn't support direct seeking, falls back to sequential reading +- โšก **Lightweight** โ€” No heavy dependencies beyond what ComfyUI already ships with +- ๐Ÿ”— **Universal Compatibility** โ€” Works with all ComfyUI versions using the stable node API + +--- + +## ๐Ÿ“ฆ Installation + +### Option A โ€” Git Clone (Recommended) + +```bash +cd ComfyUI/custom_nodes/ +git clone https://github.com/RmaNMetaverse/ComfyUI-FirstframeLastframeExtractor.git +pip install -r ComfyUI-FirstframeLastframeExtractor/requirements.txt +``` + +Restart ComfyUI. + +### Option B โ€” ComfyUI Manager + +Search for **"First & Last Frame Extractor"** in the ComfyUI Manager and click **Install**. + +### Option C โ€” Manual Download + +1. Download and extract the [latest release](https://github.com/RmaNMetaverse/ComfyUI-FirstframeLastframeExtractor/releases) +2. Place the folder inside `ComfyUI/custom_nodes/` +3. Install dependencies: `pip install -r requirements.txt` +4. Restart ComfyUI + +--- + +## ๐Ÿš€ Quick Start + +1. **Right-click** the ComfyUI canvas +2. Navigate to **Add Node โ†’ Video/Utils โ†’ First & Last Frame Extractor ๐ŸŽฌ** +3. Connect an input (see below) +4. Wire the outputs to **Preview Image**, **Save Image**, or any downstream node +5. **Queue** the prompt + +--- + +## ๐Ÿ”ง Node Reference + +### Inputs + +| Name | Type | Required | Description | +|:-----|:-----|:--------:|:------------| +| `video_path` | `STRING` | Optional | Absolute path to a video file (`.mp4`, `.avi`, `.mkv`, `.mov`, `.webm`, `.gif`) | +| `images` | `IMAGE` | Optional | Image batch tensor from another node (e.g., VHS Load Video) | + +> [!NOTE] +> At least one input must be provided. If both are connected, the **image batch** takes priority. + +### Outputs + +| Name | Type | Description | +|:-----|:-----|:------------| +| `first_frame` | `IMAGE` | The very first frame of the video `[1, H, W, C]` | +| `last_frame` | `IMAGE` | The very last frame of the video `[1, H, W, C]` | + +### Category + +๐Ÿ“ `Video/Utils` โ†’ **First & Last Frame Extractor ๐ŸŽฌ** + +--- + +## ๐Ÿ’ก Usage Examples + +### Example 1: Direct File Path + +Paste or type a video file path directly into the node โ€” no other nodes needed. + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ First & Last Frame Extractor ๐ŸŽฌ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ Preview Image โ”‚ +โ”‚ video_path: C:\video.mp4 โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ โ”‚ +โ”‚ โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ”‚ first_frame โ—‹โ”€โ”€โ”ค +โ”‚ last_frame โ—‹โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โ”‚ โ”‚ Save Image โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Example 2: From VideoHelperSuite (VHS) + +Connect the IMAGE output of a VHS **Load Video** node to extract the boundary frames from a loaded sequence. + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ VHS Load Video โ”‚ โ”‚ First & Last Frame Extractor ๐ŸŽฌ โ”‚ โ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ Preview Image โ”‚ +โ”‚ IMAGE โ—‹โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ images โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ–บโ”‚ โ”‚ +โ”‚ โ”‚ โ”‚ first_frame โ—‹โ”€โ”€โ”ค โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ last_frame โ—‹โ”€โ”€โ”ค + โ”‚ โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +### Example 3: Frame-to-Frame Comparison + +Use both outputs for side-by-side comparison, img2img pipelines, or interpolation workflows. + +--- + +## ๐Ÿ”— Compatibility + +| Component | Supported | +|:----------|:----------| +| **ComfyUI** | All versions (stable `NODE_CLASS_MAPPINGS` API) | +| **Python** | 3.8+ | +| **OS** | Windows, Linux, macOS | +| **Interop** | VideoHelperSuite (VHS), or any node that outputs `IMAGE` batches | + +--- + +## ๐Ÿ› ๏ธ Dependencies + +| Package | Notes | +|:--------|:------| +| `opencv-python-headless` โ‰ฅ 4.5.0 | For reading video files from disk | +| `torch` | Already included in ComfyUI | +| `numpy` | Already included in ComfyUI | + +--- + +## ๐Ÿงช Running Tests + +A smoke test suite is included for local verification: + +```bash +cd ComfyUI-FirstframeLastframeExtractor +python test_node.py +``` + +--- + +## ๐Ÿ“„ License + +This project is licensed under the [MIT License](LICENSE). + +--- + +## ๐Ÿค Contributing + +Contributions, issues, and feature requests are welcome! Feel free to open an [issue](https://github.com/RmaNMetaverse/ComfyUI-FirstframeLastframeExtractor/issues) or submit a pull request. + +--- + +

+ Made with โค๏ธ for the ComfyUI community +

diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..83132de --- /dev/null +++ b/__init__.py @@ -0,0 +1,17 @@ +""" +ComfyUI-FirstframeLastframeExtractor +==================================== +Registers the First & Last Frame Extractor node with ComfyUI. +""" + +from .nodes import FirstFrameLastFrameExtractor + +NODE_CLASS_MAPPINGS = { + "FirstFrameLastFrameExtractor": FirstFrameLastFrameExtractor, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "FirstFrameLastFrameExtractor": "First & Last Frame Extractor ๐ŸŽฌ", +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..1891df3 --- /dev/null +++ b/nodes.py @@ -0,0 +1,164 @@ +""" +ComfyUI-FirstframeLastframeExtractor +==================================== +A simple utility node that extracts the first and last frame from a video +file or an image batch. Works with direct file paths or piped output from +other nodes (e.g. VideoHelperSuite). +""" + +import os +import cv2 +import torch +import numpy as np + + +class FirstFrameLastFrameExtractor: + """ + Extracts the first and last frame from a video file or image batch. + + Inputs (both optional โ€” connect whichever you have): + - video_path: A STRING file path to a video file (.mp4, .avi, .mkv, etc.) + - images: An IMAGE batch tensor [B, H, W, C] from another node + + Outputs: + - first_frame: IMAGE tensor [1, H, W, C] + - last_frame: IMAGE tensor [1, H, W, C] + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": { + "video_path": ("STRING", { + "default": "", + "multiline": False, + "tooltip": ( + "Absolute path to a video file " + "(.mp4, .avi, .mkv, .mov, .webm, .gif)" + ), + }), + "images": ("IMAGE",), + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE") + RETURN_NAMES = ("first_frame", "last_frame") + FUNCTION = "extract_frames" + CATEGORY = "Video/Utils" + DESCRIPTION = ( + "Extracts the first and last frame from a video file or image batch." + ) + + # ------------------------------------------------------------------ + # Main entry point + # ------------------------------------------------------------------ + + def extract_frames(self, video_path="", images=None): + """ + Extract the first and last frames. + + Priority order: + 1. If an image batch is connected, pick frame [0] and frame [-1]. + 2. Otherwise, open the file at *video_path* with OpenCV. + """ + + # --- Path A: image batch from another node --- + if images is not None: + first_frame = images[0].unsqueeze(0) # [1, H, W, C] + last_frame = images[-1].unsqueeze(0) # [1, H, W, C] + return (first_frame, last_frame) + + # --- Path B: read video file from disk --- + if not video_path or not video_path.strip(): + raise ValueError( + "No input provided. Connect an image batch (IMAGE) " + "or enter a video file path." + ) + + video_path = video_path.strip() + + # Strip surrounding quotes that users sometimes paste + if ( + (video_path.startswith('"') and video_path.endswith('"')) + or (video_path.startswith("'") and video_path.endswith("'")) + ): + video_path = video_path[1:-1] + + if not os.path.isfile(video_path): + raise FileNotFoundError(f"Video file not found: {video_path}") + + cap = cv2.VideoCapture(video_path) + if not cap.isOpened(): + raise ValueError(f"Could not open video file: {video_path}") + + try: + first_cv2, last_cv2 = self._read_first_last(cap) + finally: + cap.release() + + first_frame = self._cv2_to_tensor(first_cv2) + last_frame = self._cv2_to_tensor(last_cv2) + + return (first_frame, last_frame) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + @staticmethod + def _read_first_last(cap): + """ + Read the first and last frames from an open cv2.VideoCapture. + + Uses seeking first; falls back to sequential reading if the codec + does not support seeking to the last frame. + """ + # ---- First frame ---- + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + ret, first_frame = cap.read() + if not ret or first_frame is None: + raise RuntimeError( + "Failed to read the first frame from the video." + ) + + # ---- Last frame ---- + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + last_frame = None + + if total_frames > 0: + # Try seeking directly to the last frame + cap.set(cv2.CAP_PROP_POS_FRAMES, total_frames - 1) + ret, last_frame = cap.read() + if not ret: + last_frame = None + + # Fallback: read sequentially until the end + if last_frame is None: + cap.set(cv2.CAP_PROP_POS_FRAMES, 0) + while True: + ret, frame = cap.read() + if not ret: + break + last_frame = frame + + if last_frame is None: + raise RuntimeError( + "Failed to read the last frame from the video." + ) + + return first_frame, last_frame + + @staticmethod + def _cv2_to_tensor(cv2_frame): + """ + Convert an OpenCV BGR uint8 frame to a ComfyUI IMAGE tensor. + + Returns a torch.float32 tensor with shape [1, H, W, C], values in + [0.0, 1.0], RGB channel order. + """ + rgb = cv2.cvtColor(cv2_frame, cv2.COLOR_BGR2RGB) + normalized = rgb.astype(np.float32) / 255.0 + tensor = torch.from_numpy(normalized).unsqueeze(0) # [1, H, W, C] + return tensor diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..18e51e4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "comfyui-firstframe-lastframe-extractor" +version = "1.0.0" +description = "Extract the first and last frame from any video file or image batch in ComfyUI." +license = { text = "MIT" } +requires-python = ">=3.8" +dependencies = [ + "opencv-python-headless>=4.5.0", + "torch", + "numpy", +] + +[project.urls] +Repository = "https://github.com/RmaNMetaverse/ComfyUI-FirstframeLastframeExtractor" + +[tool.comfy] +publisherId = "RmaNMetaverse" +displayName = "First & Last Frame Extractor" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fa03268 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +opencv-python-headless>=4.5.0 +torch +numpy diff --git a/test_node.py b/test_node.py new file mode 100644 index 0000000..b69e63c --- /dev/null +++ b/test_node.py @@ -0,0 +1,114 @@ +"""Smoke tests for FirstFrameLastFrameExtractor.""" +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) + +import torch +from nodes import FirstFrameLastFrameExtractor + + +def test_video_file(): + """Test extraction from a real video file.""" + node = FirstFrameLastFrameExtractor() + video = r"C:\Users\Virtual-Pc\Desktop\Future Egypt Veo3.mp4" + if not os.path.isfile(video): + print("SKIP: test video not found") + return + + first, last = node.extract_frames(video_path=video) + assert first.dim() == 4, f"Expected 4D, got {first.dim()}D" + assert last.dim() == 4, f"Expected 4D, got {last.dim()}D" + assert first.shape[0] == 1 + assert first.shape[3] == 3 + assert first.dtype == torch.float32 + assert 0.0 <= first.min() and first.max() <= 1.0 + print(f" Video file: first={list(first.shape)}, last={list(last.shape)} OK") + + +def test_image_batch(): + """Test extraction from a fake image batch.""" + node = FirstFrameLastFrameExtractor() + batch = torch.rand(10, 64, 64, 3) + first, last = node.extract_frames(images=batch) + assert torch.equal(first.squeeze(0), batch[0]) + assert torch.equal(last.squeeze(0), batch[-1]) + assert first.shape == torch.Size([1, 64, 64, 3]) + print(f" Image batch: first={list(first.shape)}, last={list(last.shape)} OK") + + +def test_single_frame_batch(): + """Edge case: batch with only one frame.""" + node = FirstFrameLastFrameExtractor() + batch = torch.rand(1, 32, 32, 3) + first, last = node.extract_frames(images=batch) + assert torch.equal(first, last), "Single-frame: first and last must be identical" + print(" Single-frame batch: OK") + + +def test_error_empty_input(): + """Should raise ValueError when nothing is provided.""" + node = FirstFrameLastFrameExtractor() + try: + node.extract_frames(video_path="", images=None) + assert False, "Should have raised ValueError" + except ValueError: + print(" Empty input ValueError: OK") + + +def test_error_missing_file(): + """Should raise FileNotFoundError for non-existent path.""" + node = FirstFrameLastFrameExtractor() + try: + node.extract_frames(video_path="C:/nonexistent/fake.mp4") + assert False, "Should have raised FileNotFoundError" + except FileNotFoundError: + print(" Missing file FileNotFoundError: OK") + + +def test_error_whitespace(): + """Should raise ValueError for whitespace-only path.""" + node = FirstFrameLastFrameExtractor() + try: + node.extract_frames(video_path=" ") + assert False, "Should have raised ValueError" + except ValueError: + print(" Whitespace ValueError: OK") + + +def test_quoted_path(): + """Should strip surrounding quotes from path.""" + node = FirstFrameLastFrameExtractor() + video = r"C:\Users\Virtual-Pc\Desktop\Future Egypt Veo3.mp4" + if not os.path.isfile(video): + print(" SKIP: test video not found") + return + + first, last = node.extract_frames(video_path=f'"{video}"') + assert first.dim() == 4 + print(" Quoted path stripping: OK") + + +if __name__ == "__main__": + print("Running smoke tests...\n") + tests = [ + test_video_file, + test_image_batch, + test_single_frame_batch, + test_error_empty_input, + test_error_missing_file, + test_error_whitespace, + test_quoted_path, + ] + passed = 0 + failed = 0 + for t in tests: + try: + t() + passed += 1 + except Exception as e: + print(f" FAIL {t.__name__}: {e}") + failed += 1 + + print(f"\nResults: {passed} passed, {failed} failed") + sys.exit(1 if failed else 0)