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:
+45
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
opencv-python-headless>=4.5.0
|
||||
torch
|
||||
numpy
|
||||
+114
@@ -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)
|
||||
Reference in New Issue
Block a user