Files
Kosinkadink-ComfyUI-VideoHe…/web/js/VHS.core.js
T
Austin Mroz 7565290d2b Fix old workflows, Custom Size on refresh
Further migrations to video parameters had broken display of output
videos when an old workflow is loaded. This is fixed.

When the refresh button was clicked in ui, the custom size options of
Load Video were overwritten and lost. When the refresh button is
clicked, the custom size options are now readded properly.
2023-12-06 15:05:30 -06:00

1058 lines
44 KiB
JavaScript
Executable File

import { app } from '../../../scripts/app.js'
import { api } from '../../../scripts/api.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) {
const callback_orig = object[property]
object[property] = function () {
const r = callback_orig.apply(this, arguments);
callback.apply(this, arguments);
return r
};
} else {
object[property] = callback;
}
}
function injectHidden(widget) {
widget.computeSize = (target_width) => {
if (widget.hidden) {
return [0, -4];
}
return [target_width, 20];
};
widget._type = widget.type
Object.defineProperty(widget, "type", {
set : function(value) {
widget._type = value;
},
get : function() {
if (widget.hidden) {
return "hidden";
}
return widget._type;
}
});
}
const convDict = {
VHS_LoadImages : ["directory", null, "image_load_cap", "skip_first_images", "select_every_nth"],
VHS_LoadImagesPath : ["directory", "image_load_cap", "skip_first_images", "select_every_nth"],
VHS_VideoCombine : ["frame_rate", "loop_count", "filename_prefix", "format", "pingpong", "save_image"],
VHS_LoadVideo : ["video", "force_rate", "force_size", "frame_load_cap", "skip_first_frames", "select_every_nth"],
VHS_LoadVideoPath : ["video", "force_rate", "force_size", "frame_load_cap", "skip_first_frames", "select_every_nth"]
};
const renameDict = {VHS_VideoCombine : {save_output : "save_image"}}
function useKVState(nodeType) {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
chainCallback(this, "onConfigure", function(info) {
if (!this.widgets) {
//Node has no widgets, there is nothing to restore
return
}
if (typeof(info.widgets_values) != "object") {
//widgets_values is in some unknown inactionable format
return
}
let widgetDict = info.widgets_values
if (info.widgets_values.length) {
//widgets_values is in the old list format
if (this.type in convDict) {
//widget does not have a conversion format provided
let convList = convDict[this.type];
if(info.widgets_values.length >= convList.length) {
//has all required fields
widgetDict = {}
for (let i = 0; i < convList.length; i++) {
if(!convList[i]) {
//Element should not be processed (upload button on load image sequence)
continue
}
widgetDict[convList[i]] = info.widgets_values[i];
}
} else {
//widgets_values is missing elements marked as required
//let it fall through to failure state
}
}
}
if (widgetDict.length == undefined) {
for (let w of this.widgets) {
if (w.name in widgetDict) {
w.value = widgetDict[w.name];
} 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]]
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;
}
}
}
} 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;
}
});
})
}
function fitHeight(node) {
node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]])
node?.graph?.setDirtyCanvas(true);
}
async function uploadFile(file) {
//TODO: Add uploaded file to cache with Cache.put()?
try {
// Wrap file in formdata so it includes filename
const body = new FormData();
const i = file.webkitRelativePath.lastIndexOf('/');
const subfolder = file.webkitRelativePath.slice(0,i+1)
const new_file = new File([file], file.name, {
type: file.type,
lastModified: file.lastModified,
});
body.append("image", new_file);
if (i > 0) {
body.append("subfolder", subfolder);
}
const resp = await api.fetchApi("/upload/image", {
method: "POST",
body,
});
if (resp.status === 200) {
return resp.status
} else {
alert(resp.status + " - " + resp.statusText);
}
} catch (error) {
alert(error);
}
}
function formatDate(text, date) {
const parts = {
d: (d) => d.getDate(),
M: (d) => d.getMonth() + 1,
h: (d) => d.getHours(),
m: (d) => d.getMinutes(),
s: (d) => d.getSeconds(),
};
const format =
Object.keys(parts)
.map((k) => k + k + "?")
.join("|") + "|yyy?y?";
return text.replace(new RegExp(format, "g"), function (text) {
if (text === "yy") return (date.getFullYear() + "").substring(2);
if (text === "yyyy") return date.getFullYear();
if (text[0] in parts) {
const p = parts[text[0]](date);
return (p + "").padStart(text.length, "0");
}
return text;
});
}
function addDateFormatting(nodeType, field, timestamp_widget = false) {
chainCallback(nodeType.prototype, "onNodeCreated", function() {
const widget = this.widgets.find((w) => w.name === "filename_prefix");
widget.serializeValue = () => {
return widget.value.replace(/%([^%]+)%/g, function (match, text) {
const split = text.split(".");
if (split[0].startsWith("date:")) {
return formatDate(split[0].substring(5), new Date());
}
return match;
});
};
});
}
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 addCustomSize(nodeType, nodeData, widgetName) {
//Add the extra size widgets now
//This takes some finagling as widget order is defined by key order
const newWidgets = {};
for (let key in nodeData.input.required) {
newWidgets[key] = nodeData.input.required[key]
if (key == widgetName) {
newWidgets[key][0] = newWidgets[key][0].concat(["Custom Width", "Custom Height", "Custom"])
newWidgets["custom_width"] = ["INT", {"default": 512, "min": 8, "step": 8}]
newWidgets["custom_height"] = ["INT", {"default": 512, "min": 8, "step": 8}]
}
}
nodeData.input.required = newWidgets;
//Add a callback which sets up the actual logic once the node is created
chainCallback(nodeType.prototype, "onNodeCreated", function() {
const node = this;
const sizeOptionWidget = node.widgets.find((w) => w.name === widgetName);
const widthWidget = node.widgets.find((w) => w.name === "custom_width");
const heightWidget = node.widgets.find((w) => w.name === "custom_height");
injectHidden(widthWidget);
widthWidget.options.serialize = false;
injectHidden(heightWidget);
heightWidget.options.serialize = false;
sizeOptionWidget._value = sizeOptionWidget.value;
Object.defineProperty(sizeOptionWidget, "value", {
set : function(value) {
//TODO: Only modify hidden/reset size when a change occurs
if (value == "Custom Width") {
widthWidget.hidden = false;
heightWidget.hidden = true;
} else if (value == "Custom Height") {
widthWidget.hidden = true;
heightWidget.hidden = false;
} else if (value == "Custom") {
widthWidget.hidden = false;
heightWidget.hidden = false;
} else{
widthWidget.hidden = true;
heightWidget.hidden = true;
}
node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]])
this._value = value;
},
get : function() {
return this._value;
}
});
//prevent clobbering of new options on refresh
sizeOptionWidget.options._values = sizeOptionWidget.options.values;
Object.defineProperty(sizeOptionWidget.options, "values", {
set : function(values) {
this._values = values;
this._values.push("Custom Width", "Custom Height", "Custom");
},
get : function() {
return this._values;
}
});
//Ensure proper visibility/size state for initial value
sizeOptionWidget.value = sizeOptionWidget._value;
sizeOptionWidget.serializeValue = function() {
if (this.value == "Custom Width") {
return widthWidget.value + "x?";
} else if (this.value == "Custom Height") {
return "?x" + heightWidget.value;
} else if (this.value == "Custom") {
return widthWidget.value + "x" + heightWidget.value;
} else {
return this.value;
}
};
});
}
function addUploadWidget(nodeType, nodeData, widgetName, type="video") {
chainCallback(nodeType.prototype, "onNodeCreated", function() {
const pathWidget = this.widgets.find((w) => w.name === widgetName);
const fileInput = document.createElement("input");
chainCallback(this, "onRemoved", () => {
fileInput?.remove();
});
if (type == "folder") {
Object.assign(fileInput, {
type: "file",
style: "display: none",
webkitdirectory: true,
onchange: async () => {
const directory = fileInput.files[0].webkitRelativePath;
const i = directory.lastIndexOf('/');
if (i <= 0) {
throw "No directory found";
}
const path = directory.slice(0,directory.lastIndexOf('/'))
if (pathWidget.options.values.includes(path)) {
alert("A folder of the same name already exists");
return;
}
let successes = 0;
for(const file of fileInput.files) {
if (await uploadFile(file) == 200) {
successes++;
} else {
//Upload failed, but some prior uploads may have succeeded
//Stop future uploads to prevent cascading failures
//and only add to list if an upload has succeeded
if (successes > 0) {
break
} else {
return;
}
}
}
pathWidget.options.values.push(path);
pathWidget.value = path;
},
});
} else if (type == "video") {
Object.assign(fileInput, {
type: "file",
accept: "video/webm,video/mp4,video/mkv,image/gif",
style: "display: none",
onchange: async () => {
if (fileInput.files.length) {
if (await uploadFile(fileInput.files[0]) != 200) {
//upload failed and file can not be added to options
return;
}
const filename = fileInput.files[0].name;
pathWidget.options.values.push(filename);
pathWidget.value = filename;
}
},
});
} else {
throw "Unknown upload type"
}
document.body.append(fileInput);
let uploadWidget = this.addWidget("button", "choose " + type + " to upload", "image", () => {
//clear the active click event
app.canvas.node_widget = null
fileInput.click();
});
uploadWidget.options.serialize = false;
});
}
function addVideoPreview(nodeType) {
chainCallback(nodeType.prototype, "onNodeCreated", function() {
let previewNode = this;
//preview is a made up widget type to enable user defined functions
//videopreview is widget name
//The previous implementation used type to distinguish between a video and gif,
//but the type is not serialized and would not survive a reload
var previewWidget = { name : "videopreview", type : "preview",
draw : function(ctx, node, widgetWidth, widgetY, height) {
//update widget position, hide 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 x = transform.e*scale/transform.a;
const y = transform.f*scale/transform.a;
Object.assign(this.parentEl.style, {
left: (x+15*scale) + "px",
top: (y + widgetY*scale) + "px",
width: ((widgetWidth-30)*scale) + "px",
zIndex: 2 + (node.is_selected ? 1 : 0),
position: "absolute",
});
this._boundingCount = 0;
},
computeSize : function(width) {
if (this.aspectRatio && !this.parentEl.hidden) {
let height = (previewNode.size[0]-30)/ this.aspectRatio;
if (!(height > 0)) {
height = 0;
}
return [width, height];
}
return [width, -4];//no loaded src, widget should not display
},
value : {hidden: false, paused: false, params: {}}
};
//onRemoved isn't a litegraph supported function on widgets
//Given that onremoved widget and node callbacks are sparse, this
//saves the required iteration.
chainCallback(this, "onRemoved", () => {
previewWidget?.parentEl?.remove();
});
previewWidget.options = {serialize : false};
this.addCustomWidget(previewWidget);
previewWidget.parentEl = document.createElement("div");
previewWidget.parentEl.className = "vhs_preview";
previewWidget.parentEl.style['pointer-events'] = "none"
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.imgEl = document.createElement("img");
previewWidget.imgEl.style['width'] = "100%"
previewWidget.imgEl.hidden = true;
previewWidget.imgEl.onload = () => {
previewWidget.aspectRatio = previewWidget.imgEl.naturalWidth / previewWidget.imgEl.naturalHeight;
fitHeight(this);
};
var timeout = null;
this.updateParameters = (params, force_update) => {
if (!previewWidget.value.params) {
if(typeof(previewWidget.value != 'object')) {
previewWidget.value = {hidden: false, paused: false}
}
previewWidget.value.params = {}
}
Object.assign(previewWidget.value.params, params)
if (!force_update &&
!app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false)) {
return;
}
if (timeout) {
clearTimeout(timeout);
}
if (force_update) {
previewWidget.updateSource();
} else {
timeout = setTimeout(() => previewWidget.updateSource(),100);
}
};
previewWidget.updateSource = function () {
let params = this.value.params
if (params == undefined) {
return;
}
this.parentEl.hidden = this.value.hidden;
if (params.format?.split('/')[0] == 'video' ||
app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false) &&
params.format?.split('/')[1] == 'gif') {
this.videoEl.autoplay = !this.value.paused && !this.value.hidden;
if (this.parentEl.style?.width) {
//overscale to allow scrolling. Endpoint won't return higher than native
params.force_size = (this.parentEl.style.width.slice(0,-2)*2) + "x?";
} else {
params.force_size = "256x?"
}
if (app.ui.settings.getSettingValue("VHS.AdvancedPreviews", false)) {
this.videoEl.src = api.apiURL('/viewvideo?' + new URLSearchParams(params));
} else {
previewWidget.videoEl.src = api.apiURL('/view?' + new URLSearchParams(params));
}
this.videoEl.hidden = false;
this.imgEl.hidden = true;
} else {
//Is animated image
this.imgEl.src = api.apiURL('/view?' + new URLSearchParams(params));
this.videoEl.hidden = true;
this.imgEl.hidden = false;
}
}
//Hide video element if offscreen
//The multiline input implementation moves offscreen every frame
//and doesn't apply until a node with an actual inputEl is loaded
this._boundingCount = 0;
this.onBounding = function() {
if (this._boundingCount++>5) {
previewWidget.parentEl.style.left = "-8000px";
}
}
previewWidget.parentEl.appendChild(previewWidget.videoEl)
previewWidget.parentEl.appendChild(previewWidget.imgEl)
document.body.appendChild(previewWidget.parentEl);
});
}
function addPreviewOptions(nodeType) {
chainCallback(nodeType.prototype, "getExtraMenuOptions", function(_, options) {
// The intended way of appending options is returning a list of extra options,
// but this isn't used in widgetInputs.js and would require
// less generalization of chainCallback
let optNew = []
const previewWidget = this.widgets.find((w) => w.name === "videopreview");
let url = null
if (previewWidget.videoEl?.hidden == false && previewWidget.videoEl.src) {
//Use full quality video
url = api.apiURL('/view?' + new URLSearchParams(previewWidget.value.params));
} else if (previewWidget.imgEl?.hidden == false && previewWidget.imgEl.src) {
url = previewWidget.imgEl.src;
url = new URL(url);
}
if (url) {
optNew.push(
{
content: "Open preview",
callback: () => {
window.open(url, "_blank")
},
},
{
content: "Save preview",
callback: () => {
const a = document.createElement("a");
a.href = url;
a.setAttribute("download", new URLSearchParams(previewWidget.value.params).get("filename"));
document.body.append(a);
a.click();
requestAnimationFrame(() => a.remove());
},
}
);
}
const PauseDesc = (previewWidget.value.paused ? "Resume" : "Pause") + " preview";
if(previewWidget.videoEl.hidden == false) {
optNew.push({content: PauseDesc, callback: () => {
//animated images can't be paused and are more likely to cause performance issues.
//changing src to a single keyframe is possible,
//For now, the option is disabled if an animated image is being displayed
if(previewWidget.value.paused) {
previewWidget.videoEl?.play();
} else {
previewWidget.videoEl?.pause();
}
previewWidget.value.paused = !previewWidget.value.paused;
}});
}
//TODO: Consider hiding elements if no video preview is available yet.
//It would reduce confusion at the cost of functionality
//(if a video preview lags the computer, the user should be able to hide in advance)
const visDesc = (previewWidget.value.hidden ? "Show" : "Hide") + " preview";
optNew.push({content: visDesc, callback: () => {
if (!previewWidget.videoEl.hidden && !previewWidget.value.hidden) {
previewWidget.videoEl.pause();
} else if (previewWidget.value.hidden && !previewWidget.videoEl.hidden && !previewWidget.value.paused) {
previewWidget.videoEl.play();
}
previewWidget.value.hidden = !previewWidget.value.hidden;
previewWidget.parentEl.hidden = previewWidget.value.hidden;
fitHeight(this);
}});
optNew.push({content: "Sync preview", callback: () => {
//TODO: address case where videos have varying length
//Consider a system of sync groups which are opt-in?
for (let p of document.getElementsByClassName("vhs_preview")) {
for (let child of p.children) {
if (child.tagName == "VIDEO") {
child.currentTime=0;
} else if (child.tagName == "IMG") {
child.src = child.src;
}
}
}
}});
if(options.length > 0 && options[0] != null && optNew.length > 0) {
optNew.push(null);
}
options.unshift(...optNew);
});
}
function addFormatWidgets(nodeType) {
function parseFormats(options) {
options.fullvalues = options._values;
options._values = [];
for (let format of options.fullvalues) {
if (Array.isArray(format)) {
options._values.push(format[0]);
} else {
options._values.push(format);
}
}
}
chainCallback(nodeType.prototype, "onNodeCreated", function() {
var formatWidget = null;
var formatWidgetIndex = -1;
for(let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name === "format"){
formatWidget = this.widgets[i];
formatWidgetIndex = i+1;
}
}
let formatWidgetsCount = 0;
//Pre-process options to just names
formatWidget.options._values = formatWidget.options.values;
parseFormats(formatWidget.options);
Object.defineProperty(formatWidget.options, "values", {
set : (value) => {
formatWidget.options._values = value;
parseFormats(formatWidget.options);
},
get : () => {
return formatWidget.options._values;
}
})
formatWidget._value = formatWidget.value;
Object.defineProperty(formatWidget, "value", {
set : (value) => {
formatWidget._value = value;
let newWidgets = [];
const fullDef = formatWidget.options.fullvalues.find((w) => Array.isArray(w) ? w[0] === value : w === value);
if (!Array.isArray(fullDef)) {
formatWidget._value = value;
} else {
formatWidget._value = fullDef[0];
for (let wDef of fullDef[1]) {
//create widgets. Heavy borrowed from web/scripts/app.js
//default implementation doesn't work since it automatically adds
//the widget in the wrong spot.
//TODO: consider letting this happen and just removing from list?
let w = {};
w.name = wDef[0];
let inputData = wDef.slice(1);
w.type = inputData[0];
w.options = inputData[1] ? inputData[1] : {};
if (Array.isArray(w.type)) {
w.value = w.type[0];
w.options.values = w.type;
w.type = "combo";
}
if(inputData[1]?.default) {
w.value = inputData[1].default;
}
if (w.type == "INT") {
Object.assign(w.options, {"precision": 0, "step": 10})
w.callback = function (v) {
const s = this.options.step / 10;
this.value = Math.round(v / s) * s;
}
}
const typeTable = {BOOLEAN: "toggle", STRING: "text", INT: "number", FLOAT: "number"};
if (w.type in typeTable) {
w.type = typeTable[w.type];
}
newWidgets.push(w);
}
}
this.widgets.splice(formatWidgetIndex, formatWidgetsCount, ...newWidgets);
fitHeight(this);
formatWidgetsCount = newWidgets.length;
},
get : () => {
return formatWidget._value;
}
});
});
}
function addLoadVideoCommon(nodeType, nodeData) {
addCustomSize(nodeType, nodeData, "force_size")
addVideoPreview(nodeType);
addPreviewOptions(nodeType);
chainCallback(nodeType.prototype, "onNodeCreated", function() {
const pathWidget = this.widgets.find((w) => w.name === "video");
const frameCapWidget = this.widgets.find((w) => w.name === 'frame_load_cap');
const frameSkipWidget = this.widgets.find((w) => w.name === 'skip_first_frames');
const rateWidget = this.widgets.find((w) => w.name === 'force_rate');
const skipWidget = this.widgets.find((w) => w.name === 'select_every_nth');
//widget.callback adds unused arguements which need culling
let update = function (value, _, node) {
let param = {}
param[this.name] = value
node.updateParameters(param);
}
chainCallback(frameCapWidget, "callback", update);
chainCallback(frameSkipWidget, "callback", update);
chainCallback(rateWidget, "callback", update);
chainCallback(skipWidget, "callback", update);
//do first load
requestAnimationFrame(() => {
for (let w of [frameCapWidget, frameSkipWidget, rateWidget, pathWidget, skipWidget]) {
w.callback(w.value, null, this);
}
});
});
}
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;
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) {
//Ctrl+w
//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();
}
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, extensions : pathWidget.options.extensions}
let optionsURL = api.apiURL('getpath?' + new URLSearchParams(params));
try {
let resp = await fetch(optionsURL);
options = await resp.json();
} 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;
}
app.ui.settings.addSetting({
id: "VHS.AdvancedPreviews",
name: "VHS Advanced Previews",
type: "boolean",
defaultValue: false,
});
app.registerExtension({
name: "VideoHelperSuite.Core",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if(nodeData?.name?.startsWith("VHS_")) {
useKVState(nodeType);
}
if (nodeData?.name == "VHS_LoadImages") {
addUploadWidget(nodeType, nodeData, "directory", "folder");
} else if (nodeData?.name == "VHS_LoadVideo") {
addUploadWidget(nodeType, nodeData, "video");
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);
});
});
addLoadVideoCommon(nodeType, nodeData);
} else if (nodeData?.name =="VHS_LoadVideoPath") {
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);
});
});
addLoadVideoCommon(nodeType, nodeData);
} 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);
addPreviewOptions(nodeType);
addFormatWidgets(nodeType);
//Hide the information passing 'gif' output
//TODO: check how this is implemented for save image
chainCallback(nodeType.prototype, "onNodeCreated", function() {
this._outputs = this.outputs
Object.defineProperty(this, "outputs", {
set : function(value) {
this._outputs = value;
requestAnimationFrame(() => {
if (app.nodeOutputs[this.id + ""]) {
this.updateParameters(app.nodeOutputs[this.id+""].gifs[0], true);
}
})
},
get : function() {
return this._outputs;
}
});
});
} 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")
}
},
async getCustomWidgets() {
return {
VHSPATH(node, inputName, inputData) {
let w = {
name : inputName,
type : "VHS.PATH",
value : "",
draw : function(ctx, node, widget_width, y, H) {
//Adapted from litegraph.core.js:drawNodeWidgets
var show_text = app.canvas.ds.scale > 0.5;
var margin = 15;
var text_color = LiteGraph.WIDGET_TEXT_COLOR;
var secondary_text_color = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR;
ctx.textAlign = "left";
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.beginPath();
if (show_text)
ctx.roundRect(margin, y, widget_width - margin * 2, H, [H * 0.5]);
else
ctx.rect( margin, y, widget_width - margin * 2, H );
ctx.fill();
if (show_text) {
if(!this.disabled)
ctx.stroke();
ctx.save();
ctx.beginPath();
ctx.rect(margin, y, widget_width - margin * 2, H);
ctx.clip();
//ctx.stroke();
ctx.fillStyle = secondary_text_color;
const label = this.label || this.name;
if (label != null) {
ctx.fillText(label, margin * 2, y + H * 0.7);
}
ctx.fillStyle = text_color;
ctx.textAlign = "right";
let disp_text = this.format_path(String(this.value))
ctx.fillText(disp_text, widget_width - margin * 2, y + H * 0.7); //30 chars max
ctx.restore();
}
},
mouse : searchBox,
options : {},
format_path : function(path) {
//Formats the full path to be under 30 characters
if (path.length <= 30) {
return path;
}
let filename = path_stem(path)[1]
if (filename.length > 28) {
//may all fit, but can't squeeze more info
return filename.substr(0,30);
}
//TODO: find solution for windows, path[1] == ':'?
let isAbs = path[0] == '/';
let partial = path.substr(path.length - (isAbs ? 28:29))
let cutoff = partial.indexOf('/');
if (cutoff < 0) {
//Can occur, but there isn't a nicer way to format
return path.substr(path.length-30);
}
return (isAbs ? '/…':'…') + partial.substr(cutoff);
}
};
if (inputData.length > 1) {
if (inputData[1].extensions) {
w.options.extensions = inputData[1].extensions;
}
if (inputData[1].default) {
w.value = inputData[1].default;
}
}
if (!node.widgets) {
node.widgets = [];
}
node.widgets.push(w);
return w;
}
}
}
});