diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dab899..14ede06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.1.0 + +- Add optional `video_in` input (accepts path-like inputs and IMAGE frame batches). +- UI: add recent video list + Refresh + Browse/upload button. +- Add `/videoframenode/recent` endpoint (shows recent MP4 from input/output). + ## 1.0.1 - Add project icon (1024 and 256) and set Registry icon URL. diff --git a/README.md b/README.md index d98ccdb..989e831 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,20 @@ Custom node for ComfyUI that loads an `.mp4` video and outputs two images: ## What this node does - Reads a video file from `ComfyUI/input` (by filename) or from an absolute path. +- Optionally accepts a **wired video input** from another node (`video_in`). - Outputs two `IMAGE` values: `FIRST_FRAME` and `LAST_FRAME`. +- Supports choosing the video from a **recent files list** or via **file picker upload** (UI feature). - Supports drag & drop of an `.mp4` directly onto the node (UI feature). ## How to use 1. Add the node **Video: First & Last Frame** to your graph. 2. Provide the input video using one of these methods: + - Connect `video_in` from another node (if it outputs a path/filename, a dict with a path, or an IMAGE batch of frames). - Set `video` to a filename from `ComfyUI/input`. - Set `video` to an absolute path to an `.mp4` file. + - (UI) Use the `recent` dropdown or `Browse…` to pick an `.mp4` from your computer (it uploads into `ComfyUI/input`). + The `recent` list can include entries like `input/your.mp4` and `output/your.mp4`. - (UI) Drag & drop an `.mp4` onto the node. It will upload into `ComfyUI/input` and set `video` automatically. 3. Execute the graph. 4. Use outputs `FIRST_FRAME` and `LAST_FRAME` (both are ComfyUI `IMAGE`). diff --git a/pyproject.toml b/pyproject.toml index 1e78c1d..c8d57a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,14 +1,13 @@ [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.1" # Must be semver. +version = "1.1.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] diff --git a/video_first_last_frame.py b/video_first_last_frame.py index fb8e085..ced3a45 100644 --- a/video_first_last_frame.py +++ b/video_first_last_frame.py @@ -1,7 +1,7 @@ from __future__ import annotations import os -from typing import Optional, Tuple, TYPE_CHECKING +from typing import Any, Optional, Tuple, TYPE_CHECKING from uuid import uuid4 @@ -103,6 +103,27 @@ def _resolve_video_path(video: str) -> str: 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: @@ -118,6 +139,75 @@ def _resolve_video_path(video: str) -> str: 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 @@ -173,6 +263,35 @@ try: 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 @@ -190,7 +309,12 @@ class VideoFirstLastFrame: "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") @@ -198,8 +322,13 @@ class VideoFirstLastFrame: FUNCTION = "load" CATEGORY = "video" - def load(self, video: str): - video_path = _resolve_video_path(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) diff --git a/web/videoframenode.js b/web/videoframenode.js index 23cb2f9..389e15d 100644 --- a/web/videoframenode.js +++ b/web/videoframenode.js @@ -3,6 +3,61 @@ import { api } from "../../scripts/api.js"; console.log("[VideoFrameNode] extension loaded"); +async function fetchRecentMp4List() { + try { + const resp = await api.fetchApi("/videoframenode/recent", { method: "GET" }); + if (!resp.ok) return []; + const data = await resp.json(); + const files = data?.files; + return Array.isArray(files) ? files : []; + } catch (_) { + return []; + } +} + +function createHiddenFileInput(accept = ".mp4") { + const input = document.createElement("input"); + input.type = "file"; + input.accept = accept; + input.style.display = "none"; + document.body.appendChild(input); + return input; +} + +let __VideoFrameNodeBrowseInput = null; +let __VideoFrameNodeBrowseTarget = null; + +function getSharedBrowseInput() { + if (__VideoFrameNodeBrowseInput) return __VideoFrameNodeBrowseInput; + + const fileInput = createHiddenFileInput(".mp4"); + fileInput.addEventListener("change", async () => { + const node = __VideoFrameNodeBrowseTarget; + __VideoFrameNodeBrowseTarget = null; + try { + const file = fileInput.files?.[0]; + fileInput.value = ""; + if (!file || !node) return; + if (!file.name?.toLowerCase().endsWith(".mp4")) return; + + const uploadedName = await uploadMp4(file); + setNodeVideoValue(node, uploadedName); + + // Refresh recent values if present. + const recentWidget = node.widgets?.find((w) => w?.name === "recent"); + if (recentWidget?.options) { + const files = await fetchRecentMp4List(); + recentWidget.options.values = ["", ...files]; + } + } catch (e) { + console.warn("[VideoFrameNode] browse/upload failed", e); + } + }); + + __VideoFrameNodeBrowseInput = fileInput; + return fileInput; +} + async function uploadMp4(file) { const formData = new FormData(); formData.append("file", file, file.name); @@ -127,6 +182,57 @@ app.registerExtension({ nodeType.prototype.onNodeCreated = function () { const r = origOnNodeCreated?.apply(this, arguments); + // Add UI helpers: recent list + browse/upload button. + // These write into the existing STRING widget named "video". + try { + // Recent dropdown + const recentWidget = this.addWidget( + "combo", + "recent", + "", + async (v) => { + if (typeof v === "string" && v) setNodeVideoValue(this, v); + }, + { values: [""], serialize: false } + ); + + const refreshRecent = async () => { + const files = await fetchRecentMp4List(); + const values = ["", ...files]; + recentWidget.options.values = values; + // If current selection no longer exists, reset to empty. + if (!values.includes(recentWidget.value)) recentWidget.value = ""; + this.setDirtyCanvas?.(true, true); + }; + + // Refresh button + this.addWidget( + "button", + "refresh recent", + "Refresh", + async () => { + await refreshRecent(); + }, + { serialize: false } + ); + + this.addWidget( + "button", + "browse", + "Browse…", + () => { + __VideoFrameNodeBrowseTarget = this; + getSharedBrowseInput().click(); + }, + { serialize: false } + ); + + // Load recent list once. + refreshRecent(); + } catch (e) { + console.warn("[VideoFrameNode] failed to add UI widgets", e); + } + // Enable dropping an .mp4 file onto the node to upload into ComfyUI/input this.onDropFile = async (file) => { try { @@ -139,6 +245,13 @@ app.registerExtension({ const uploadedName = await uploadMp4(file); setNodeVideoValue(this, uploadedName); + // Best-effort refresh of recent widget values. + const recentWidget = this.widgets?.find((w) => w?.name === "recent"); + if (recentWidget?.options) { + const files = await fetchRecentMp4List(); + recentWidget.options.values = ["", ...files]; + } + return true; } catch (e) { console.warn("VideoFrameNode drop error", e);