import { app } from "../../scripts/app.js"; import { createModuleLogger } from "./utils/LoggerUtils.js"; import { loadIcons } from "./utils/IconUtils.js"; import { tooltips, presetCategories } from "./utils/ResolutionMasterConfig.js"; import { DialogManager } from "./DialogManager.js"; import { SearchableDropdown } from "./SearchableDropdown.js"; import { AspectRatioSelector } from "./AspectRatioSelector.js"; import { CustomPresetsManager } from "./utils/CustomPresetsManager.js"; import { PresetManagerDialog } from "./utils/PresetManagerDialog.js"; const log = createModuleLogger('ResolutionMaster'); class ResolutionMasterCanvas { constructor(node) { this.node = node; this.node.properties = this.node.properties || {}; this.initializeProperties(); this.node.intpos = { x: 0.5, y: 0.5 }; this.node.capture = false; this.node.configured = false; this._isInitializing = true; // Flag to prevent setDirtyCanvas during init this._pendingCanvasUpdate = false; this.hoverElement = null; this.scrollOffset = 0; this.dropdownOpen = null; this.collapsedSections = {}; this.dialogManager = new DialogManager(this); this.searchableDropdown = new SearchableDropdown(); this.aspectRatioSelector = new AspectRatioSelector(); this.customPresetsManager = new CustomPresetsManager(this); this.presetManagerDialog = new PresetManagerDialog(this.customPresetsManager); this.tooltipElement = null; this.tooltipTimer = null; this.tooltipDelay = 500; this.showTooltip = false; this.tooltipMousePos = null; this.detectedDimensions = null; this.dimensionCheckInterval = null; this.manuallySetByAutoFit = false; this.controls = {}; this.resolutions = ['144p', '240p', '360p', '480p', '720p', '820p', '1080p', '1440p', '2160p', '4320p']; this.icons = {}; loadIcons(this.icons); this.tooltips = tooltips; this.presetCategories = presetCategories; this.setupNode(); import('./css-loader.js').then(module => { module.loadStylesWhenNeeded(); }).catch(error => { log.error('Failed to load CSS:', error); }); // Mark initialization complete after a short delay to allow ComfyUI to finish setup requestAnimationFrame(() => { this._isInitializing = false; }); } /** * Safely request a canvas update - prevents multiple rapid updates during initialization * and graph configuration that can cause ComfyUI freezing issues */ requestCanvasUpdate(force = false) { // Skip updates during initialization unless forced if (this._isInitializing && !force) { this._pendingCanvasUpdate = true; return; } // Ensure graph exists and is ready if (!app.graph) { this._pendingCanvasUpdate = true; return; } // Use requestAnimationFrame to batch updates and avoid conflicts if (!this._canvasUpdateScheduled) { this._canvasUpdateScheduled = true; requestAnimationFrame(() => { this._canvasUpdateScheduled = false; this._pendingCanvasUpdate = false; if (app.graph) { app.graph.setDirtyCanvas(true); } }); } } ensureMinimumSize() { if (this.node.size[0] < 330) { this.node.size[0] = 330; } const neededHeight = this.calculateNeededHeight(); if (neededHeight > 0) { this.node.size[1] = Math.max(neededHeight, this.node.min_size[1]); } else { if (this.node.size[1] < this.node.min_size[1]) { this.node.size[1] = this.node.min_size[1]; } } } calculateNeededHeight() { const props = this.node.properties; if (!props || props.mode !== "Manual") return 0; let currentY = LiteGraph.NODE_TITLE_HEIGHT + 2; const spacing = 8; const canvasHeight = 200; currentY += canvasHeight + spacing; currentY += 15 + spacing; const sectionHeights = { actions: this.collapsedSections?.actions ? 25 : 55, scaling: this.collapsedSections?.scaling ? 25 : 130, autoDetect: this.collapsedSections?.autoDetect ? 25 : 125, presets: this.collapsedSections?.presets ? 25 : 90 }; Object.values(sectionHeights).forEach(height => { currentY += height + spacing; }); if (props.useCustomCalc && props.selectedCategory) { currentY += 40; } return currentY + 20; } initializeProperties() { const defaultProperties = { mode: "Manual", valueX: 512, valueY: 512, batch_size: 1, canvas_min_x: 0, canvas_max_x: 2048, canvas_step_x: 64, canvas_min_y: 0, canvas_max_y: 2048, canvas_step_y: 64, canvas_decimals_x: 0, canvas_decimals_y: 0, canvas_snap: true, canvas_dots: true, canvas_frame: true, action_slider_snap_min: 16, action_slider_snap_max: 256, action_slider_snap_step: 16, scaling_slider_min: 0.1, scaling_slider_max: 4.0, scaling_slider_step: 0.1, megapixels_slider_min: 0.5, megapixels_slider_max: 6.0, megapixels_slider_step: 0.1, snapValue: 64, upscaleValue: 1.0, targetResolution: 1080, targetMegapixels: 2.0, rescaleMode: "resolution", rescaleValue: 1.0, autoDetect: false, autoFitOnChange: false, autoResizeOnChange: false, selectedCategory: "Standard", selectedPreset: null, useCustomCalc: false, manual_slider_min_w: 64, manual_slider_max_w: 2048, manual_slider_step_w: 64, manual_slider_min_h: 64, manual_slider_max_h: 2048, manual_slider_step_h: 64, section_actions_collapsed: false, section_scaling_collapsed: false, section_autoDetect_collapsed: false, section_presets_collapsed: false, dropdown_resolution_expanded: false, dropdown_category_expanded: false, dropdown_preset_expanded: false, preset_selector_mode: 'visual', customPresetsJSON: '', }; Object.entries(defaultProperties).forEach(([key, defaultValue]) => { this.node.properties[key] = this.node.properties[key] ?? defaultValue; }); } setupNode() { const node = this.node; const self = this; node.size = [330, 400]; node.min_size = [330, 200]; if (node.outputs) { node.outputs.forEach(output => { output.name = output.localized_name = ""; }); } const widthWidget = node.widgets?.find(w => w.name === 'width'); const heightWidget = node.widgets?.find(w => w.name === 'height'); const modeWidget = node.widgets?.find(w => w.name === 'mode'); const latentTypeWidget = node.widgets?.find(w => w.name === 'latent_type'); const autoDetectWidget = node.widgets?.find(w => w.name === 'auto_detect'); const rescaleModeWidget = node.widgets?.find(w => w.name === 'rescale_mode'); const rescaleValueWidget = node.widgets?.find(w => w.name === 'rescale_value'); const batchSizeWidget = node.widgets?.find(w => w.name === 'batch_size'); if (rescaleModeWidget) { rescaleModeWidget.value = node.properties.rescaleMode; } if (rescaleValueWidget) { rescaleValueWidget.value = node.properties.rescaleValue; } if (widthWidget && heightWidget) { node.properties.valueX = widthWidget.value; node.properties.valueY = heightWidget.value; node.intpos.x = (widthWidget.value - node.properties.canvas_min_x) / (node.properties.canvas_max_x - node.properties.canvas_min_x); node.intpos.y = (heightWidget.value - node.properties.canvas_min_y) / (node.properties.canvas_max_y - node.properties.canvas_min_y); } if (batchSizeWidget) { node.properties.batch_size = batchSizeWidget.value; } this.widthWidget = widthWidget; this.heightWidget = heightWidget; this.latentTypeWidget = latentTypeWidget; this.rescaleModeWidget = rescaleModeWidget; this.rescaleValueWidget = rescaleValueWidget; this.batchSizeWidget = batchSizeWidget; // Latent type is now manually controlled via LAT selector - no automatic initialization based on category node.onDrawForeground = function(ctx) { if (this.flags.collapsed) return; self.ensureMinimumSize(); self.drawInterface(ctx); }; node.onMouseDown = function(e, pos, canvas) { if (e.canvasY - this.pos[1] < 0) return false; return self.handleMouseDown(e, pos, canvas); }; node.onMouseMove = function(e, pos, canvas) { if (!this.capture) { self.handleMouseHover(e, pos, canvas); return false; } return self.handleMouseMove(e, pos, canvas); }; node.onMouseUp = function(e) { if (!this.capture) return false; return self.handleMouseUp(e); }; node.onPropertyChanged = function(property) { self.handlePropertyChange(property); }; node.onResize = function() { self.ensureMinimumSize(); app.graph.setDirtyCanvas(true); }; const origOnRemoved = node.onRemoved; node.onRemoved = function() { if (self.dimensionCheckInterval) { clearInterval(self.dimensionCheckInterval); self.dimensionCheckInterval = null; } if (self.tooltipTimer) { clearTimeout(self.tooltipTimer); self.tooltipTimer = null; } if (self.dialogManager.customInputDialog) { self.dialogManager.closeCustomInputDialog(); } if (origOnRemoved) origOnRemoved.apply(this, arguments); }; node.onGraphConfigured = function() { this.configured = true; // Defer initialization to next frame to avoid interfering with ComfyUI's graph setup requestAnimationFrame(() => { if (!this.graph) return; // Node was removed self.customPresetsManager.loadCustomPresets(); log.debug('Reloaded custom presets after graph configured'); self.collapsedSections = { actions: this.properties.section_actions_collapsed, scaling: this.properties.section_scaling_collapsed, autoDetect: this.properties.section_autoDetect_collapsed, presets: this.properties.section_presets_collapsed }; // Update internal position from saved properties self.updateCanvasFromWidgets(); self.updateRescaleValue(); // Start auto-detect after everything is initialized if (this.properties.autoDetect) { self.startAutoDetect(); } }); }; [widthWidget, heightWidget, modeWidget, latentTypeWidget, autoDetectWidget, rescaleModeWidget, rescaleValueWidget, batchSizeWidget].forEach(widget => { if (widget) { widget.hidden = true; widget.type = "hidden"; widget.computeSize = () => [0, -4]; } }); } drawInterface(ctx) { const node = this.node; const props = node.properties; const margin = 10; const spacing = 8; let currentY = LiteGraph.NODE_TITLE_HEIGHT + 2; if (props.mode === "Manual") { this.controls = {}; const collapsibleSection = (title, sectionKey, drawContent) => { const contentHeight = drawContent(ctx, currentY + 25, true); const sectionInfo = this.drawCollapsibleSection(ctx, title, sectionKey, margin, currentY, node.size[0] - margin * 2, contentHeight); if (!sectionInfo.isCollapsed) { drawContent(ctx, sectionInfo.contentStartY, false); } currentY += sectionInfo.totalHeight + spacing; }; const canvasHeight = 200; this.draw2DCanvas(ctx, margin, currentY, node.size[0] - margin * 2, canvasHeight); currentY += canvasHeight + spacing; this.drawInfoText(ctx, currentY); currentY += 15 + spacing; collapsibleSection("Actions", "actions", (ctx, y, preview) => { if (!preview) this.drawPrimaryControls(ctx, y); return 30; }); collapsibleSection("Scaling", "scaling", (ctx, y, preview) => { if (!preview) return this.drawScalingGrid(ctx, y); return 105; }); collapsibleSection("Auto-Detect", "autoDetect", (ctx, y, preview) => { if (!preview) return this.drawAutoDetectSection(ctx, y); return 100; }); collapsibleSection("Presets", "presets", (ctx, y, preview) => { if (!preview) return this.drawPresetSection(ctx, y); return 30; }); if (props.useCustomCalc && props.selectedCategory) { const messageHeight = this.drawInfoMessage(ctx, currentY); if (messageHeight > 0) { currentY += messageHeight + spacing; } } this.drawOutputValues(ctx); } else if (props.mode === "Manual Sliders") { this.drawSliderMode(ctx, currentY); } const neededHeight = currentY + 20; const heightDiff = Math.abs(node.size[1] - neededHeight); if (heightDiff > 1) { node.size[1] = Math.max(neededHeight, node.min_size[1]); } if (this.showTooltip && this.tooltipElement && this.tooltips[this.tooltipElement]) { this.drawTooltip(ctx); } } drawSection(ctx, title, x, y, w, h) { ctx.fillStyle = "rgba(0,0,0,0.2)"; ctx.strokeStyle = "rgba(255,255,255,0.1)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, w, h, 6); ctx.fill(); ctx.stroke(); ctx.fillStyle = "#ccc"; ctx.font = "bold 12px Arial"; ctx.textAlign = "center"; ctx.fillText(title, x + w / 2, y + 10); } drawCollapsibleSection(ctx, title, sectionKey, x, y, w, contentHeight) { const isCollapsed = this.collapsedSections[sectionKey] || false; const headerHeight = 25; const totalHeight = isCollapsed ? headerHeight : headerHeight + contentHeight; ctx.fillStyle = "rgba(0,0,0,0.2)"; ctx.strokeStyle = "rgba(255,255,255,0.1)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, w, totalHeight, 6); ctx.fill(); ctx.stroke(); const headerControl = `${sectionKey}Header`; this.controls[headerControl] = { x, y, w, h: headerHeight }; if (this.hoverElement === headerControl) { ctx.fillStyle = "rgba(255,255,255,0.1)"; ctx.beginPath(); ctx.roundRect(x, y, w, headerHeight, 6); ctx.fill(); } const arrow = isCollapsed ? "▶" : "▼"; const titleText = `${arrow} ${title}`; ctx.fillStyle = this.hoverElement === headerControl ? "#fff" : "#ccc"; ctx.font = "bold 12px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(titleText, x + w / 2, y + headerHeight / 2); return { totalHeight, isCollapsed, contentStartY: y + headerHeight }; } drawOutputValues(ctx) { const node = this.node; const props = node.properties; ctx.font = "bold 14px Arial"; ctx.textAlign = "right"; ctx.textBaseline = "middle"; if (this.widthWidget && this.heightWidget && this.batchSizeWidget) { const y_offset_1 = 5 + (LiteGraph.NODE_SLOT_HEIGHT * 0.5); const y_offset_2 = 5 + (LiteGraph.NODE_SLOT_HEIGHT * 1.5); const y_offset_3 = 5 + (LiteGraph.NODE_SLOT_HEIGHT * 2.5); const y_offset_4 = 5 + (LiteGraph.NODE_SLOT_HEIGHT * 3.5); const valueAreaWidth = 60; const batchSizeAreaWidth = 35; const valueAreaHeight = 20; const valueAreaX = node.size[0] - valueAreaWidth - 5; const batchSizeAreaX = node.size[0] - batchSizeAreaWidth - 5; this.controls.widthValueArea = { x: valueAreaX, y: y_offset_1 - valueAreaHeight/2, w: valueAreaWidth, h: valueAreaHeight }; this.drawValueAreaHoverBackground(ctx, 'widthValueArea', valueAreaX, y_offset_1 - valueAreaHeight/2, valueAreaWidth, valueAreaHeight, [136, 153, 255]); ctx.fillStyle = this.hoverElement === 'widthValueArea' ? "#89F" : "#89F"; ctx.fillText(this.widthWidget.value.toString(), node.size[0] - 20, y_offset_1); this.controls.heightValueArea = { x: valueAreaX, y: y_offset_2 - valueAreaHeight/2, w: valueAreaWidth, h: valueAreaHeight }; this.drawValueAreaHoverBackground(ctx, 'heightValueArea', valueAreaX, y_offset_2 - valueAreaHeight/2, valueAreaWidth, valueAreaHeight, [248, 136, 153]); ctx.fillStyle = this.hoverElement === 'heightValueArea' ? "#F89" : "#F89"; ctx.fillText(this.heightWidget.value.toString(), node.size[0] - 20, y_offset_2); ctx.fillStyle = "#9F8"; ctx.fillText(props.rescaleValue.toFixed(2), node.size[0] - 20, y_offset_3); this.controls.batchSizeValueArea = { x: batchSizeAreaX, y: y_offset_4 - valueAreaHeight/2, w: batchSizeAreaWidth, h: valueAreaHeight }; this.drawValueAreaHoverBackground(ctx, 'batchSizeValueArea', batchSizeAreaX, y_offset_4 - valueAreaHeight/2, batchSizeAreaWidth, valueAreaHeight, [255, 136, 187]); ctx.fillStyle = this.hoverElement === 'batchSizeValueArea' ? "#FAB" : "#F8B"; ctx.fillText(this.batchSizeWidget.value.toString(), node.size[0] - 20, y_offset_4); const y_offset_5 = 5 + (LiteGraph.NODE_SLOT_HEIGHT * 4.5); // Create clickable area for LAT selector const latAreaWidth = 50; const latAreaHeight = 28; const latAreaX = node.size[0] - latAreaWidth - 5; this.controls.latValueArea = { x: latAreaX, y: y_offset_5 - 10, w: latAreaWidth, h: latAreaHeight }; this.drawValueAreaHoverBackground(ctx, 'latValueArea', latAreaX, y_offset_5 - 10, latAreaWidth, latAreaHeight, [248, 136, 187]); ctx.fillStyle = this.hoverElement === 'latValueArea' ? "#FAB" : "#F8B"; ctx.font = "bold 12px Arial"; ctx.textAlign = "right"; ctx.fillText("LAT", node.size[0] - 20, y_offset_5); // Draw latent type info in smaller gray font below LAT if (this.latentTypeWidget) { const latentType = this.latentTypeWidget.value || 'latent_4x8'; const shortType = String(latentType).replace('latent_', ''); ctx.fillStyle = this.hoverElement === 'latValueArea' ? "#999" : "#777"; ctx.font = "9px Arial"; ctx.textAlign = "right"; ctx.fillText(shortType, node.size[0] - 20, y_offset_5 + 12); } } } drawPrimaryControls(ctx, y) { const node = this.node; const props = node.properties; const margin = 20; const buttonWidth = 70; const gap = 5; let x = margin; this.controls.swapBtn = { x, y, w: buttonWidth, h: 28 }; this.drawButton(ctx, x, y, buttonWidth, 28, this.icons.swap, this.hoverElement === 'swapBtn', false, "Swap", true); x += buttonWidth + gap; this.controls.snapBtn = { x, y, w: buttonWidth, h: 28 }; this.drawButton(ctx, x, y, buttonWidth, 28, this.icons.snap, this.hoverElement === 'snapBtn', false, "Snap", true); x += buttonWidth + gap; const sliderX = x; const valueWidth = 35; const sliderWidth = node.size[0] - sliderX - valueWidth - margin; this.controls.snapSlider = { x: sliderX, y, w: sliderWidth, h: 28 }; this.drawSlider(ctx, sliderX, y, sliderWidth, 28, props.snapValue, props.action_slider_snap_min, props.action_slider_snap_max, props.action_slider_snap_step); const snapValueX = sliderX + sliderWidth + gap; this.controls.snapValueArea = { x: snapValueX, y, w: valueWidth, h: 28 }; this.drawValueAreaHoverBackground(ctx, 'snapValueArea', snapValueX, y, valueWidth, 28, [100, 150, 255]); ctx.fillStyle = this.hoverElement === 'snapValueArea' ? "#5af" : "#ccc"; ctx.font = "12px Arial"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(props.snapValue.toString(), snapValueX + 10, y + 14); } draw2DCanvas(ctx, x, y, w, h) { const node = this.node; const props = node.properties; this.controls.canvas2d = { x, y, w, h }; const rangeX = props.canvas_max_x - props.canvas_min_x; const rangeY = props.canvas_max_y - props.canvas_min_y; const aspectRatio = rangeX / rangeY; let canvasW = w - 20; let canvasH = h - 20; if (aspectRatio > canvasW / canvasH) { canvasH = canvasW / aspectRatio; } else { canvasW = canvasH * aspectRatio; } const offsetX = x + (w - canvasW) / 2; const offsetY = y + (h - canvasH) / 2; this.controls.canvas2d = { x: offsetX, y: offsetY, w: canvasW, h: canvasH }; ctx.fillStyle = "rgba(20,20,20,0.8)"; ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(offsetX - 4, offsetY - 4, canvasW + 8, canvasH + 8, 6); ctx.fill(); ctx.stroke(); if (props.canvas_dots) { ctx.fillStyle = "rgba(200,200,200,0.5)"; ctx.beginPath(); let stX = canvasW * props.canvas_step_x / rangeX; let stY = canvasH * props.canvas_step_y / rangeY; for (let ix = stX; ix < canvasW; ix += stX) { for (let iy = stY; iy < canvasH; iy += stY) { ctx.rect(offsetX + ix - 0.5, offsetY + iy - 0.5, 1, 1); } } ctx.fill(); } if (props.canvas_frame) { ctx.fillStyle = "rgba(150,150,250,0.1)"; ctx.strokeStyle = "rgba(150,150,250,0.7)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.rect(offsetX, offsetY + canvasH * (1 - node.intpos.y), canvasW * node.intpos.x, canvasH * node.intpos.y); ctx.fill(); ctx.stroke(); } const knobX = offsetX + canvasW * node.intpos.x; const knobY = offsetY + canvasH * (1 - node.intpos.y); const rightEdgeX = offsetX + canvasW * node.intpos.x; const rightEdgeY = offsetY + canvasH * (1 - node.intpos.y / 2); const topEdgeX = offsetX + canvasW * node.intpos.x / 2; const topEdgeY = offsetY + canvasH * (1 - node.intpos.y); this.controls.canvas2dRightHandle = { x: rightEdgeX - 10, y: rightEdgeY - 10, w: 20, h: 20 }; this.controls.canvas2dTopHandle = { x: topEdgeX - 10, y: topEdgeY - 10, w: 20, h: 20 }; ctx.fillStyle = "#FFF"; ctx.strokeStyle = "#000"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(knobX, knobY, 8, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); const isHoveringRight = this.hoverElement === 'canvas2dRightHandle'; ctx.fillStyle = isHoveringRight ? "#5AF" : "#89F"; ctx.strokeStyle = isHoveringRight ? "#FFF" : "#000"; ctx.lineWidth = isHoveringRight ? 3 : 2; ctx.beginPath(); ctx.arc(rightEdgeX, rightEdgeY, isHoveringRight ? 7 : 6, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); const isHoveringTop = this.hoverElement === 'canvas2dTopHandle'; ctx.fillStyle = isHoveringTop ? "#FAB" : "#F89"; ctx.strokeStyle = isHoveringTop ? "#FFF" : "#000"; ctx.lineWidth = isHoveringTop ? 3 : 2; ctx.beginPath(); ctx.arc(topEdgeX, topEdgeY, isHoveringTop ? 7 : 6, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); } static gcd(a, b) { a = Math.abs(Math.floor(Number(a))) || 1; b = Math.abs(Math.floor(Number(b))) || 1; while (b !== 0) { const t = a % b; a = b; b = t; } return a; } static aspectRatioString(w, h) { const g = ResolutionMasterCanvas.gcd(w, h); return `${w / g}:${h / g}`; } drawInfoText(ctx, y) { const node = this.node; if (this.widthWidget && this.heightWidget) { const width = this.widthWidget.value; const height = this.heightWidget.value; const mp = ((width * height) / 1000000).toFixed(2); const pResolution = this.getClosestPResolution(width, height); const aspectRatio = ResolutionMasterCanvas.aspectRatioString(width, height); ctx.fillStyle = "#bbb"; ctx.font = "12px Arial"; ctx.textAlign = "center"; ctx.fillText(`${width} × ${height} | ${mp} MP ${pResolution} | ${aspectRatio}`, node.size[0] / 2, y); } } getScalingRowLayout() { const margin = 20; const availableWidth = this.node.size[0] - margin * 2; const gap = 8; const btnWidth = 50; const valueWidth = 45; const previewWidth = 70; const radioWidth = 18; return { margin, availableWidth, gap, btnWidth, valueWidth, previewWidth, radioWidth, sliderWidth: availableWidth - btnWidth - valueWidth - previewWidth - radioWidth - (gap * 4), dropdownWidth: availableWidth - btnWidth - valueWidth - previewWidth - radioWidth - (gap * 4) }; } drawScalingGrid(ctx, y) { const margin = 20; const props = this.node.properties; this.drawScalingRowBase(ctx, margin, y, { buttonControl: 'scaleBtn', mainControl: 'scaleSlider', radioControl: 'upscaleRadio', controlType: 'slider', icon: this.icons.upscale, valueProperty: 'upscaleValue', min: props.scaling_slider_min, max: props.scaling_slider_max, step: props.scaling_slider_step, displayValue: props.upscaleValue.toFixed(1) + "x", scaleFactor: props.upscaleValue, rescaleMode: 'manual' }); const resScale = this.calculateResolutionScale(props.targetResolution); this.drawScalingRowBase(ctx, margin, y + 35, { buttonControl: 'resolutionBtn', mainControl: 'resolutionDropdown', radioControl: 'resolutionRadio', controlType: 'dropdown', icon: this.icons.resolution, selectedText: `${props.targetResolution}p`, displayValue: `×${resScale.toFixed(2)}`, scaleFactor: resScale, rescaleMode: 'resolution' }); const mpScale = this.calculateMegapixelsScale(props.targetMegapixels); this.drawScalingRowBase(ctx, margin, y + 70, { buttonControl: 'megapixelsBtn', mainControl: 'megapixelsSlider', radioControl: 'megapixelsRadio', controlType: 'slider', icon: this.icons.megapixels, valueProperty: 'targetMegapixels', min: props.megapixels_slider_min, max: props.megapixels_slider_max, step: props.megapixels_slider_step, displayValue: `${props.targetMegapixels.toFixed(1)}MP`, scaleFactor: mpScale, rescaleMode: 'megapixels' }); return 105; } drawAutoDetectSection(ctx, y) { const node = this.node; const props = node.properties; const margin = 20; const availableWidth = node.size[0] - margin * 2; const gap = 8; const toggleWidth = 110; const checkboxWidth = 18; const checkboxLabelWidth = 30; const autoFitWidth = availableWidth - toggleWidth - checkboxWidth - checkboxLabelWidth - (gap * 2); let currentX = margin; let currentY = y; this.controls.autoDetectToggle = { x: currentX, y: currentY, w: toggleWidth, h: 28 }; this.drawToggle(ctx, currentX, currentY, toggleWidth, 28, props.autoDetect, props.autoDetect ? "Auto-detect ON" : "Auto-detect OFF", this.hoverElement === 'autoDetectToggle'); const autoFitStartX = currentX + toggleWidth + gap; this.controls.autoFitBtn = { x: autoFitStartX, y: currentY, w: autoFitWidth, h: 28 }; const btnEnabled = props.selectedCategory; this.drawButton(ctx, autoFitStartX, currentY, autoFitWidth, 28, this.icons.autoFit, this.hoverElement === 'autoFitBtn', !btnEnabled, "Auto-fit"); const autoCheckboxX = autoFitStartX + autoFitWidth + gap; this.controls.autoFitCheckbox = { x: autoCheckboxX, y: currentY + 5, w: checkboxWidth, h: 18 }; this.drawCheckbox(ctx, autoCheckboxX, currentY + 5, checkboxWidth, props.autoFitOnChange, this.hoverElement === 'autoFitCheckbox', !btnEnabled); ctx.fillStyle = btnEnabled ? "#ddd" : "#777"; ctx.font = "11px Arial"; ctx.textAlign = "left"; ctx.fillText("Auto", autoCheckboxX + checkboxWidth + 4, currentY + 14); currentY += 35; if (props.autoDetect && this.detectedDimensions) { const detectedText = `Detected: ${this.detectedDimensions.width}×${this.detectedDimensions.height}`; ctx.font = "12px Arial"; const textWidth = ctx.measureText(detectedText).width; const toggleCenterX = margin + (toggleWidth / 2); const textX = toggleCenterX - (textWidth / 2); this.controls.detectedInfo = { x: textX - 5, y: currentY + 2, w: textWidth + 10, h: 24 }; this.drawValueAreaHoverBackground(ctx, 'detectedInfo', textX - 5, currentY + 2, textWidth + 10, 20, [95, 255, 95]); ctx.fillStyle = this.hoverElement === 'detectedInfo' ? "#7f7" : "#5f5"; ctx.textAlign = "left"; ctx.fillText(detectedText, textX, currentY + 14); } this.controls.autoResizeBtn = { x: autoFitStartX, y: currentY, w: autoFitWidth, h: 28 }; this.drawButton(ctx, autoFitStartX, currentY, autoFitWidth, 28, this.icons.autoResize, this.hoverElement === 'autoResizeBtn', false, "Auto-Resize"); this.controls.autoResizeCheckbox = { x: autoCheckboxX, y: currentY + 5, w: checkboxWidth, h: 18 }; this.drawCheckbox(ctx, autoCheckboxX, currentY + 5, checkboxWidth, props.autoResizeOnChange, this.hoverElement === 'autoResizeCheckbox'); ctx.fillStyle = "#ddd"; ctx.font = "11px Arial"; ctx.textAlign = "left"; ctx.fillText("Auto", autoCheckboxX + checkboxWidth + 4, currentY + 14); currentY += 35; this.controls.autoCalcBtn = { x: autoFitStartX, y: currentY, w: autoFitWidth, h: 28 }; const calcEnabled = props.useCustomCalc && props.selectedCategory; this.drawButton(ctx, autoFitStartX, currentY, autoFitWidth, 28, this.icons.autoCalculate, this.hoverElement === 'autoCalcBtn', !calcEnabled, "Auto-calc"); this.controls.customCalcCheckbox = { x: autoCheckboxX, y: currentY + 5, w: checkboxWidth, h: 18 }; this.drawCheckbox(ctx, autoCheckboxX, currentY + 5, checkboxWidth, props.useCustomCalc, this.hoverElement === 'customCalcCheckbox'); ctx.fillStyle = "#ddd"; ctx.font = "11px Arial"; ctx.textAlign = "left"; ctx.fillText("Calc", autoCheckboxX + checkboxWidth + 4, currentY + 14); return 100; } drawPresetSection(ctx, y) { const node = this.node; const props = node.properties; const margin = 20; const availableWidth = node.size[0] - margin * 2; let currentHeight = 30; const gap = 8; let currentX = margin; let currentY = y; const iconBtnWidth = 28; const settingsBtnX = node.size[0] - margin - iconBtnWidth; if (props.selectedCategory) { const dropdownsWidth = availableWidth - iconBtnWidth - gap; const categoryDDWidth = dropdownsWidth * 0.4; const presetDDWidth = dropdownsWidth - categoryDDWidth - gap; this.controls.categoryDropdown = { x: currentX, y: currentY, w: categoryDDWidth, h: 28 }; const categoryText = props.selectedCategory || "Category..."; this.drawDropdown(ctx, currentX, currentY, categoryDDWidth, 28, categoryText, this.hoverElement === 'categoryDropdown'); currentX += categoryDDWidth + gap; this.controls.presetDropdown = { x: currentX, y: currentY, w: presetDDWidth, h: 28 }; const presetText = props.selectedPreset || "Select Preset..."; this.drawDropdown(ctx, currentX, currentY, presetDDWidth, 28, presetText, this.hoverElement === 'presetDropdown'); } else { const categoryDDWidth = availableWidth - iconBtnWidth - gap; this.controls.categoryDropdown = { x: currentX, y: currentY, w: categoryDDWidth, h: 28 }; const categoryText = props.selectedCategory || "Category..."; this.drawDropdown(ctx, currentX, currentY, categoryDDWidth, 28, categoryText, this.hoverElement === 'categoryDropdown'); } this.controls.managePresetsBtn = { x: settingsBtnX, y: currentY, w: iconBtnWidth, h: 28 }; this.drawButton(ctx, settingsBtnX, currentY, iconBtnWidth, 28, this.icons.settings, this.hoverElement === 'managePresetsBtn'); return currentHeight; } drawInfoMessage(ctx, y) { const node = this.node; const props = node.properties; const category = props.selectedCategory; let message = ""; if (category === "SDXL" && props.useCustomCalc) { message = "💡 SDXL Mode: Only using presets!"; } else if (category === "Flux" && props.useCustomCalc) { message = "💡 FLUX Mode: Round to: 32px | Edge range: 320-2560px | Max resolution: 4.0 MP"; } else if (category === "Flux.2" && props.useCustomCalc) { message = "💡 FLUX.2 Mode: Round to: 16px | Edge range: 320-3840px | Max resolution: 6.0 MP"; } else if (category === "WAN" && props.useCustomCalc && this.widthWidget && this.heightWidget) { const pixels = this.widthWidget.value * this.heightWidget.value; const model = pixels < 600000 ? "480p" : "720p"; message = `💡 WAN Mode: Suggesting ${model} model | Round to: 16px | Resolution range: 320p-820p`; } else if (category === "HiDream Dev" && props.useCustomCalc) { message = "💡 HiDream Dev: Only using presets!"; } else if (category === "Qwen-Image" && props.useCustomCalc) { message = "💡 Qwen-Image: Resolution range: ~0.6MP-4.2MP. If input is already in this range, it remains unchanged."; } else if (['Standard', 'Social Media', 'Print', 'Cinema'].includes(category) && props.useCustomCalc) { message = "💡 Calc Mode: Scales the selected preset to the closest current resolution, maintaining the preset's aspect ratio."; } else if (props.useCustomCalc) { message = "⚠️ Calc Mode: Custom calculation not available for this category)"; } if (message) { const paddingX = 10; const paddingTop = 8; const paddingBottom = 8; const lineHeight = 14; const maxWidth = node.size[0] - 40 - (paddingX * 2); ctx.font = "11px Arial"; const words = message.split(' '); const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine ? `${currentLine} ${word}` : word; if (ctx.measureText(testLine).width > maxWidth && currentLine) { lines.push(currentLine); currentLine = word; } else { currentLine = testLine; } } if (currentLine) lines.push(currentLine); const textHeight = lines.length * lineHeight - (lineHeight - ctx.measureText("M").width); const boxHeight = textHeight + paddingTop + paddingBottom; ctx.fillStyle = "rgba(250, 165, 90, 0.15)"; ctx.strokeStyle = "rgba(250, 165, 90, 0.5)"; ctx.beginPath(); ctx.roundRect(20, y, node.size[0] - 40, boxHeight, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = "#fa5"; ctx.textAlign = "center"; ctx.textBaseline = "top"; lines.forEach((line, index) => { ctx.fillText(line, node.size[0] / 2, y + paddingTop + (index * lineHeight)); }); return boxHeight; } return 0; } drawSliderMode(ctx, y) { const node = this.node; const props = node.properties; const margin = 10; const w = node.size[0] - margin * 2; if (!this.widthWidget || !this.heightWidget) return; y = this.drawDimensionSlider(ctx, y, margin, w, "Width:", "widthSlider", this.widthWidget.value, props.manual_slider_min_w, props.manual_slider_max_w, props.manual_slider_step_w); this.drawDimensionSlider(ctx, y, margin, w, "Height:", "heightSlider", this.heightWidget.value, props.manual_slider_min_h, props.manual_slider_max_h, props.manual_slider_step_h); } drawDimensionSlider(ctx, y, margin, w, label, controlName, value, min, max, step) { const node = this.node; ctx.fillStyle = "#ccc"; ctx.font = "12px Arial"; ctx.textAlign = "left"; ctx.fillText(label, margin, y); this.controls[controlName] = { x: margin, y: y + 10, w, h: 25 }; this.drawSlider(ctx, margin, y + 10, w, 25, value, min, max, step); ctx.textAlign = "right"; ctx.fillText(value.toString(), node.size[0] - margin, y + 25); return y + 45; } drawButton(ctx, x, y, w, h, content, hover = false, disabled = false, text = null, centerIconAndText = false) { const grad = ctx.createLinearGradient(x, y, x, y + h); if (disabled) { grad.addColorStop(0, "#4a4a4a"); grad.addColorStop(1, "#404040"); } else if (hover) { grad.addColorStop(0, "#6a6a6a"); grad.addColorStop(1, "#606060"); } else { grad.addColorStop(0, "#5a5a5a"); grad.addColorStop(1, "#505050"); } ctx.fillStyle = grad; ctx.strokeStyle = disabled ? "#333" : hover ? "#777" : "#222"; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, w, h, 5); ctx.fill(); ctx.stroke(); if (typeof content === 'string') { ctx.fillStyle = disabled ? "#888" : "#ddd"; ctx.font = "12px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(content, x + w / 2, y + h / 2 + 1); } else if (content instanceof Image) { const iconSize = Math.min(w, h) - 12; let iconX, iconY; if (text) { if (centerIconAndText) { ctx.font = "12px Arial"; const textWidth = ctx.measureText(text).width; const gap = 4; const totalWidth = iconSize + gap + textWidth; const startX = x + (w - totalWidth) / 2; iconX = startX; iconY = y + (h - iconSize) / 2; } else { const iconPadding = 4; iconX = x + iconPadding; iconY = y + (h - iconSize) / 2; } } else { iconX = x + (w - iconSize) / 2; iconY = y + (h - iconSize) / 2; } if (content.complete) { try { if (disabled) ctx.globalAlpha = 0.5; ctx.drawImage(content, iconX, iconY, iconSize, iconSize); if (disabled) ctx.globalAlpha = 1.0; } catch (e) { log.error("Error drawing SVG icon:", e); ctx.fillStyle = "#f55"; ctx.font = "bold 14px Arial"; ctx.fillText("?", x + w / 2, y + h / 2 + 1); } } if (text) { ctx.fillStyle = disabled ? "#888" : "#ddd"; ctx.font = "12px Arial"; ctx.textBaseline = "middle"; if (centerIconAndText) { ctx.textAlign = "left"; const textWidth = ctx.measureText(text).width; const gap = 4; const totalWidth = iconSize + gap + textWidth; const startX = x + (w - totalWidth) / 2; const textX = startX + iconSize + gap; ctx.fillText(text, textX, y + h / 2 + 1); } else { ctx.textAlign = "center"; ctx.fillText(text, x + w / 2, y + h / 2 + 1); } } } } drawSlider(ctx, x, y, w, h, value, min, max, step) { ctx.fillStyle = "#222"; ctx.beginPath(); ctx.roundRect(x, y + h / 2 - 3, w, 6, 3); ctx.fill(); const pos = Math.max(0, Math.min(1, (value - min) / (max - min))); const knobX = x + w * pos; const knobY = y + h / 2; const grad = ctx.createLinearGradient(knobX - 7, knobY - 7, knobX + 7, knobY + 7); grad.addColorStop(0, "#e0e0e0"); grad.addColorStop(1, "#c0c0c0"); ctx.fillStyle = grad; ctx.strokeStyle = "#111"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(knobX, knobY, 8, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); } drawDropdown(ctx, x, y, w, h, text, hover = false) { this.drawButton(ctx, x, y, w, h, "", hover); ctx.fillStyle = "#ddd"; ctx.font = "11px Arial"; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.save(); ctx.beginPath(); ctx.rect(x + 5, y, w - 25, h); ctx.clip(); ctx.fillText(text, x + 8, y + h / 2 + 1); ctx.restore(); ctx.fillStyle = "#aaa"; ctx.beginPath(); ctx.moveTo(x + w - 18, y + h / 2 - 3); ctx.lineTo(x + w - 10, y + h / 2 + 3); ctx.lineTo(x + w - 2, y + h / 2 - 3); ctx.fill(); } drawRadioButton(ctx, x, y, size, checked, hover = false) { ctx.strokeStyle = hover ? "#ccc" : "#999"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x + size / 2, y + size / 2, size / 2 - 2, 0, 2 * Math.PI); ctx.stroke(); if (checked) { ctx.fillStyle = "#5af"; ctx.beginPath(); ctx.arc(x + size / 2, y + size / 2, size / 2 - 6, 0, 2 * Math.PI); ctx.fill(); } } drawCheckbox(ctx, x, y, size, checked, hover = false, disabled = false) { ctx.strokeStyle = disabled ? "#555" : hover ? "#ccc" : "#999"; ctx.lineWidth = 2; ctx.beginPath(); ctx.roundRect(x, y, size, size, 4); ctx.stroke(); if (checked) { ctx.fillStyle = disabled ? "#666" : "#5af"; ctx.beginPath(); ctx.moveTo(x + 4, y + size / 2); ctx.lineTo(x + size / 2 - 1, y + size - 4); ctx.lineTo(x + size - 4, y + 4); ctx.strokeStyle = "#fff"; ctx.lineWidth = 2; ctx.stroke(); } } drawToggle(ctx, x, y, w, h, isOn, text, hover = false) { ctx.beginPath(); ctx.roundRect(x, y, w, h, h / 2); const grad = ctx.createLinearGradient(x, y, x + w, y); if (isOn) { grad.addColorStop(0, "#3a76d6"); grad.addColorStop(1, "#5a96f6"); } else { grad.addColorStop(0, "#555"); grad.addColorStop(1, "#666"); } ctx.fillStyle = grad; ctx.fill(); ctx.strokeStyle = hover ? "#888" : "#222"; ctx.stroke(); const knobX = isOn ? x + w - h + 2 : x + 2; const knobGrad = ctx.createLinearGradient(knobX, y, knobX, y + h - 4); knobGrad.addColorStop(0, "#f0f0f0"); knobGrad.addColorStop(1, "#d0d0d0"); ctx.fillStyle = knobGrad; ctx.beginPath(); ctx.arc(knobX + (h-4)/2, y + h/2, (h-6)/2, 0, 2 * Math.PI); ctx.fill(); ctx.fillStyle = "#fff"; ctx.font = "bold 11px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(text, x + w / 2, y + h / 2 + 1); } drawValueAreaHoverBackground(ctx, controlName, x, y, w, h, color, borderRadius = 4) { if (this.hoverElement === controlName) { ctx.fillStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.2)`; ctx.strokeStyle = `rgba(${color[0]}, ${color[1]}, ${color[2]}, 0.5)`; ctx.lineWidth = 1; ctx.beginPath(); ctx.roundRect(x, y, w, h, borderRadius); ctx.fill(); ctx.stroke(); } } drawTooltip(ctx) { if (!this.tooltipMousePos || !this.tooltips[this.tooltipElement]) { log.debug("Tooltip draw failed: missing mouse pos or tooltip text"); return; } const tooltipText = this.tooltips[this.tooltipElement]; const paddingX = 8; const paddingTop = 8; const paddingBottom = 4; const maxWidth = 250; const lineHeight = 16; ctx.font = "12px Arial"; const words = tooltipText.split(' '); const lines = []; let currentLine = ''; for (const word of words) { const testLine = currentLine + (currentLine ? ' ' : '') + word; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine) { lines.push(currentLine); currentLine = word; } else { currentLine = testLine; } } if (currentLine) { lines.push(currentLine); } const textWidth = Math.max(...lines.map(line => ctx.measureText(line).width)); const tooltipWidth = Math.min(textWidth + paddingX * 2, maxWidth + paddingX * 2); const tooltipHeight = lines.length * lineHeight + paddingTop + paddingBottom; const mouseRelX = this.tooltipMousePos.x - this.node.pos[0]; const mouseRelY = this.tooltipMousePos.y - this.node.pos[1]; let tooltipX = mouseRelX + 15; let tooltipY = mouseRelY - tooltipHeight - 10; if (tooltipX + tooltipWidth > this.node.size[0] + 50) { tooltipX = mouseRelX - tooltipWidth - 15; } if (tooltipY < -50) { tooltipY = mouseRelY + 20; } ctx.save(); ctx.fillStyle = "rgba(0, 0, 0, 0.3)"; ctx.beginPath(); ctx.roundRect(tooltipX + 2, tooltipY + 2, tooltipWidth, tooltipHeight, 6); ctx.fill(); const bgGrad = ctx.createLinearGradient(tooltipX, tooltipY, tooltipX, tooltipY + tooltipHeight); bgGrad.addColorStop(0, "rgba(45, 45, 45, 0.95)"); bgGrad.addColorStop(1, "rgba(35, 35, 35, 0.95)"); ctx.fillStyle = bgGrad; ctx.beginPath(); ctx.roundRect(tooltipX, tooltipY, tooltipWidth, tooltipHeight, 6); ctx.fill(); ctx.strokeStyle = "rgba(200, 200, 200, 0.3)"; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = "#ffffff"; ctx.font = "12px Arial"; ctx.textAlign = "left"; ctx.textBaseline = "top"; lines.forEach((line, index) => { ctx.fillText(line, tooltipX + paddingX, tooltipY + paddingTop + index * lineHeight); }); ctx.restore(); } handleMouseDown(e, pos, canvas) { const node = this.node; const props = node.properties; const relX = e.canvasX - node.pos[0]; const relY = e.canvasY - node.pos[1]; if (props.mode === "Manual") { if (this.controls.canvas2dRightHandle && this.isPointInControl(relX, relY, this.controls.canvas2dRightHandle)) { node.capture = 'canvas2dRightHandle'; node.captureInput(true); return true; } if (this.controls.canvas2dTopHandle && this.isPointInControl(relX, relY, this.controls.canvas2dTopHandle)) { node.capture = 'canvas2dTopHandle'; node.captureInput(true); return true; } const c2d = this.controls.canvas2d; if (c2d && this.isPointInControl(relX, relY, c2d)) { node.capture = 'canvas2d'; node.captureInput(true); this.updateCanvasValue(relX - c2d.x, relY - c2d.y, c2d.w, c2d.h, e.shiftKey, e.ctrlKey); return true; } } for (const key in this.controls) { if (this.isPointInControl(relX, relY, this.controls[key])) { log.debug(`Mouse down on control: ${key} at (${relX}, ${relY})`); if (key.endsWith('Btn') || key === 'detectedInfo') { this.handleButtonClick(key); return true; } if (key.endsWith('Slider')) { node.capture = key; node.captureInput(true); this.updateSliderValue(key, relX - this.controls[key].x, this.controls[key].w); return true; } if (key.endsWith('Dropdown')) { this.showDropdownMenu(key, e); return true; } if (key.endsWith('Toggle')) { this.handleToggleClick(key); return true; } if (key.endsWith('Checkbox')) { this.handleCheckboxClick(key); return true; } if (key.endsWith('Radio')) { this.handleRadioClick(key); return true; } if (key.endsWith('ValueArea')) { log.debug(`Detected ValueArea click: ${key}`); if (key === 'latValueArea') { this.showLatentTypeSelector(e); } else { this.dialogManager.showCustomValueDialog(key, e); } return true; } if (key.endsWith('Header')) { this.handleSectionHeaderClick(key); return true; } } } log.debug(`No control found at (${relX}, ${relY}). Available controls:`, Object.keys(this.controls)); return false; } handleMouseMove(e, pos, canvas) { const node = this.node; if (!node.capture) return false; if (e.buttons === 0) { this.handleMouseUp(e); return true; } const relX = e.canvasX - node.pos[0]; const relY = e.canvasY - node.pos[1]; if (node.capture === 'canvas2d') { const c2d = this.controls.canvas2d; if (c2d) { this.updateCanvasValue(relX - c2d.x, relY - c2d.y, c2d.w, c2d.h, e.shiftKey, e.ctrlKey); } return true; } if (node.capture === 'canvas2dRightHandle') { const c2d = this.controls.canvas2d; if (c2d) { this.updateCanvasValueWidth(relX - c2d.x, c2d.w, e.ctrlKey); } return true; } if (node.capture === 'canvas2dTopHandle') { const c2d = this.controls.canvas2d; if (c2d) { this.updateCanvasValueHeight(relY - c2d.y, c2d.h, e.ctrlKey); } return true; } if (node.capture.endsWith('Slider')) { const control = this.controls[node.capture]; if (control) { this.updateSliderValue(node.capture, relX - control.x, control.w); } return true; } return false; } handleMouseHover(e, pos, canvas) { const node = this.node; const relX = e.canvasX - node.pos[0]; const relY = e.canvasY - node.pos[1]; let newHover = null; if (this.controls.canvas2dRightHandle && this.isPointInControl(relX, relY, this.controls.canvas2dRightHandle)) { newHover = 'canvas2dRightHandle'; } else if (this.controls.canvas2dTopHandle && this.isPointInControl(relX, relY, this.controls.canvas2dTopHandle)) { newHover = 'canvas2dTopHandle'; } else { for (const element in this.controls) { if (element !== 'canvas2dRightHandle' && element !== 'canvas2dTopHandle' && this.isPointInControl(relX, relY, this.controls[element])) { newHover = element; break; } } } this.tooltipMousePos = { x: e.canvasX, y: e.canvasY }; if (newHover !== this.hoverElement) { this.hoverElement = newHover; this.handleTooltipHover(newHover, e); app.graph.setDirtyCanvas(true); } } handleTooltipHover(element, e) { if (this.tooltipTimer) { clearTimeout(this.tooltipTimer); this.tooltipTimer = null; } if (this.showTooltip) { this.showTooltip = false; this.tooltipElement = null; app.graph.setDirtyCanvas(true); } if (element && this.tooltips[element]) { const initialMousePos = { x: e.canvasX, y: e.canvasY }; this.tooltipTimer = setTimeout(() => { this.tooltipElement = element; this.showTooltip = true; this.tooltipFixedPos = initialMousePos; app.graph.setDirtyCanvas(true); }, this.tooltipDelay); } } handleMouseUp(e) { const node = this.node; if (!node.capture) return false; node.capture = false; node.captureInput(false); if (this.widthWidget && this.heightWidget) { this.widthWidget.value = node.properties.valueX; this.heightWidget.value = node.properties.valueY; } this.updateRescaleValue(); return true; } handlePropertyChange(property) { const node = this.node; if (!node.configured) return; node.intpos.x = (node.properties.valueX - node.properties.canvas_min_x) / (node.properties.canvas_max_x - node.properties.canvas_min_x); node.intpos.y = (node.properties.valueY - node.properties.canvas_min_y) / (node.properties.canvas_max_y - node.properties.canvas_min_y); node.intpos.x = Math.max(0, Math.min(1, node.intpos.x)); node.intpos.y = Math.max(0, Math.min(1, node.intpos.y)); app.graph.setDirtyCanvas(true); } handleButtonClick(buttonName) { const actions = { swapBtn: () => this.handleSwap(), snapBtn: () => this.handleSnap(), scaleBtn: () => this.handleScale(), resolutionBtn: () => this.handleResolutionScale(), megapixelsBtn: () => this.handleMegapixelsScale(), autoFitBtn: () => this.handleAutoFit(), autoResizeBtn: () => this.handleAutoResize(), autoCalcBtn: () => this.handleAutoCalc(), detectedInfo: () => this.handleDetectedClick(), managePresetsBtn: () => this.handleManagePresets() }; actions[buttonName]?.(); } handleToggleClick(toggleName) { const props = this.node.properties; if (toggleName === 'autoDetectToggle') { props.autoDetect = !props.autoDetect; if (props.autoDetect) this.startAutoDetect(); else this.stopAutoDetect(); const widget = this.node.widgets?.find(w => w.name === 'auto_detect'); if (widget) widget.value = props.autoDetect; app.graph.setDirtyCanvas(true); } } handleCheckboxClick(checkboxName) { const props = this.node.properties; if (checkboxName === 'autoFitCheckbox' && props.selectedCategory) { props.autoFitOnChange = !props.autoFitOnChange; } else if (checkboxName === 'autoResizeCheckbox') { props.autoResizeOnChange = !props.autoResizeOnChange; } else if (checkboxName === 'customCalcCheckbox') { props.useCustomCalc = !props.useCustomCalc; } app.graph.setDirtyCanvas(true); } handleRadioClick(radioName) { const props = this.node.properties; const radioMap = { upscaleRadio: 'manual', resolutionRadio: 'resolution', megapixelsRadio: 'megapixels' }; props.rescaleMode = radioMap[radioName]; this.updateRescaleValue(); app.graph.setDirtyCanvas(true); } handleSectionHeaderClick(headerKey) { const sectionKey = headerKey.replace('Header', ''); this.collapsedSections[sectionKey] = !this.collapsedSections[sectionKey]; const propertyKey = `section_${sectionKey}_collapsed`; this.node.properties[propertyKey] = this.collapsedSections[sectionKey]; app.graph.setDirtyCanvas(true, true); log.debug(`Section ${sectionKey} ${this.collapsedSections[sectionKey] ? 'collapsed' : 'expanded'}`); } getAllPresets() { return this.customPresetsManager.getMergedPresets(this.presetCategories); } validateWidgets() { return this.widthWidget && this.heightWidget; } setDimensions(width, height) { if (!this.validateWidgets()) return; this.node.properties.valueX = width; this.node.properties.valueY = height; this.widthWidget.value = width; this.heightWidget.value = height; this.handlePropertyChange(); this.updateRescaleValue(); this.updateCanvasFromWidgets(); app.graph.setDirtyCanvas(true); } updateCanvasFromWidgets() { if (!this.validateWidgets()) return; const node = this.node; const props = node.properties; props.valueX = this.widthWidget.value; props.valueY = this.heightWidget.value; node.intpos.x = (this.widthWidget.value - props.canvas_min_x) / (props.canvas_max_x - props.canvas_min_x); node.intpos.y = (this.heightWidget.value - props.canvas_min_y) / (props.canvas_max_y - props.canvas_min_y); node.intpos.x = Math.max(0, Math.min(1, node.intpos.x)); node.intpos.y = Math.max(0, Math.min(1, node.intpos.y)); this.updateRescaleValue(); app.graph.setDirtyCanvas(true); } setCanvasTextStyle(ctx, style = {}) { const defaults = { fillStyle: "#ccc", font: "12px Arial", textAlign: "center", textBaseline: "middle" }; const finalStyle = { ...defaults, ...style }; Object.entries(finalStyle).forEach(([key, value]) => { ctx[key] = value; }); } drawScalingRowBase(ctx, x, y, config) { const props = this.node.properties; const layout = this.getScalingRowLayout(); let currentX = x; this.controls[config.buttonControl] = { x: currentX, y, w: layout.btnWidth, h: 28 }; this.drawButton(ctx, currentX, y, layout.btnWidth, 28, config.icon, this.hoverElement === config.buttonControl); currentX += layout.btnWidth + layout.gap; if (config.controlType === 'slider') { this.controls[config.mainControl] = { x: currentX, y, w: layout.sliderWidth, h: 28 }; this.drawSlider(ctx, currentX, y, layout.sliderWidth, 28, props[config.valueProperty], config.min, config.max, config.step); currentX += layout.sliderWidth + layout.gap; } else if (config.controlType === 'dropdown') { this.controls[config.mainControl] = { x: currentX, y, w: layout.dropdownWidth, h: 28 }; this.drawDropdown(ctx, currentX, y, layout.dropdownWidth, 28, config.selectedText, this.hoverElement === config.mainControl); currentX += layout.dropdownWidth + layout.gap; } const valueAreaControl = config.buttonControl.replace('Btn', 'ValueArea'); this.controls[valueAreaControl] = { x: currentX, y, w: layout.valueWidth, h: 28 }; this.drawValueAreaHoverBackground(ctx, valueAreaControl, currentX, y, layout.valueWidth, 28, [100, 150, 255]); this.setCanvasTextStyle(ctx, { fillStyle: this.hoverElement === valueAreaControl ? "#5af" : "#ccc", textAlign: "center" }); ctx.fillText(config.displayValue, currentX + layout.valueWidth / 2, y + 14); currentX += layout.valueWidth + layout.gap; if (this.validateWidgets() && config.scaleFactor) { const newW = Math.round(this.widthWidget.value * config.scaleFactor); const newH = Math.round(this.heightWidget.value * config.scaleFactor); this.setCanvasTextStyle(ctx, { fillStyle: "#888", font: "11px Arial", textAlign: "left" }); ctx.fillText(`${newW}×${newH}`, currentX, y + 14); } currentX += layout.previewWidth + layout.gap; this.controls[config.radioControl] = { x: currentX, y: y + 5, w: layout.radioWidth, h: 18 }; this.drawRadioButton(ctx, currentX, y + 5, layout.radioWidth, props.rescaleMode === config.rescaleMode, this.hoverElement === config.radioControl); } updateCanvasValue(x, y, w, h, shiftKey, ctrlKey) { const node = this.node; const props = node.properties; let vX = Math.max(0, Math.min(1, x / w)); let vY = Math.max(0, Math.min(1, 1 - y / h)); if (ctrlKey && shiftKey) { const currentAspect = this.widthWidget.value / this.heightWidget.value; let newX = props.canvas_min_x + (props.canvas_max_x - props.canvas_min_x) * vX; let newY = props.canvas_min_y + (props.canvas_max_y - props.canvas_min_y) * vY; newX = Math.round(newX); newY = Math.round(newY); newY = Math.round(newX / currentAspect); vX = (newX - props.canvas_min_x) / (props.canvas_max_x - props.canvas_min_x); vY = (newY - props.canvas_min_y) / (props.canvas_max_y - props.canvas_min_y); } else if (shiftKey && !ctrlKey) { const currentAspect = this.widthWidget.value / this.heightWidget.value; let newX = props.canvas_min_x + (props.canvas_max_x - props.canvas_min_x) * vX; let newY = props.canvas_min_y + (props.canvas_max_y - props.canvas_min_y) * vY; let sX = props.canvas_step_x / (props.canvas_max_x - props.canvas_min_x); let sY = props.canvas_step_y / (props.canvas_max_y - props.canvas_min_y); vX = Math.round(vX / sX) * sX; newX = props.canvas_min_x + (props.canvas_max_x - props.canvas_min_x) * vX; newY = newX / currentAspect; vY = (newY - props.canvas_min_y) / (props.canvas_max_y - props.canvas_min_y); } else if (ctrlKey && !shiftKey) { } else { let sX = props.canvas_step_x / (props.canvas_max_x - props.canvas_min_x); let sY = props.canvas_step_y / (props.canvas_max_y - props.canvas_min_y); vX = Math.round(vX / sX) * sX; vY = Math.round(vY / sY) * sY; } node.intpos.x = vX; node.intpos.y = vY; let newX = props.canvas_min_x + (props.canvas_max_x - props.canvas_min_x) * vX; let newY = props.canvas_min_y + (props.canvas_max_y - props.canvas_min_y) * vY; const rnX = Math.pow(10, props.canvas_decimals_x); const rnY = Math.pow(10, props.canvas_decimals_y); newX = Math.round(rnX * newX) / rnX; newY = Math.round(rnY * newY) / rnY; if (props.valueX !== newX || props.valueY !== newY) { this.setDimensions(newX, newY); } } updateCanvasValueWidth(x, w, ctrlKey) { const node = this.node; const props = node.properties; let vX = Math.max(0, Math.min(1, x / w)); if (!ctrlKey) { let sX = props.canvas_step_x / (props.canvas_max_x - props.canvas_min_x); vX = Math.round(vX / sX) * sX; } node.intpos.x = vX; let newX = props.canvas_min_x + (props.canvas_max_x - props.canvas_min_x) * vX; const rnX = Math.pow(10, props.canvas_decimals_x); newX = Math.round(rnX * newX) / rnX; this.setDimensions(newX, this.heightWidget.value); app.graph.setDirtyCanvas(true); } updateCanvasValueHeight(y, h, ctrlKey) { const node = this.node; const props = node.properties; let vY = Math.max(0, Math.min(1, 1 - y / h)); if (!ctrlKey) { let sY = props.canvas_step_y / (props.canvas_max_y - props.canvas_min_y); vY = Math.round(vY / sY) * sY; } node.intpos.y = vY; let newY = props.canvas_min_y + (props.canvas_max_y - props.canvas_min_y) * vY; const rnY = Math.pow(10, props.canvas_decimals_y); newY = Math.round(rnY * newY) / rnY; this.setDimensions(this.widthWidget.value, newY); app.graph.setDirtyCanvas(true); } updateSliderValue(sliderName, x, w) { const props = this.node.properties; let value = Math.max(0, Math.min(1, x / w)); const sliderConfig = { snapSlider: { prop: 'snapValue', min: props.action_slider_snap_min, max: props.action_slider_snap_max, step: props.action_slider_snap_step }, scaleSlider: { prop: 'upscaleValue', min: props.scaling_slider_min, max: props.scaling_slider_max, step: props.scaling_slider_step, updateOn: 'manual' }, megapixelsSlider: { prop: 'targetMegapixels', min: props.megapixels_slider_min, max: props.megapixels_slider_max, step: props.megapixels_slider_step, updateOn: 'megapixels' }, widthSlider: { prop: 'valueX', min: props.manual_slider_min_w, max: props.manual_slider_max_w, step: props.manual_slider_step_w }, heightSlider: { prop: 'valueY', min: props.manual_slider_min_h, max: props.manual_slider_max_h, step: props.manual_slider_step_h } }; const config = sliderConfig[sliderName]; if (config) { let newValue = config.min + value * (config.max - config.min); props[config.prop] = Math.round(newValue / config.step) * config.step; if (sliderName === 'scaleSlider' || sliderName === 'megapixelsSlider') { props[config.prop] = parseFloat(props[config.prop].toFixed(1)); } if (config.updateOn && props.rescaleMode === config.updateOn) { this.updateRescaleValue(); } if (sliderName === 'widthSlider') { this.setDimensions(props.valueX, this.heightWidget.value); } else if (sliderName === 'heightSlider') { this.setDimensions(this.widthWidget.value, props.valueY); } else if (sliderName.includes('Slider')) { this.handlePropertyChange(); } } app.graph.setDirtyCanvas(true); } showPresetSelector(e, mode) { const props = this.node.properties; const allPresets = this.getAllPresets(); const presets = allPresets[props.selectedCategory]; if (!presets) return; const commonCallback = (presetName) => { this.applyPreset(props.selectedCategory, presetName); }; const commonModeChange = (newMode) => { props.preset_selector_mode = newMode; this.showPresetSelector(e, newMode); }; if (mode === 'list') { const presetItems = Object.entries(presets) .filter(([name, dims]) => !dims.isHidden) .map(([name, dims]) => { const isCustom = this.customPresetsManager.isCustomPreset(props.selectedCategory, name); return { text: `${name} (${dims.width}×${dims.height})`, isCustom: isCustom }; }); this.searchableDropdown.show(presetItems, { event: e, title: 'Select Preset', currentMode: 'list', initialExpanded: props.dropdown_preset_expanded || false, onExpandedChange: (isExpanded) => { props.dropdown_preset_expanded = isExpanded; }, callback: (selectedItem) => { const presetName = selectedItem.replace(/\s*\([^)]*\)$/, ''); commonCallback(presetName); }, onModeChange: () => commonModeChange('visual') }); } else { this.aspectRatioSelector.show(presets, { event: e, selectedPreset: props.selectedPreset, currentMode: 'visual', callback: commonCallback, onModeChange: () => commonModeChange('list') }); } } showLatentTypeSelector(e) { if (!this.latentTypeWidget) { log.debug("Latent type selector: latent_type widget not found"); return; } const currentValue = this.latentTypeWidget.value || 'latent_4x8'; // Available latent types with descriptive names const latentTypes = [ { text: '4x8 (Standard SD/SDXL/Flux)', value: 'latent_4x8' }, { text: '128x16 (Flux.2)', value: 'latent_128x16' } ]; const items = latentTypes.map(type => ({ text: type.text, value: type.value, isCustom: type.value === currentValue // Highlight current selection })); this.searchableDropdown.show(items, { event: e, title: 'Select Latent Type', callback: (selectedText) => { // Find the selected type by its display text const selectedType = latentTypes.find(t => t.text === selectedText); if (selectedType && this.latentTypeWidget) { this.latentTypeWidget.value = selectedType.value; log.debug(`Latent type manually changed to: ${selectedType.value}`); app.graph.setDirtyCanvas(true); } } }); } showDropdownMenu(dropdownName, e) { const props = this.node.properties; let items, callback, title, propertyKey; if (dropdownName === 'categoryDropdown') { const allPresets = this.getAllPresets(); items = Object.keys(allPresets) .filter(categoryName => { const categoryPresets = allPresets[categoryName]; const hasVisiblePresets = Object.values(categoryPresets).some(preset => !preset.isHidden); return hasVisiblePresets; }) .map(categoryName => { const isCustomCategory = this.customPresetsManager.categoryExists(categoryName); return { text: categoryName, isCustom: isCustomCategory }; }); title = 'Select Category'; propertyKey = 'dropdown_category_expanded'; callback = (value) => { props.selectedCategory = value; props.selectedPreset = null; // Latent type is now manually controlled via LAT selector - no automatic change app.graph.setDirtyCanvas(true); }; } else if (dropdownName === 'presetDropdown' && props.selectedCategory) { const selectorMode = props.preset_selector_mode || 'visual'; this.showPresetSelector(e, selectorMode); return; } else if (dropdownName === 'resolutionDropdown') { items = this.resolutions; title = 'Select Resolution'; propertyKey = 'dropdown_resolution_expanded'; callback = (value) => { let resolutionValue = value.trim(); if (!resolutionValue.endsWith('p')) { resolutionValue = resolutionValue + 'p'; } props.targetResolution = parseInt(resolutionValue); if (props.rescaleMode === 'resolution') this.updateRescaleValue(); app.graph.setDirtyCanvas(true); }; } if (items?.length && propertyKey) { this.searchableDropdown.show(items, { event: e, callback, title, allowCustomValues: dropdownName === 'resolutionDropdown', initialExpanded: props[propertyKey] || false, onExpandedChange: (isExpanded) => { props[propertyKey] = isExpanded; } }); } } handleSwap() { if (!this.validateWidgets()) return; const newWidth = this.heightWidget.value; const newHeight = this.widthWidget.value; this.setDimensions(newWidth, newHeight); } handleSnap() { if (!this.validateWidgets()) return; const snap = this.node.properties.snapValue; const newWidth = Math.round(this.widthWidget.value / snap) * snap; const newHeight = Math.round(this.heightWidget.value / snap) * snap; this.setDimensions(newWidth, newHeight); } applyScaling(scaleCalculator, resetValue = null) { if (!this.validateWidgets()) return; const scale = scaleCalculator(); const newWidth = Math.round(this.widthWidget.value * scale); const newHeight = Math.round(this.heightWidget.value * scale); if (resetValue) { resetValue(); } this.setDimensions(newWidth, newHeight); } handleScale() { this.applyScaling(() => this.node.properties.upscaleValue); } handleResolutionScale() { this.applyScaling(() => this.calculateResolutionScale(this.node.properties.targetResolution)); } handleMegapixelsScale() { this.applyScaling(() => this.calculateMegapixelsScale(this.node.properties.targetMegapixels)); } handleAutoFit() { const props = this.node.properties; const category = props.selectedCategory; if (!category) return; if (!this.widthWidget || !this.heightWidget) { log.debug("Auto-fit: Width or height widget not found"); return; } const currentWidth = this.widthWidget.value; const currentHeight = this.heightWidget.value; this.applyAutoFit(currentWidth, currentHeight, props.useCustomCalc, category, 'current'); } applyAutoFit(width, height, useCalc, category, source = 'detected') { const props = this.node.properties; if (!this.widthWidget || !this.heightWidget) return; const closestPreset = this.findClosestPreset(width, height, category); if (!closestPreset) return; let finalWidth, finalHeight; if (useCalc) { const presetAspect = closestPreset.width / closestPreset.height; const currentAspect = width / height; if (Math.abs(currentAspect - presetAspect) < 0.01) { const result = this.applyCustomCalculation(width, height, category); finalWidth = result.width; finalHeight = result.height; log.debug(`Auto-fit with calc (${source}): ${closestPreset.name} has matching aspect ratio. Using ${finalWidth}x${finalHeight} (from ${width}x${height})`); } else { const scaledDimensions = this.scaleToPresetAspectRatio(width, height, presetAspect); const result = this.applyCustomCalculation(scaledDimensions.width, scaledDimensions.height, category); finalWidth = result.width; finalHeight = result.height; log.debug(`Auto-fit with calc (${source}): ${closestPreset.name} different aspect. Scaled to ${finalWidth}x${finalHeight} (from ${width}x${height})`); } } else { finalWidth = closestPreset.width; finalHeight = closestPreset.height; log.debug(`Auto-fit preset only (${source}): ${closestPreset.name} (${finalWidth}x${finalHeight}) for input ${width}x${height}`); } props.selectedPreset = closestPreset.name; this.setDimensions(finalWidth, finalHeight); } handleAutoCalc() { const props = this.node.properties; if (!props.useCustomCalc || !props.selectedCategory) { log.debug("Auto-calc: Calc checkbox or category not selected"); return; } if (!this.widthWidget || !this.heightWidget) { log.debug("Auto-calc: Width or height widget not found"); return; } const currentWidth = this.widthWidget.value; const currentHeight = this.heightWidget.value; const result = this.applyCustomCalculation(currentWidth, currentHeight, props.selectedCategory); this.setDimensions(result.width, result.height); log.debug(`Auto-calc applied: ${currentWidth}x${currentHeight} → ${result.width}x${result.height} (${props.selectedCategory})`); } handleAutoResize() { const props = this.node.properties; if (!this.widthWidget || !this.heightWidget) { log.debug("Auto-Resize: Width or height widget not found"); return; } if (props.rescaleMode === 'manual') { this.handleScale(); log.debug(`Auto-Resize: Applied manual scaling with factor ${props.upscaleValue}`); } else if (props.rescaleMode === 'resolution') { this.handleResolutionScale(); log.debug(`Auto-Resize: Applied resolution scaling to ${props.targetResolution}p`); } else if (props.rescaleMode === 'megapixels') { this.handleMegapixelsScale(); log.debug(`Auto-Resize: Applied megapixels scaling to ${props.targetMegapixels}MP`); } } handleDetectedClick() { if (!this.detectedDimensions) { log.debug("Detected click: No detected dimensions available"); return; } if (!this.widthWidget || !this.heightWidget) { log.debug("Detected click: Width or height widget not found"); return; } this.setDimensions(this.detectedDimensions.width, this.detectedDimensions.height); log.debug(`Detected click applied: Set dimensions to ${this.detectedDimensions.width}x${this.detectedDimensions.height}`); } handleManagePresets() { log.debug("Manage Presets button clicked - opening dialog"); this.presetManagerDialog.show(); } applyDimensionChange() { const props = this.node.properties; let { value: width } = this.widthWidget; let { value: height } = this.heightWidget; if (props.useCustomCalc && props.selectedCategory) { ({ width, height } = this.applyCustomCalculation(width, height, props.selectedCategory)); } // Removed canvas min/max clamping - allow presets to set any resolution this.setDimensions(width, height); } applyPreset(category, presetName) { const props = this.node.properties; const allPresets = this.getAllPresets(); const preset = allPresets[category]?.[presetName]; if (!preset) return; if (this.widthWidget && this.heightWidget) { this.widthWidget.value = preset.width; this.heightWidget.value = preset.height; props.selectedPreset = presetName; this.applyDimensionChange(); this.updateCanvasFromWidgets(); } } applyCustomCalculation(width, height, category) { const calculations = { Flux: () => this.applyFluxCalculation(width, height), 'Flux.2': () => this.applyFlux2Calculation(width, height), WAN: () => this.applyWANCalculation(width, height), 'Qwen-Image': () => this.applyQwenCalculation(width, height), 'SDXL': () => this.findBestPresetForCategory(width, height, 'SDXL'), 'HiDream Dev': () => this.findBestPresetForCategory(width, height, 'HiDream Dev'), 'Standard': () => this.scaleToNearestPresetAspectRatio(width, height, 'Standard'), 'Social Media': () => this.scaleToNearestPresetAspectRatio(width, height, 'Social Media'), 'Print': () => this.scaleToNearestPresetAspectRatio(width, height, 'Print'), 'Cinema': () => this.scaleToNearestPresetAspectRatio(width, height, 'Cinema'), 'Display Resolutions': () => this.scaleToNearestPresetAspectRatio(width, height, 'Display Resolutions') }; return calculations[category] ? calculations[category]() : { width, height }; } findBestPresetForCategory(width, height, category) { const closestPreset = this.findClosestPreset(width, height, category); if (closestPreset) { log.debug(`${category} Calc: Found best preset ${closestPreset.name} (${closestPreset.width}x${closestPreset.height}) for input ${width}x${height}`); return { width: closestPreset.width, height: closestPreset.height }; } return { width, height }; } scaleToNearestPresetAspectRatio(width, height, category) { const closestPreset = this.findClosestPreset(width, height, category); if (closestPreset) { const presetAspect = closestPreset.width / closestPreset.height; const currentAspect = width / height; if (Math.abs(currentAspect - presetAspect) < 0.01) { log.debug(`Calc for ${category}: Preset ${closestPreset.name} has matching aspect ratio (${presetAspect.toFixed(2)}). Keeping current size ${width}x${height}`); return { width, height }; } const result = this.scaleToPresetAspectRatio(width, height, presetAspect); log.debug(`Calc for ${category}: Found preset ${closestPreset.name}. Scaling ${width}x${height} -> ${result.width}x${result.height} with aspect ${presetAspect.toFixed(2)}`); return result; } return { width, height }; } findClosestPreset(width, height, category, options = {}) { const allPresets = this.getAllPresets(); const presets = allPresets[category]; if (!presets) return null; let closestPreset = null; let closestAspectDiff = Infinity; let closestPixelDiff = Infinity; const inputAspect = width / height; const inputPixels = width * height; Object.entries(presets).forEach(([presetName, preset]) => { const orientations = [ { width: preset.width, height: preset.height, flipped: false }, { width: preset.height, height: preset.width, flipped: true } ]; orientations.forEach(orientation => { const presetAspect = orientation.width / orientation.height; const presetPixels = orientation.width * orientation.height; const aspectDiff = Math.abs(inputAspect - presetAspect); if (aspectDiff < closestAspectDiff || (Math.abs(aspectDiff - closestAspectDiff) < 0.01)) { if (aspectDiff < 0.01 || aspectDiff < closestAspectDiff) { const pixelDiff = Math.abs(Math.log(inputPixels / presetPixels)); if (aspectDiff < closestAspectDiff || (Math.abs(aspectDiff - closestAspectDiff) < 0.01 && pixelDiff < closestPixelDiff)) { closestAspectDiff = aspectDiff; closestPixelDiff = pixelDiff; closestPreset = { name: presetName, width: orientation.width, height: orientation.height, originalPreset: preset }; } } } }); }); log.debug(`findClosestPreset: Input ${width}x${height} (aspect ${inputAspect.toFixed(2)}) → Selected "${closestPreset?.name}" ${closestPreset?.width}x${closestPreset?.height} (aspect ${(closestPreset?.width/closestPreset?.height)?.toFixed(2)})`); return closestPreset; } chooseBestScalingOption(currentPixels, option1Pixels, option2Pixels, option1Dims, option2Dims) { const option1Diff = Math.abs(option1Pixels - currentPixels); const option2Diff = Math.abs(option2Pixels - currentPixels); if (option1Diff <= option2Diff) { return option1Dims; } else { return option2Dims; } } scaleToPresetAspectRatio(currentWidth, currentHeight, presetAspect) { const currentPixels = currentWidth * currentHeight; const option1Width = currentWidth; const option1Height = Math.round(currentWidth / presetAspect); const option1Pixels = option1Width * option1Height; const option2Height = currentHeight; const option2Width = Math.round(currentHeight * presetAspect); const option2Pixels = option2Width * option2Height; return this.chooseBestScalingOption( currentPixels, option1Pixels, option2Pixels, { width: option1Width, height: option1Height }, { width: option2Width, height: option2Height } ); } getClosestPResolution(width, height) { const pValue = Math.sqrt(width * height * 9 / 16); return `(${Math.round(pValue)}p)`; } applyFluxLikeCalculation(width, height, maxMP, maxDim, minDim, multiple) { const currentMP = (width * height) / 1000000; let w = width, h = height; if (currentMP > maxMP) { const scale = Math.sqrt(maxMP / currentMP); w *= scale; h *= scale; } const maxD = Math.max(w, h); if (maxD > maxDim) { const s = maxDim / maxD; w *= s; h *= s; } const minD = Math.min(w, h); if (minD < minDim) { const s = minDim / minD; w *= s; h *= s; } return { width: Math.max(minDim, Math.min(maxDim, Math.round(w / multiple) * multiple)), height: Math.max(minDim, Math.min(maxDim, Math.round(h / multiple) * multiple)) }; } applyFluxCalculation(width, height) { return this.applyFluxLikeCalculation(width, height, 4.0, 2560, 320, 32); } applyFlux2Calculation(width, height) { return this.applyFluxLikeCalculation(width, height, 6.0, 3840, 320, 16); } applyWANCalculation(width, height) { const targetPixels = Math.max(182080, Math.min(1195560, width * height)); const aspect = width / height; let targetHeight = Math.sqrt(targetPixels / aspect); let targetWidth = targetHeight * aspect; return { width: Math.round(targetWidth / 16) * 16, height: Math.round(targetHeight / 16) * 16 }; } applyQwenCalculation(width, height) { const currentPixels = width * height; const minPixels = 589824; const maxPixels = 4194304; if (currentPixels >= minPixels && currentPixels <= maxPixels) { return { width, height }; } else { let targetPixels; if (currentPixels < minPixels) { targetPixels = minPixels; } else { targetPixels = maxPixels; } const aspect = width / height; let targetHeight = Math.sqrt(targetPixels / aspect); let targetWidth = targetHeight * aspect; return { width: Math.round(targetWidth), height: Math.round(targetHeight) }; } } calculateResolutionScale(targetP) { const targetPixels = (targetP * (16 / 9)) * targetP; return this.calculateScaleFromPixels(targetPixels); } calculateMegapixelsScale(targetMP) { const targetPixels = targetMP * 1000000; return this.calculateScaleFromPixels(targetPixels); } calculateScaleFromPixels(targetPixels) { if (!this.widthWidget || !this.heightWidget) return 1.0; const currentPixels = this.widthWidget.value * this.heightWidget.value; return Math.sqrt(targetPixels / currentPixels); } updateRescaleValue() { const props = this.node.properties; const modeCalculations = { manual: () => props.upscaleValue, resolution: () => this.calculateResolutionScale(props.targetResolution), megapixels: () => this.calculateMegapixelsScale(props.targetMegapixels), }; const value = modeCalculations[props.rescaleMode]?.() || 1.0; props.rescaleValue = value; const rescaleValueWidget = this.node.widgets?.find(w => w.name === 'rescale_value'); if (rescaleValueWidget) { rescaleValueWidget.value = value; } const rescaleModeWidget = this.node.widgets?.find(w => w.name === 'rescale_mode'); if (rescaleModeWidget) { rescaleModeWidget.value = props.rescaleMode; } } startAutoDetect() { if (this.dimensionCheckInterval) return; this.checkForImageDimensions(); this.dimensionCheckInterval = setInterval(() => this.checkForImageDimensions(), 1000); } stopAutoDetect() { if (this.dimensionCheckInterval) { clearInterval(this.dimensionCheckInterval); this.dimensionCheckInterval = null; } } async checkForImageDimensions() { const node = this.node; try { const inputLink = node.inputs?.[0]?.link; if (!inputLink) { this.detectedDimensions = null; return; } const link = app.graph.links[inputLink]; if (link) { const sourceNode = app.graph.getNodeById(link.origin_id); const img = sourceNode?.imgs?.[0]; if (img && (!this.detectedDimensions || this.detectedDimensions.width !== img.naturalWidth || this.detectedDimensions.height !== img.naturalHeight)) { this.detectedDimensions = { width: img.naturalWidth, height: img.naturalHeight }; this.manuallySetByAutoFit = false; const props = node.properties; if (props.autoDetect && !props.autoFitOnChange && !props.useCustomCalc) { if (this.widthWidget && this.heightWidget) { this.widthWidget.value = this.detectedDimensions.width; this.heightWidget.value = this.detectedDimensions.height; this.setDimensions(this.detectedDimensions.width, this.detectedDimensions.height); } } else if (props.autoFitOnChange && !props.useCustomCalc && props.selectedCategory) { this.applyAutoFit(this.detectedDimensions.width, this.detectedDimensions.height, false, props.selectedCategory, 'detected'); } else if (props.autoFitOnChange && props.useCustomCalc && props.selectedCategory) { this.applyAutoFit(this.detectedDimensions.width, this.detectedDimensions.height, true, props.selectedCategory, 'detected'); } else if (props.autoDetect && !props.autoFitOnChange && props.useCustomCalc && props.selectedCategory) { if (this.widthWidget && this.heightWidget) { this.widthWidget.value = this.detectedDimensions.width; this.heightWidget.value = this.detectedDimensions.height; this.applyDimensionChange(); } } else if (props.autoDetect && this.widthWidget && this.heightWidget) { this.widthWidget.value = this.detectedDimensions.width; this.heightWidget.value = this.detectedDimensions.height; this.setDimensions(this.detectedDimensions.width, this.detectedDimensions.height); } if (props.autoResizeOnChange) { this.handleAutoResize(); } app.graph.setDirtyCanvas(true); } } } catch (error) { log.error('Error checking for image dimensions:', error); } } isPointInControl(x, y, control) { if (!control) return false; return x >= control.x && x <= control.x + control.w && y >= control.y && y <= control.y + control.h; } } app.registerExtension({ name: "azResolutionMaster", async beforeRegisterNodeDef(nodeType, nodeData, _app) { if (nodeData.name === "ResolutionMaster") { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function() { onNodeCreated?.apply(this, []); this.resolutionMaster = new ResolutionMasterCanvas(this); }; } } });