Files

351 lines
12 KiB
Python

from __future__ import annotations
import os
from typing import Any, Optional, Tuple, TYPE_CHECKING
from uuid import uuid4
import numpy as np
if TYPE_CHECKING:
import torch
def _to_comfy_image(frame_rgb: np.ndarray):
import torch
if frame_rgb is None:
raise ValueError("Got empty frame")
if frame_rgb.ndim != 3 or frame_rgb.shape[2] != 3:
raise ValueError(f"Expected RGB HxWx3 frame, got shape={frame_rgb.shape}")
frame_rgb = np.ascontiguousarray(frame_rgb)
image = torch.from_numpy(frame_rgb).float() / 255.0
# ComfyUI IMAGE is typically [B,H,W,C]
return image.unsqueeze(0)
def _read_first_last_frames(video_path: str) -> Tuple[np.ndarray, np.ndarray]:
if not isinstance(video_path, str) or not video_path.strip():
raise ValueError("video_path is required")
video_path = os.path.expandvars(os.path.expanduser(video_path.strip()))
if not os.path.isfile(video_path):
raise FileNotFoundError(f"Video file not found: {video_path}")
# Prefer OpenCV when available
try:
import cv2 # type: ignore
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise ValueError(f"Failed to open video: {video_path}")
# First frame
ok, first_bgr = cap.read()
if not ok or first_bgr is None:
cap.release()
raise ValueError("Failed to read the first frame")
first_rgb = cv2.cvtColor(first_bgr, cv2.COLOR_BGR2RGB)
# Try to jump to the last frame using frame count
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
last_rgb: Optional[np.ndarray] = None
if frame_count > 1:
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_count - 1)
ok, last_bgr = cap.read()
if ok and last_bgr is not None:
last_rgb = cv2.cvtColor(last_bgr, cv2.COLOR_BGR2RGB)
# Fallback: scan to the end to find last decodable frame
if last_rgb is None:
last_bgr = first_bgr
while True:
ok, frame_bgr = cap.read()
if not ok or frame_bgr is None:
break
last_bgr = frame_bgr
last_rgb = cv2.cvtColor(last_bgr, cv2.COLOR_BGR2RGB)
cap.release()
return first_rgb, last_rgb
except ImportError:
# Fallback to imageio if OpenCV isn't installed
try:
import imageio.v3 as iio # type: ignore
# Read first frame
first = iio.imread(video_path, index=0)
# Read last frame: imageio supports index=-1 for many backends
last = iio.imread(video_path, index=-1)
# Ensure RGB
first = np.asarray(first)
last = np.asarray(last)
if first.ndim == 3 and first.shape[2] >= 3:
first = first[:, :, :3]
if last.ndim == 3 and last.shape[2] >= 3:
last = last[:, :, :3]
return first, last
except Exception as e: # noqa: BLE001
raise RuntimeError(
"Could not read video frames. Install opencv-python (recommended) or imageio[ffmpeg]. "
f"Original error: {e}"
)
def _resolve_video_path(video: str) -> str:
video = (video or "").strip()
if not video:
return video
# Allow explicit prefixes so UI can offer both input/ and output/ lists.
# Examples: input/foo.mp4, output/bar.mp4, temp/baz.mp4
lowered = video.replace("\\", "/").lower()
try:
import folder_paths # type: ignore
if lowered.startswith("input/"):
candidate = os.path.join(folder_paths.get_input_directory(), video.split("/", 1)[1])
if os.path.isfile(candidate):
return candidate
if lowered.startswith("output/"):
candidate = os.path.join(folder_paths.get_output_directory(), video.split("/", 1)[1])
if os.path.isfile(candidate):
return candidate
if lowered.startswith("temp/"):
candidate = os.path.join(folder_paths.get_temp_directory(), video.split("/", 1)[1])
if os.path.isfile(candidate):
return candidate
except Exception:
pass
# If user provides a plain filename, resolve it relative to ComfyUI input.
# This enables the drag&drop workflow: upload -> input folder -> set filename in widget.
try:
import folder_paths # type: ignore
input_dir = folder_paths.get_input_directory()
candidate = os.path.join(input_dir, video)
if os.path.isfile(candidate):
return candidate
except Exception:
pass
return video
def _extract_video_source(video_in: Any) -> tuple[Optional[str], Optional["torch.Tensor"], Optional["torch.Tensor"]]:
"""Try to interpret `video_in` coming from another node.
Returns:
- (video_path, first_image_tensor, last_image_tensor)
If frames are provided, returns tensors directly and video_path will be None.
"""
if video_in is None:
return None, None, None
# Many nodes pass a plain path/filename.
if isinstance(video_in, str):
return video_in, None, None
# Some nodes pass a dict-like object with common keys.
if isinstance(video_in, dict):
for key in ("path", "fullpath", "filename", "file", "video", "name"):
v = video_in.get(key)
if isinstance(v, str) and v.strip():
return v, None, None
# Sometimes the frames are embedded.
frames = video_in.get("frames")
if frames is not None:
video_in = frames
# If a list/tuple is passed, attempt first element.
if isinstance(video_in, (list, tuple)) and video_in:
# If list of IMAGE tensors, pick first and last.
try:
import torch
if all(isinstance(x, torch.Tensor) for x in video_in):
first = video_in[0]
last = video_in[-1]
# Ensure [B,H,W,C]
if first.ndim == 3:
first = first.unsqueeze(0)
if last.ndim == 3:
last = last.unsqueeze(0)
return None, first, last
except Exception:
pass
head = video_in[0]
if isinstance(head, str):
return head, None, None
if isinstance(head, dict):
for key in ("path", "fullpath", "filename", "file", "video", "name"):
v = head.get(key)
if isinstance(v, str) and v.strip():
return v, None, None
# Direct IMAGE batch tensor (frames). Common in video workflows.
try:
import torch
if isinstance(video_in, torch.Tensor):
# Expected ComfyUI IMAGE: [B,H,W,C]
if video_in.ndim == 4 and video_in.shape[0] >= 1:
first = video_in[0:1]
last = video_in[-1:]
return None, first, last
except Exception:
pass
return None, None, None
def _save_temp_preview_png(frame_rgb: np.ndarray) -> Optional[dict]:
try:
import folder_paths # type: ignore
from PIL import Image
temp_dir = folder_paths.get_temp_directory()
os.makedirs(temp_dir, exist_ok=True)
filename = f"videoframe_preview_{uuid4().hex}.png"
path = os.path.join(temp_dir, filename)
Image.fromarray(frame_rgb).save(path)
return {"filename": filename, "subfolder": "", "type": "temp"}
except Exception:
return None
# Expose frontend assets (./web)
WEB_DIRECTORY = "./web"
# Optional upload endpoint for drag&drop in the node UI.
try:
import folder_paths # type: ignore
from server import PromptServer # type: ignore
from aiohttp import web # type: ignore
@PromptServer.instance.routes.post("/videoframenode/upload")
async def videoframenode_upload(request):
try:
post = await request.post()
except Exception as e:
return web.json_response({"error": f"invalid form data: {e}"}, status=400)
file = post.get("file")
if file is None:
return web.json_response({"error": "missing file"}, status=400)
filename = getattr(file, "filename", None) or ""
if not filename.lower().endswith(".mp4"):
return web.json_response({"error": "only .mp4 supported"}, status=400)
input_dir = folder_paths.get_input_directory()
os.makedirs(input_dir, exist_ok=True)
safe_name = os.path.basename(filename)
save_path = os.path.join(input_dir, safe_name)
data = file.file.read()
with open(save_path, "wb") as f:
f.write(data)
return web.json_response({"name": safe_name})
@PromptServer.instance.routes.get("/videoframenode/recent")
async def videoframenode_recent(request):
"""Return recently modified .mp4 files from ComfyUI input dir."""
try:
candidates = [
("input", folder_paths.get_input_directory()),
("output", folder_paths.get_output_directory()),
]
entries: list[tuple[float, str]] = []
for prefix, directory in candidates:
if not os.path.isdir(directory):
continue
for name in os.listdir(directory):
if not name.lower().endswith(".mp4"):
continue
p = os.path.join(directory, name)
try:
mtime = os.path.getmtime(p)
except Exception:
mtime = 0
entries.append((mtime, f"{prefix}/{name}"))
entries.sort(key=lambda x: x[0], reverse=True)
files = [n for _, n in entries[:50]]
return web.json_response({"files": files})
except Exception as e:
return web.json_response({"files": [], "error": str(e)})
except Exception:
# When importing outside ComfyUI, server/folder_paths won't exist.
pass
class VideoFirstLastFrame:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video": (
"STRING",
{
"default": "",
"multiline": False,
},
)
},
"optional": {
# Allows wiring a video-like output from other nodes.
# Supported: path string, dict with common keys, or IMAGE batch tensor.
"video_in": ("*",),
},
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("FIRST_FRAME", "LAST_FRAME")
FUNCTION = "load"
CATEGORY = "video"
def load(self, video: str, video_in=None):
path_from_in, first_tensor, last_tensor = _extract_video_source(video_in)
if first_tensor is not None and last_tensor is not None:
return (first_tensor, last_tensor)
video_path = _resolve_video_path(path_from_in or video)
first_rgb, last_rgb = _read_first_last_frames(video_path)
preview = _save_temp_preview_png(first_rgb)
if preview is not None:
return {
"ui": {"images": [preview]},
"result": (_to_comfy_image(first_rgb), _to_comfy_image(last_rgb)),
}
return (_to_comfy_image(first_rgb), _to_comfy_image(last_rgb))
NODE_CLASS_MAPPINGS = {
"VideoFirstLastFrame": VideoFirstLastFrame,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VideoFirstLastFrame": "Video: First & Last Frame",
}