613 lines
22 KiB
JavaScript
613 lines
22 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import { api } from '../../../scripts/api.js'
|
|
import { ComfyWidgets } from "../../../scripts/widgets.js";
|
|
|
|
// Many functions copied from:
|
|
// https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite
|
|
// Thank you!
|
|
|
|
|
|
document.getElementById("comfy-file-input").accept += ",video/webm,video/mp4";
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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 fitHeight(node) {
|
|
node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1] + 20])
|
|
node?.graph?.setDirtyCanvas(true);
|
|
}
|
|
function addVideoPreview(nodeType) {
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function() {
|
|
var element = document.createElement("div");
|
|
const previewNode = this;
|
|
var previewWidget = this.addDOMWidget("videopreview", "preview", element, {
|
|
serialize: false,
|
|
hideOnZoom: false,
|
|
getValue() {
|
|
return element.value;
|
|
},
|
|
setValue(v) {
|
|
element.value = v;
|
|
},
|
|
});
|
|
previewWidget.computeSize = function(width) {
|
|
if (this.aspectRatio && !this.parentEl.hidden) {
|
|
let height = (previewNode.size[0]-20)/ this.aspectRatio + 10;
|
|
if (!(height > 0)) {
|
|
height = 0;
|
|
}
|
|
this.computedHeight = height + 10;
|
|
return [width, height];
|
|
}
|
|
return [width, -4];//no loaded src, widget should not display
|
|
}
|
|
//element.style['pointer-events'] = "none"
|
|
previewWidget.value = {hidden: false, paused: false, params: {}}
|
|
previewWidget.parentEl = document.createElement("div");
|
|
previewWidget.parentEl.className = "deforumVideoSavePreview";
|
|
previewWidget.parentEl.style['width'] = "100%"
|
|
element.appendChild(previewWidget.parentEl);
|
|
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);
|
|
};
|
|
|
|
// Add transport controls
|
|
const controls = document.createElement("div");
|
|
controls.style.display = "flex";
|
|
controls.style.justifyContent = "space-around";
|
|
previewWidget.parentEl.appendChild(controls);
|
|
|
|
// Play button
|
|
const playButton = document.createElement("button");
|
|
playButton.innerText = "Play";
|
|
playButton.onclick = () => {
|
|
if (!this.playing) {
|
|
this.startPlayback(this.playbackInterval);
|
|
}
|
|
};
|
|
controls.appendChild(playButton);
|
|
|
|
// Stop button
|
|
const stopButton = document.createElement("button");
|
|
stopButton.innerText = "Stop";
|
|
stopButton.onclick = () => {
|
|
this.stopPlayback();
|
|
// Reset frameIndex if needed
|
|
// frameIndex = 0; // Uncomment to reset frame index on stop
|
|
};
|
|
controls.appendChild(stopButton);
|
|
|
|
// Step Back button
|
|
const stepBackButton = document.createElement("button");
|
|
stepBackButton.innerText = "Step Back";
|
|
stepBackButton.onclick = () => {
|
|
if (frameIndex > 0) {
|
|
frameIndex -= 1;
|
|
} else {
|
|
frameIndex = cachedFrames.length - 1; // Wrap around to the last frame
|
|
}
|
|
updateFrame(frameIndex);
|
|
};
|
|
controls.appendChild(stepBackButton);
|
|
|
|
// Step Forward button
|
|
const stepForwardButton = document.createElement("button");
|
|
stepForwardButton.innerText = "Step Forward";
|
|
stepForwardButton.onclick = () => {
|
|
frameIndex = (frameIndex + 1) % cachedFrames.length;
|
|
updateFrame(frameIndex);
|
|
};
|
|
controls.appendChild(stepForwardButton);
|
|
|
|
|
|
previewWidget.parentEl.appendChild(previewWidget.imgEl)
|
|
let frameIndex = 0;
|
|
let cachedFrames = this.getCachedFrames(); // Assuming this method exists and retrieves an array of frame data
|
|
|
|
// Function to update frame view
|
|
function updateFrame(index) {
|
|
cachedFrames = previewNode .getCachedFrames()
|
|
if (cachedFrames && cachedFrames.length > 0) {
|
|
previewWidget.imgEl.hidden = false;
|
|
previewWidget.imgEl.src = 'data:image/png;base64,' + cachedFrames[index];
|
|
}
|
|
}
|
|
|
|
|
|
this.playing = false;
|
|
this.playbackInterval = 80;
|
|
this.startPlayback = function(playbackInterval) {
|
|
if (this.playing) {
|
|
this.stopPlayback(); // Stop current playback if it's running
|
|
}
|
|
|
|
this.playbackInterval = playbackInterval;
|
|
const widget = this; // Capture 'this' to use inside setInterval function
|
|
this.imageSequenceInterval = setInterval(() => {
|
|
const cachedFrames = this.getCachedFrames();
|
|
//const displayFrames = cachedFrames.length > 0 ? cachedFrames : frames;
|
|
if (cachedFrames && cachedFrames.length > 0) {
|
|
previewWidget.imgEl.hidden = false;
|
|
previewWidget.imgEl.src = 'data:image/png;base64,' + cachedFrames[frameIndex];
|
|
frameIndex = (frameIndex + 1) % cachedFrames.length;
|
|
}
|
|
}, this.playbackInterval); // Update frame every 80ms
|
|
};
|
|
// Function to stop playback
|
|
this.stopPlayback = function() {
|
|
if (this.imageSequenceInterval) {
|
|
clearInterval(this.imageSequenceInterval);
|
|
this.imageSequenceInterval = null; // Clear the interval ID
|
|
this.playing = false; // Mark as not playing
|
|
}
|
|
};
|
|
this.setPlaybackInterval = function(newInterval) {
|
|
this.playbackInterval = newInterval;
|
|
if (this.playing) {
|
|
this.stopPlayback();
|
|
this.startPlayback(newInterval); // Restart playback with new interval if it's currently playing
|
|
}
|
|
};
|
|
|
|
});
|
|
}
|
|
|
|
|
|
|
|
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");
|
|
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;
|
|
if (pathWidget.callback) {
|
|
pathWidget.callback(path)
|
|
}
|
|
},
|
|
});
|
|
} else if (type == "video") {
|
|
Object.assign(fileInput, {
|
|
type: "file",
|
|
accept: "video/webm,video/mp4,video/mkv,image/gif",
|
|
style: "display: none",
|
|
onchange: async () => {
|
|
if (fileInput.files.length) {
|
|
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;
|
|
if (pathWidget.callback) {
|
|
pathWidget.callback(filename)
|
|
}
|
|
}
|
|
},
|
|
});
|
|
} else {
|
|
throw "Unknown upload type"
|
|
}
|
|
document.body.append(fileInput);
|
|
let uploadWidget = this.addWidget("button", "choose " + type + " to upload", "image", () => {
|
|
//clear the active click event
|
|
app.canvas.node_widget = null
|
|
|
|
fileInput.click();
|
|
});
|
|
uploadWidget.options.serialize = false;
|
|
});
|
|
}
|
|
|
|
// Extend the node prototype to include frame caching capabilities
|
|
function extendNodePrototypeWithFrameCaching(nodeType) {
|
|
nodeType.prototype.frameCache = []; // Initialize an empty cache
|
|
|
|
// Method to add frames to the cache
|
|
nodeType.prototype.cacheFrames = function(frames) {
|
|
this.frameCache = this.frameCache.concat(frames);
|
|
};
|
|
|
|
// Method to clear the frame cache
|
|
nodeType.prototype.clearFrameCache = function() {
|
|
this.frameCache = [];
|
|
};
|
|
|
|
// Method to get cached frames
|
|
nodeType.prototype.getCachedFrames = function() {
|
|
return this.frameCache;
|
|
};
|
|
}
|
|
|
|
|
|
app.registerExtension({
|
|
name: "deforum.deforumIterator",
|
|
init() {
|
|
const STRING = ComfyWidgets.STRING;
|
|
ComfyWidgets.STRING = function (node, inputName, inputData) {
|
|
const r = STRING.apply(this, arguments);
|
|
r.widget.dynamicPrompts = inputData?.[1].dynamicPrompts;
|
|
return r;
|
|
};
|
|
},
|
|
beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeType.comfyClass === "DeforumIteratorNode") {
|
|
const onIteratorExecuted = nodeType.prototype.onExecuted
|
|
nodeType.prototype.onExecuted = function (message) {
|
|
const r = onIteratorExecuted ? onIteratorExecuted.apply(this, message) : undefined
|
|
for (const w of this.widgets || []) {
|
|
if (w.name === "reset_counter") {
|
|
const counterWidget = w;
|
|
counterWidget.value = false;
|
|
|
|
}
|
|
}
|
|
const v = app.nodeOutputs?.[this.id + ""];
|
|
if (!this.flags.collapsed && v) {
|
|
|
|
const counter = v["counter"]
|
|
const max_frames = v["max_frames"]
|
|
if (counter[0] >= max_frames[0]) {
|
|
if (document.getElementById('autoQueueCheckbox').checked === true) {
|
|
document.getElementById('autoQueueCheckbox').click();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
return r
|
|
}
|
|
|
|
const onDrawForeground = nodeType.prototype.onDrawForeground;
|
|
nodeType.prototype.onDrawForeground = function (ctx) {
|
|
const r = onDrawForeground?.apply?.(this, arguments);
|
|
const v = app.nodeOutputs?.[this.id + ""];
|
|
if (!this.flags.collapsed && v) {
|
|
|
|
const text = v["counter"] + "";
|
|
ctx.save();
|
|
ctx.font = "bold 48px sans-serif";
|
|
ctx.fillStyle = "dodgerblue";
|
|
const sz = ctx.measureText(text);
|
|
ctx.fillText(text, (this.size[0]) / 2 - sz.width - 5, LiteGraph.NODE_SLOT_HEIGHT * 3);
|
|
ctx.restore();
|
|
}
|
|
|
|
return r;
|
|
};
|
|
} else if (nodeType.comfyClass === "DeforumLoadVideo") {
|
|
addUploadWidget(nodeType, nodeData, "video");
|
|
|
|
} else if (nodeType.comfyClass === "DeforumVideoSaveNode") {
|
|
extendNodePrototypeWithFrameCaching(nodeType);
|
|
addVideoPreview(nodeType);
|
|
const onVideoSaveExecuted = nodeType.prototype.onExecuted
|
|
nodeType.prototype.onExecuted = function (message) {
|
|
|
|
const r = onVideoSaveExecuted ? onVideoSaveExecuted.apply(this, message) : undefined
|
|
for (const w of this.widgets || []) {
|
|
if (w.name === "dump_now") {
|
|
const dumpWidget = w;
|
|
dumpWidget.value = false;
|
|
this.shouldResetAnimation = true;
|
|
}
|
|
}
|
|
const output = app.nodeOutputs?.[this.id + ""];
|
|
const should_reset = output["should_dump"]
|
|
const fps = output["fps"]
|
|
const millisecondsPerFrame = 1000 / fps[0];
|
|
|
|
|
|
|
|
if (this.playing === false) {
|
|
this.playing = true;
|
|
this.cacheFrames(output["frames"]);
|
|
this.startPlayback(millisecondsPerFrame);
|
|
|
|
} else {
|
|
this.setPlaybackInterval(millisecondsPerFrame)
|
|
this.cacheFrames(output["frames"]);
|
|
}
|
|
|
|
if (should_reset[0] === true) {
|
|
this.stopPlayback();
|
|
this.clearFrameCache();
|
|
|
|
}
|
|
|
|
|
|
return r
|
|
}
|
|
const onVideoSaveForeground = nodeType.prototype.onDrawForeground;
|
|
nodeType.prototype.onDrawForeground = function (ctx) {
|
|
const r = onVideoSaveForeground?.apply?.(this, arguments);
|
|
const v = app.nodeOutputs?.[this.id + ""];
|
|
if (!this.flags.collapsed && v) {
|
|
|
|
const text = v["counter"] + " cached frame(s)";
|
|
ctx.save();
|
|
|
|
// Set font for measuring text width and for drawing
|
|
ctx.font = "bold 14px sans-serif";
|
|
|
|
// Measure text to center it on the rectangle
|
|
const sz = ctx.measureText(text);
|
|
const textWidth = sz.width;
|
|
const textHeight = 14; // Approximation based on font size
|
|
|
|
// Rectangle dimensions and position
|
|
const rectWidth = textWidth + 20; // Padding around text
|
|
const rectHeight = textHeight + 10; // Padding around text
|
|
const rectX = (this.size[0] - rectWidth) / 2;
|
|
const rectY = LiteGraph.NODE_TITLE_HEIGHT - rectHeight / 2 - 15;
|
|
|
|
// Draw rectangle
|
|
ctx.fillStyle = "rgba(0, 0, 0, 0.8)"; // Semi-transparent dark rectangle
|
|
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
|
|
|
|
// Draw text centered in the rectangle
|
|
ctx.fillStyle = "white"; // White text color
|
|
const textX = (this.size[0] - textWidth) / 2;
|
|
const textY = LiteGraph.NODE_TITLE_HEIGHT + textHeight / 2 - 15; // Adjust based on the font size
|
|
|
|
ctx.fillText(text, textX, textY);
|
|
ctx.restore();
|
|
|
|
|
|
}
|
|
|
|
return r;
|
|
};
|
|
|
|
};
|
|
},
|
|
});
|
|
class FloatingConsole {
|
|
constructor() {
|
|
this.element = document.createElement('div');
|
|
this.element.id = 'floating-console';
|
|
this.titleBar = this.createTitleBar();
|
|
this.contentContainer = this.createContentContainer();
|
|
|
|
this.element.appendChild(this.titleBar);
|
|
this.element.appendChild(this.contentContainer);
|
|
|
|
document.body.appendChild(this.element);
|
|
|
|
this.dragging = false;
|
|
this.prevX = 0;
|
|
this.prevY = 0;
|
|
|
|
this.setupStyles();
|
|
this.addEventListeners();
|
|
this.addMenuButton();
|
|
}
|
|
|
|
setupStyles() {
|
|
Object.assign(this.element.style, {
|
|
position: 'fixed',
|
|
bottom: '10px',
|
|
right: '10px',
|
|
width: '300px',
|
|
maxHeight: '600px',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
color: 'white',
|
|
borderRadius: '5px',
|
|
zIndex: '1000',
|
|
display: 'none', // Consider starting visible for debugging
|
|
boxSizing: 'border-box',
|
|
overflow: 'hidden',
|
|
resize: 'both',
|
|
});
|
|
|
|
// Ensure the content container allows for scrolling overflow content
|
|
Object.assign(this.contentContainer.style, {
|
|
overflowY: 'auto',
|
|
maxHeight: '565px', // Adjust based on titleBar height to prevent overflow
|
|
});
|
|
this.adjustContentContainerSize();
|
|
}
|
|
adjustContentContainerSize() {
|
|
// Calculate available height for content container
|
|
const titleBarHeight = this.titleBar.offsetHeight;
|
|
const consoleHeight = this.element.offsetHeight;
|
|
const availableHeight = consoleHeight - titleBarHeight;
|
|
|
|
// Update content container's maxHeight to fill available space
|
|
this.contentContainer.style.maxHeight = `${availableHeight}px`;
|
|
}
|
|
createTitleBar() {
|
|
const bar = document.createElement('div');
|
|
bar.textContent = 'Console';
|
|
Object.assign(bar.style, {
|
|
padding: '5px',
|
|
cursor: 'move',
|
|
backgroundColor: '#333',
|
|
borderTopLeftRadius: '5px',
|
|
borderTopRightRadius: '5px',
|
|
userSelect: 'none',
|
|
});
|
|
return bar;
|
|
}
|
|
|
|
createContentContainer() {
|
|
const container = document.createElement('div');
|
|
return container;
|
|
}
|
|
|
|
addEventListeners() {
|
|
this.titleBar.addEventListener('mousedown', (e) => {
|
|
// Mark as dragging
|
|
this.dragging = true;
|
|
|
|
// Record the initial mouse position
|
|
this.prevX = e.clientX;
|
|
this.prevY = e.clientY;
|
|
|
|
if (!this.element.style.left || !this.element.style.top) {
|
|
// Calculate initial left and top based on current position
|
|
const rect = this.element.getBoundingClientRect();
|
|
this.element.style.right = ''; // Clear 'right' since we're switching to 'left/top' positioning
|
|
this.element.style.bottom = ''; // Clear 'bottom' as well
|
|
|
|
// Set initial left and top based on the element's current position
|
|
this.element.style.left = `${rect.left}px`;
|
|
this.element.style.top = `${rect.top}px`;
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!this.dragging) return;
|
|
const dx = e.clientX - this.prevX;
|
|
const dy = e.clientY - this.prevY;
|
|
|
|
const { style } = this.element;
|
|
style.left = `${parseInt(style.left || 0, 10) + dx}px`;
|
|
style.top = `${parseInt(style.top || 0, 10) + dy}px`;
|
|
|
|
this.prevX = e.clientX;
|
|
this.prevY = e.clientY;
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
this.dragging = false;
|
|
this.adjustContentContainerSize();
|
|
});
|
|
}
|
|
|
|
addMenuButton() {
|
|
const menu = document.querySelector(".comfy-menu");
|
|
// Create and append the toggle button for the floating console
|
|
const consoleToggleButton = document.createElement("button");
|
|
consoleToggleButton.textContent = "Toggle Console";
|
|
consoleToggleButton.onclick = () => {
|
|
// Check if the console is currently visible and toggle accordingly
|
|
if (floatingConsole.isVisible()) {
|
|
floatingConsole.hide();
|
|
consoleToggleButton.textContent = "Show Console"; // Update button text
|
|
} else {
|
|
floatingConsole.show();
|
|
consoleToggleButton.textContent = "Hide Console"; // Update button text
|
|
}
|
|
}
|
|
menu.append(consoleToggleButton);
|
|
}
|
|
|
|
show() {
|
|
this.element.style.display = 'block';
|
|
}
|
|
|
|
hide() {
|
|
this.element.style.display = 'none';
|
|
}
|
|
|
|
isVisible() {
|
|
return this.element.style.display !== 'none';
|
|
}
|
|
|
|
log(message) {
|
|
const msgElement = document.createElement('div');
|
|
msgElement.textContent = message;
|
|
this.contentContainer.appendChild(msgElement);
|
|
this.contentContainer.scrollTop = this.contentContainer.scrollHeight; // Auto-scroll to bottom
|
|
}
|
|
|
|
clear() {
|
|
this.contentContainer.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// Instantiate the floating console
|
|
const floatingConsole = new FloatingConsole();
|
|
|
|
// Extend the app plugin to handle console_output events
|
|
app.registerExtension({
|
|
name: "consoleOutput",
|
|
init() {
|
|
api.addEventListener('console_output', (event) => {
|
|
floatingConsole.log(event.detail.message);
|
|
});
|
|
}
|
|
});
|
|
|
|
|