Spaces:
Running
on
Zero
Running
on
Zero
| 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; | |
| } | |
| } | |
| 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") | |
| capWidget.annotation = (value, width) => { | |
| let max_frames = this.video_query?.loaded?.frames | |
| if (!max_frames || value && value < 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 = max_frames | |
| if ((max_frames % div) != mod) { | |
| loadable_frames = ((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/x-matroska,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); | |
| element.addEventListener('pointermove', (e) => { | |
| e.preventDefault() | |
| return app.canvas._mousemove_callback(e) | |
| }, true); | |
| element.addEventListener('pointerup', (e) => { | |
| e.preventDefault() | |
| return app.canvas._mouseup_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; | |
| } | |
| delete previewNode.video_query | |
| const doQuery = 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 | |
| } | |
| previewNode.video_query = query | |
| } | |
| doQuery() | |
| } | |
| previewWidget.callback = previewWidget.updateSource | |
| 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 (this.video_query?.source) { | |
| let info_string = this.video_query.source.size.join('x') + | |
| '@' + this.video_query.source.fps + 'fps ' + | |
| this.video_query.source.frames + 'frames' | |
| optNew.push({content: info_string, disabled: true}) | |
| } | |
| 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) { | |
| 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; | |
| break | |
| } | |
| } | |
| let formatWidgetsCount = 0; | |
| chainCallback(formatWidget, "callback", (value) => { | |
| const formats = (LiteGraph.registered_node_types[this.type] | |
| ?.nodeData?.input?.required?.format?.[1]?.formats) | |
| let newWidgets = []; | |
| if (formats?.[value]) { | |
| let formatWidgets = formats[value] | |
| for (let wDef of formatWidgets) { | |
| let type = wDef[1] | |
| if (Array.isArray(type)) { | |
| type = "COMBO" | |
| } | |
| app.widgets[type](this, wDef[0], wDef.slice(1), app) | |
| let w = this.widgets.pop() | |
| if (['INT', 'FLOAT'].includes(type)) { | |
| makeAnnotated(w, wDef.slice(1)) | |
| } | |
| w.config = wDef.slice(1) | |
| newWidgets.push(w) | |
| } | |
| } | |
| let removed = this.widgets.splice(formatWidgetIndex, | |
| formatWidgetsCount, ...newWidgets); | |
| for (let w of removed) { | |
| w?.onRemove?.() | |
| } | |
| fitHeight(this); | |
| formatWidgetsCount = newWidgets.length; | |
| }); | |
| }); | |
| } | |
| 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 = '<span class="name">Path</span> <input autofocus="" type="text" class="value"><button class="rounded">OK</button><div class="helper"></div>' | |
| 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 >= (app.canvas.low_quality_zoom_threshold ?? 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) { | |
| const d_callback = (v) => { | |
| this.value = Number(v) | |
| inner_value_change(this, this.value, node, [x, y]) | |
| } | |
| const dialog = app.canvas.prompt( | |
| 'Value', | |
| this.value, | |
| d_callback, | |
| event | |
| ) | |
| const input = dialog.querySelector(".value") | |
| input.addEventListener("keydown", (e) => { | |
| if (e.keyCode == 9) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| d_callback(input.value) | |
| dialog.close() | |
| node?.graph?.setDirtyCanvas(true); | |
| let i=0 | |
| for (;i<node.widgets.length;i++) { | |
| if (node.widgets[i] == this) { | |
| break | |
| } | |
| } | |
| if (node.widgets[++i]?.type == "BOOLEAN") {//restrict to annotatedNUmbers | |
| node.widgets[i]?.mouse(event, [x, y+24], node) | |
| } | |
| } | |
| }) | |
| } | |
| } | |
| 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('<br>') | |
| } 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) | |
| } 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) | |
| } else if (nodeData?.name == "VHS_SelectLatest") { | |
| chainCallback(nodeType.prototype, "onNodeCreated", function() { | |
| this.isVirtualNode = true | |
| chainCallback(this, "onConnectionsChange", function (contype, slot, iscon, linfo) { | |
| if (iscon) { | |
| this.update_links() | |
| } | |
| }) | |
| this.update_links = function(extraLinks = []) { | |
| if (!this.outputs[0].links?.length) return | |
| function get_links(node) { | |
| let links = [] | |
| for (const l of node.outputs[0].links) { | |
| const linkInfo = app.graph.links[l] | |
| const n = node.graph.getNodeById(linkInfo.target_id) | |
| if (n.type == 'Reroute') { | |
| links = links.concat(get_links(n)) | |
| } else { | |
| links.push(l) | |
| } | |
| } | |
| return links | |
| } | |
| let links = [ | |
| ...get_links(this).map((l) => app.graph.links[l]), | |
| ...extraLinks | |
| ] | |
| let v = this.latest_file | |
| if (!v) { | |
| return | |
| } | |
| // For each output link copy our value over the original widget value | |
| for (const linkInfo of links) { | |
| const node = this.graph.getNodeById(linkInfo.target_id) | |
| const input = node.inputs[linkInfo.target_slot] | |
| const widgetName = input.widget.name | |
| const widget = node.widgets.find((w) => w.name === widgetName) | |
| if (widget) { | |
| widget.value = v | |
| if (widget.callback) { | |
| widget.callback( widget.value, app.canvas, | |
| node, app.canvas.graph_mouse, {}) | |
| } | |
| } | |
| } | |
| } | |
| let fetch_files = async () => { | |
| let [path, remainder] = path_stem(this.widgets[0].value) | |
| let params = {path : path} | |
| let optionsURL = api.apiURL('/vhs/getpath?' + new URLSearchParams(params)); | |
| let options | |
| try { | |
| let resp = await fetch(optionsURL); | |
| options = await resp.json(); | |
| } catch(e) { | |
| options = [] | |
| } | |
| options = options.filter((file) => file.startsWith(remainder) && file.endsWith(this.widgets[1].value)) | |
| if (options.length && this.latest_file != options[options.length-1]) { | |
| this.latest_file = path + options[options.length-1] | |
| this.update_links() | |
| } | |
| } | |
| this.widgets[0].callback = fetch_files | |
| this.widgets[1].callback = fetch_files | |
| this.onPromptExecuted = fetch_files | |
| this.applyToGraph = this.update_links | |
| }) | |
| } | |
| }, | |
| 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 >= (app.canvas.low_quality_zoom_threshold ?? 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, len=30) { | |
| //Formats the full path to be under 30 characters | |
| if (path.length <= len) { | |
| return path; | |
| } | |
| let filename = path_stem(path)[1] | |
| if (filename.length > len-2) { | |
| //may all fit, but can't squeeze more info | |
| return filename.substr(0,len); | |
| } | |
| //TODO: find solution for windows, path[1] == ':'? | |
| let isAbs = path[0] == '/'; | |
| let partial = path.substr(path.length - (isAbs ? len-2:len-1)) | |
| let cutoff = partial.indexOf('/'); | |
| if (cutoff < 0) { | |
| //Can occur, but there isn't a nicer way to format | |
| return path.substr(path.length-len); | |
| } | |
| 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) { | |
| let w = app.widgets.FLOAT(node, inputName, inputData, app) | |
| return makeAnnotated(w, inputData); | |
| }, | |
| VHSINT(node, inputName, inputData) { | |
| 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('executing', ({ detail }) => { | |
| if (detail === null) { | |
| for (let node of app.graph._nodes) { | |
| if (node.type.startsWith("VHS_")) { | |
| node.onPromptExecuted?.() | |
| } | |
| } | |
| } | |
| }) | |
| 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, | |
| }); | |
| canvasEl.addEventListener('contextmenu', (e) => { | |
| e.preventDefault() | |
| return app.canvas._mousedown_callback(e) | |
| }, true); | |
| canvasEl.addEventListener('pointerdown', (e) => { | |
| e.preventDefault() | |
| return app.canvas._mousedown_callback(e) | |
| }, true); | |
| canvasEl.addEventListener('mousewheel', (e) => { | |
| e.preventDefault() | |
| return app.canvas._mousewheel_callback(e) | |
| }, true); | |
| canvasEl.addEventListener('pointermove', (e) => { | |
| e.preventDefault() | |
| return app.canvas._mousemove_callback(e) | |
| }, true); | |
| canvasEl.addEventListener('pointerup', (e) => { | |
| e.preventDefault() | |
| return app.canvas._mouseup_callback(e) | |
| }, true); | |
| 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); | |