feat: video input + recent/browse selector

This commit is contained in:
Szymon Janek
2026-01-24 22:20:56 +01:00
parent cb7dba23af
commit 246e85de62
5 changed files with 258 additions and 6 deletions
+6
View File
@@ -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.
+5
View File
@@ -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`).
+1 -2
View File
@@ -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]
+133 -4
View File
@@ -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)
+113
View File
@@ -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);