Initial release: VideoFrameNode

This commit is contained in:
Szymon Janek
2025-12-27 18:44:39 +01:00
commit d9b2df1bbe
10 changed files with 508 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
name: Publish to Comfy registry
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "pyproject.toml"
jobs:
publish-node:
name: Publish Custom Node to registry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
+4
View File
@@ -0,0 +1,4 @@
.venv/
__pycache__/
*.pyc
.DS_Store
+6
View File
@@ -0,0 +1,6 @@
# Changelog
## 1.0.0
- Initial release.
- Loads MP4 and outputs FIRST_FRAME / LAST_FRAME as ComfyUI IMAGE.
- Optional UI enhancements: preview thumbnail + drag&drop upload into ComfyUI/input.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 VideoFrameNode contributors
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.
+47
View File
@@ -0,0 +1,47 @@
# VideoFrameNode (ComfyUI)
Custom node for ComfyUI that loads an MP4 video and outputs two images:
- first frame
- last frame
## Install
1. Copy/clone this repo folder into your ComfyUI:
- `ComfyUI/custom_nodes/VideoFrameNode` (this repo root is the node folder)
2. Install dependencies:
- `pip install -r requirements.txt`
Note about `torch` / Pylance
This node uses `torch` because ComfyUI images are `torch.Tensor`. If Pylance shows `Import "torch" could not be resolved`, install dependencies from `requirements.txt`.
3. Restart ComfyUI.
## Publishing / Compatibility notes
- This repo includes `pyproject.toml` for Comfy Registry / ComfyUI-Manager publishing. Fill in `PublisherId` and `Repository` before publishing.
- Versioning: follow SemVer. Changing node identifiers, input names, output names, or types should be treated as a breaking change.
- Security: no `eval/exec`, no runtime `pip install` from within the node.
- API-mode: the core node works in API mode, but the drag&drop upload feature is UI-only (it adds a custom upload route + frontend JS).
## Publish to Registry (GitHub Actions)
1. Create a Registry publishing API key and add it as a GitHub repo secret named `REGISTRY_ACCESS_TOKEN`.
2. Ensure `pyproject.toml` has the correct `PublisherId` and `Repository`.
3. Bump `version` in `pyproject.toml` and push to `main`.
## Node
- Name: **Video: First & Last Frame**
- Input: `video` (filename in `ComfyUI/input` or an absolute path)
- Outputs: `FIRST_FRAME`, `LAST_FRAME` (ComfyUI `IMAGE`)
### Drag & drop workflow
1. Drag & drop your `.mp4` directly onto the node.
2. The file is uploaded into `ComfyUI/input` and the node input is set to the uploaded filename.
3. The node displays a preview (first frame) after execution.
+6
View File
@@ -0,0 +1,6 @@
from .video_first_last_frame import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
# ComfyUI loads frontend extensions from WEB_DIRECTORY on the module it imports.
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+21
View File
@@ -0,0 +1,21 @@
[project]
name = "comfyui-videoframenode" # Unique identifier for your node. Immutable after creation in the Registry.
description = "ComfyUI custom node: load an MP4 and output first/last frame as IMAGE."
version = "1.0.0" # Must be semver.
license = { file = "LICENSE.txt" }
# Registry/Manager will fill this from requirements.txt during publishing in many workflows,
# but keeping it explicit helps for tooling.
dependencies = [
"opencv-python",
"imageio[ffmpeg]",
"torch",
]
[project.urls]
Repository = "https://github.com/REPLACE_ME/ComfyUI_VideoFrameNode"
[tool.comfy]
# Fill this with your publisher id from https://registry.comfy.org (the part after '@' on your profile).
PublisherId = "video-frame-node"
DisplayName = "VideoFrameNode"
Icon = "" # Optional URL to an icon (PNG/JPG/GIF/SVG)
+4
View File
@@ -0,0 +1,4 @@
opencv-python
imageio[ffmpeg]
torch
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import os
from typing import 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
# 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 _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})
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,
},
)
}
}
RETURN_TYPES = ("IMAGE", "IMAGE")
RETURN_NAMES = ("FIRST_FRAME", "LAST_FRAME")
FUNCTION = "load"
CATEGORY = "video"
def load(self, video: str):
video_path = _resolve_video_path(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",
}
+157
View File
@@ -0,0 +1,157 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
console.log("[VideoFrameNode] extension loaded");
async function uploadMp4(file) {
const formData = new FormData();
formData.append("file", file, file.name);
const resp = await api.fetchApi("/videoframenode/upload", {
method: "POST",
body: formData,
});
if (!resp.ok) {
const t = await resp.text();
throw new Error(`upload failed (${resp.status}): ${t}`);
}
const data = await resp.json();
const uploadedName = data?.name;
if (!uploadedName) throw new Error("missing response name");
return uploadedName;
}
function setNodeVideoValue(node, value) {
const videoWidget = node?.widgets?.find((w) => w?.name === "video");
if (videoWidget) {
videoWidget.value = value;
node.setDirtyCanvas?.(true, true);
return true;
}
return false;
}
function getSelectedVideoFrameNode() {
const canvas = app?.canvas;
const selected = canvas?.selected_nodes || canvas?.graph?.selected_nodes;
if (selected) {
for (const k in selected) {
const n = selected[k];
if (n?.type === "VideoFirstLastFrame" || n?.comfyClass === "VideoFirstLastFrame") return n;
}
}
const n = canvas?.node_selected;
if (n?.type === "VideoFirstLastFrame" || n?.comfyClass === "VideoFirstLastFrame") return n;
return null;
}
function getVideoFrameNodeUnderPointer(e) {
try {
const canvas = app?.canvas;
const graph = canvas?.graph;
if (!canvas || !graph) return null;
// Try common LiteGraph helpers used across ComfyUI builds.
let pos = null;
if (typeof canvas.convertEventToCanvasOffset === "function") {
pos = canvas.convertEventToCanvasOffset(e);
} else if (typeof canvas.convertEventToCanvas === "function") {
pos = canvas.convertEventToCanvas(e);
}
if (pos && typeof graph.getNodeOnPos === "function") {
const node = graph.getNodeOnPos(pos[0], pos[1]);
if (node?.type === "VideoFirstLastFrame" || node?.comfyClass === "VideoFirstLastFrame") return node;
}
} catch (_) {
// ignore
}
return null;
}
function installGlobalDropHandlerOnce() {
if (window.__VideoFrameNodeDropInstalled) return;
window.__VideoFrameNodeDropInstalled = true;
document.addEventListener(
"dragover",
(e) => {
const dt = e.dataTransfer;
if (!dt) return;
if (dt.types && !dt.types.includes("Files")) return;
e.preventDefault();
},
true
);
document.addEventListener(
"drop",
async (e) => {
try {
const files = e.dataTransfer?.files;
if (!files || !files.length) return;
const file = files[0];
if (!file?.name || !file.name.toLowerCase().endsWith(".mp4")) return;
const node = getVideoFrameNodeUnderPointer(e) || getSelectedVideoFrameNode();
if (!node) return;
e.preventDefault();
e.stopPropagation();
console.log("[VideoFrameNode] drop captured, uploading", file.name);
const uploadedName = await uploadMp4(file);
setNodeVideoValue(node, uploadedName);
} catch (err) {
console.warn("[VideoFrameNode] global drop failed", err);
}
},
true
);
}
app.registerExtension({
name: "VideoFrameNode.DragDropPreview",
setup() {
installGlobalDropHandlerOnce();
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== "VideoFirstLastFrame") return;
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = origOnNodeCreated?.apply(this, arguments);
// Enable dropping an .mp4 file onto the node to upload into ComfyUI/input
this.onDropFile = async (file) => {
try {
if (Array.isArray(file)) file = file[0];
if (!file?.name || !file.name.toLowerCase().endsWith(".mp4")) {
return false;
}
console.log("[VideoFrameNode] node drop, uploading", file.name);
const uploadedName = await uploadMp4(file);
setNodeVideoValue(this, uploadedName);
return true;
} catch (e) {
console.warn("VideoFrameNode drop error", e);
return false;
}
};
// Some builds require this to allow drop.
this.onDragOver = () => true;
return r;
};
// No need to override onExecuted: ComfyUI will render ui.images automatically.
},
});