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.
This commit is contained in:
Arman Jangmiri
2026-07-13 10:20:18 +03:30
commit de638169dc
8 changed files with 547 additions and 0 deletions
+45
View File
@@ -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/
+21
View File
@@ -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.
+165
View File
@@ -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.
---
<p align="center">
Made with ❤️ for the ComfyUI community
</p>
+17
View File
@@ -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"]
+164
View File
@@ -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
+18
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
opencv-python-headless>=4.5.0
torch
numpy
+114
View File
@@ -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)