Initial release: VideoFrameNode
This commit is contained in:
@@ -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 }}
|
||||
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,4 @@
|
||||
opencv-python
|
||||
imageio[ffmpeg]
|
||||
torch
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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.
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user