/** * File: mtb_widgets.js * Project: comfy_mtb * Author: Mel Massadian * * Copyright (c) 2023 Mel Massadian * */ /// // TODO: Use the builtin addDOMWidget everywhere appropriate import { app } from '../../scripts/app.js' import { api } from '../../scripts/api.js' import * as mtb_ui from './mtb_ui.js' import parseCss from './extern/parse-css.js' import * as shared from './comfy_shared.js' import { infoLogger } from './comfy_shared.js' import { NumberInputWidget } from './numberInput.js' // NOTE: new widget types registered by MTB Widgets const newTypes = [/*'BOOL'*/ , 'COLOR', 'BBOX'] const deprecated_nodes = { // 'Animation Builder': // 'Kept to avoid breaking older script but replaced by TimeEngine', } const withFont = (ctx, font, cb) => { const oldFont = ctx.font ctx.font = font cb() ctx.font = oldFont } const calculateTextDimensions = (ctx, value, width, fontSize = 16) => { const words = value.split(' ') const lines = [] let currentLine = '' for (const word of words) { const testLine = currentLine.length === 0 ? word : `${currentLine} ${word}` const testWidth = ctx.measureText(testLine).width if (testWidth > width) { lines.push(currentLine) currentLine = word } else { currentLine = testLine } } if (lines.length === 0) lines.push(value) const textHeight = (lines.length + 1) * fontSize const maxLineWidth = lines.reduce( (maxWidth, line) => Math.max(maxWidth, ctx.measureText(line).width), 0, ) return { textHeight, maxLineWidth } } export function addMultilineWidget(node, name, opts, callback) { const inputEl = document.createElement('textarea') inputEl.className = 'comfy-multiline-input' inputEl.value = opts.defaultVal inputEl.placeholder = opts.placeholder || name const widget = node.addDOMWidget(name, 'textmultiline', inputEl, { getValue() { return inputEl.value }, setValue(v) { inputEl.value = v }, }) widget.inputEl = inputEl inputEl.addEventListener('input', () => { callback?.(widget.value) widget.callback?.(widget.value) }) widget.onRemove = () => { inputEl.remove() } return { minWidth: 400, minHeight: 200, widget } } export const VECTOR_AXIS = { 0: 'x', 1: 'y', 2: 'z', 3: 'w', } export function addVectorWidgetW( node, name, value, vector_size, _callback, app, ) { // const inputEl = document.createElement('div') // const vecEl = document.createElement('div') // // inputEl.style.background = 'red' // // inputEl.className = 'comfy-vector-container' // vecEl.className = 'comfy-vector-input' // // vecEl.style.display = 'flex' // inputEl.appendChild(vecEl) const inputs = [] for (let i = 0; i < vector_size; i++) { // const input = document.createElement('input') // input.type = 'number' // input.value = value[VECTOR_AXIS[i]] const input = node.addWidget( 'number', `${name}_${VECTOR_AXIS[i]}`, value[VECTOR_AXIS[i]], (val) => {}, ) inputs.push(input) // vecEl.appendChild(input) } // // const widget = node.addDOMWidget(name, 'vector', inputEl, { // getValue() { // return JSON.stringify(widget._value) // }, // setValue(v) { // widget._value = v // }, // afterResize(node, widget) { // console.log('After resize', { that: this, node, widget }) // }, // }) // // console.log('prev callback', widget.callback) // widget.callback = callback // widget._value = value // // for (let i = 0; i < vector_size; i++) { // const input = inputs[i] // input.addEventListener('change', (event) => { // widget._value[VECTOR_AXIS[i]] = Number.parseFloat(event.target.value) // widget.callback?.(widget._value) // node.graph._version++ // node.setDirtyCanvas(true, true) // }) // } // // document.body.append(inputEl) // // widget.inputEl = inputEl // widget.vecEl = vecEl // // inputEl.addEventListener('input', () => { // widget.callback?.(widget.value) // }) // return { minWidth: 400, minHeight: 200, widget } } export function addVectorWidget(node, name, value, vector_size, callback, app) { const inputEl = document.createElement('div') const vecEl = document.createElement('div') inputEl.className = 'comfy-vector-container' vecEl.className = 'comfy-vector-input' vecEl.id = 'vecEl' vecEl.style.display = 'flex' vecEl.style.flexDirection = 'column' inputEl.appendChild(vecEl) const inputs = [] // // for (let i = 0; i < vector_size; i++) { // const input = document.createElement('input') // input.type = 'number' // input.value = value[VECTOR_AXIS[i]] // inputs.push(input) // vecEl.appendChild(input) // } const widget = node.addDOMWidget(name, 'vector', inputEl, { getValue() { return JSON.stringify(widget._value) }, setValue(v) { widget._value = v }, }) const vec = new NumberInputWidget('vecEl', vector_size, true) vec.setValue(...Object.values(value)) vec.onChange = (value) => { for (let i = 0; i < value.length; i++) { const val = value[i] widget._value[VECTOR_AXIS[i]] = Number.parseFloat(val) } widget.callback?.(widget._value) // widget._value[VECTOR_AXIS[index]] = Number.parseFloat(value) } console.log('prev callback', widget.callback) widget.callback = callback widget._value = value // for (let i = 0; i < vector_size; i++) { // const input = inputs[i] // input.addEventListener('change', (event) => { // widget._value[VECTOR_AXIS[i]] = Number.parseFloat(event.target.value) // widget.callback?.(widget._value) // node.graph._version++ // node.setDirtyCanvas(true, true) // }) // } widget.inputEl = inputEl widget.vecEl = vecEl widget.vec = vec return { minWidth: 400, minHeight: 200 * vector_size, widget } } export const MtbWidgets = { //TODO: complete this properly /** * Creates a vector widget. * @param {string} key - The key for the widget. * @param {number[]} [val] - The initial value for the widget. * @param {number} size - The size of the vector. * @returns {VectorWidget} The vector widget. */ VECTOR: (key, val, size) => { shared.infoLogger('Adding VECTOR widget', { key, val, size }) /** @type {VectorWidget} */ const widget = { name: key, type: `vector${size}`, y: 0, options: { default: Array.from({ length: size }, () => 0.0) }, _value: val || Array.from({ length: size }, () => 0.0), draw: (ctx, node, width, widgetY, height) => { ctx.textAlign = 'left' ctx.strokeStyle = outline_color ctx.fillStyle = background_color 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 (!w.disabled) ctx.stroke() ctx.fillStyle = text_color if (!w.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() } ctx.fillStyle = secondary_text_color ctx.fillText(w.label || w.name, margin * 2 + 5, y + H * 0.7) ctx.fillStyle = text_color ctx.textAlign = 'right' if (w.type === 'number') { ctx.fillText( Number(w.value).toFixed( w.options.precision !== undefined ? w.options.precision : 3, ), widget_width - margin * 2 - 20, y + H * 0.7, ) } else { let v = w.value if (w.options.values) { let values = w.options.values if (values.constructor === Function) values = values() if (values && values.constructor !== Array) v = values[w.value] } ctx.fillText(v, widget_width - margin * 2 - 20, y + H * 0.7) } } }, get value() { return this._value }, set value(val) { this._value = val this.callback?.(this._value) }, } return widget }, BBOX: (key, val) => { /** @type {import("./types/litegraph").IWidget} */ const widget = { name: key, type: 'BBOX', // options: val, y: 0, value: val?.default || [0, 0, 0, 0], options: {}, draw: function (ctx, _node, widget_width, widgetY, _height) { const hide = this.type !== 'BBOX' && app.canvas.ds.scale > 0.5 const show_text = true const outline_color = LiteGraph.WIDGET_OUTLINE_COLOR const background_color = LiteGraph.WIDGET_BGCOLOR const text_color = LiteGraph.WIDGET_TEXT_COLOR const secondary_text_color = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR const H = LiteGraph.NODE_WIDGET_HEIGHT const margin = 15 const numWidgets = 4 // Number of stacked widgets if (hide) return for (let i = 0; i < numWidgets; i++) { const currentY = widgetY + i * (H + margin) // Adjust Y position for each widget ctx.textAlign = 'left' ctx.strokeStyle = outline_color ctx.fillStyle = background_color ctx.beginPath() if (show_text) ctx.roundRect(margin, currentY, widget_width - margin * 2, H, [ H * 0.5, ]) else ctx.rect(margin, currentY, widget_width - margin * 2, H) ctx.fill() if (show_text) { if (!this.disabled) ctx.stroke() ctx.fillStyle = text_color if (!this.disabled) { ctx.beginPath() ctx.moveTo(margin + 16, currentY + 5) ctx.lineTo(margin + 6, currentY + H * 0.5) ctx.lineTo(margin + 16, currentY + H - 5) ctx.fill() ctx.beginPath() ctx.moveTo(widget_width - margin - 16, currentY + 5) ctx.lineTo(widget_width - margin - 6, currentY + H * 0.5) ctx.lineTo(widget_width - margin - 16, currentY + H - 5) ctx.fill() } ctx.fillStyle = secondary_text_color ctx.fillText( this.label || this.name, margin * 2 + 5, currentY + H * 0.7, ) ctx.fillStyle = text_color ctx.textAlign = 'right' ctx.fillText( Number(this.value).toFixed( this.options?.precision !== undefined ? this.options.precision : 3, ), widget_width - margin * 2 - 20, currentY + H * 0.7, ) } } }, mouse: function (event, pos, node) { let old_value = this.value let x = pos[0] - node.pos[0] let y = pos[1] - node.pos[1] let width = node.size[0] let H = LiteGraph.NODE_WIDGET_HEIGHT let margin = 5 let numWidgets = 4 // Number of stacked widgets for (let i = 0; i < numWidgets; i++) { let currentY = y + i * (H + margin) // Adjust Y position for each widget if ( event.type == LiteGraph.pointerevents_method + 'move' && this.type == 'BBOX' ) { if (event.deltaX) this.value += event.deltaX * 0.1 * (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 == LiteGraph.pointerevents_method + 'down') { let values = this.options?.values if (values && values.constructor === Function) { values = this.options.values(w, node) } let values_list = null let delta = x < 40 ? -1 : x > widget_width - 40 ? 1 : 0 if (this.type == 'BBOX') { this.value += delta * 0.1 * (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 (delta) { //clicked in arrow, used for combos let index = -1 this.last_mouseclick = 0 //avoids dobl click event if (values.constructor === Object) index = values_list.indexOf(String(this.value)) + delta else index = values_list.indexOf(this.value) + delta if (index >= values_list.length) { index = values_list.length - 1 } if (index < 0) { index = 0 } if (values.constructor === Array) this.value = values[index] else this.value = index } } //end mousedown else if ( event.type == LiteGraph.pointerevents_method + 'up' && this.type == 'BBOX' ) { let delta = x < 40 ? -1 : x > widget_width - 40 ? 1 : 0 if (event.click_time < 200 && delta == 0) { this.prompt( 'Value', this.value, function (v) { // check if v is a valid equation or a number if (/^[0-9+\-*/()\s]+|\d+\.\d+$/.test(v)) { try { //solve the equation if possible v = eval(v) } catch (e) {} } this.value = Number(v) shared.inner_value_change(this, this.value, event) }.bind(w), event, ) } } if (old_value != this.value) setTimeout( function () { shared.inner_value_change(this, this.value, event) }.bind(this), 20, ) app.canvas.setDirty(true) } }, computeSize: function (width) { return [width, LiteGraph.NODE_WIDGET_HEIGHT * 4] }, // onDrawBackground: function (ctx) { // if (!this.flags.collapsed) return; // this.inputEl.style.display = "block"; // this.inputEl.style.top = this.graphcanvas.offsetTop + this.pos[1] + "px"; // this.inputEl.style.left = this.graphcanvas.offsetLeft + this.pos[0] + "px"; // }, // onInputChange: function (e) { // const property = e.target.dataset.property; // const bbox = this.getInputData(0); // if (!bbox) return; // bbox[property] = parseFloat(e.target.value); // this.setOutputData(0, bbox); // } } widget.desc = 'Represents a Bounding Box with x, y, width, and height.' return widget }, COLOR: (key, val, compute = false) => { /** @type {import("/types/litegraph").IWidget} */ const widget = {} widget.y = 0 widget.name = key widget.type = 'COLOR' widget.options = { default: '#ff0000' } widget.value = val || '#ff0000' widget.draw = function (ctx, node, widgetWidth, widgetY, height) { const hide = this.type !== 'COLOR' && app.canvas.ds.scale > 0.5 if (hide) { return } const border = 3 ctx.fillStyle = '#000' ctx.fillRect(0, widgetY, widgetWidth, height) ctx.fillStyle = this.value ctx.fillRect( border, widgetY + border, widgetWidth - border * 2, height - border * 2, ) const color = parseCss(this.value.default || this.value) if (!color) { return } ctx.fillStyle = shared.isColorBright(color.values, 125) ? '#000' : '#fff' ctx.font = '14px Arial' ctx.textAlign = 'center' ctx.fillText(this.name, widgetWidth * 0.5, widgetY + 14) } widget.mouse = function (e, pos, node) { if (e.type === 'pointerdown') { const widgets = node.widgets.filter((w) => w.type === 'COLOR') for (const w of widgets) { // color picker const rect = [w.last_y, w.last_y + 32] if (pos[1] > rect[0] && pos[1] < rect[1]) { const picker = document.createElement('input') picker.type = 'color' picker.value = this.value picker.style.position = 'absolute' picker.style.left = '999999px' //(window.innerWidth / 2) + "px"; picker.style.top = '999999px' //(window.innerHeight / 2) + "px"; document.body.appendChild(picker) picker.addEventListener('change', () => { this.value = picker.value this.callback?.(this.value) node.graph._version++ node.setDirtyCanvas(true, true) picker.remove() }) picker.click() } } } } widget.computeSize = function (width) { return [width, 32] } return widget }, DEBUG_IMG: (name, val) => { const w = { name, type: 'image', value: val, draw: function (ctx, node, widgetWidth, widgetY, height) { const [cw, ch] = this.computeSize(widgetWidth) shared.offsetDOMWidget(this, ctx, node, widgetWidth, widgetY, ch) }, computeSize: function (width) { const ratio = this.inputRatio || 1 if (width) { return [width, width / ratio + 4] } return [128, 128] }, onRemoved: function () { if (this.inputEl) { this.inputEl.remove() } }, } w.inputEl = document.createElement('img') w.inputEl.src = w.value w.inputEl.onload = function () { w.inputRatio = w.inputEl.naturalWidth / w.inputEl.naturalHeight } document.body.appendChild(w.inputEl) return w }, DEBUG_STRING: (name, val) => { const fontSize = 16 const w = { name, type: 'debug_text', draw: function (ctx, node, widgetWidth, widgetY, height) { // const [cw, ch] = this.computeSize(widgetWidth) shared.offsetDOMWidget(this, ctx, node, widgetWidth, widgetY, height) }, computeSize(width) { if (!this.value) { return [32, 32] } if (!width) { console.debug(`No width ${this.parent.size}`) } let dimensions withFont(app.ctx, `${fontSize}px monospace`, () => { dimensions = calculateTextDimensions(app.ctx, this.value, width) }) const widgetWidth = Math.max( width || this.width || 32, dimensions.maxLineWidth, ) const widgetHeight = dimensions.textHeight * 1.5 return [widgetWidth, widgetHeight] }, onRemoved: function () { if (this.inputEl) { this.inputEl.remove() } }, get value() { return this.inputEl.innerHTML }, set value(val) { this.inputEl.innerHTML = val this.parent?.setSize?.(this.parent?.computeSize()) }, } w.inputEl = document.createElement('p') w.inputEl.style = ` text-align: center; font-size: ${fontSize}px; color: var(--input-text); line-height: 1em; font-family: monospace; ` w.value = val document.body.appendChild(w.inputEl) return w }, } /** * @returns {import("./types/comfy").ComfyExtension} extension */ const mtb_widgets = { name: 'mtb.widgets', init: async () => { infoLogger('Registering mtb.widgets') try { const res = await api.fetchApi('/mtb/debug') const msg = await res.json() if (!window.MTB) { window.MTB = {} } window.MTB.DEBUG = msg.enabled } catch (e) { console.error('Error:', e) } }, setup: () => { app.ui.settings.addSetting({ id: 'mtb.Main.debug-enabled', category: ['mtb', 'Main', 'debug-enabled'], name: 'Enable Debug (py and js)', type: 'boolean', defaultValue: false, tooltip: 'This will enable debug messages in the console and in the python console respectively, no need to restart the server, but do reload the webui', attrs: { style: { // fontFamily: 'monospace', }, }, async onChange(value) { if (!window.MTB) { window.MTB = {} } window.MTB.DEBUG = value if (value) { infoLogger('Enabled DEBUG mode') } await api .fetchApi('/mtb/debug', { method: 'POST', body: JSON.stringify({ enabled: value, }), }) .then((_response) => {}) .catch((error) => { console.error('Error:', error) }) }, }) }, getCustomWidgets: () => { return { // BOOL: (node, inputName, inputData, _app) => { // console.debug('Registering bool') // // return { // widget: node.addCustomWidget( // MtbWidgets.BOOL(inputName, inputData[1]?.default || false), // ), // minWidth: 150, // minHeight: 30, // } // }, COLOR: (node, inputName, inputData, _app) => { console.debug('Registering color') return { widget: node.addCustomWidget( MtbWidgets.COLOR(inputName, inputData[1]?.default || '#ff0000'), ), minWidth: 150, minHeight: 30, } }, // BBOX: (node, inputName, inputData, app) => { // console.debug("Registering bbox") // return { // widget: node.addCustomWidget(MtbWidgets.BBOX(inputName, inputData[1]?.default || [0, 0, 0, 0])), // minWidth: 150, // minHeight: 30, // } // } } }, /** * @param {NodeType} nodeType * @param {NodeData} nodeData * @param {import("./types/comfy").App} app */ async beforeRegisterNodeDef(nodeType, nodeData, app) { // const rinputs = nodeData.input?.required let has_custom = false if (nodeData.input?.required) { for (const i of Object.keys(nodeData.input.required)) { const input_type = nodeData.input.required[i][0] if (newTypes.includes(input_type)) { has_custom = true break } } } if (has_custom) { //- Add widgets on node creation const onNodeCreated = nodeType.prototype.onNodeCreated nodeType.prototype.onNodeCreated = function (...args) { const r = onNodeCreated ? onNodeCreated.apply(this, args) : undefined this.serialize_widgets = true this.setSize?.(this.computeSize()) this.onRemoved = function () { // When removing this node we need to remove the input from the DOM shared.cleanupNode(this) } return r } //- Extra menus const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions nodeType.prototype.getExtraMenuOptions = function (_, options) { const r = origGetExtraMenuOptions ? origGetExtraMenuOptions.apply(this, arguments) : undefined if (this.widgets) { const toInput = [] const toWidget = [] for (const w of this.widgets) { if (w.type === shared.CONVERTED_TYPE) { //- This is already handled by widgetinputs.js // toWidget.push({ // content: `Convert ${w.name} to widget`, // callback: () => shared.convertToWidget(this, w), // }); } else if (newTypes.includes(w.type)) { const config = nodeData?.input?.required[w.name] || nodeData?.input?.optional?.[w.name] || [w.type, w.options || {}] toInput.push({ content: `Convert ${w.name} to input`, callback: () => shared.convertToInput(this, w, config), }) } } if (toInput.length) { options.push(...toInput, null) } if (toWidget.length) { options.push(...toWidget, null) } } return r } } if (!nodeData.name.endsWith('(mtb)')) { return } // console.log('MTB Node', { description: nodeData.description, nodeType }) shared.addDocumentation(nodeData, nodeType) const deprecation = deprecated_nodes[nodeData.name.replace(' (mtb)', '')] if (deprecation) { shared.addDeprecation(nodeType, deprecation) } //- Extending Python Nodes switch (nodeData.name) { //TODO: remove this non sense case 'Get Batch From History (mtb)': case 'Get Batch From History V2 (mtb)': { const onNodeCreated = nodeType.prototype.onNodeCreated nodeType.prototype.onNodeCreated = function () { const r = onNodeCreated ? onNodeCreated.apply(this, []) : undefined const internal_count = this.widgets.find( (w) => w.name === 'internal_count', ) shared.hideWidgetForGood(this, internal_count) internal_count.afterQueued = function () { this.value++ } return r } const onExecuted = nodeType.prototype.onExecuted nodeType.prototype.onExecuted = function (message) { const r = onExecuted ? onExecuted.apply(this, message) : undefined return r } break } case 'Save Gif (mtb)': case 'Save Animated Image (mtb)': { const onExecuted = nodeType.prototype.onExecuted nodeType.prototype.onExecuted = function (message) { const prefix = 'anything_' const r = onExecuted ? onExecuted.apply(this, message) : undefined if (this.widgets) { const pos = this.widgets.findIndex((w) => w.name === `${prefix}_0`) if (pos !== -1) { for (let i = pos; i < this.widgets.length; i++) { this.widgets[i].onRemoved?.() } this.widgets.length = pos } let imgURLs = [] if (message) { if (message.gif) { imgURLs = imgURLs.concat( message.gif.map((params) => { return api.apiURL( `/view?${new URLSearchParams(params).toString()}`, ) }), ) } if (message.apng) { imgURLs = imgURLs.concat( message.apng.map((params) => { return api.apiURL( `/view?${new URLSearchParams(params).toString()}`, ) }), ) } let i = 0 for (const img of imgURLs) { const w = this.addCustomWidget( MtbWidgets.DEBUG_IMG(`${prefix}_${i}`, img), ) w.parent = this i++ } } const onRemoved = this.onRemoved this.onRemoved = () => { shared.cleanupNode(this) return onRemoved?.() } } this.setSize?.(this.computeSize()) return r } break } case 'Animation Builder (mtb)': { const onNodeCreated = nodeType.prototype.onNodeCreated nodeType.prototype.onNodeCreated = function (...args) { const r = onNodeCreated ? onNodeCreated.apply(this, args) : undefined this.changeMode(LiteGraph.ALWAYS) const { raw_iteration, raw_loop, total_frames, loop_count } = shared.getNamedWidget( this, 'raw_iteration', 'raw_loop', 'total_frames', 'loop_count', ) shared.hideWidgetForGood(this, raw_iteration) shared.hideWidgetForGood(this, raw_loop) raw_iteration._value = 0 // const value_preview = this.addCustomWidget( // MtbWidgets.DEBUG_STRING('value_preview', 'Idle'), // ) const dom_value_preview = mtb_ui.makeElement('p', { fontWeigth: '700', textAlign: 'center', fontSize: '1.5em', margin: 0, }) const value_preview = this.addDOMWidget( 'value_preview', 'DISPLAY', dom_value_preview, { hideOnZoom: false, setValue: (val) => { if (val) { value_preview.element.innerHTML = val } }, }, ) value_preview.value = 'Idle' const dom_loop_preview = mtb_ui.makeElement('p', { textAlign: 'center', margin: 0, }) const loop_preview = this.addDOMWidget( 'loop_preview', 'DISPLAY', dom_loop_preview, { hideOnZoom: false, setValue: (val) => { if (val) { dom_loop_preview.innerHTML = val } }, getValue: () => { dom_loop_preview.innerHTML }, }, ) loop_preview.value = 'Iteration: Idle' const onReset = () => { raw_iteration.value = 0 raw_loop.value = 0 value_preview.value = 'Idle' loop_preview.value = 'Iteration: Idle' app.canvas.setDirty(true) } // reset button this.addWidget('button', 'Reset', 'reset', onReset) // run button this.addWidget('button', 'Queue', 'queue', () => { onReset() // this could maybe be a setting or checkbox app.queuePrompt(0, total_frames.value * loop_count.value) window.MTB?.notify?.( `Started a queue of ${total_frames.value} frames (for ${ loop_count.value } loop, so ${total_frames.value * loop_count.value})`, 5000, ) }) this.onRemoved = () => { shared.cleanupNode(this) app.canvas.setDirty(true) } raw_iteration.afterQueued = function () { this.value++ raw_loop.value = Math.floor(this.value / total_frames.value) value_preview.value = `frame: ${ raw_iteration.value % total_frames.value } / ${total_frames.value - 1}` if (raw_loop.value + 1 > loop_count.value) { loop_preview.value = 'Done 😎!' } else { loop_preview.value = `current loop: ${raw_loop.value + 1}/${ loop_count.value }` } } return r } break } case 'Interpolate Clip Sequential (mtb)': { const onNodeCreated = nodeType.prototype.onNodeCreated nodeType.prototype.onNodeCreated = function (...args) { const r = onNodeCreated ? onNodeCreated.apply(this, ...args) : undefined const addReplacement = () => { const input = this.addInput( `replacement_${this.widgets.length}`, 'STRING', '', ) console.log(input) this.addWidget('STRING', `replacement_${this.widgets.length}`, '') } //- add this.addWidget('button', '+', 'add', (value, widget, node) => { console.log('Button clicked', value, widget, node) addReplacement() }) //- remove this.addWidget('button', '-', 'remove', (value, widget, node) => { console.log(`Button clicked: ${value}`, widget, node) }) return r } break } case 'Styles Loader (mtb)': { const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions nodeType.prototype.getExtraMenuOptions = function (_, options) { const r = origGetExtraMenuOptions ? origGetExtraMenuOptions.apply(this, arguments) : undefined const getStyle = async (node) => { try { const getStyles = await api.fetchApi('/mtb/actions', { method: 'POST', body: JSON.stringify({ name: 'getStyles', args: node.widgets?.[0].value ? node.widgets[0].value : '', }), }) const output = await getStyles.json() return output?.result } catch (e) { console.error(e) } } const extracters = [ { content: 'Extract Positive to Text node', callback: async () => { const style = await getStyle(this) if (style && style.length >= 1) { if (style[0]) { window.MTB?.notify?.( `Extracted positive from ${this.widgets[0].value}`, ) // const tn = LiteGraph.createNode('Text box') const tn = LiteGraph.createNode('CLIPTextEncode') app.graph.add(tn) tn.title = `${this.widgets[0].value} (Positive)` tn.widgets[0].value = style[0] } else { window.MTB?.notify?.( `No positive to extract for ${this.widgets[0].value}`, ) } } }, }, { content: 'Extract Negative to Text node', callback: async () => { const style = await getStyle(this) if (style && style.length >= 2) { if (style[1]) { window.MTB?.notify?.( `Extracted negative from ${this.widgets[0].value}`, ) const tn = LiteGraph.createNode('CLIPTextEncode') app.graph.add(tn) tn.title = `${this.widgets[0].value} (Negative)` tn.widgets[0].value = style[1] } else { window.MTB.notify( `No negative to extract for ${this.widgets[0].value}`, ) } } }, }, ] options.push(...extracters) } break } //NOTE: dynamic nodes case 'Apply Text Template (mtb)': { shared.setupDynamicConnections(nodeType, 'var', '*') break } case 'Save Data Bundle (mtb)': { shared.setupDynamicConnections(nodeType, 'data', '*') // [MASK,IMAGE] break } case 'Add To Playlist (mtb)': { shared.setupDynamicConnections(nodeType, 'video', 'VIDEO') break } case 'Interpolate Condition (mtb)': { shared.setupDynamicConnections(nodeType, 'condition', 'CONDITIONING') break } case 'Psd Save (mtb)': { shared.setupDynamicConnections(nodeType, 'input_', 'PSDLAYER') break } // case 'Text Encode Frames (mtb)' : { // shared.setupDynamicConnections(nodeType, 'input_', 'IMAGE') // break // } case 'Stack Images (mtb)': case 'Concat Images (mtb)': { shared.setupDynamicConnections(nodeType, 'image', 'IMAGE') break } case 'Audio Sequence (mtb)': case 'Audio Stack (mtb)': { shared.setupDynamicConnections(nodeType, 'audio', 'AUDIO') break } case 'Batch Float Assemble (mtb)': case 'Batch Float Math (mtb)': case 'Plot Batch Float (mtb)': { shared.setupDynamicConnections(nodeType, 'floats', 'FLOATS') break } case 'Batch Merge (mtb)': { shared.setupDynamicConnections(nodeType, 'batches', 'IMAGE') break } // TODO: remove this, recommend pythongoss's version that is much better case 'Math Expression (mtb)': { const onNodeCreated = nodeType.prototype.onNodeCreated nodeType.prototype.onNodeCreated = function () { const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined this.addInput('x', '*') return r } const onConnectionsChange = nodeType.prototype.onConnectionsChange nodeType.prototype.onConnectionsChange = function ( _type, index, connected, link_info, ) { const r = onConnectionsChange ? onConnectionsChange.apply(this, arguments) : undefined shared.dynamic_connection(this, index, connected, 'var_', '*', { nameArray: ['x', 'y', 'z'], }) //- infer type if (link_info) { const fromNode = this.graph._nodes.find( (otherNode) => otherNode.id !== link_info.origin_id, ) const type = fromNode.outputs[link_info.origin_slot].type this.inputs[index].type = type // this.inputs[index].label = type.toLowerCase() } //- restore dynamic input if (!connected) { this.inputs[index].type = '*' this.inputs[index].label = `number_${index + 1}` } } break } case 'Batch Shape (mtb)': case 'Mask To Image (mtb)': case 'Text To Image (mtb)': { shared.addMenuHandler(nodeType, function (_app, options) { /** @type {ContextMenuItem} */ const item = { content: 'swap colors', title: 'Swap BG/FG Color ⚡', callback: (_menuItem) => { const color_w = this.widgets.find((w) => w.name === 'color') const bg_w = this.widgets.find( (w) => w.name === 'background' || w.name === 'bg_color', ) const color = color_w.value const bg = bg_w.value color_w.value = bg bg_w.value = color }, } options.push(item) return [item] }) break } case 'Save Tensors (mtb)': { const onDrawBackground = nodeType.prototype.onDrawBackground nodeType.prototype.onDrawBackground = function (ctx, canvas) { const r = onDrawBackground ? onDrawBackground.apply(this, arguments) : undefined // // draw a circle on the top right of the node, with text inside // ctx.fillStyle = "#fff"; // ctx.beginPath(); // ctx.arc(this.size[0] - this.node_width * 0.5, this.size[1] - this.node_height * 0.5, this.node_width * 0.5, 0, Math.PI * 2); // ctx.fill(); // ctx.fillStyle = "#000"; // ctx.textAlign = "center"; // ctx.font = "bold 12px Arial"; // ctx.fillText("Save Tensors", this.size[0] - this.node_width * 0.5, this.size[1] - this.node_height * 0.5); return r } break } default: { break } } }, } app.registerExtension(mtb_widgets)