mirror of
https://git.datalinker.icu/comfyanonymous/ComfyUI
synced 2026-08-22 09:37:52 +08:00
2464 lines
100 KiB
JavaScript
2464 lines
100 KiB
JavaScript
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.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 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, "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 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 = '<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 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=0
|
|
for (;i<node.widgets.length;i++) {
|
|
if (node.widgets[i] == this) {
|
|
break
|
|
}
|
|
}
|
|
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('<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())
|
|
})
|
|
}
|
|
//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);
|
|
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();
|
|
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.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?.()
|
|
}
|
|
}
|
|
}
|
|
})
|
|
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")
|
|
}
|
|
//TODO: Figure out means of concurrency here. map of active nodes and finish event?
|
|
// Information has been squirreled away to the execution store which isn't exposed.
|
|
api.addEventListener('VHS_latentpreview', ({ detail }) => {
|
|
let setting = app.ui.settings.getSettingValue("VHS.LatentPreview")
|
|
if (!setting) {
|
|
return
|
|
}
|
|
let id = detail.id
|
|
if (id == null) {
|
|
return
|
|
}
|
|
latentPreviewNodes.add(id)
|
|
|
|
previewImages = []
|
|
previewImages.length = detail.length
|
|
let displayIndex = 0
|
|
if (animateInterval) {
|
|
clearTimeout(animateInterval)
|
|
}
|
|
//While progress is safely cleared on execution completion.
|
|
//Initial progress must be started here to avoid a race condition
|
|
getNodeById(id).progress = 0
|
|
animateInterval = setInterval(() => {
|
|
if (getNodeById(id).progress == undefined) {
|
|
clearTimeout(animateInterval)
|
|
animateInterval = undefined
|
|
return
|
|
}
|
|
if (!previewImages[displayIndex]) {
|
|
return
|
|
}
|
|
getLatentPreviewCtx(id, previewImages[displayIndex].width,
|
|
previewImages[displayIndex].height)?.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 dv = new DataView(await e.detail.slice(0,24).arrayBuffer())
|
|
const index = dv.getUint32(4)
|
|
//const idlen = dv.getUint8(5)
|
|
//const id = dv.getstring???(6,idlen)
|
|
previewImages[index] = await window.createImageBitmap(e.detail.slice(24))
|
|
return false
|
|
}, true);
|