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; previewNode.size[1] += 45; 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; // 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 previewWidget.imgEl.onload = () => { previewWidget.aspectRatio = previewWidget.imgEl.naturalWidth / previewWidget.imgEl.naturalHeight; fitHeight(this); }; // 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; } else if (w.name === "reset_latent") { const resetWidget = w; resetWidget.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); }); } });