Skip to content

Commit 246e85d

Browse files
committed
feat: video input + recent/browse selector
1 parent cb7dba2 commit 246e85d

File tree

5 files changed

+258
-6
lines changed

5 files changed

+258
-6
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 1.1.0
4+
5+
- Add optional `video_in` input (accepts path-like inputs and IMAGE frame batches).
6+
- UI: add recent video list + Refresh + Browse/upload button.
7+
- Add `/videoframenode/recent` endpoint (shows recent MP4 from input/output).
8+
39
## 1.0.1
410

511
- Add project icon (1024 and 256) and set Registry icon URL.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,20 @@ Custom node for ComfyUI that loads an `.mp4` video and outputs two images:
1010
## What this node does
1111

1212
- Reads a video file from `ComfyUI/input` (by filename) or from an absolute path.
13+
- Optionally accepts a **wired video input** from another node (`video_in`).
1314
- Outputs two `IMAGE` values: `FIRST_FRAME` and `LAST_FRAME`.
15+
- Supports choosing the video from a **recent files list** or via **file picker upload** (UI feature).
1416
- Supports drag & drop of an `.mp4` directly onto the node (UI feature).
1517

1618
## How to use
1719

1820
1. Add the node **Video: First & Last Frame** to your graph.
1921
2. Provide the input video using one of these methods:
22+
- Connect `video_in` from another node (if it outputs a path/filename, a dict with a path, or an IMAGE batch of frames).
2023
- Set `video` to a filename from `ComfyUI/input`.
2124
- Set `video` to an absolute path to an `.mp4` file.
25+
- (UI) Use the `recent` dropdown or `Browse…` to pick an `.mp4` from your computer (it uploads into `ComfyUI/input`).
26+
The `recent` list can include entries like `input/your.mp4` and `output/your.mp4`.
2227
- (UI) Drag & drop an `.mp4` onto the node. It will upload into `ComfyUI/input` and set `video` automatically.
2328
3. Execute the graph.
2429
4. Use outputs `FIRST_FRAME` and `LAST_FRAME` (both are ComfyUI `IMAGE`).

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
[project]
22
name = "comfyui-videoframenode" # Unique identifier for your node. Immutable after creation in the Registry.
33
description = "ComfyUI custom node: load an MP4 and output first/last frame as IMAGE."
4-
version = "1.0.1" # Must be semver.
4+
version = "1.1.0" # Must be semver.
55
license = { file = "LICENSE.txt" }
66
# Registry/Manager will fill this from requirements.txt during publishing in many workflows,
77
# but keeping it explicit helps for tooling.
88
dependencies = [
99
"opencv-python",
1010
"imageio[ffmpeg]",
11-
"torch",
1211
]
1312

1413
[project.urls]

video_first_last_frame.py

Lines changed: 133 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
import os
4-
from typing import Optional, Tuple, TYPE_CHECKING
4+
from typing import Any, Optional, Tuple, TYPE_CHECKING
55

66
from uuid import uuid4
77

@@ -103,6 +103,27 @@ def _resolve_video_path(video: str) -> str:
103103
if not video:
104104
return video
105105

106+
# Allow explicit prefixes so UI can offer both input/ and output/ lists.
107+
# Examples: input/foo.mp4, output/bar.mp4, temp/baz.mp4
108+
lowered = video.replace("\\", "/").lower()
109+
try:
110+
import folder_paths # type: ignore
111+
112+
if lowered.startswith("input/"):
113+
candidate = os.path.join(folder_paths.get_input_directory(), video.split("/", 1)[1])
114+
if os.path.isfile(candidate):
115+
return candidate
116+
if lowered.startswith("output/"):
117+
candidate = os.path.join(folder_paths.get_output_directory(), video.split("/", 1)[1])
118+
if os.path.isfile(candidate):
119+
return candidate
120+
if lowered.startswith("temp/"):
121+
candidate = os.path.join(folder_paths.get_temp_directory(), video.split("/", 1)[1])
122+
if os.path.isfile(candidate):
123+
return candidate
124+
except Exception:
125+
pass
126+
106127
# If user provides a plain filename, resolve it relative to ComfyUI input.
107128
# This enables the drag&drop workflow: upload -> input folder -> set filename in widget.
108129
try:
@@ -118,6 +139,75 @@ def _resolve_video_path(video: str) -> str:
118139
return video
119140

120141

142+
def _extract_video_source(video_in: Any) -> tuple[Optional[str], Optional["torch.Tensor"], Optional["torch.Tensor"]]:
143+
"""Try to interpret `video_in` coming from another node.
144+
145+
Returns:
146+
- (video_path, first_image_tensor, last_image_tensor)
147+
148+
If frames are provided, returns tensors directly and video_path will be None.
149+
"""
150+
if video_in is None:
151+
return None, None, None
152+
153+
# Many nodes pass a plain path/filename.
154+
if isinstance(video_in, str):
155+
return video_in, None, None
156+
157+
# Some nodes pass a dict-like object with common keys.
158+
if isinstance(video_in, dict):
159+
for key in ("path", "fullpath", "filename", "file", "video", "name"):
160+
v = video_in.get(key)
161+
if isinstance(v, str) and v.strip():
162+
return v, None, None
163+
# Sometimes the frames are embedded.
164+
frames = video_in.get("frames")
165+
if frames is not None:
166+
video_in = frames
167+
168+
# If a list/tuple is passed, attempt first element.
169+
if isinstance(video_in, (list, tuple)) and video_in:
170+
# If list of IMAGE tensors, pick first and last.
171+
try:
172+
import torch
173+
174+
if all(isinstance(x, torch.Tensor) for x in video_in):
175+
first = video_in[0]
176+
last = video_in[-1]
177+
# Ensure [B,H,W,C]
178+
if first.ndim == 3:
179+
first = first.unsqueeze(0)
180+
if last.ndim == 3:
181+
last = last.unsqueeze(0)
182+
return None, first, last
183+
except Exception:
184+
pass
185+
186+
head = video_in[0]
187+
if isinstance(head, str):
188+
return head, None, None
189+
if isinstance(head, dict):
190+
for key in ("path", "fullpath", "filename", "file", "video", "name"):
191+
v = head.get(key)
192+
if isinstance(v, str) and v.strip():
193+
return v, None, None
194+
195+
# Direct IMAGE batch tensor (frames). Common in video workflows.
196+
try:
197+
import torch
198+
199+
if isinstance(video_in, torch.Tensor):
200+
# Expected ComfyUI IMAGE: [B,H,W,C]
201+
if video_in.ndim == 4 and video_in.shape[0] >= 1:
202+
first = video_in[0:1]
203+
last = video_in[-1:]
204+
return None, first, last
205+
except Exception:
206+
pass
207+
208+
return None, None, None
209+
210+
121211
def _save_temp_preview_png(frame_rgb: np.ndarray) -> Optional[dict]:
122212
try:
123213
import folder_paths # type: ignore
@@ -173,6 +263,35 @@ async def videoframenode_upload(request):
173263

174264
return web.json_response({"name": safe_name})
175265

266+
@PromptServer.instance.routes.get("/videoframenode/recent")
267+
async def videoframenode_recent(request):
268+
"""Return recently modified .mp4 files from ComfyUI input dir."""
269+
try:
270+
candidates = [
271+
("input", folder_paths.get_input_directory()),
272+
("output", folder_paths.get_output_directory()),
273+
]
274+
275+
entries: list[tuple[float, str]] = []
276+
for prefix, directory in candidates:
277+
if not os.path.isdir(directory):
278+
continue
279+
for name in os.listdir(directory):
280+
if not name.lower().endswith(".mp4"):
281+
continue
282+
p = os.path.join(directory, name)
283+
try:
284+
mtime = os.path.getmtime(p)
285+
except Exception:
286+
mtime = 0
287+
entries.append((mtime, f"{prefix}/{name}"))
288+
289+
entries.sort(key=lambda x: x[0], reverse=True)
290+
files = [n for _, n in entries[:50]]
291+
return web.json_response({"files": files})
292+
except Exception as e:
293+
return web.json_response({"files": [], "error": str(e)})
294+
176295
except Exception:
177296
# When importing outside ComfyUI, server/folder_paths won't exist.
178297
pass
@@ -190,16 +309,26 @@ def INPUT_TYPES(cls):
190309
"multiline": False,
191310
},
192311
)
193-
}
312+
},
313+
"optional": {
314+
# Allows wiring a video-like output from other nodes.
315+
# Supported: path string, dict with common keys, or IMAGE batch tensor.
316+
"video_in": ("*",),
317+
},
194318
}
195319

196320
RETURN_TYPES = ("IMAGE", "IMAGE")
197321
RETURN_NAMES = ("FIRST_FRAME", "LAST_FRAME")
198322
FUNCTION = "load"
199323
CATEGORY = "video"
200324

201-
def load(self, video: str):
202-
video_path = _resolve_video_path(video)
325+
def load(self, video: str, video_in=None):
326+
path_from_in, first_tensor, last_tensor = _extract_video_source(video_in)
327+
328+
if first_tensor is not None and last_tensor is not None:
329+
return (first_tensor, last_tensor)
330+
331+
video_path = _resolve_video_path(path_from_in or video)
203332
first_rgb, last_rgb = _read_first_last_frames(video_path)
204333

205334
preview = _save_temp_preview_png(first_rgb)

web/videoframenode.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,61 @@ import { api } from "../../scripts/api.js";
33

44
console.log("[VideoFrameNode] extension loaded");
55

6+
async function fetchRecentMp4List() {
7+
try {
8+
const resp = await api.fetchApi("/videoframenode/recent", { method: "GET" });
9+
if (!resp.ok) return [];
10+
const data = await resp.json();
11+
const files = data?.files;
12+
return Array.isArray(files) ? files : [];
13+
} catch (_) {
14+
return [];
15+
}
16+
}
17+
18+
function createHiddenFileInput(accept = ".mp4") {
19+
const input = document.createElement("input");
20+
input.type = "file";
21+
input.accept = accept;
22+
input.style.display = "none";
23+
document.body.appendChild(input);
24+
return input;
25+
}
26+
27+
let __VideoFrameNodeBrowseInput = null;
28+
let __VideoFrameNodeBrowseTarget = null;
29+
30+
function getSharedBrowseInput() {
31+
if (__VideoFrameNodeBrowseInput) return __VideoFrameNodeBrowseInput;
32+
33+
const fileInput = createHiddenFileInput(".mp4");
34+
fileInput.addEventListener("change", async () => {
35+
const node = __VideoFrameNodeBrowseTarget;
36+
__VideoFrameNodeBrowseTarget = null;
37+
try {
38+
const file = fileInput.files?.[0];
39+
fileInput.value = "";
40+
if (!file || !node) return;
41+
if (!file.name?.toLowerCase().endsWith(".mp4")) return;
42+
43+
const uploadedName = await uploadMp4(file);
44+
setNodeVideoValue(node, uploadedName);
45+
46+
// Refresh recent values if present.
47+
const recentWidget = node.widgets?.find((w) => w?.name === "recent");
48+
if (recentWidget?.options) {
49+
const files = await fetchRecentMp4List();
50+
recentWidget.options.values = ["", ...files];
51+
}
52+
} catch (e) {
53+
console.warn("[VideoFrameNode] browse/upload failed", e);
54+
}
55+
});
56+
57+
__VideoFrameNodeBrowseInput = fileInput;
58+
return fileInput;
59+
}
60+
661
async function uploadMp4(file) {
762
const formData = new FormData();
863
formData.append("file", file, file.name);
@@ -127,6 +182,57 @@ app.registerExtension({
127182
nodeType.prototype.onNodeCreated = function () {
128183
const r = origOnNodeCreated?.apply(this, arguments);
129184

185+
// Add UI helpers: recent list + browse/upload button.
186+
// These write into the existing STRING widget named "video".
187+
try {
188+
// Recent dropdown
189+
const recentWidget = this.addWidget(
190+
"combo",
191+
"recent",
192+
"",
193+
async (v) => {
194+
if (typeof v === "string" && v) setNodeVideoValue(this, v);
195+
},
196+
{ values: [""], serialize: false }
197+
);
198+
199+
const refreshRecent = async () => {
200+
const files = await fetchRecentMp4List();
201+
const values = ["", ...files];
202+
recentWidget.options.values = values;
203+
// If current selection no longer exists, reset to empty.
204+
if (!values.includes(recentWidget.value)) recentWidget.value = "";
205+
this.setDirtyCanvas?.(true, true);
206+
};
207+
208+
// Refresh button
209+
this.addWidget(
210+
"button",
211+
"refresh recent",
212+
"Refresh",
213+
async () => {
214+
await refreshRecent();
215+
},
216+
{ serialize: false }
217+
);
218+
219+
this.addWidget(
220+
"button",
221+
"browse",
222+
"Browse…",
223+
() => {
224+
__VideoFrameNodeBrowseTarget = this;
225+
getSharedBrowseInput().click();
226+
},
227+
{ serialize: false }
228+
);
229+
230+
// Load recent list once.
231+
refreshRecent();
232+
} catch (e) {
233+
console.warn("[VideoFrameNode] failed to add UI widgets", e);
234+
}
235+
130236
// Enable dropping an .mp4 file onto the node to upload into ComfyUI/input
131237
this.onDropFile = async (file) => {
132238
try {
@@ -139,6 +245,13 @@ app.registerExtension({
139245
const uploadedName = await uploadMp4(file);
140246
setNodeVideoValue(this, uploadedName);
141247

248+
// Best-effort refresh of recent widget values.
249+
const recentWidget = this.widgets?.find((w) => w?.name === "recent");
250+
if (recentWidget?.options) {
251+
const files = await fetchRecentMp4List();
252+
recentWidget.options.values = ["", ...files];
253+
}
254+
142255
return true;
143256
} catch (e) {
144257
console.warn("VideoFrameNode drop error", e);

0 commit comments

Comments
 (0)