diff --git a/comfy-nodes/input_video.py b/comfy-nodes/input_video.py index e18d7b7..48e94a0 100644 --- a/comfy-nodes/input_video.py +++ b/comfy-nodes/input_video.py @@ -88,8 +88,12 @@ class ShellAgentPluginInputVideo: {"multiline": False, "default": "input_video"}, ), "default_value": ( - "STRING", {"video_upload": True, "default": files[0] if len(files) else ""}, + sorted(files), + { "video_upload": True } ), + # "default_value": ( + # "STRING", {"video_upload": True, "default": files[0] if len(files) else ""}, + # ), }, "optional": { "description": ( diff --git a/web/shellagent.js b/web/shellagent.js index d7466ca..dee5163 100644 --- a/web/shellagent.js +++ b/web/shellagent.js @@ -49,6 +49,30 @@ app.registerExtension({ } } + if (nodeData.name === "ShellAgentPluginInputVideo") { + addUploadWidget(nodeType, nodeData, "default_value"); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + // const pathWidget = this.widgets.find((w) => w.name === "video"); + const pathWidget = this.widgets.find((w) => w.name === "default_value"); + chainCallback(pathWidget, "callback", (value) => { + if (!value) { + return; + } + let parts = ["input", value]; + let extension_index = parts[1].lastIndexOf("."); + let extension = parts[1].slice(extension_index + 1); + let format = "video" + if (["gif", "webp", "avif"].includes(extension)) { + format = "image" + } + format += "/" + extension; + let params = { filename: parts[1], type: parts[0], format: format }; + this.updateParameters(params, true); + }); + }); + addLoadVideoCommon(nodeType, nodeData); + } + if (nodeData.name.indexOf('ShellAgentPlugin') === -1) { addMenuHandler(nodeType, function (_, options) { if (this.widgets) { @@ -60,7 +84,7 @@ app.registerExtension({ content: `Input Text -> ${w.name}`, callback: () => { this.convertWidgetToInput(w); - const node = addNode("ShellAgentPluginInputText", this, { before: true }); + const node = addNode("ShellAgentPluginInputText", this, { before: true }); node.connect(0, this, 1); } }) @@ -90,16 +114,365 @@ function addMenuHandler(nodeType, cb) { }; } +function fitHeight(node) { + node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]]) + node?.graph?.setDirtyCanvas(true); +} + function addNode(name, nextTo, options) { - options = { select: true, shiftY: 0, before: false, ...(options || {}) }; - const node = LiteGraph.createNode(name); - app.graph.add(node); - node.pos = [ - options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30, - nextTo.pos[1] + options.shiftY, - ]; - if (options.select) { - app.canvas.selectNode(node, false); - } - return node; + options = { select: true, shiftY: 0, before: false, ...(options || {}) }; + const node = LiteGraph.createNode(name); + app.graph.add(node); + node.pos = [ + options.before ? nextTo.pos[0] - node.size[0] - 30 : nextTo.pos[0] + nextTo.size[0] + 30, + nextTo.pos[1] + options.shiftY, + ]; + if (options.select) { + app.canvas.selectNode(node, false); + } + return node; +} + +function chainCallback(object, property, callback) { + if (object == undefined) { + //This should not happen. + console.error("Tried to add callback to non-existant object") + return; + } + if (property in object && object[property]) { + const callback_orig = object[property] + object[property] = function () { + const r = callback_orig.apply(this, arguments); + callback.apply(this, arguments); + return r + }; + } else { + object[property] = callback; + } +} + +async function uploadFile(file) { + //TODO: Add uploaded file to cache with Cache.put()? + try { + // Wrap file in formdata so it includes filename + const body = new FormData(); + const i = file.webkitRelativePath.lastIndexOf('/'); + const subfolder = file.webkitRelativePath.slice(0, i + 1) + const new_file = new File([file], file.name, { + type: file.type, + lastModified: file.lastModified, + }); + body.append("image", new_file); + if (i > 0) { + body.append("subfolder", subfolder); + } + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body, + }); + + if (resp.status === 200) { + return resp + } else { + alert(resp.status + " - " + resp.statusText); + } + } catch (error) { + alert(error); + } +} + +function addVideoPreview(nodeType) { + chainCallback(nodeType.prototype, "onNodeCreated", function () { + var element = document.createElement("div"); + const previewNode = this; + var previewWidget = this.addDOMWidget("videopreview", "preview", element, { + serialize: false, + hideOnZoom: false, + getValue() { + return element.value; + }, + setValue(v) { + element.value = v; + }, + }); + previewWidget.computeSize = function (width) { + if (this.aspectRatio && !this.parentEl.hidden) { + let height = (previewNode.size[0] - 20) / this.aspectRatio + 10; + if (!(height > 0)) { + height = 0; + } + this.computedHeight = height + 10; + return [width, height]; + } + return [width, -4];//no loaded src, widget should not display + } + element.addEventListener('contextmenu', (e) => { + e.preventDefault() + return app.canvas._mousedown_callback(e) + }, true); + element.addEventListener('pointerdown', (e) => { + e.preventDefault() + return app.canvas._mousedown_callback(e) + }, true); + element.addEventListener('mousewheel', (e) => { + e.preventDefault() + return app.canvas._mousewheel_callback(e) + }, true); + previewWidget.value = { + hidden: false, paused: false, params: {}, + muted: app.ui.settings.getSettingValue("VHS.DefaultMute", false) + } + previewWidget.parentEl = document.createElement("div"); + previewWidget.parentEl.className = "vhs_preview"; + previewWidget.parentEl.style['width'] = "100%" + element.appendChild(previewWidget.parentEl); + previewWidget.videoEl = document.createElement("video"); + previewWidget.videoEl.controls = false; + previewWidget.videoEl.loop = true; + previewWidget.videoEl.muted = true; + previewWidget.videoEl.style['width'] = "100%" + previewWidget.videoEl.addEventListener("loadedmetadata", () => { + + previewWidget.aspectRatio = previewWidget.videoEl.videoWidth / previewWidget.videoEl.videoHeight; + fitHeight(this); + }); + previewWidget.videoEl.addEventListener("error", () => { + //TODO: consider a way to properly notify the user why a preview isn't shown. + previewWidget.parentEl.hidden = true; + fitHeight(this); + }); + previewWidget.videoEl.onmouseenter = () => { + previewWidget.videoEl.muted = previewWidget.value.muted + }; + previewWidget.videoEl.onmouseleave = () => { + previewWidget.videoEl.muted = true; + }; + + previewWidget.imgEl = document.createElement("img"); + previewWidget.imgEl.style['width'] = "100%" + previewWidget.imgEl.hidden = true; + previewWidget.imgEl.onload = () => { + previewWidget.aspectRatio = previewWidget.imgEl.naturalWidth / previewWidget.imgEl.naturalHeight; + fitHeight(this); + }; + + var timeout = null; + this.updateParameters = (params, force_update) => { + if (!previewWidget.value.params) { + if (typeof (previewWidget.value != 'object')) { + previewWidget.value = { hidden: false, paused: false } + } + previewWidget.value.params = {} + } + Object.assign(previewWidget.value.params, params) + if (!force_update && + !app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false)) { + return; + } + if (timeout) { + clearTimeout(timeout); + } + if (force_update) { + previewWidget.updateSource(); + } else { + timeout = setTimeout(() => previewWidget.updateSource(), 100); + } + }; + previewWidget.updateSource = function () { + if (this.value.params == undefined) { + return; + } + let params = {} + Object.assign(params, this.value.params);//shallow copy + this.parentEl.hidden = this.value.hidden; + if (params.format?.split('/')[0] == 'video' || + app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false) && + (params.format?.split('/')[1] == 'gif') || params.format == 'folder') { + this.videoEl.autoplay = !this.value.paused && !this.value.hidden; + let target_width = 256 + if (element.style?.width) { + //overscale to allow scrolling. Endpoint won't return higher than native + target_width = element.style.width.slice(0, -2) * 2; + } + if (!params.force_size || params.force_size.includes("?") || params.force_size == "Disabled") { + params.force_size = target_width + "x?" + } else { + let size = params.force_size.split("x") + let ar = parseInt(size[0]) / parseInt(size[1]) + params.force_size = target_width + "x" + (target_width / ar) + } + if (app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false)) { + this.videoEl.src = api.apiURL('/viewvideo?' + new URLSearchParams(params)); + } else { + previewWidget.videoEl.src = api.apiURL('/view?' + new URLSearchParams(params)); + } + this.videoEl.hidden = false; + this.imgEl.hidden = true; + } else if (params.format?.split('/')[0] == 'image') { + //Is animated image + this.imgEl.src = api.apiURL('/view?' + new URLSearchParams(params)); + this.videoEl.hidden = true; + this.imgEl.hidden = false; + } + } + previewWidget.parentEl.appendChild(previewWidget.videoEl) + previewWidget.parentEl.appendChild(previewWidget.imgEl) + }); +} + +function addUploadWidget(nodeType, nodeData, widgetName, type = "video") { + chainCallback(nodeType.prototype, "onNodeCreated", function () { + const pathWidget = this.widgets.find((w) => w.name === widgetName); + const fileInput = document.createElement("input"); + chainCallback(this, "onRemoved", () => { + fileInput?.remove(); + }); + if (type == "video") { + Object.assign(fileInput, { + type: "file", + accept: "video/webm,video/mp4,video/mkv,image/gif", + style: "display: none", + onchange: async () => { + if (fileInput.files.length) { + let resp = await uploadFile(fileInput.files[0]) + if (resp.status != 200) { + //upload failed and file can not be added to options + return; + } + const filename = (await resp.json()).name; + pathWidget.options.values.push(filename); + pathWidget.value = filename; + if (pathWidget.callback) { + pathWidget.callback(filename) + } + } + }, + }); + } else { + throw "Unknown upload type" + } + document.body.append(fileInput); + let uploadWidget = this.addWidget("button", "choose " + type + " to upload", "image", () => { + //clear the active click event + app.canvas.node_widget = null + + fileInput.click(); + }); + uploadWidget.options.serialize = false; + }); +} + +function addPreviewOptions(nodeType) { + chainCallback(nodeType.prototype, "getExtraMenuOptions", function (_, options) { + // The intended way of appending options is returning a list of extra options, + // but this isn't used in widgetInputs.js and would require + // less generalization of chainCallback + let optNew = [] + const previewWidget = this.widgets.find((w) => w.name === "videopreview"); + + let url = null + if (previewWidget.videoEl?.hidden == false && previewWidget.videoEl.src) { + //Use full quality video + url = api.apiURL('/view?' + new URLSearchParams(previewWidget.value.params)); + //Workaround for 16bit png: Just do first frame + url = url.replace('%2503d', '001') + } else if (previewWidget.imgEl?.hidden == false && previewWidget.imgEl.src) { + url = previewWidget.imgEl.src; + url = new URL(url); + } + if (url) { + optNew.push( + { + content: "Open preview", + callback: () => { + window.open(url, "_blank") + }, + }, + { + content: "Save preview", + callback: () => { + const a = document.createElement("a"); + a.href = url; + a.setAttribute("download", new URLSearchParams(previewWidget.value.params).get("filename")); + document.body.append(a); + a.click(); + requestAnimationFrame(() => a.remove()); + }, + } + ); + } + const PauseDesc = (previewWidget.value.paused ? "Resume" : "Pause") + " preview"; + if (previewWidget.videoEl.hidden == false) { + optNew.push({ + content: PauseDesc, callback: () => { + //animated images can't be paused and are more likely to cause performance issues. + //changing src to a single keyframe is possible, + //For now, the option is disabled if an animated image is being displayed + if (previewWidget.value.paused) { + previewWidget.videoEl?.play(); + } else { + previewWidget.videoEl?.pause(); + } + previewWidget.value.paused = !previewWidget.value.paused; + } + }); + } + //TODO: Consider hiding elements if no video preview is available yet. + //It would reduce confusion at the cost of functionality + //(if a video preview lags the computer, the user should be able to hide in advance) + const visDesc = (previewWidget.value.hidden ? "Show" : "Hide") + " preview"; + optNew.push({ + content: visDesc, callback: () => { + if (!previewWidget.videoEl.hidden && !previewWidget.value.hidden) { + previewWidget.videoEl.pause(); + } else if (previewWidget.value.hidden && !previewWidget.videoEl.hidden && !previewWidget.value.paused) { + previewWidget.videoEl.play(); + } + previewWidget.value.hidden = !previewWidget.value.hidden; + previewWidget.parentEl.hidden = previewWidget.value.hidden; + fitHeight(this); + + } + }); + optNew.push({ + content: "Sync preview", callback: () => { + //TODO: address case where videos have varying length + //Consider a system of sync groups which are opt-in? + for (let p of document.getElementsByClassName("vhs_preview")) { + for (let child of p.children) { + if (child.tagName == "VIDEO") { + child.currentTime = 0; + } else if (child.tagName == "IMG") { + child.src = child.src; + } + } + } + } + }); + const muteDesc = (previewWidget.value.muted ? "Unmute" : "Mute") + " Preview" + optNew.push({ + content: muteDesc, callback: () => { + previewWidget.value.muted = !previewWidget.value.muted + } + }) + if (options.length > 0 && options[0] != null && optNew.length > 0) { + optNew.push(null); + } + options.unshift(...optNew); + }); +} + +function addLoadVideoCommon(nodeType, nodeData) { + addVideoPreview(nodeType); + addPreviewOptions(nodeType); + chainCallback(nodeType.prototype, "onNodeCreated", function () { + // const pathWidget = this.widgets.find((w) => w.name === "video"); + const pathWidget = this.widgets.find((w) => w.name === "default_value"); + //do first load + requestAnimationFrame(() => { + for (let w of [pathWidget]) { + w.callback(w.value, null, this); + } + }); + }); } \ No newline at end of file