import { app } from '../../../scripts/app.js' import { api } from '../../../scripts/api.js' import { setWidgetConfig } from '../../../extensions/core/widgetInputs.js' import { applyTextReplacements } from "../../../scripts/utils.js"; 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); return callback.apply(this, arguments) ?? r }; } else { object[property] = callback; } } function injectHidden(widget) { widget.computeSize = (target_width) => { if (widget.hidden) { return [0, -4]; } return [target_width, 20]; }; widget._type = widget.type Object.defineProperty(widget, "type", { set : function(value) { widget._type = value; }, get : function() { if (widget.hidden) { return "hidden"; } return widget._type; } }); } const convDict = { VHS_LoadImages : ["directory", null, "image_load_cap", "skip_first_images", "select_every_nth"], VHS_LoadImagesPath : ["directory", "image_load_cap", "skip_first_images", "select_every_nth"], VHS_VideoCombine : ["frame_rate", "loop_count", "filename_prefix", "format", "pingpong", "save_image"], VHS_LoadVideo : ["video", "force_rate", "force_size", "frame_load_cap", "skip_first_frames", "select_every_nth"], VHS_LoadVideoPath : ["video", "force_rate", "force_size", "frame_load_cap", "skip_first_frames", "select_every_nth"], }; const renameDict = {VHS_VideoCombine : {save_output : "save_image"}} function useKVState(nodeType) { chainCallback(nodeType.prototype, "onNodeCreated", function () { chainCallback(this, "onConfigure", function(info) { if (!this.widgets) { //Node has no widgets, there is nothing to restore return } if (typeof(info.widgets_values) != "object") { //widgets_values is in some unknown inactionable format return } let widgetDict = info.widgets_values if (info.widgets_values.length) { //widgets_values is in the old list format if (this.type in convDict) { //widget does not have a conversion format provided let convList = convDict[this.type]; if(info.widgets_values.length >= convList.length) { //has all required fields widgetDict = {} for (let i = 0; i < convList.length; i++) { if(!convList[i]) { //Element should not be processed (upload button on load image sequence) continue } widgetDict[convList[i]] = info.widgets_values[i]; } } else { //widgets_values is missing elements marked as required //let it fall through to failure state } } } if ('force_size' in widgetDict) { //force size has been phased out, Migrate state if (widgetDict.force_size.includes?.('x')) { let sizes = widgetDict.force_size.split('x') if (sizes[0] != '?') { widgetDict.custom_width = parseInt(sizes[0]) } else { widgetDict.custom_width = 0 } if (sizes[1] != '?') { widgetDict.custom_height = parseInt(sizes[1]) } else { widgetDict.custom_height = 0 } } else { if (['Disabled', 'Custom Height'].includes(widgetDict.force_size)) { widgetDict.custom_width = 0 } if (['Disabled', 'Custom Width'].includes(widgetDict.force_size)) { widgetDict.custom_height = 0 } } } if (widgetDict.videopreview?.params?.force_size) { delete widgetDict.videopreview.params.force_size } let inputs = {} for (let i of this.inputs) { inputs[i.name] = i } if (widgetDict.length == undefined) { for (let w of this.widgets) { if (w.type =="button") { continue } if (w.name in widgetDict) { w.value = widgetDict[w.name]; w.callback?.(w.value) } else { //Check for a legacy name that needs migrating if (this.type in renameDict && w.name in renameDict[this.type]) { if (renameDict[this.type][w.name] in widgetDict) { w.value = widgetDict[renameDict[this.type][w.name]] w.callback?.(w.value) continue } } //attempt to restore default value let inputs = LiteGraph.getNodeType(this.type).nodeData.input; let initialValue = null; if (inputs?.required?.hasOwnProperty(w.name)) { if (inputs.required[w.name][1]?.hasOwnProperty("default")) { initialValue = inputs.required[w.name][1].default; } else if (inputs.required[w.name][0].length) { initialValue = inputs.required[w.name][0][0]; } } else if (inputs?.optional?.hasOwnProperty(w.name)) { if (inputs.optional[w.name][1]?.hasOwnProperty("default")) { initialValue = inputs.optional[w.name][1].default; } else if (inputs.optional[w.name][0].length) { initialValue = inputs.optional[w.name][0][0]; } } if (initialValue) { w.value = initialValue; w.callback?.(w.value) } } if (w.name in inputs && w.config) { setWidgetConfig(inputs[w.name], w.config, w) } } } else { //Saved data was not a map made by this method //and a conversion dict for it does not exist //It's likely an array and that has been blindly applied if (info?.widgets_values?.length != this.widgets.length) { //Widget could not have restored properly //Note if multiple node loads fail, only the latest error dialog displays app.ui.dialog.show("Failed to restore node: " + this.title + "\nPlease remove and re-add it.") this.bgcolor = "#C00" } } }); chainCallback(this, "onSerialize", function(info) { info.widgets_values = {}; if (!this.widgets) { //object has no widgets, there is nothing to store return; } for (let w of this.widgets) { info.widgets_values[w.name] = w.value; } }); }) } var helpDOM; if (!app.helpDOM) { helpDOM = document.createElement("div"); app.VHSHelp = helpDOM } function initHelpDOM() { let parentDOM = document.createElement("div"); parentDOM.className = "VHS_floatinghelp" document.body.appendChild(parentDOM) parentDOM.appendChild(helpDOM) helpDOM.className = "litegraph"; let scrollbarStyle = document.createElement('style'); scrollbarStyle.innerHTML = ` .VHS_floatinghelp { scrollbar-width: 6px; scrollbar-color: #0003 #0000; &::-webkit-scrollbar { background: transparent; width: 6px; } &::-webkit-scrollbar-thumb { background: #0005; border-radius: 20px } &::-webkit-scrollbar-button { display: none; } } .VHS_loopedvideo::-webkit-media-controls-mute-button { display:none; } .VHS_loopedvideo::-webkit-media-controls-fullscreen-button { display:none; } ` scrollbarStyle.id = 'scroll-properties' parentDOM.appendChild(scrollbarStyle) chainCallback(app.canvas, "onDrawForeground", function (ctx, visible_rect){ let n = helpDOM.node if (!n || !n?.graph) { parentDOM.style['left'] = '-5000px' return } //draw : function(ctx, node, widgetWidth, widgetY, height) { //update widget position, even if off screen const transform = ctx.getTransform(); const scale = app.canvas.ds.scale;//gets the litegraph zoom //calculate coordinates with account for browser zoom const bcr = app.canvas.canvas.getBoundingClientRect() const x = transform.e*scale/transform.a + bcr.x; const y = transform.f*scale/transform.a + bcr.y; //TODO: text reflows at low zoom. investigate alternatives Object.assign(parentDOM.style, { left: (x+(n.pos[0] + n.size[0]+15)*scale) + "px", top: (y+(n.pos[1]-LiteGraph.NODE_TITLE_HEIGHT)*scale) + "px", width: "400px", minHeight: "100px", maxHeight: "600px", overflowY: 'scroll', transformOrigin: '0 0', transform: 'scale(' + scale + ',' + scale +')', fontSize: '18px', backgroundColor: LiteGraph.NODE_DEFAULT_BGCOLOR, boxShadow: '0 0 10px black', borderRadius: '4px', padding: '3px', zIndex: 3, position: "absolute", display: 'inline', }); }); function setCollapse(el, doCollapse) { if (doCollapse) { el.children[0].children[0].innerHTML = '+' Object.assign(el.children[1].style, { color: '#CCC', overflowX: 'hidden', width: '0px', minWidth: 'calc(100% - 20px)', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }) for (let child of el.children[1].children) { if (child.style.display != 'none'){ child.origDisplay = child.style.display } child.style.display = 'none' } } else { el.children[0].children[0].innerHTML = '-' Object.assign(el.children[1].style, { color: '', overflowX: '', width: '100%', minWidth: '', textOverflow: '', whiteSpace: '', }) for (let child of el.children[1].children) { child.style.display = child.origDisplay } } } helpDOM.collapseOnClick = function() { let doCollapse = this.children[0].innerHTML == '-' setCollapse(this.parentElement, doCollapse) } helpDOM.selectHelp = function(name, value) { //attempt to navigate to name in help function collapseUnlessMatch(items,t) { var match = items.querySelector('[vhs_title="' + t + '"]') if (!match) { for (let i of items.children) { if (i.innerHTML.slice(0,t.length+5).includes(t)) { match = i break } } } if (!match) { return null } //For longer documentation items with fewer collapsable elements, //scroll to make sure the entirety of the selected item is visible //This has the unfortunate side effect of trying to scroll the main //window if the documentation windows is forcibly offscreen, //but it's easy to simply scroll the main window back and seems to //have no visual side effects match.scrollIntoView(false) window.scrollTo(0,0) for (let i of items.querySelectorAll('.VHS_collapse')) { if (i.contains(match)) { setCollapse(i, false) } else { setCollapse(i, true) } } return match } let target = collapseUnlessMatch(helpDOM, name) if (target && value) { collapseUnlessMatch(target, value) } } let titleContext = document.createElement("canvas").getContext("2d") titleContext.font = app.canvas.title_text_font; helpDOM.calculateTitleLength = function(text) { return titleContext.measureText(text).width } helpDOM.addHelp = function(node, nodeType, description) { if (!description) { return } //Pad computed size for the clickable question mark let originalComputeSize = node.computeSize node.computeSize = function() { let size = originalComputeSize.apply(this, arguments) if (!this.title) { return size } let title_width = helpDOM.calculateTitleLength(this.title) size[0] = Math.max(size[0], title_width + LiteGraph.NODE_TITLE_HEIGHT*2) return size } node.description = description chainCallback(node, "onDrawForeground", function (ctx) { if (this?.flags?.collapsed) { return } //draw question mark ctx.save() ctx.font = 'bold 20px Arial' ctx.fillText("?", this.size[0]-17, -8) ctx.restore() }) chainCallback(node, "onMouseDown", function (e, pos, canvas) { if (this?.flags?.collapsed) { return } //On click would be preferred, but this'll be good enough if (pos[1] < 0 && pos[0] + LiteGraph.NODE_TITLE_HEIGHT > this.size[0]) { //corner question mark clicked if (helpDOM.node == this) { helpDOM.node = undefined } else { helpDOM.node = this; helpDOM.innerHTML = this.description || "no help provided " for (let e of helpDOM.querySelectorAll('.VHS_collapse')) { e.children[0].onclick = helpDOM.collapseOnClick e.children[0].style.cursor = 'pointer' } for (let e of helpDOM.querySelectorAll('.VHS_precollapse')) { setCollapse(e, true) } for (let e of helpDOM.querySelectorAll('.VHS_loopedvideo')) { e?.play() } helpDOM.parentElement.scrollTo(0,0) } return true } }) let timeout = null chainCallback(node, "onMouseMove", function (e, pos, canvas) { if (timeout) { clearTimeout(timeout) timeout = null } if (helpDOM.node != this) { return } timeout = setTimeout(() => { let n = this if (pos[0] > 0 && pos[0] < n.size[0] && pos[1] > 0 && pos[1] < n.size[1]) { //TODO: provide help specific to element clicked let inputRows = Math.max(n.inputs?.length || 0, n.outputs?.length || 0) if (pos[1] < LiteGraph.NODE_SLOT_HEIGHT * inputRows) { let row = Math.floor((pos[1] - 7) / LiteGraph.NODE_SLOT_HEIGHT) if (pos[0] < n.size[0]/2) { if (row < n.inputs.length) { helpDOM.selectHelp(n.inputs[row].name) } } else { if (row < n.outputs.length) { helpDOM.selectHelp(n.outputs[row].name) } } } else { //probably widget, but widgets have variable height. let basey = LiteGraph.NODE_SLOT_HEIGHT * inputRows + 6 for (let w of n.widgets) { if (w.y) { basey = w.y } let wheight = LiteGraph.NODE_WIDGET_HEIGHT+4 if (w.computeSize) { wheight = w.computeSize(n.size[0])[1] } if (pos[1] < basey + wheight) { helpDOM.selectHelp(w.name, w.value) break } basey += wheight } } } }, 500) }) chainCallback(node, "onMouseLeave", function (e, pos, canvas) { if (timeout) { clearTimeout(timeout) timeout = null } }); } } function fitHeight(node) { node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]]) node?.graph?.setDirtyCanvas(true); } 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 applyVHSAudioLinksFix(nodeType, nodeData, audio_slot) { chainCallback(nodeType.prototype, "onConnectionsChange", function(contype, slot, iscon, linfo) { if (contype == LiteGraph.OUTPUT && slot == audio_slot) { if (linfo.type == "VHS_AUDIO") { this.outputs[audio_slot].type = "AUDIO" let tnode = app.graph._nodes_by_id[linfo.target_id] let inputDef = LiteGraph.registered_node_types[tnode.type]?.nodeData?.input let has_migrated = true if (inputDef?.required) { for (let k in inputDef.required) { if (inputDef.required[k][0] == "VHS_AUDIO") { has_migrated = false break } } } if (has_migrated &&inputDef?.optional) { for (let k in inputDef.optional) { if (inputDef.optional[k][0] == "VHS_AUDIO") { has_migrated = false break } } } if (!has_migrated) { //need to add node and migrate app.ui.dialog.show("This workflow contains one or more nodes which use the old VHS_AUDIO format. They have been highlighted in red. An AudioToVHSAudio node must be added to convert to this legacy format") tnode.bgcolor = "#C00" } } } }) } function addVAEOutputToggle(nodeType, nodeData) { chainCallback(nodeType.prototype, "onConnectionsChange", function(contype, slot, iscon, linfo) { let slotType = this.inputs[slot]?.type if (contype == LiteGraph.INPUT && slotType == "VAE") { if (iscon && linfo) { if (this.linkTimeout) { clearTimeout(this.linkTimeout) this.linkTimeout = false } else if (this.outputs[0].type == "IMAGE") { this.linkTimeout = setTimeout(() => { if (this.outputs[0].type != "IMAGE") { return } this.linkTimeout = false this.disconnectOutput(0); }, 50) } this.outputs[0].name = 'LATENT'; this.outputs[0].type = 'LATENT'; } else{ if (this.outputs[0].type == "LATENT") { this.linkTimeout = setTimeout(() => { this.linkTimeout = false this.disconnectOutput(0); }, 50) } this.outputs[0].name = "IMAGE"; this.outputs[0].type = "IMAGE"; } } }); } function addVAEInputToggle(nodeType, nodeData) { chainCallback(nodeType.prototype, "onConnectionsChange", function(contype, slot, iscon, linf) { if (contype == LiteGraph.INPUT && slot == 3 && this.inputs[3].type == "VAE") { if (iscon && linf) { if (this.linkTimeout) { clearTimeout(this.linkTimeout) this.linkTimeout = false } else if (this.inputs[0].type == "IMAGE") { this.linkTimeout = setTimeout(() => { //workaround for out of order loading if (this.inputs[0].type != "IMAGE") { return } this.linkTimeout = false this.disconnectInput(0); }, 50) } this.inputs[0].type = 'LATENT'; } else { if (this.inputs[0].type == "LATENT") { this.linkTimeout = setTimeout(() => { this.linkTimeout = false this.disconnectInput(0); }, 50) } this.inputs[0].type = "IMAGE"; } } }); } function cloneType(nodeType, nodeData) { nodeData.output[0] = "VHS_DUMMY_NONE" chainCallback(nodeType.prototype, "onConnectionsChange", function(contype, slot, iscon, linf) { if (contype == LiteGraph.INPUT && slot == 0) { let new_type = "VHS_DUMMY_NONE" if (iscon && linf) { new_type = app.graph.getNodeById(linf.origin_id).outputs[linf.origin_slot].type } if (this.linkTimeout) { clearTimeout(this.linkTimeout) this.linkTimeout = false } this.linkTimeout = setTimeout(() => { if (this.outputs[0].type != new_type) { this.outputs[0].type = new_type this.disconnectOutput(0); } this.linkTimeout = false }, 50) } }); } function addDateFormatting(nodeType, field, timestamp_widget = false) { chainCallback(nodeType.prototype, "onNodeCreated", function() { const widget = this.widgets.find((w) => w.name === field); widget.serializeValue = () => { return applyTextReplacements(app, widget.value); }; }); } function addTimestampWidget(nodeType, nodeData, targetWidget) { const newWidgets = {}; for (let key in nodeData.input.required) { if (key == targetWidget) { //TODO: account for duplicate entries? newWidgets["timestamp_directory"] = ["BOOLEAN", {"default": true}] } newWidgets[key] = nodeData.input.required[key]; } nodeDta.input.required = newWidgets; chainCallback(nodeType.prototype, "onNodeCreated", function () { const directoryWidget = this.widgets.find((w) => w.name === "directory_name"); const timestampWidget = this.widgets.find((w) => w.name === "timestamp_directory"); directoryWidget.serializeValue = () => { if (timestampWidget.value) { //ignore actual value and return timestamp return formatDate("yyyy-MM-ddThh:mm:ss", new Date()); } return directoryWidget.value }; timestampWidget._value = value; Object.definteProperty(timestampWidget, "value", { set : function(value) { this._value = value; directoryWidget.disabled = value; }, get : function() { return this._value; } }); }); } function initializeLoadFormat(nodeType, nodeData) { if (!nodeData?.input?.optional?.format) { return } chainCallback(nodeType.prototype, "onNodeCreated", function() { let node = this let formatWidget = this.widgets.find((w) => w.name === "format") formatWidget.options.formats = nodeData.input.optional.format[1].formats let base = {} for (let widget of this.widgets) { if (['force_rate', 'custom_width', 'custom_height', 'frame_load_cap'].includes(widget.name)) { //TODO: filter these options? base[widget.name] = widget.options } } chainCallback(formatWidget, "callback", function(value) { let format = this.options.formats[value] if (!format) { return } if ('target_rate' in format) { format.force_rate = {'reset': format.target_rate} } if ('dim' in format) { format.custom_width = {'step': format.dim[0]*10, 'mod': format.dim[1]} format.custom_height = {'step': format.dim[0]*10, 'mod': format.dim[1]} if (format.dim[2]) { format.custom_width.reset = format.dim[2] } if (format.dim[3]) { format.custom_height.reset = format.dim[3] } } if ('frames' in format) { format.frame_load_cap = {'step': format.frames[0]*10, 'mod': format.frames[1]} } for (let widget of node.widgets) { if (widget.name in base) { let wasDefault = widget.options?.reset == widget.value widget.options = Object.assign({}, base[widget.name], format[widget.name]) if (wasDefault && widget.options.reset != undefined) { widget.value = widget.options.reset } widget.callback(widget.value) } } }); let capWidget = this.widgets.find((w) => w.name === "frame_load_cap") let previewWidget = this.widgets.find((w) => w.name === "videopreview") chainCallback(previewWidget, "updateSource", () => setTimeout(async () => { if (!previewWidget?.value?.params?.filename) { return } let qurl = api.apiURL('/vhs/queryvideo?' + new URLSearchParams(previewWidget.value.params)) let query = undefined try { let query_res = await fetch(qurl) query = await query_res.json() } catch(e) { return } if (!query?.loaded) { return } this.max_frames = query.loaded.frames }, 100)); capWidget.annotation = (value, width) => { if (!this.max_frames || value && value < this.max_frames) { return } let format = formatWidget.options.formats[formatWidget.value] const div = format?.frames?.[0] ?? 1 const mod = format?.frames?.[1] ?? 0 let loadable_frames = this.max_frames if ((this.max_frames % div) != mod) { loadable_frames = ((this.max_frames - mod)/div|0) * div + mod } return loadable_frames + "\u21FD" } }); } 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 == "folder") { Object.assign(fileInput, { type: "file", style: "display: none", webkitdirectory: true, onchange: async () => { const directory = fileInput.files[0].webkitRelativePath; const i = directory.lastIndexOf('/'); if (i <= 0) { throw "No directory found"; } const path = directory.slice(0,directory.lastIndexOf('/')) if (pathWidget.options.values.includes(path)) { alert("A folder of the same name already exists"); return; } let successes = 0; for(const file of fileInput.files) { if ((await uploadFile(file)).status == 200) { successes++; } else { //Upload failed, but some prior uploads may have succeeded //Stop future uploads to prevent cascading failures //and only add to list if an upload has succeeded if (successes > 0) { break } else { return; } } } pathWidget.options.values.push(path); pathWidget.value = path; if (pathWidget.callback) { pathWidget.callback(path) } }, }); } else 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 if (type == "audio") { Object.assign(fileInput, { type: "file", accept: "audio/mpeg,audio/wav,audio/x-wav,audio/ogg", 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 addVideoPreview(nodeType, isInput=true) { 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")} 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); }; previewWidget.parentEl.appendChild(previewWidget.videoEl) previewWidget.parentEl.appendChild(previewWidget.imgEl) 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") == 'Never') { 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 = {} let advp = app.ui.settings.getSettingValue("VHS.AdvancedPreviews") Object.assign(params, this.value.params);//shallow copy params.timestamp = Date.now() this.parentEl.hidden = this.value.hidden; if (params.format?.split('/')[0] == 'video' || advp != 'Never' && (params.format?.split('/')[1] == 'gif') || params.format == 'folder') { this.videoEl.autoplay = !this.value.paused && !this.value.hidden; let target_width = 256 if (previewWidget.element?.style?.width) { //overscale to allow scrolling. Endpoint won't return higher than native target_width = previewWidget.element.style.width.slice(0,-2)*2; } let minWidth = app.ui.settings.getSettingValue("VHS.AdvancedPreviewsMinWidth") if (target_width < minWidth) { target_width = minWidth } if (!params.custom_width || !params.custom_height) { params.force_size = target_width+"x?" } else { let ar = params.custom_width/params.custom_height params.force_size = target_width+"x"+(target_width/ar) } params.deadline = app.ui.settings.getSettingValue("VHS.AdvancedPreviewsDeadline") if (advp == 'Never' || advp == 'Input Only' && !isInput) { this.videoEl.src = api.apiURL('/view?' + new URLSearchParams(params)); } else { this.videoEl.src = api.apiURL('/vhs/viewvideo?' + 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) }); } let copiedPath = undefined 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) { if (['input', 'output', 'temp'].includes(previewWidget.value.params.type)) { //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", previewWidget.value.params.filename); document.body.append(a); a.click(); requestAnimationFrame(() => a.remove()); }, } ); if (previewWidget.value.params.fullpath) { copiedPath = previewWidget.value.params.fullpath const blob = new Blob([previewWidget.value.params.fullpath], { type: 'text/plain'}) optNew.push({ content: "Copy output filepath", callback: async () => { await navigator.clipboard.write([ new ClipboardItem({ 'text/plain': blob })])} }); } if (previewWidget.value.params.workflow) { let wParams = {...previewWidget.value.params, filename: previewWidget.value.params.workflow} let wUrl = api.apiURL('/view?' + new URLSearchParams(wParams)); optNew.push({ content: "Save workflow image", callback: () => { const a = document.createElement("a"); a.href = wUrl; a.setAttribute("download", previewWidget.value.params.workflow); 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 addFormatWidgets(nodeType, nodeData) { const formats = nodeData?.input?.required?.format?.[1]?.formats function parseFormats(options) { options.fullvalues = options._values; options._values = []; for (let format of options.fullvalues) { if (Array.isArray(format)) { options._values.push(format[0]); } else { options._values.push(format); } } } chainCallback(nodeType.prototype, "onNodeCreated", function() { var formatWidget = null; var formatWidgetIndex = -1; for(let i = 0; i < this.widgets.length; i++) { if (this.widgets[i].name === "format"){ formatWidget = this.widgets[i]; formatWidgetIndex = i+1; } } let formatWidgetsCount = 0; //Pre-process options to just names formatWidget.options._values = formatWidget.options.values; parseFormats(formatWidget.options); Object.defineProperty(formatWidget.options, "values", { set : (value) => { formatWidget.options._values = value; parseFormats(formatWidget.options); }, get : () => { return formatWidget.options._values; } }) formatWidget._value = formatWidget.value; Object.defineProperty(formatWidget, "value", { set : (value) => { formatWidget._value = value; let newWidgets = []; const fullDef = formatWidget.options.fullvalues.find((w) => Array.isArray(w) ? w[0] === value : w === value); if (!Array.isArray(fullDef) && !formats?.[value]) { formatWidget._value = value; } else { formatWidget._value = value; let formatWidgets = formats?.[value] ?? fullDef[1] for (let wDef of formatWidgets) { //create widgets. Heavy borrowed from web/scripts/app.js //default implementation doesn't work since it automatically adds //the widget in the wrong spot. //TODO: consider letting this happen and just removing from list? let w = {}; w.name = wDef[0]; w.config = wDef.slice(1); let inputData = wDef.slice(1); w.type = inputData[0]; w.options = inputData[1] ? inputData[1] : {}; if (Array.isArray(w.type)) { w.value = w.type[0]; w.options.values = w.type; w.type = "combo"; } if(inputData[1]?.default != undefined) { w.value = inputData[1].default; } if (w.type == "INT") { Object.assign(w.options, {"precision": 0, "step": 10}) w.callback = function (v) { const s = this.options.step / 10; this.value = Math.round(v / s) * s; } } const typeTable = {BOOLEAN: "toggle", STRING: "text", INT: "number", FLOAT: "number"}; if (w.type in typeTable) { w.type = typeTable[w.type]; } newWidgets.push(w); } } this.widgets.splice(formatWidgetIndex, formatWidgetsCount, ...newWidgets); fitHeight(this); formatWidgetsCount = newWidgets.length; }, get : () => { return formatWidget._value; } }); }); } function addLoadCommon(nodeType, nodeData) { addVideoPreview(nodeType); initializeLoadFormat(nodeType, nodeData) addPreviewOptions(nodeType); chainCallback(nodeType.prototype, "onNodeCreated", function() { //widget.callback adds unused arguements which need culling const node = this function update(key) { return (value) => { let params = {} params[key] = value node?.updateParameters(params) } } let prior_ar = -2 const widthWidget = this.widgets.find((w) => w.name === "custom_width"); const heightWidget = this.widgets.find((w) => w.name === "custom_height"); function updateAR(value) { let new_ar = -1 if (widthWidget.value & heightWidget.value) { new_ar = widthWidget.value / heightWidget.value } if (new_ar != prior_ar) { node?.updateParameters({'custom_width': widthWidget.value, 'custom_height': heightWidget.value}) prior_ar = new_ar } } const offsetWidget = this.widgets.find((w) => w.name === "start_time"); if (offsetWidget) { offsetWidget.options.step = 10 } let widgetMap = {'frame_load_cap': 'frame_load_cap', 'skip_first_frames': 'skip_first_frames', 'select_every_nth': 'select_every_nth', 'start_time': 'start_time', 'force_rate': 'force_rate', 'custom_width': updateAR, 'custom_height': updateAR, 'image_load_cap': 'image_load_cap', 'skip_first_images': 'skip_first_images' } for (let widget of this.widgets) { if (widget.name in widgetMap) { if (typeof(widgetMap[widget.name]) == 'function') { chainCallback(widget, "callback", widgetMap[widget.name]); } else { chainCallback(widget, "callback", update(widgetMap[widget.name])) } } if (widget.type != "button") { widget.callback?.(widget.value) } } }); } function path_stem(path) { let i = path.lastIndexOf("/"); if (i >= 0) { return [path.slice(0,i+1),path.slice(i+1)]; } return ["",path]; } function searchBox(event, [x,y], node) { //Ensure only one dialogue shows at a time if (this.prompt) return; this.prompt = true; let pathWidget = this; let dialog = document.createElement("div"); dialog.className = "litegraph litesearchbox graphdialog rounded" dialog.innerHTML = 'Path
' dialog.close = () => { dialog.remove(); } document.body.append(dialog); if (app.canvas.ds.scale > 1) { dialog.style.transform = "scale(" + app.canvas.ds.scale + ")"; } var name_element = dialog.querySelector(".name"); var input = dialog.querySelector(".value"); var options_element = dialog.querySelector(".helper"); input.value = pathWidget.value; var timeout = null; let last_path = null; let extensions = pathWidget.options.vhs_path_extensions input.addEventListener("keydown", (e) => { dialog.is_modified = true; if (e.keyCode == 27) { //ESC dialog.close(); } else if (e.keyCode == 13 && e.target.localName != "textarea") { pathWidget.value = input.value; if (pathWidget.callback) { pathWidget.callback(pathWidget.value); } dialog.close(); } else { if (e.keyCode == 9) { //TAB input.value = last_path + options_element.firstChild.innerText; e.preventDefault(); e.stopPropagation(); } else if (e.ctrlKey && (e.keyCode == 87 || e.keyCode == 66)) { //Ctrl+w or Ctrl+b //most browsers won't support, but it's good QOL for those that do input.value = path_stem(input.value.slice(0,-1))[0] e.preventDefault(); e.stopPropagation(); } else if (e.ctrlKey && e.keyCode == 71) { //Ctrl+g //Temporarily disables extension filtering to show all files e.preventDefault(); e.stopPropagation(); extensions = undefined last_path = null; } if (timeout) { clearTimeout(timeout); } timeout = setTimeout(updateOptions, 10); return; } this.prompt=false; e.preventDefault(); e.stopPropagation(); }); var button = dialog.querySelector("button"); button.addEventListener("click", (e) => { pathWidget.value = input.value; if (pathWidget.callback) { pathWidget.callback(pathWidget.value); } //unsure why dirty is set here, but not on enter-key above node.graph.setDirtyCanvas(true); dialog.close(); this.prompt = false; }); var rect = app.canvas.canvas.getBoundingClientRect(); var offsetx = -20; var offsety = -20; if (rect) { offsetx -= rect.left; offsety -= rect.top; } if (event) { dialog.style.left = event.clientX + offsetx + "px"; dialog.style.top = event.clientY + offsety + "px"; } else { dialog.style.left = canvas.width * 0.5 + offsetx + "px"; dialog.style.top = canvas.height * 0.5 + offsety + "px"; } //Search code let options = [] function addResult(name, isDir) { let el = document.createElement("div"); el.innerText = name; el.className = "litegraph lite-search-item"; if (isDir) { el.className += " is-dir"; el.addEventListener("click", (e) => { input.value = last_path+name if (timeout) { clearTimeout(timeout); } timeout = setTimeout(updateOptions, 10); }); } else { el.addEventListener("click", (e) => { pathWidget.value = last_path+name; if (pathWidget.callback) { pathWidget.callback(pathWidget.value); } dialog.close(); pathWidget.prompt = false; }); } options_element.appendChild(el); } async function updateOptions() { timeout = null; let [path, remainder] = path_stem(input.value); if (last_path != path) { //fetch options. Must block execution here, so update should be async? let params = {path : path} if (extensions) { params.extensions = extensions } let optionsURL = api.apiURL('/vhs/getpath?' + new URLSearchParams(params)); try { let resp = await fetch(optionsURL); options = await resp.json(); options = options.map((o) => o.replace('.','\0')) options = options.sort() options = options.map((o) => o.replace('\0','.')) } catch(e) { options = [] } last_path = path; } options_element.innerHTML = ''; //filter options based on remainder for (let option of options) { if (option.startsWith(remainder)) { let isDir = option.endsWith('/') addResult(option, isDir); } } } setTimeout(async function() { input.focus(); await updateOptions(); }, 10); return dialog; } function button_action(widget) { if ( widget.options?.reset == undefined && widget.options?.disable == undefined ) { return 'None' } if ( widget.options.reset != undefined && widget.value != widget.options.reset ) { return 'Reset' } if ( widget.options.disable != undefined && widget.value != widget.options.disable ) { return 'Disable' } if (widget.options.reset != undefined) { return 'No Reset' } return 'No Disable' } function inner_value_change(widget, value, node, pos) { widget.value = value if (widget.options?.property && widget.options.property in node.properties) { node.setProperty(widget.options.property, value) } if (widget.callback) { widget.callback(widget.value, app.canvas, node, event) } } function drawAnnotated(ctx, node, widget_width, y, H) { const litegraph_base = LiteGraph const show_text = app.canvas.ds.scale > 0.5 const margin = 15 ctx.textAlign = 'left' ctx.strokeStyle = litegraph_base.WIDGET_OUTLINE_COLOR ctx.fillStyle = litegraph_base.WIDGET_BGCOLOR ctx.beginPath() if (show_text) ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]) else ctx.rect(margin, y, widget_width - margin * 2, H) ctx.fill() if (show_text) { const monospace_font = ctx.font.split(' ')[0] + ' monospace' if (!this.disabled) ctx.stroke() const button = button_action(this) if (button != 'None') { ctx.save() ctx.font = monospace_font if (button.startsWith('No ')) { ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR } else { ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR } if (button.endsWith('Reset')) { ctx.fillText('\u21ba', widget_width - margin - 33, y + H * 0.7) } else { ctx.fillText('\u2298', widget_width - margin - 33, y + H * 0.7) } ctx.restore() } ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR if (!this.disabled) { ctx.beginPath() ctx.moveTo(margin + 16, y + 5) ctx.lineTo(margin + 6, y + H * 0.5) ctx.lineTo(margin + 16, y + H - 5) ctx.fill() ctx.beginPath() ctx.moveTo(widget_width - margin - 16, y + 5) ctx.lineTo(widget_width - margin - 6, y + H * 0.5) ctx.lineTo(widget_width - margin - 16, y + H - 5) ctx.fill() } ctx.fillStyle = litegraph_base.WIDGET_SECONDARY_TEXT_COLOR ctx.fillText(this.label || this.name, margin * 2 + 5, y + H * 0.7) ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR ctx.textAlign = 'right' const text = Number(this.value).toFixed( this.options.precision !== undefined ? this.options.precision : 3 ) let value_offset = margin * 2 + 20 if (this.options.unit) { ctx.save() ctx.font = monospace_font ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR ctx.fillText(this.options.unit, widget_width - value_offset, y + H * 0.7) value_offset += ctx.measureText(this.options.unit).width ctx.restore() } ctx.fillText(text, widget_width - value_offset, y + H * 0.7) const value_width = ctx.measureText(text).width const name_width = ctx.measureText(this.label || this.name).width const free_width = widget_width - (value_width + name_width + value_offset + 40) let annotation = '' if (this.annotation) { annotation = this.annotation(this.value, free_width) } else if ( this.options.annotation && this.value in this.options.annotation ) { annotation = this.options.annotation[this.value] } if (annotation) { ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR const annotation_width = ctx.measureText(annotation).width if (free_width < annotation_width) { //Enforcing a widget's requested minimum width seems ill supported //hiding annotation is best, but existence should still be indicated annotation = '…' } ctx.fillText( annotation, widget_width - 5 - value_width - value_offset, y + H * 0.7 ) } } } function mouseAnnotated(event, [x, y], node) { const button = button_action(this) const widget_width = this.width || node.size[0] const old_value = this.value const delta = x < 40 ? -1 : x > widget_width - 48 ? 1 : 0 const margin = 15 var allow_scroll = true if (delta) { if (x > -3 && x < widget_width + 3) { allow_scroll = false } } if (allow_scroll && event.type == 'pointermove') { if (event.deltaX) this.value += event.deltaX * 0.1 * (this.options.step || 1) if (this.options.min != null && this.value < this.options.min) { this.value = this.options.min } if (this.options.max != null && this.value > this.options.max) { this.value = this.options.max } } else if (event.type == 'pointerdown') { if (x > widget_width - margin - 34 && x < widget_width - margin - 18) { if (button == 'Reset') { this.value = this.options.reset } else if (button == 'Disable') { this.value = this.options.disable } } else { this.value += delta * 0.1 * (this.options.step || 1) if (this.options.min != null && this.value < this.options.min) { this.value = this.options.min } if (this.options.max != null && this.value > this.options.max) { this.value = this.options.max } } } //end mousedown else if (event.type == 'pointerup') { if (event.click_time < 200 && delta == 0) { app.canvas.prompt( 'Value', this.value, function (v) { //NOTE: Original code uses eval here. This will not be reproduced this.value = Number(v) inner_value_change(this, this.value, node, [x, y]) }.bind(this), event ) } } if (old_value != this.value) setTimeout( function () { inner_value_change(this, this.value, node, [x, y]) }.bind(this), 20 ) return true } function makeAnnotated(widget, inputData) { const callback_orig = widget.callback Object.assign(widget, { type: "BOOLEAN",//Horrific, not namespaced, nonsensical, easier than upstreaming draw: drawAnnotated, mouse: mouseAnnotated, computeSize(width) { return [width, 20] }, callback(v) { if (v == 0) { return } if (this.options?.mod == undefined) { return callback_orig.apply(this, arguments); } const s = this.options.step / 10 let sh = this.options.mod this.value = Math.round((v - sh) / s) * s + sh }, config: inputData, options: Object.assign({}, inputData[1], widget.options) }) return widget } let latentPreviewNodes = new Set() app.registerExtension({ name: "VideoHelperSuite.Core", settings: [ { id: 'VHS.AdvancedPreviews', category: ['🎥🅥🅗🅢', 'Previews', 'Advanced Previews'], name: 'Advanced Previews', tooltip: 'Automatically transcode previews on request. Required for advanced functionality', type: 'combo', options: ['Never', 'Always', 'Input Only'], defaultValue: 'Input Only', }, { id: 'VHS.AdvancedPreviewsMinWidth', category: ['🎥🅥🅗🅢', 'Previews', 'Min Width'], name: 'Minimum preview width', tooltip: 'Advanced previews have their resolution downscaled to the node size for performance. While a node can be resized to increase preview quality, a minimum width can be set that previews won\'t be downscaled beneath. Preveiws will never be upscaled, so this can safely be set large.', type: 'number', attrs: { min: 0, step: 1, max: 3840, }, defaultValue: 0, }, { id: 'VHS.AdvancedPreviewsDeadline', category: ['🎥🅥🅗🅢', 'Previews', 'Deadline'], name: 'Deadline', tooltip: 'Determines how much time can be spent when encoding advanced previews. Realtime results in reduced quality, but good will likely cause the preview to stutter as initial generation occurs', type: 'combo', options: ['realtime', 'good'], defaultValue: 'realtime', }, { id: 'VHS.AdvancedPreviewsDefaultMute', category: ['🎥🅥🅗🅢', 'Previews', 'Default Mute'], name: 'Mute videos by default', type: 'boolean', defaultValue: false, }, { id: 'VHS.LatentPreview', category: ['🎥🅥🅗🅢', 'Sampling', 'Latent Previews'], name: 'Display animated previews when sampling', type: 'boolean', defaultValue: false, onChange(value) { if (!value) { //Remove any previewWidgets for (let n of latentPreviewNodes) { let i = n?.widgets?.findIndex((w) => w.name == 'vhslatentpreview') if (i >= 0) { n.widgets.splice(i,1)[0].onRemove() } } latentPreviewNodes = new Set() } }, }, { id: "VHS.LatentPreviewRate", category: ['🎥🅥🅗🅢', 'Sampling', 'Latent Preview Rate'], name: "Playback rate override.", type: 'number', attrs: { min: 0, step: 1, max: 60 }, tooltip: 'Force a specific frame rate for the playback of latent frames. This should not be confused with the output frame rate and will not match for video models.', defaultValue: 0, }, { id: 'VHS.MetadataImage', category: ['🎥🅥🅗🅢', 'Output', 'MetadataImage'], name: 'Save png of first frame for metadata', type: 'boolean', defaultValue: true, }, { id: 'VHS.KeepIntermediate', category: ['🎥🅥🅗🅢', 'Output', 'Keep Intermediate'], name: 'Keep required intermediate files after sucessful execution', type: 'boolean', defaultValue: true, }, ], async beforeRegisterNodeDef(nodeType, nodeData, app) { if(nodeData?.name?.startsWith("VHS_")) { useKVState(nodeType); if (nodeData.description) { let description = nodeData.description let el = document.createElement("div") el.innerHTML = description if (!el.children.length) { //Is plaintext. Do minor convenience formatting let chunks = description.split('\n') nodeData.description = chunks[0] description = chunks.join('
') } else { nodeData.description = el.querySelector('#VHS_shortdesc')?.innerHTML || el.children[1]?.firstChild?.innerHTML } chainCallback(nodeType.prototype, "onNodeCreated", function () { helpDOM.addHelp(this, nodeType, description) this.setSize(this.computeSize()) }) } chainCallback(nodeType.prototype, "onNodeCreated", function () { let new_widgets = [] if (this.widgets) { for (let w of this.widgets) { let input = this.constructor.nodeData.input let config = input?.required[w.name] ?? input.optional[w.name] if (!config) { continue } if (w?.type == "text" && config[1].vhs_path_extensions) { new_widgets.push(app.widgets.VHSPATH({}, w.name, ["VHSPATH", config[1]])); } else if (w?.type == "number") { new_widgets.push(makeAnnotated(w, config)) } else { new_widgets.push(w) } } this.widgets = new_widgets; } const originalAddInput = this.addInput; this.addInput = function(name, type, options) { if (options.widget) { //Is Converted Widget const widget = this.widgets.find((w) => w.name == name) if (widget?.config) { //Has override for type type = widget.config[0] if (type == 'FLOAT') { type = "FLOAT,INT" } const symbol = Object.getOwnPropertySymbols(options.widget)[0] options.widget[symbol] = () => widget.config } } return originalAddInput.apply(this, [name, type, options]) } }); } if (nodeData?.name == "VHS_LoadImages") { addUploadWidget(nodeType, nodeData, "directory", "folder"); chainCallback(nodeType.prototype, "onNodeCreated", function() { const pathWidget = this.widgets.find((w) => w.name === "directory"); chainCallback(pathWidget, "callback", (value) => { if (!value) { return; } let params = {filename : value, type : "input", format: "folder"}; this.updateParameters(params, true); }); }); addLoadCommon(nodeType, nodeData); } else if (nodeData?.name == "VHS_LoadImagesPath") { addUploadWidget(nodeType, nodeData, "directory", "folder"); chainCallback(nodeType.prototype, "onNodeCreated", function() { const pathWidget = this.widgets.find((w) => w.name === "directory"); chainCallback(pathWidget, "callback", (value) => { if (!value) { return; } let params = {filename : value, type : "path", format: "folder"}; this.updateParameters(params, true); }); }); addLoadCommon(nodeType, nodeData); } else if (nodeData?.name == "VHS_LoadVideo" || nodeData?.name == "VHS_LoadVideoFFmpeg") { chainCallback(nodeType.prototype, "onNodeCreated", function() { const pathWidget = this.widgets.find((w) => w.name === "video"); 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); }); }); addUploadWidget(nodeType, nodeData, "video"); addLoadCommon(nodeType, nodeData); addVAEOutputToggle(nodeType, nodeData); applyVHSAudioLinksFix(nodeType, nodeData, 2) } else if (nodeData?.name == "VHS_LoadAudioUpload") { addUploadWidget(nodeType, nodeData, "audio", "audio"); applyVHSAudioLinksFix(nodeType, nodeData, 0) } else if (nodeData?.name == "VHS_LoadAudio"){ applyVHSAudioLinksFix(nodeType, nodeData, 0) } else if (nodeData?.name == "VHS_LoadVideoPath" || nodeData?.name == "VHS_LoadVideoFFmpegPath") { chainCallback(nodeType.prototype, "onNodeCreated", function() { const pathWidget = this.widgets.find((w) => w.name === "video"); chainCallback(pathWidget, "callback", (value) => { let extension_index = value.lastIndexOf("."); let extension = value.slice(extension_index+1); let format = "video" if (["gif", "webp", "avif"].includes(extension)) { format = "image" } format += "/" + extension; let params = {filename : value, type: "path", format: format}; this.updateParameters(params, true); }); }); addLoadCommon(nodeType, nodeData); addVAEOutputToggle(nodeType, nodeData); applyVHSAudioLinksFix(nodeType, nodeData, 2) } else if (nodeData?.name == "VHS_LoadImagePath") { addLoadCommon(nodeType, nodeData); addVAEOutputToggle(nodeType, nodeData); chainCallback(nodeType.prototype, "onNodeCreated", function() { const pathWidget = this.widgets.find((w) => w.name === "image"); chainCallback(pathWidget, "callback", (value) => { let extension_index = value.lastIndexOf("."); let extension = value.slice(extension_index+1); let format = "video" + "/" + extension; let params = {filename : value, type: "path", format: format}; this.updateParameters(params, true); }); }); } else if (nodeData?.name == "VHS_VideoCombine") { addDateFormatting(nodeType, "filename_prefix"); chainCallback(nodeType.prototype, "onExecuted", function(message) { if (message?.gifs) { this.updateParameters(message.gifs[0], true); } }); addVideoPreview(nodeType, false); addPreviewOptions(nodeType); addFormatWidgets(nodeType, nodeData); addVAEInputToggle(nodeType, nodeData) chainCallback(nodeType.prototype, "onNodeCreated", function() { this._outputs = this.outputs Object.defineProperty(this, "outputs", { set : function(value) { this._outputs = value; requestAnimationFrame(() => { if (app.nodeOutputs[this.id + ""]) { this.updateParameters(app.nodeOutputs[this.id+""].gifs[0], true); } }) }, get : function() { return this._outputs; } }); //Display previews after reload/ loading workflow this.updateParameters({}, true); }); } else if (nodeData?.name == "VHS_SaveImageSequence") { //Disabled for safety as VHS_SaveImageSequence is not currently merged //addDateFormating(nodeType, "directory_name", timestamp_widget=true); //addTimestampWidget(nodeType, nodeData, "directory_name") } else if (nodeData?.name == "VHS_BatchManager") { chainCallback(nodeType.prototype, "onNodeCreated", function() { this.widgets.push({name: "count", type: "dummy", value: 0, computeSize: () => {return [0,-4]}, afterQueued: function() {this.value++;}}); }); } else if (nodeData?.name == "VHS_Unbatch") { cloneType(nodeType, nodeData) } }, async getCustomWidgets() { return { VHSPATH(node, inputName, inputData) { let w = { name : inputName, type : "VHS.PATH", value : "", draw : function(ctx, node, widget_width, y, H) { //Adapted from litegraph.core.js:drawNodeWidgets var show_text = app.canvas.ds.scale > 0.5; var margin = 15; var text_color = LiteGraph.WIDGET_TEXT_COLOR; var secondary_text_color = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR; ctx.textAlign = "left"; ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; ctx.beginPath(); if (show_text) ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]); else ctx.rect( margin, y, widget_width - margin * 2, H ); ctx.fill(); if (show_text) { if(!this.disabled) ctx.stroke(); ctx.save(); ctx.beginPath(); ctx.rect(margin, y, widget_width - margin * 2, H); ctx.clip(); //ctx.stroke(); ctx.fillStyle = secondary_text_color; const label = this.label || this.name; if (label != null) { ctx.fillText(label, margin * 2, y + H * 0.7); } ctx.fillStyle = this.value ? text_color : '#777'; ctx.textAlign = "right"; let disp_text = this.format_path(String(this.value || this.options.placeholder)) ctx.fillText(disp_text, widget_width - margin * 2, y + H * 0.7); //30 chars max ctx.restore(); } }, mouse : searchBox, options : {}, format_path : function(path) { //Formats the full path to be under 30 characters if (path.length <= 30) { return path; } let filename = path_stem(path)[1] if (filename.length > 28) { //may all fit, but can't squeeze more info return filename.substr(0,30); } //TODO: find solution for windows, path[1] == ':'? let isAbs = path[0] == '/'; let partial = path.substr(path.length - (isAbs ? 28:29)) let cutoff = partial.indexOf('/'); if (cutoff < 0) { //Can occur, but there isn't a nicer way to format return path.substr(path.length-30); } return (isAbs ? '/…':'…') + partial.substr(cutoff); } }; if (inputData.length > 1) { w.options = inputData[1] if (inputData[1].default) { w.value = inputData[1].default; } } if (!node.widgets) { node.widgets = []; } node.widgets.push(w); return w; }, VHSFLOAT(node, inputName, inputData, app) { let w = app.widgets.FLOAT(node, inputName, inputData, app) return makeAnnotated(w, inputData); }, VHSINT(node, inputName, inputData, app_arg) { let w = app.widgets.INT(node, inputName, inputData, app) return makeAnnotated(w, inputData); } } }, async loadedGraphNode(node) { //Check and migrate inputs named batch_manager from old workflows if (node.type?.startsWith("VHS_") && node.inputs) { const batchInput = node.inputs.find((i) => i.name == "batch_manager") if (batchInput) { batchInput.name = "meta_batch" } } }, async beforeConfigureGraph(graphData, missingNodeTypes) { if(helpDOM?.node) { helpDOM.node = undefined } }, async setup() { //cg-use-everywhere link workaround //particularly invasive, plan to remove let originalGraphToPrompt = app.graphToPrompt let graphToPrompt = async function() { let res = await originalGraphToPrompt.apply(this, arguments); for (let n of app.graph._nodes) { if (n?.type?.startsWith('VHS_LoadVideo')) { if (!n?.inputs[1]?.link && res?.output[n.id]?.inputs?.vae) { delete res.output[n.id].inputs.vae } } } res.workflow.extra['VHS_latentpreview'] = app.ui.settings.getSettingValue("VHS.LatentPreview") res.workflow.extra['VHS_latentpreviewrate'] = app.ui.settings.getSettingValue("VHS.LatentPreviewRate") res.workflow.extra['VHS_MetadataImage'] = app.ui.settings.getSettingValue("VHS.MetadataImage") res.workflow.extra['VHS_KeepIntermediate'] = app.ui.settings.getSettingValue("VHS.KeepIntermediate") return res } app.graphToPrompt = graphToPrompt //Add a handler for pasting video data document.addEventListener('paste', async (e) => { if (!e.target.classList.contains('litegraph') && !e.target.classList.contains('graph-canvas-container')) { return } let data = e.clipboardData || window.clipboardData let filepath = data.getData('text/plain') let video for (const item of data.items) { if (item.type.startsWith('video/')) { video = item break } } if (filepath && copiedPath == filepath) { //Add a Load Video (Path) and populate filepath const pastedNode = LiteGraph.createNode('VHS_LoadVideoPath') app.graph.add(pastedNode) pastedNode.pos[0] = app.canvas.graph_mouse[0] pastedNode.pos[1] = app.canvas.graph_mouse[1] pastedNode.widgets[0].value = filepath pastedNode.widgets[0].callback?.(filepath) } else if (video && false) { //Disabled due to lack of testing //Add a Load Video (Upload), then upload the file, then select the file const pastedNode = LiteGraph.createNode('VHS_LoadVideo') app.graph.add(pastedNode) pastedNode.pos[0] = app.canvas.graph_mouse[0] pastedNode.pos[1] = app.canvas.graph_mouse[1] const pathWidget = pastedNode.widgets[0] //TODO: upload to pasted dir? const blob = video.getAsFile() const resp = await uploadFile(blob) 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; pathWidget.callback?.(filename) } else { return } e.preventDefault() e.stopImmediatePropagation() return false }, true) }, async init() { if (app.ui.settings.getSettingValue("VHS.AdvancedPreviews") == true) { app.ui.settings.setSettingValue("VHS.AdvancedPreviews", 'Always') } if (app.ui.settings.getSettingValue("VHS.AdvancedPreviews") == false) { app.ui.settings.setSettingValue("VHS.AdvancedPreviews", 'Never') } if (app.VHSHelp != helpDOM) { helpDOM = app.VHSHelp } else { initHelpDOM() } let e = app.extensions.filter((w) => w.name == 'UVR5.AudioPreviewer') if (e.length) { let orig = e[0].beforeRegisterNodeDef e[0].beforeRegisterNodeDef = function(nodeType, nodeData, app) { if(!nodeData?.name?.startsWith("VHS_")) { return orig.apply(this, arguments); } } } }, }); let previewImages = [] let animateInterval api.addEventListener('VHS_latentpreview', ({ detail }) => { let setting = app.ui.settings.getSettingValue("VHS.LatentPreview") if (!setting) { return } let id = app.runningNodeId if (id == null) { return } let previewNode = app.graph.getNodeById(id) latentPreviewNodes.add(previewNode) let previewWidget = previewNode.widgets.find((w) => w.name == "vhslatentpreview") if (!previewWidget) { let canvasEl = document.createElement("canvas") previewWidget = previewNode.addDOMWidget("vhslatentpreview", "vhscanvas", canvasEl, { serialize: false, hideOnZoom: false, }); previewWidget.computeSize = function(width) { if (this.aspectRatio) { 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 } } let firstPreview = true let ctx previewImages = [] previewImages.length = detail.length let displayIndex = 0 if (animateInterval) { clearTimeout(animateInterval) } animateInterval = setInterval(() => { if (app.runningNodeId != id) { clearTimeout(animateInterval) animateInterval = undefined return } if (!previewImages[displayIndex]) { return } let canvasEl = previewWidget.element if (!ctx) { previewWidget.aspectRatio = previewImages[displayIndex].width / previewImages[displayIndex].height canvasEl.width = previewImages[displayIndex].width canvasEl.height = previewImages[displayIndex].height ctx = canvasEl.getContext("2d") fitHeight(previewNode) } ctx.drawImage(previewImages[displayIndex],0,0) displayIndex = (displayIndex + 1) % previewImages.length }, 1000/detail.rate); }); api.addEventListener('b_preview', async (e) => { if (!animateInterval) { return } e.preventDefault() e.stopImmediatePropagation() e.stopPropagation() const ab = await e.detail.slice(0,8).arrayBuffer() const index = new DataView(ab).getUint32(4) previewImages[index] = await window.createImageBitmap(e.detail.slice(8)) return false }, true);