- Added RmaN (Arman Jangmiri) credit to README, pyproject.toml, and nodes.py - Added searchable keywords (firstframe, lastframe, first frame last frame, etc.) to pyproject.toml
169 lines
5.4 KiB
Python
169 lines
5.4 KiB
Python
"""
|
|
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).
|
|
|
|
Author: RmaN (Arman Jangmiri)
|
|
GitHub: https://github.com/RmaNMetaverse/ComfyUI-FirstframeLastframeExtractor
|
|
License: MIT
|
|
"""
|
|
|
|
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
|