diff --git "a/web/js/VHS.core.js" "b/web/js/VHS.core.js" new file mode 100644--- /dev/null +++ "b/web/js/VHS.core.js" @@ -0,0 +1,2538 @@ +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 getNodeById(id, graph=app.graph) { + let cg = graph + let node = undefined + for (let sid of (''+id).split(':')) { + node = cg?.getNodeById?.(sid) + cg = node?.subgraph + } + return node +} + +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) + } + } + } 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); +} +function startDraggingItems(node, pointer) { + app.canvas.emitBeforeChange() + app.canvas.graph?.beforeChange() + // Ensure that dragging is properly cleaned up, on success or failure. + pointer.finally = () => { + app.canvas.isDragging = false + app.canvas.graph?.afterChange() + app.canvas.emitAfterChange() + } + app.canvas.processSelect(node, pointer.eDown, true) + app.canvas.isDragging = true +} +function processDraggedItems(e) { + if (e.shiftKey || LiteGraph.alwaysSnapToGrid) + app.canvas?.graph?.snapToGrid(app.canvas.selectedItems) + app.canvas.dirty_canvas = true + app.canvas.dirty_bgcanvas = true + app.canvas.onNodeMoved?.(findFirstNode(app.canvas.selectedItems)) +} +function allowDragFromWidget(widget) { + widget.onPointerDown = function(pointer, node) { + pointer.onDragStart = () => startDraggingItems(node, pointer) + pointer.onDragEnd = processDraggedItems + app.canvas.dirty_canvas = true + return true + } +} + +async function uploadFile(file, progressCallback) { + 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 url = api.apiURL("/upload/image") + const resp = await new Promise((resolve) => { + let req = new XMLHttpRequest() + req.upload.onprogress = (e) => progressCallback?.(e.loaded/e.total) + req.onload = () => resolve(req) + req.open('post', url, true) + req.send(body) + }) + + if (resp.status !== 200) { + alert(resp.status + " - " + resp.statusText); + } + return resp + } catch (error) { + alert(error); + } +} + +function addVAEOutputToggle(nodeType, nodeData) { + chainCallback(nodeType.prototype, "onNodeCreated", function() { + this.reject_ue_connection = (input) => input?.name == "vae" + }) + 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, "onNodeCreated", function() { + this.reject_ue_connection = (input) => input?.name == "vae" + }) + 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) { + chainCallback(nodeType.prototype, "onNodeCreated", function() { + this.changeOutputType = function (new_type) { + this.linkTimeout = setTimeout(() => { + this.linkTimeout = false + if (this.outputs[0].type != new_type) { + this.outputs[0].type = new_type + //check and potentially remove links + if (!this.outputs[0].links) { + return + } + let removed_links = [] + for (let link_id of this.outputs[0].links) { + let link = app.graph.links[link_id] + if (!link) + debugger + let target_node = app.graph.getNodeById(link.target_id) + let target_input = target_node.inputs[link.target_slot] + let keep = LiteGraph.isValidConnection(new_type, target_input.type) + if (!keep) { + link.disconnect(app.graph, 'input') + removed_links.push(link_id) + } + target_node.onConnectionsChange?.(LiteGraph.INPUT, + link.target_slot, keep, link, target_input) + } + this.outputs[0].links = this.outputs[0].links + .filter((v) => !removed_links.includes(v)) + } + }, 50) + } + this.changeOutputType("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.changeOutputType(new_type) + } + }); +} + +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], 'mod': format.dim[1]} + format.custom_height = {'step': format.dim[0], '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], '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") + 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" + } + let rateWidget = this.widgets.find((w) => w.name === "force_rate") + rateWidget.annotation = (value, width) => { + if (value == 0 && this.video_query?.source?.fps != undefined) { + return roundToPrecision(this.video_query.source.fps, 2) + "\u21FD" + } + } + }); +} + +function addUploadWidget(nodeType, nodeData, widgetName, type="video") { + let accept = {'video': ["video/webm","video/mp4","video/x-matroska","image/gif"], + 'audio': ["audio/mpeg","audio/wav","audio/x-wav","audio/ogg"]} + chainCallback(nodeType.prototype, "onNodeCreated", function() { + const node = this + 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; + const onProg = (p) => this.progress = (successes + p) / fileInput.files.length + for(const file of fileInput.files) { + if ((await uploadFile(file, onProg)).status == 200) { + successes++; + } else { + this.progress = undefined + //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; + } + } + } + this.progress = undefined + pathWidget.options.values.push(path); + pathWidget.value = path; + if (pathWidget.callback) { + pathWidget.callback(path) + } + }, + }); + } else { + let accept = {'video': ["video/webm","video/mp4","video/x-matroska","image/gif"], + 'audio': ["audio/mpeg","audio/wav","audio/x-wav","audio/ogg"]}[type] + async function doUpload(file) { + let resp = await uploadFile(file, (p) => node.progress = p) + node.progress = undefined + if (resp.status != 200) { + return false + } + const filename = JSON.parse(resp.responseText).name; + pathWidget.options.values.push(filename); + pathWidget.value = filename; + if (pathWidget.callback) { + pathWidget.callback(filename) + } + return true + } + Object.assign(fileInput, { + type: "file", + accept: accept.join(','), + style: "display: none", + onchange: async () => { + if (fileInput.files.length) { + return await doUpload(fileInput.files[0]) + } + }, + }); + this.onDragOver = (e) => !!e?.dataTransfer?.types?.includes?.('Files') + this.onDragDrop = async function(e) { + if (!e?.dataTransfer?.types?.includes?.('Files')) { + return false + } + //TODO: Allow dragging multiple files at once? + const item = e.dataTransfer?.files?.[0] + if (accept.includes(item?.type)) { + return await doUpload(item) + } + return false + } + } + 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 addAudioPreview(nodeType, isInput=true) { + chainCallback(nodeType.prototype, "onNodeCreated", function() { + var element = document.createElement("audio"); + element.controls = true + const previewNode = this; + var previewWidget = this.addDOMWidget("audiopreview", "preview", element, { + serialize: false, + hideOnZoom: true, + getValue() { + return element.value; + }, + setValue(v) { + element.value = v; + }, + }); + previewWidget.computeSize = function(width) { + return [width, 50]; + } + var timeout = null; + this.updateParameters = (params, force_update) => { + if (!previewWidget.value.params) { + if(typeof(previewWidget.value) != 'object') { + previewWidget.value = {} + } + 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") + if (advp == 'Never') { + advp = false + } else if (advp == 'Input Only') { + advp = isInput + } else { + advp = true + } + Object.assign(params, this.value.params);//shallow copy + params.timestamp = Date.now() + if (!advp) { + element.src = api.apiURL('/view?' + new URLSearchParams(params)); + } else { + params.deadline = app.ui.settings.getSettingValue("VHS.AdvancedPreviewsDeadline") + element.src = api.apiURL('/vhs/viewaudio?' + new URLSearchParams(params)); + } + } + previewWidget.callback = previewWidget.updateSource + + + //setup widget tracking + function update(key) { + return function(value) { + let params = {} + params[key] = this.value + previewNode?.updateParameters(params) + } + } + let widgetMap = { 'seek_seconds': 'start_time', 'duration': 'duration', + 'start_time': 'start_time' } + 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 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; + }, + }); + allowDragFromWidget(previewWidget) + 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); + element.addEventListener('dragover', (e) => { + //A little hacky, but allows drag events onto the preview itself + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + app.dragOverNode = this + }) + 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") + if (advp == 'Never') { + advp = false + } else if (advp == 'Input Only') { + advp = isInput + } else { + advp = true + } + 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 && (params.format?.split('/')[1] == 'gif') + || params.format == 'folder') { + + this.videoEl.autoplay = !this.value.paused && !this.value.hidden; + if (!advp) { + this.videoEl.src = api.apiURL('/view?' + new URLSearchParams(params)); + } else { + let target_width = (previewNode.size[0]-20)*2 || 256; + 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") + 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[2]?.widgetType ?? wDef[1] + if (Array.isArray(type)) { + type = "COMBO" + } + app.widgets[type](this, wDef[0], wDef.slice(1), app) + let w = this.widgets.pop() + w.config = wDef.slice(1) + newWidgets.push(w) + } + } + let removed = this.widgets.splice(formatWidgetIndex, + formatWidgetsCount, ...newWidgets); + let newNames = new Set(newWidgets.map((w) => w.name)) + for (let w of removed) { + w?.onRemove?.() + if (w.name in newNames) { + continue + } + //I do not like the performance of this, but it's safe + let slot = this.inputs.findIndex((i) => i.name == w.name) + if (slot >= 0) { + this.removeInput(slot) + } + } + for (let w of newWidgets) { + let existingInput = this.inputs.find((i) => i.name == w.name) + if (existingInput) { + setWidgetConfig(existingInput, w.config) + //TODO: Consider forcing disconnection if props change? + } else { + //NOTE: config is applied in wrapped addInput call + this.addInput(w.name, w.config[0], {widget: {name: w.name}}) + } + } + 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 function(value) { + let params = {} + params[key] = this.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) { + Object.defineProperty(offsetWidget.options, "step", { + set : (value) => {}, + get : () => { + return 1 / (this.video_query?.loaded?.fps ?? 1) + } + }) + } + 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 fitText(ctx, text, maxLength) { + if (maxLength <= 0) { + return ['', 0] + } + let fullLength = ctx.measureText(text).width + if (fullLength < maxLength) { + return [text, fullLength] + } + //determine approx safe cutoff + let cutoff = maxLength / fullLength * text.length | 0 + let shortened = text.slice(0, Math.max(0, cutoff - 2)) + '…' + return [shortened, ctx.measureText(shortened).width] +} +function fitPath(ctx, path, maxLength) { + let fullLength = ctx.measureText(path).width + if (fullLength < maxLength) { + return [path, fullLength] + } + //determine approx safe cutoff + let len = (maxLength / fullLength * path.length | 0) - 1 + + let displayPath = '' + let filename = path_stem(path)[1] + if (filename.length > len-2) { + //may all fit, but can't squeeze more info + displayPath = filename.substr(0,len); + } else { + //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 + displayPath = path.substr(path.length-len); + } else { + displayPath = (isAbs ? '/…':'…') + partial.substr(cutoff); + } + } + return [displayPath, ctx.measureText(displayPath).width] +} +function roundToPrecision(num, precision) { + let strnum = Number(num).toFixed(precision) + let deci = strnum.indexOf('.') + if (deci > 0) { + let i = strnum.length - 1 + while (i > deci && strnum[i] == '0') { + i-- + } + if (i == deci) { + i-- + } + return strnum.slice(0, i+1) + } + return strnum +} +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.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) { + if (!this.disabled) ctx.stroke() + const button = button_action(this) + if (button != 'None') { + ctx.save() + if (button.startsWith('No ')) { + ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR + ctx.strokeStyle = litegraph_base.WIDGET_OUTLINE_COLOR + } else { + ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR + ctx.strokeStyle = litegraph_base.WIDGET_TEXT_COLOR + } + ctx.beginPath() + if (button.endsWith('Reset')) { + ctx.arc(widget_width - margin - 26, y + H/2, 4, Math.PI*3/2, Math.PI) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(widget_width - margin - 26, y + H/2 - 1.5) + ctx.lineTo(widget_width - margin - 26, y + H/2 - 6.5) + ctx.lineTo(widget_width - margin - 30, y + H/2 - 3.5) + ctx.fill() + } else { + ctx.arc(widget_width - margin - 26, y + H/2, 4, Math.PI*2/3, Math.PI*8/3) + ctx.moveTo(widget_width - margin - 26 - 8 ** .5, y + H/2 + 8 ** .5) + ctx.lineTo(widget_width - margin - 26 + 8 ** .5, y + H/2 - 8 ** .5) + ctx.stroke() + } + 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() + } + let freeWidth = widget_width - (40 + margin * 2 + 20) + let [valueText, valueWidth] = fitText(ctx, this.displayValue(), freeWidth) + freeWidth -= valueWidth + + ctx.textAlign = 'left' + ctx.fillStyle = litegraph_base.WIDGET_SECONDARY_TEXT_COLOR + if (freeWidth > 20) { + let [name, nameWidth] = fitText(ctx, this.label || this.name, freeWidth) + freeWidth -= nameWidth + ctx.fillText(name, margin * 2 + 5, y + H * 0.7) + } + + let value_offset = margin * 2 + 20 + ctx.textAlign = 'right' + if (this.options.unit) { + ctx.fillStyle = litegraph_base.WIDGET_OUTLINE_COLOR + let [unitText, unitWidth] = fitText(ctx, this.options.unit, freeWidth) + if (unitText == this.options.unit) { + ctx.fillText(this.options.unit, widget_width - value_offset, y + H * 0.7) + value_offset += unitWidth + freeWidth -= unitWidth + } + } + ctx.fillStyle = litegraph_base.WIDGET_TEXT_COLOR + ctx.fillText(valueText, widget_width - value_offset, y + H * 0.7) + ctx.fillStyle = litegraph_base.WIDGET_SECONDARY_TEXT_COLOR + + + let annotation = '' + if (this.annotation) { + annotation = this.annotation(this.value, freeWidth) + } 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 + let [annoDisplay, annoWidth] = fitText(ctx, annotation, freeWidth) + ctx.fillText( + annoDisplay, + widget_width - 5 - valueWidth - value_offset, + y + H * 0.7 + ) + } + } +} +function mouseAnnotated(event, [x, y], node) { + //NOTE: Mouse actions contain no history element. + //This can cause overlapping actions since each triggers on different event type (down/move/up) + //TODO: Consider further rework + const widget_width = this.width || node.size[0] + const old_value = this.value + const margin = 15 + let isButton = 0 + if (x > margin + 6 && x < margin + 16) { + isButton = -1 + } else if (x > widget_width - margin - 16 & x < widget_width - margin - 6) { + isButton = 1 + } else if (x > widget_width - margin - 34 && x < widget_width - margin - 18) { + isButton = 2 + } + var allow_scroll = true + if (allow_scroll && event.type == 'pointermove') { + if (event.deltaX) + this.value += event.deltaX * (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') { + const buttonType = button_action(this) + if (isButton == 2) { + if (buttonType == 'Reset') { + this.value = this.options.reset + } else if (buttonType == 'Disable') { + this.value = this.options.disable + } + } else { + this.value += isButton * (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 && !isButton) { + const d_callback = (v) => { + this.value = this.parseValue?.(v) ?? Number(v) + inner_value_change(this, this.value, node, [x, y]) + } + const dialog = app.canvas.prompt( + 'Value', + this.value,//TODO: Consider making this displayValue? + 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 = node.widgets.findIndex((w) => w == this) + if (e.shiftKey) + i-- + else + i++ + if (node.widgets[i]?.type == "VHS.ANNOTATED") {//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 +} +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 id of latentPreviewNodes) { + let n = app.graph.getNodeById(id) + 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()) + }) + } + //set widgetType to use VHS widgets where possible + for(let inp of Object.values({...nodeData.input?.required, ...nodeData.input?.optional})) { + if (["INT", "FLOAT"].includes(inp[0])) { + if (!inp[1]) { + inp[1] = {} + } + inp[1].widgetType ??= "VHS" + inp[0] + } + } + 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 { + 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" + } + setWidgetConfig(options, 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") { + 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); + } else if (nodeData?.name == "VHS_LoadAudio") { + addAudioPreview(nodeType) + chainCallback(nodeType.prototype, "onNodeCreated", function() { + const pathWidget = this.widgets.find((w) => w.name === "audio_file"); + chainCallback(pathWidget, "callback", (filename) => { + this.updateParameters({filename, type: 'path'}, true); + }); + }); + } else if (nodeData?.name == "VHS_LoadAudioUpload") { + addUploadWidget(nodeType, nodeData, "audio", "audio"); + addAudioPreview(nodeType) + chainCallback(nodeType.prototype, "onNodeCreated", function() { + const pathWidget = this.widgets.find((w) => w.name === "audio"); + chainCallback(pathWidget, "callback", (filename) => { + if (!filename) return + let params = {filename, type : "input"}; + this.updateParameters(params, true); + }); + }); + } 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); + } 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 = this.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) => this.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(); + let freeWidth = widget_width - (margin * 2 + 40) + ctx.fillStyle = secondary_text_color; + const label = this.label || this.name; + if (label != null) { + let [labelDisplay, labelWidth] = fitText(ctx, label, freeWidth) + freeWidth -= labelWidth + ctx.fillText(labelDisplay, margin * 2, y + H * 0.7); + } + ctx.fillStyle = this.value ? text_color : '#777'; + ctx.textAlign = "right"; + let disp_text = fitPath(ctx, String(this.value || this.options.placeholder), freeWidth)[0] + ctx.fillText(disp_text, widget_width - margin * 2, y + H * 0.7); + ctx.restore(); + } + }, + mouse : searchBox, + options : {}, + }; + 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 = { + name: inputName, + type: "VHS.ANNOTATED", + value: inputData[1]?.default ?? 0, + draw: drawAnnotated, + mouse: mouseAnnotated, + computeSize(width) { + return [width, 20] + }, + callback(v) { + if (this.options.round) { + //TODO adopt ComfyUI_frontend#4291? + v = Math.round((v + Number.EPSILON) / + this.options.round) * this.options.round + } + if (this.options.max && v > this.options.max) { + v = this.options.max + } + if (this.options.min && v < this.options.max) { + v = this.options.min + } + this.value = v + }, + config: inputData, + displayValue: function() { + return roundToPrecision(this.value, this.options.precision ?? 3) + }, + options: Object.assign({}, inputData[1]) + } + if (!node.widgets) { + node.widgets = [] + } + node.widgets.push(w) + return w + }, + VHSINT(node, inputName, inputData) { + let w = { + name: inputName, + type: "VHS.ANNOTATED", + value: inputData[1]?.default ?? 0, + draw: drawAnnotated, + mouse: mouseAnnotated, + computeSize(width) { + return [width, 20] + }, + callback(v) { + if (this.options.max && v > this.options.max) { + v = this.options.max + } + if (this.options.min && v < this.options.min) { + v = this.options.min + } + if (v == 0) { + return + } + const s = this.options.step + let sh = this.options.mod ?? 0 + this.value = Math.round((v - sh) / s) * s + sh + }, + config: inputData, + displayValue: function() { + return this.value | 0 + }, + options: Object.assign({}, inputData[1]) + } + if (!node.widgets) { + node.widgets = [] + } + node.widgets.push(w) + return w + }, + VHSTIMESTAMP(node, inputName, inputData) { + let w = { + name: inputName, + type: "VHS.TIMESTAMP", + value: inputData[1]?.default ?? 0, + draw: drawAnnotated, + mouse: mouseAnnotated, + computeSize(width) { + return [width, 20] + }, + parseValue(v) { + if (typeof(v) == "string") { + let val = 0 + for (let chunk of v.split(":")) { + val = val * 60 + parseFloat(chunk) + } + return val + } + }, + callback(v) {}, + config: inputData, + options: Object.assign({}, inputData[1]), + displayValue() { + let seconds = this.value + let hours = seconds / 3600 | 0 + seconds -= 3600 * hours + let minutes = seconds / 60 | 0 + seconds -= 60 * minutes + let display = "" + if (hours > 0) { + display += hours + ":" + } + if (hours > 0 || minutes > 0) { + if (hours > 0) { + minutes = (''+minutes).padStart(2,'0') + } + display += minutes + ":" + } + seconds = roundToPrecision(seconds, 4) + if ((seconds[1] == '.' || seconds.length == 1) && (minutes > 0 || hours > 0)) { + seconds = '0'+seconds + } + display += seconds + return display + } + } + if (!node.widgets) { + node.widgets = [] + } + node.widgets.push(w) + return w + }, + } + }, + 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() { + let originalGraphToPrompt = app.graphToPrompt + let graphToPrompt = async function() { + let res = await originalGraphToPrompt.apply(this, arguments); + 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.canvas.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.canvas.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 = [] +api.addEventListener('executing', ({ detail }) => { + if (detail === null) { + for (let graph of [app.graph, ...app.graph.subgraphs.values()]) { + for (let node of graph._nodes) { + if (node.type.startsWith("VHS_")) { + node.onPromptExecuted?.() + } + } + } + } +}) +function getLatentPreviewCtx(id, width, height) { + const node = getNodeById(id) + if (!node) { + return undefined + } + + let previewWidget = node.widgets.find((w) => w.name == "vhslatentpreview") + if (!previewWidget) { + let canvasEl = document.createElement("canvas") + previewWidget = node.addDOMWidget("vhslatentpreview", "vhscanvas", canvasEl, { + serialize: false, + hideOnZoom: false, + }); + previewWidget.serialize = false + allowDragFromWidget(previewWidget) + 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 = (node.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 canvasEl = previewWidget.element + if (!previewWidget.ctx || canvasEl.width != width + || canvasEl.height != height) { + previewWidget.aspectRatio = width / height + canvasEl.width = width + canvasEl.height = height + fitHeight(node) + } + return canvasEl.getContext("2d") +} +let animateIntervals = {} +function beginLatentPreview(id, previewImages, rate) { + latentPreviewNodes.add(id) + if (animateIntervals[id]) { + clearTimeout(animateIntervals[id]) + } + let displayIndex = 0 + let node = getNodeById(id) + //While progress is safely cleared on execution completion. + //Initial progress must be started here to avoid a race condition + node.progress = 0 + animateIntervals[id] = setInterval(() => { + if (getNodeById(id)?.progress == undefined + || app.canvas.graph.rootGraph != node.graph.rootGraph) { + clearTimeout(animateIntervals[id]) + delete animateIntervals[id] + return + } + if (!previewImages[displayIndex]) { + return + } + getLatentPreviewCtx(id, previewImages[displayIndex].width, + previewImages[displayIndex].height)?.drawImage?.(previewImages[displayIndex],0,0) + displayIndex = (displayIndex + 1) % previewImages.length + }, 1000/rate); + +} +let previewImagesDict = {} +api.addEventListener('VHS_latentpreview', ({ detail }) => { + if (detail.id == null) { + return + } + let previewImages = previewImagesDict[detail.id] = [] + previewImages.length = detail.length + + let idParts = detail.id.split(':') + for (let i=1; i <= idParts.length; i++) { + let id = idParts.slice(0,i).join(':') + beginLatentPreview(id, previewImages, detail.rate) + } +}); +let td = new TextDecoder() +api.addEventListener('b_preview', async (e) => { + if (Object.keys(animateIntervals).length == 0) { + return + } + e.preventDefault() + e.stopImmediatePropagation() + e.stopPropagation() + const dv = new DataView(await e.detail.slice(0,24).arrayBuffer()) + const index = dv.getUint32(4) + const idlen = dv.getUint8(8) + const id = td.decode(dv.buffer.slice(9,9+idlen)) + previewImagesDict[id][index] = await window.createImageBitmap(e.detail.slice(24)) + return false +}, true);