Files
Azornes-Comfyui-Model-Resolver/web/resolver/shell/lifecycle_graph_methods.js
T
Dariusz L 5a8e68655f refactor: rename Model Linker to Model Resolver
Replace all link dialog terminology with resolver terminology
throughout the codebase, including module names, CSS classes,
IDs, JS variables, SVG gradient IDs, and log module names.
Remove the old web/linker/ tree and introduce the new web/resolver/
tree with parallel structure. Also replace core/linker.py with
core/resolver.py.
2026-06-05 05:53:39 +02:00

454 lines
18 KiB
JavaScript

import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
export const lifecycleGraphMethods = {
async show(workflow = null) {
this.undockToFloating({ persist: false });
await this.showContent(workflow, { restoreFullscreen: true });
},
async showDocked(container, workflow = null) {
this.dockTo(container);
await this.showContent(workflow, { restoreFullscreen: false });
},
async showContent(workflow = null, { restoreFullscreen = true } = {}) {
this.backdrop.style.display = "none";
this.element.style.display = "flex";
if (!this.docked) {
this.scheduleModalViewportClamp();
}
// Update button state in case there are active downloads
this.updateDownloadAllButtonState();
// Ensure all models are loaded for dropdown
await this.ensureCapabilitiesLoaded();
await this.ensureAllModelsLoaded();
await this.ensureDownloadDirectoriesLoaded();
// Restore fullscreen state if enabled
try {
const fs = localStorage.getItem('model_resolver_modal_fullscreen');
if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
} catch (e) { }
// Attach drag handle event listener (only once)
this.attachDragHandleIfNeeded();
this.activeTab = this.restoreActiveTab();
this.updateTabButtonStates();
this.updateQueueVisibility();
this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow());
if (this.activeTab === 'missing') {
await this.loadWorkflowData(workflow);
} else if (this.activeTab === 'loaded') {
this.switchTab('loaded', { force: true });
} else {
this.switchTab('options', { force: true });
}
},
// Attach topbar drag listeners while preserving the handle-based viewport clamp.
attachDragHandleIfNeeded() {
if (this._dragHandleAttached) return;
const handle = document.getElementById('model-resolver-drag-handle');
const topbar = this.element?.querySelector('.mr-dialog-topbar');
if (!handle || !topbar) return;
const onMouseDown = (e) => {
if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode
if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return;
topbar.classList.add('mr-is-dragging');
this.startDrag(e);
};
const onMouseUp = () => { topbar.classList.remove('mr-is-dragging'); };
topbar.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
this._dragHandleAttached = true;
},
isTopbarDragExcluded(target) {
if (!(target instanceof Element)) return false;
return !!target.closest([
'.mr-tabs',
'.mr-dialog-controls',
'button',
'a',
'input',
'select',
'textarea',
'[role="button"]',
'[data-ml-no-drag]'
].join(','));
},
close({ collapseSidebar = true } = {}) {
const wasDocked = this.docked;
const dockContainer = this.dockContainer;
this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating');
this._hidePreview?.();
this.hideTooltip();
this.backdrop.style.display = "none";
this.element.style.display = "none";
if (wasDocked && collapseSidebar) {
this.closeComfySidebar(dockContainer);
}
},
/**
* Load workflow data and display missing models
*/
async loadWorkflowData(workflow = null) {
if (!this.contentElement) return;
// Show loading state
try {
// Use provided workflow, or get current workflow from ComfyUI
if (!workflow) {
workflow = this.getCurrentWorkflow();
}
if (!workflow) {
this._analysisProgressToken = null;
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
return;
}
this.syncWorkflowScopedQueue(workflow);
const workflowSignature = this.getWorkflowSignature(workflow);
if (
workflowSignature &&
this.cachedWorkflowSignature === workflowSignature &&
this.cachedAnalysisData
) {
this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
this.reconnectActiveDownloads();
return;
}
const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._analysisProgressToken = analysisId;
this.contentElement.innerHTML = this.renderAnalysisProgress({
status: 'starting',
message: 'Starting analysis...',
current: 0,
total: 0
});
// Call analyze endpoint
const progressPromise = this.pollAnalysisProgress(analysisId, analysisId);
const response = await api.fetchApi('/model_resolver/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow, analysis_id: analysisId })
});
this._analysisProgressToken = null;
await progressPromise;
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
this.cachedWorkflowSignature = workflowSignature;
this.cachedAnalysisData = data;
this.displayMissingModels(this.contentElement, data);
// Reconnect any active downloads to their new progress divs
this.reconnectActiveDownloads();
} catch (error) {
this._analysisProgressToken = null;
console.error('Model Resolver: Error loading workflow data:', error);
if (this.contentElement) {
this.contentElement.innerHTML = `<p class="mr-error-text">Error: ${error.message}</p>`;
}
}
},
/**
* Get current workflow from ComfyUI
*/
getCurrentWorkflow() {
// Try to get workflow from app
if (app?.graph) {
try {
// Use ComfyUI's workflow serialization
const workflow = app.graph.serialize();
return workflow;
} catch (e) {
console.warn('Model Resolver: Could not serialize workflow from graph:', e);
}
}
return null;
},
/**
* Locate and focus a node in the ComfyUI canvas
*/
locateNodeInGraph(nodeId) {
try {
if (!app?.graph) {
this.showNotification('Cannot locate node - graph not available', 'error');
return;
}
// Find the node in the graph
const node = app.graph.getNodeById(nodeId);
if (!node) {
this.showNotification(`Node #${nodeId} not found in graph`, 'error');
return;
}
let locatedWithAnimation = false;
// Focus on the node in the canvas with animated panning when possible
if (app.canvas?.ds && app.canvas?.canvas) {
locatedWithAnimation = this.animateCanvasToNode(node);
}
if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) {
// Alternative method for older versions
const canvasNode = app.graph._nodes.get(nodeId);
if (canvasNode && canvasNode.setSelected && canvasNode.graph) {
canvasNode.setSelected(true);
// Scroll to node
app.canvas.scrollToNode(canvasNode);
}
} else if (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') {
// Alternative for other versions
app.ui.nodeGraph.scrollToNode(node);
} else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') {
// Final fallback for versions where animation path is unavailable
app.canvas.centerOnNode(node);
}
// Also try to flash/select the node
// Deselect all nodes first
if (app.graph && app.graph.nodes) {
app.graph.nodes.forEach(n => {
if (n.selected) n.selected = false;
});
}
// Select and highlight our node
node.selected = true;
this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info');
} catch (e) {
console.error('Model Resolver: Error locating node:', e);
this.showNotification('Error locating node: ' + e.message, 'error');
}
},
animateCanvasToNode(node) {
const canvas = app?.canvas;
const ds = canvas?.ds;
const htmlCanvas = canvas?.canvas;
if (!canvas || !ds || !htmlCanvas) return false;
const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0];
const centerX = rect[0] + (rect[2] || 0) / 2;
const centerY = rect[1] + (rect[3] || 0) / 2;
const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1;
const locateViewport = this.getLocateViewport(htmlCanvas);
const viewportWidth = locateViewport.width;
const viewportHeight = locateViewport.height;
if (!viewportWidth || !viewportHeight) return false;
const nodeWidth = Math.max(rect[2] || 0, 1);
const nodeHeight = Math.max(rect[3] || 0, 1);
const paddingX = 36;
const paddingY = 32;
const maxLocateScale = 2.5;
const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2));
const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2));
const targetScale = Math.max(0.15, Math.min(maxLocateScale, fitScaleX, fitScaleY));
const getCenteredOffset = (scale) => {
return {
x: -centerX + (locateViewport.centerX / scale),
y: -centerY + (locateViewport.centerY / scale)
};
};
const startOffset = {
x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x,
y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y
};
const targetOffset = getCenteredOffset(targetScale);
if (this._locateAnimationFrame) {
cancelAnimationFrame(this._locateAnimationFrame);
this._locateAnimationFrame = null;
}
const durationMs = 380;
const startTime = performance.now();
const easeInOutCubic = (t) => t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
const tick = (now) => {
const progress = Math.min(1, (now - startTime) / durationMs);
const eased = easeInOutCubic(progress);
ds.scale = startScale + ((targetScale - startScale) * eased);
ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased);
ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased);
canvas.setDirty?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
if (progress < 1) {
this._locateAnimationFrame = requestAnimationFrame(tick);
} else {
this._locateAnimationFrame = null;
}
};
this._locateAnimationFrame = requestAnimationFrame(tick);
return true;
},
getLocateViewport(htmlCanvas) {
const canvasRect = htmlCanvas.getBoundingClientRect?.();
const dpr = window.devicePixelRatio || 1;
const canvasWidth = canvasRect?.width || htmlCanvas.clientWidth || ((htmlCanvas.width || 0) / dpr);
const canvasHeight = canvasRect?.height || htmlCanvas.clientHeight || ((htmlCanvas.height || 0) / dpr);
const viewport = {
left: 0,
top: 0,
right: canvasWidth,
bottom: canvasHeight
};
if (this.docked && canvasRect?.width && canvasRect?.height) {
const obstructionElement = this.dockContainer instanceof HTMLElement
? this.dockContainer
: this.element;
const obstructionRect = obstructionElement?.getBoundingClientRect?.();
if (obstructionRect?.width && obstructionRect?.height) {
this.subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect);
}
}
const width = Math.max(1, viewport.right - viewport.left);
const height = Math.max(1, viewport.bottom - viewport.top);
return {
width,
height,
centerX: viewport.left + (width / 2),
centerY: viewport.top + (height / 2)
};
},
subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect) {
const obstruction = {
left: Math.max(0, obstructionRect.left - canvasRect.left),
top: Math.max(0, obstructionRect.top - canvasRect.top),
right: Math.min(canvasRect.width, obstructionRect.right - canvasRect.left),
bottom: Math.min(canvasRect.height, obstructionRect.bottom - canvasRect.top)
};
const overlapWidth = obstruction.right - obstruction.left;
const overlapHeight = obstruction.bottom - obstruction.top;
if (overlapWidth <= 0 || overlapHeight <= 0) return;
const coversMostHeight = overlapHeight >= canvasRect.height * 0.5;
const coversMostWidth = overlapWidth >= canvasRect.width * 0.5;
const edgeTolerance = 2;
if (coversMostHeight) {
if (obstruction.left <= edgeTolerance) {
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
return;
}
if (obstruction.right >= canvasRect.width - edgeTolerance) {
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
return;
}
const leftSpace = obstruction.left - viewport.left;
const rightSpace = viewport.right - obstruction.right;
if (rightSpace >= leftSpace) {
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
} else {
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
}
return;
}
if (coversMostWidth) {
if (obstruction.top <= edgeTolerance) {
viewport.top = Math.min(viewport.bottom, Math.max(viewport.top, obstruction.bottom));
return;
}
if (obstruction.bottom >= canvasRect.height - edgeTolerance) {
viewport.bottom = Math.max(viewport.top, Math.min(viewport.bottom, obstruction.top));
}
}
},
/**
* Reconnect active downloads to their new progress div elements after UI refresh
*/
reconnectActiveDownloads() {
if (!this.contentElement) return;
for (const [downloadId, info] of Object.entries(this.activeDownloads)) {
const { missing } = info;
if (!missing) continue;
// Find the new progress div by ID
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
const newProgressDiv = this.contentElement.querySelector(`#${progressId}`);
const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`);
if (newProgressDiv) {
// Update the reference
info.progressDiv = newProgressDiv;
info.downloadBtn = newDownloadBtn;
// Show that download is in progress
newProgressDiv.classList.remove('mr-is-hidden');
newProgressDiv.classList.add('mr-is-visible');
newProgressDiv.innerHTML = this.renderProgressWithAction({
percent: 0,
leftText: '<span class="mr-info-accent-text">Downloading...</span>',
rightText: '',
actionClass: 'cancel-download-btn mr-btn mr-btn-danger mr-btn-sm',
actionText: 'Cancel',
actionDataAttr: `data-download-id="${downloadId}"`
});
// Attach cancel handler
const cancelBtn = newProgressDiv.querySelector('.cancel-download-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
}
// Update download button if exists
if (newDownloadBtn) {
newDownloadBtn.disabled = true;
newDownloadBtn.textContent = 'Downloading...';
}
}
}
},
getMissingModelKey(missing = {}) {
const nodeId = missing.node_id ?? '';
const widgetIndex = missing.widget_index ?? '';
const subgraphId = missing.subgraph_id || '';
const scope = missing.is_top_level !== false ? 'T' : 'F';
return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`;
}
};