import { app } from 'scripts/comfyAPI' const LiteGraph = window.LiteGraph const modelNodeType = { checkpoints: 'CheckpointLoaderSimple', clip: 'CLIPLoader', clip_vision: 'CLIPVisionLoader', controlnet: 'ControlNetLoader', diffusers: 'DiffusersLoader', embeddings: 'Embedding', gligen: 'GLIGENLoader', hypernetworks: 'HypernetworkLoader', photomaker: 'PhotoMakerLoader', loras: 'LoraLoader', style_models: 'StyleModelLoader', unet: 'UNETLoader', upscale_models: 'UpscaleModelLoader', vae: 'VAELoader', vae_approx: undefined, } export class ModelGrid { /** * @param {string} nodeType * @returns {int} */ static modelWidgetIndex(nodeType) { return nodeType === undefined ? -1 : 0 } /** * @param {string} text * @param {string} file * @param {boolean} removeExtension * @returns {string} */ static insertEmbeddingIntoText(text, file, removeExtension) { let name = file if (removeExtension) { name = SearchPath.splitExtension(name)[0] } const sep = text.length === 0 || text.slice(-1).match(/\s/) ? '' : ' ' return text + sep + '(embedding:' + name + ':1.0)' } /** * @param {Array} list * @param {string} searchString * @returns {Array} */ static #filter(list, searchString) { /** @type {string[]} */ const keywords = searchString //.replace("*", " ") // TODO: this is wrong for wildcards .split(/(-?".*?"|[^\s"]+)+/g) .map((item) => item .trim() .replace(/(?:")+/g, '') .toLowerCase(), ) .filter(Boolean) const regexSHA256 = /^[a-f0-9]{64}$/gi const fields = ['name', 'path'] return list.filter((element) => { const text = fields .reduce((memo, field) => memo + ' ' + element[field], '') .toLowerCase() return keywords.reduce((memo, target) => { const excludeTarget = target[0] === '-' if (excludeTarget && target.length === 1) { return memo } const filteredTarget = excludeTarget ? target.slice(1) : target if ( element['SHA256'] !== undefined && regexSHA256.test(filteredTarget) ) { return ( memo && excludeTarget !== (filteredTarget === element['SHA256']) ) } else { return memo && excludeTarget !== text.includes(filteredTarget) } }, true) }) } /** * In-place sort. Returns an array alias. * @param {Array} list * @param {string} sortBy * @param {bool} [reverse=false] * @returns {Array} */ static #sort(list, sortBy, reverse = false) { let compareFn = null switch (sortBy) { case MODEL_SORT_DATE_NAME: compareFn = (a, b) => { return a[MODEL_SORT_DATE_NAME].localeCompare(b[MODEL_SORT_DATE_NAME]) } break case MODEL_SORT_DATE_MODIFIED: compareFn = (a, b) => { return b[MODEL_SORT_DATE_MODIFIED] - a[MODEL_SORT_DATE_MODIFIED] } break case MODEL_SORT_DATE_CREATED: compareFn = (a, b) => { return b[MODEL_SORT_DATE_CREATED] - a[MODEL_SORT_DATE_CREATED] } break case MODEL_SORT_SIZE_BYTES: compareFn = (a, b) => { return b[MODEL_SORT_SIZE_BYTES] - a[MODEL_SORT_SIZE_BYTES] } break default: console.warn("Invalid filter sort value: '" + sortBy + "'") return list } const sorted = list.sort(compareFn) return reverse ? sorted.reverse() : sorted } /** * @param {Event} event * @param {string} modelType * @param {string} path * @param {boolean} removeEmbeddingExtension * @param {int} addOffset */ static #addModel( event, modelType, path, removeEmbeddingExtension, addOffset, ) { let success = false if (modelType !== 'embeddings') { const nodeType = modelNodeType[modelType] const widgetIndex = ModelGrid.modelWidgetIndex(nodeType) let node = LiteGraph.createNode(nodeType, null, []) if (widgetIndex !== -1 && node) { node.widgets[widgetIndex].value = path const selectedNodes = app.canvas.selected_nodes let isSelectedNode = false for (var i in selectedNodes) { const selectedNode = selectedNodes[i] node.pos[0] = selectedNode.pos[0] + addOffset node.pos[1] = selectedNode.pos[1] + addOffset isSelectedNode = true break } if (!isSelectedNode) { const graphMouse = app.canvas.graph_mouse node.pos[0] = graphMouse[0] node.pos[1] = graphMouse[1] } app.graph.add(node, { doProcessChange: true }) app.canvas.selectNode(node) success = true } event.stopPropagation() } else if (modelType === 'embeddings') { const [embeddingDirectory, embeddingFile] = SearchPath.split(path) const selectedNodes = app.canvas.selected_nodes for (var i in selectedNodes) { const selectedNode = selectedNodes[i] const nodeType = modelNodeType[modelType] const widgetIndex = ModelGrid.modelWidgetIndex(nodeType) const target = selectedNode?.widgets[widgetIndex]?.element if (target && target.type === 'textarea') { target.value = ModelGrid.insertEmbeddingIntoText( target.value, embeddingFile, removeEmbeddingExtension, ) success = true } } if (!success) { console.warn('Try selecting a node before adding the embedding.') } event.stopPropagation() } comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick') } static #getWidgetComboIndices(node, value) { const widgetIndices = [] node?.widgets?.forEach((widget, index) => { if (widget.type === 'combo' && widget.options.values?.includes(value)) { widgetIndices.push(index) } }) return widgetIndices } /** * @param {DragEvent} event * @param {string} modelType * @param {string} path * @param {boolean} removeEmbeddingExtension * @param {boolean} strictlyOnWidget */ static dragAddModel( event, modelType, path, removeEmbeddingExtension, strictlyOnWidget, ) { const target = document.elementFromPoint(event.clientX, event.clientY) if (modelType !== 'embeddings' && target.id === 'graph-canvas') { const pos = app.canvas.convertEventToCanvasOffset(event) const node = app.graph.getNodeOnPos( pos[0], pos[1], app.canvas.visible_nodes, ) let widgetIndex = -1 if (widgetIndex === -1) { const widgetIndices = this.#getWidgetComboIndices(node, path) if (widgetIndices.length === 0) { widgetIndex = -1 } else if (widgetIndices.length === 1) { widgetIndex = widgetIndices[0] if (strictlyOnWidget) { const draggedWidget = app.canvas.processNodeWidgets( node, pos, event, ) const widget = node.widgets[widgetIndex] if (draggedWidget != widget) { // != check NOT same object widgetIndex = -1 } } } else { // ambiguous widget (strictlyOnWidget always true) const draggedWidget = app.canvas.processNodeWidgets(node, pos, event) widgetIndex = widgetIndices.findIndex((index) => { return draggedWidget == node.widgets[index] // == check same object }) } } if (widgetIndex !== -1) { node.widgets[widgetIndex].value = path app.canvas.selectNode(node) } else { const expectedNodeType = modelNodeType[modelType] const newNode = LiteGraph.createNode(expectedNodeType, null, []) let newWidgetIndex = ModelGrid.modelWidgetIndex(expectedNodeType) if (newWidgetIndex === -1) { newWidgetIndex = this.#getWidgetComboIndices(newNode, path)[0] ?? -1 } if ( newNode !== undefined && newNode !== null && newWidgetIndex !== -1 ) { newNode.pos[0] = pos[0] newNode.pos[1] = pos[1] newNode.widgets[newWidgetIndex].value = path app.graph.add(newNode, { doProcessChange: true }) app.canvas.selectNode(newNode) } } event.stopPropagation() } else if (modelType === 'embeddings' && target.type === 'textarea') { const pos = app.canvas.convertEventToCanvasOffset(event) const nodeAtPos = app.graph.getNodeOnPos( pos[0], pos[1], app.canvas.visible_nodes, ) if (nodeAtPos) { app.canvas.selectNode(nodeAtPos) const [embeddingDirectory, embeddingFile] = SearchPath.split(path) target.value = ModelGrid.insertEmbeddingIntoText( target.value, embeddingFile, removeEmbeddingExtension, ) event.stopPropagation() } } } /** * @param {Event} event * @param {string} modelType * @param {string} path * @param {boolean} removeEmbeddingExtension */ static #copyModelToClipboard( event, modelType, path, removeEmbeddingExtension, ) { const nodeType = modelNodeType[modelType] let success = false if (nodeType === 'Embedding') { if (navigator.clipboard) { const [embeddingDirectory, embeddingFile] = SearchPath.split(path) const embeddingText = ModelGrid.insertEmbeddingIntoText( '', embeddingFile, removeEmbeddingExtension, ) navigator.clipboard.writeText(embeddingText) success = true } else { console.warn( 'Cannot copy the embedding to the system clipboard; Try dragging it instead.', ) } } else if (nodeType) { const node = LiteGraph.createNode(nodeType, null, []) const widgetIndex = ModelGrid.modelWidgetIndex(nodeType) if (widgetIndex !== -1) { node.widgets[widgetIndex].value = path app.canvas.copyToClipboard([node]) success = true } } else { console.warn(`Unable to copy unknown model type '${modelType}.`) } comfyButtonAlert(event.target, success, 'mdi-check-bold', 'mdi-close-thick') } /** * @param {Array} models * @param {string} modelType * @param {Object.} settingsElements * @param {String} searchSeparator * @param {String} systemSeparator * @param {(searchPath: string) => Promise} showModelInfo * @returns {HTMLElement[]} */ static #generateInnerHtml( models, modelType, settingsElements, searchSeparator, systemSeparator, showModelInfo, ) { // TODO: separate text and model logic; getting too messy // TODO: fallback on button failure to copy text? const canShowButtons = modelNodeType[modelType] !== undefined const showAddButton = canShowButtons && settingsElements['model-show-add-button'].checked const showCopyButton = canShowButtons && settingsElements['model-show-copy-button'].checked const showLoadWorkflowButton = canShowButtons && settingsElements['model-show-load-workflow-button'].checked const strictDragToAdd = settingsElements['model-add-drag-strict-on-field'].checked const addOffset = parseInt(settingsElements['model-add-offset'].value) const showModelExtension = settingsElements['model-show-label-extensions'].checked const modelInfoButtonOnLeft = !settingsElements['model-info-button-on-left'].checked const removeEmbeddingExtension = !settingsElements['model-add-embedding-extension'].checked const previewThumbnailFormat = settingsElements['model-preview-thumbnail-type'].value const previewThumbnailWidth = Math.round( settingsElements['model-preview-thumbnail-width'].value / 0.75, ) const previewThumbnailHeight = Math.round( settingsElements['model-preview-thumbnail-height'].value / 0.75, ) const buttonsOnlyOnHover = settingsElements['model-buttons-only-on-hover'].checked if (models.length > 0) { const $overlay = IS_FIREFOX ? (modelType, path, removeEmbeddingExtension, strictDragToAdd) => { return $el('div.model-preview-overlay', { ondragstart: (e) => { const data = { modelType: modelType, path: path, removeEmbeddingExtension: removeEmbeddingExtension, strictDragToAdd: strictDragToAdd, } e.dataTransfer.setData('manager-model', JSON.stringify(data)) e.dataTransfer.setData('text/plain', '') }, draggable: true, }) } : (modelType, path, removeEmbeddingExtension, strictDragToAdd) => { return $el('div.model-preview-overlay', { ondragend: (e) => ModelGrid.dragAddModel( e, modelType, path, removeEmbeddingExtension, strictDragToAdd, ), draggable: true, }) } const forHiddingButtonsClass = buttonsOnlyOnHover ? 'model-buttons-hidden' : 'model-buttons-visible' return models.map((item) => { const previewInfo = item.preview const previewThumbnail = $el('img.model-preview', { loading: 'lazy' /* `loading` BEFORE `src`; Known bug in Firefox 124.0.2 and Safari for iOS 17.4.1 (https://stackoverflow.com/a/76252772) */, src: imageUri( previewInfo?.path, previewInfo?.dateModified, previewThumbnailWidth, previewThumbnailHeight, previewThumbnailFormat, ), draggable: false, }) const searchPath = item.path const path = SearchPath.systemPath( searchPath, searchSeparator, systemSeparator, ) let actionButtons = [] if (showCopyButton) { actionButtons.push( new ComfyButton({ icon: 'content-copy', tooltip: 'Copy model to clipboard', classList: 'comfyui-button icon-button model-button', action: (e) => ModelGrid.#copyModelToClipboard( e, modelType, path, removeEmbeddingExtension, ), }).element, ) } if ( showAddButton && !(modelType === 'embeddings' && !navigator.clipboard) ) { actionButtons.push( new ComfyButton({ icon: 'plus-box-outline', tooltip: 'Add model to node grid', classList: 'comfyui-button icon-button model-button', action: (e) => ModelGrid.#addModel( e, modelType, path, removeEmbeddingExtension, addOffset, ), }).element, ) } if (showLoadWorkflowButton) { actionButtons.push( new ComfyButton({ icon: 'arrow-bottom-left-bold-box-outline', tooltip: 'Load preview workflow', classList: 'comfyui-button icon-button model-button', action: async (e) => { const urlString = previewThumbnail.src const url = new URL(urlString) const urlSearchParams = url.searchParams const uri = urlSearchParams.get('uri') const v = urlSearchParams.get('v') const urlFull = urlString.substring(0, urlString.indexOf('?')) + '?uri=' + uri + '&v=' + v await loadWorkflow(urlFull) }, }).element, ) } const infoButtons = [ new ComfyButton({ icon: 'information-outline', tooltip: 'View model information', classList: 'comfyui-button icon-button model-button', action: async () => { await showModelInfo(searchPath) }, }).element, ] return $el('div.item', {}, [ previewThumbnail, $overlay(modelType, path, removeEmbeddingExtension, strictDragToAdd), $el( 'div.model-preview-top-right.' + forHiddingButtonsClass, { draggable: false, }, modelInfoButtonOnLeft ? infoButtons : actionButtons, ), $el( 'div.model-preview-top-left.' + forHiddingButtonsClass, { draggable: false, }, modelInfoButtonOnLeft ? actionButtons : infoButtons, ), $el( 'div.model-label', { draggable: false, }, [ $el('p', [ showModelExtension ? item.name : SearchPath.splitExtension(item.name)[0], ]), ], ), ]) }) } else { return [$el('h2', ['No Models'])] } } /** * @param {HTMLDivElement} modelGrid * @param {ModelData} modelData * @param {HTMLSelectElement} modelSelect * @param {Object.<{value: string}>} previousModelType * @param {Object} settings * @param {string} sortBy * @param {boolean} reverseSort * @param {Array} previousModelFilters * @param {HTMLInputElement} modelFilter * @param {(searchPath: string) => Promise} showModelInfo */ static update( modelGrid, modelData, modelSelect, previousModelType, settings, sortBy, reverseSort, previousModelFilters, modelFilter, showModelInfo, ) { const models = modelData.models let modelType = modelSelect.value if (models[modelType] === undefined) { modelType = settings['model-default-browser-model-type'].value } if (models[modelType] === undefined) { modelType = 'checkpoints' // panic fallback } if (modelType !== previousModelType.value) { if (settings['model-persistent-search'].checked) { previousModelFilters.splice(0, previousModelFilters.length) // TODO: make sure this actually worked! } else { // cache previous filter text previousModelFilters[previousModelType.value] = modelFilter.value // read cached filter text modelFilter.value = previousModelFilters[modelType] ?? '' } previousModelType.value = modelType } let modelTypeOptions = [] for (const [key, value] of Object.entries(models)) { const el = $el('option', [key]) modelTypeOptions.push(el) } modelSelect.innerHTML = '' modelTypeOptions.forEach((option) => modelSelect.add(option)) modelSelect.value = modelType const searchAppend = settings['model-search-always-append'].value const searchText = modelFilter.value + ' ' + searchAppend const modelList = ModelGrid.#filter(models[modelType], searchText) ModelGrid.#sort(modelList, sortBy, reverseSort) modelGrid.innerHTML = '' const modelGridModels = ModelGrid.#generateInnerHtml( modelList, modelType, settings, modelData.searchSeparator, modelData.systemSeparator, showModelInfo, ) modelGrid.append.apply(modelGrid, modelGridModels) } }