commit d9b2df1bbe7eb9c1003f9c4c8e77695736d59e0f Author: Szymon Janek Date: Sat Dec 27 18:44:39 2025 +0100 Initial release: VideoFrameNode diff --git a/.github/workflows/publish_action.yml b/.github/workflows/publish_action.yml new file mode 100644 index 0000000..641e202 --- /dev/null +++ b/.github/workflows/publish_action.yml @@ -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 }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2c75e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f6ea63f --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..ee4b6d2 --- /dev/null +++ b/LICENSE.txt @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1cdc88a --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ec2bfb0 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a7808d6 --- /dev/null +++ b/pyproject.toml @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71118e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +opencv-python +imageio[ffmpeg] +torch + diff --git a/video_first_last_frame.py b/video_first_last_frame.py new file mode 100644 index 0000000..fb8e085 --- /dev/null +++ b/video_first_last_frame.py @@ -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", +} diff --git a/web/videoframenode.js b/web/videoframenode.js new file mode 100644 index 0000000..23cb2f9 --- /dev/null +++ b/web/videoframenode.js @@ -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. + }, +});