feat: video input + recent/browse selector
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user