diff --git a/src/magnify-glass/EventHandler.ts b/src/magnify-glass/EventHandler.ts index 23624dc..f25a4c8 100644 --- a/src/magnify-glass/EventHandler.ts +++ b/src/magnify-glass/EventHandler.ts @@ -259,9 +259,7 @@ export class EventHandler { this.magnifyGlass.state.y = pixelY; this.magnifyGlass.ui.positionGlass(clientX, clientY); - // Optimization: Pass the cached rect to avoid re-measuring in calculateSourceRegion - // which would cause layout thrashing (Read-Write-Read) - this.magnifyGlass.updateMagnifiedView(rect); + this.magnifyGlass.updateMagnifiedView(); } }); } @@ -307,8 +305,7 @@ export class EventHandler { this.magnifyGlass.ui.positionGlass(clientX, clientY); } - // Optimization: Pass the cached rect - this.magnifyGlass.updateMagnifiedView(rect); + this.magnifyGlass.updateMagnifiedView(); } } } diff --git a/src/magnify-glass/MagnifierState.ts b/src/magnify-glass/MagnifierState.ts index 39b1bad..3fd3669 100644 --- a/src/magnify-glass/MagnifierState.ts +++ b/src/magnify-glass/MagnifierState.ts @@ -42,9 +42,6 @@ export class MagnifierState { /** Canvas translation Y */ canvasOffsetY: number; - /** Flag to manage requestAnimationFrame */ - isRenderScheduled: boolean; - /** Whether glass drag mode is enabled (move icon on hover controls) */ isDragModeEnabled: boolean; @@ -75,7 +72,6 @@ export class MagnifierState { this.canvasScale = 1.0; this.canvasOffsetX = 0; this.canvasOffsetY = 0; - this.isRenderScheduled = false; this.isDragModeEnabled = false; this.isPreviewHidden = false; @@ -104,6 +100,5 @@ export class MagnifierState { this.canvasScale = 1.0; this.canvasOffsetX = 0; this.canvasOffsetY = 0; - this.isRenderScheduled = false; } } diff --git a/src/magnify-glass/MagnifyGlass.ts b/src/magnify-glass/MagnifyGlass.ts index 09a0955..4ab1bd2 100644 --- a/src/magnify-glass/MagnifyGlass.ts +++ b/src/magnify-glass/MagnifyGlass.ts @@ -4,8 +4,8 @@ * Orchestrates all components of the magnifying glass. */ -import type { ComfyApp, ComfyNode } from '../types/comfyui'; -import { findLiteGraphCanvas, rectsOverlap, Rectangle } from '../shared/utils'; +import type { ComfyApp } from '../types/comfyui'; +import { findLiteGraphCanvas } from '../shared/utils'; import { DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET, INFO_PANEL_ID } from '../shared/constants'; import { registerGlassSettings, registerAccessibilitySettings } from '../shared/settings'; import { ConfigManager } from './ConfigManager'; @@ -16,6 +16,7 @@ import { DebugManager } from './DebugManager'; import { EventHandler } from './EventHandler'; import { PopOutManager } from './PopOutManager'; import { OffscreenRenderer } from './OffscreenRenderer'; +import { captureRegion } from './mediaLayout'; // External ComfyUI globals declare const app: ComfyApp; @@ -47,12 +48,9 @@ export class MagnifyGlass { /** Current media element under cursor */ currentMediaElement: HTMLImageElement | HTMLVideoElement | null; - /** Animation loop ID for continuous video rendering */ + /** Render loop ID; the loop runs while the glass is active and visible */ private animationLoopId: number | null; - /** Whether videos are currently visible in magnified view */ - private hasVisibleVideos: boolean; - constructor() { this.config = new ConfigManager(); this.state = new MagnifierState(); @@ -81,9 +79,7 @@ export class MagnifyGlass { this.isOverMedia = false; this.currentMediaElement = null; - // Animation loop for video rendering this.animationLoopId = null; - this.hasVisibleVideos = false; } /** @@ -157,7 +153,6 @@ export class MagnifyGlass { if (this.eventHandler) { this.eventHandler.updateInitialPosition(); } - // Start continuous animation loop for smooth video playback this.startAnimationLoop(); } } @@ -169,7 +164,6 @@ export class MagnifyGlass { private forceHideAllComponents(): void { this.state.active = false; this.stopAnimationLoop(); - this.hasVisibleVideos = false; this.ui.hide(); // 1. Nuclear option: Direct DOM ID targeting @@ -200,100 +194,26 @@ export class MagnifyGlass { /** * Set glass preview visibility WITHOUT affecting info panel. * This is used by the toggle-glass hover control button. - * When hidden, the renderer stops completely to save performance. - * When shown, the renderer resumes. + * When hidden, the render loop stops completely to save performance. + * When shown, the render loop resumes. * * @param visible - true to show and enable rendering, false to hide and disable */ setGlassPreviewActive(visible: boolean): void { - // Track this state internally this.state.isPreviewHidden = !visible; - - if (visible) { - // Show the glass preview - this.ui.setPreviewVisibility(true); - // Resume rendering on next mouse move - } else { - // Hide the glass preview visually - this.ui.setPreviewVisibility(false); - // The rendering will be skipped in updateMagnifiedView due to isPreviewHidden check + this.ui.setPreviewVisibility(visible); + if (visible && this.state.active) { + this.startAnimationLoop(); } } /** - * Check if the glass preview is visible. - * Returns false if hidden via hover controls. + * Update the magnified view. Rendering happens in the animation loop, + * so this only makes sure the loop is running. */ - private isGlassPreviewVisible(): boolean { - // Check info panel extension state for isGlassPreviewVisible - const extensions = (window as any).comfyUIMagnifyGlassExtensions; - if (extensions && extensions.length > 0) { - const infoPanel = extensions[0]; - if (infoPanel?.stateManager?.state) { - return infoPanel.stateManager.state.isGlassPreviewVisible !== false; - } - } - // Default to visible if we can't find the state - return true; - } - - /** - * Update the magnified view. - * @param rect - Optional pre-calculated bounding rect of the canvas to avoid layout thrashing - */ - updateMagnifiedView(rect?: DOMRect): void { - if (!this.state.active || !this.renderer || !this.litegraphCanvas) { - return; - } - - // Skip rendering if glass preview is hidden via hover controls - // This prevents double FPS when only the info panel is active - if (this.state.isPreviewHidden) { - return; - } - - // Get canvas transformation info - this.updateCanvasTransformation(); - - // Calculate the source region - this.calculateSourceRegion(rect); - - // Schedule the rendering operation - if (!this.state.isRenderScheduled) { - this.state.isRenderScheduled = true; - requestAnimationFrame(() => { - if (!this.state.active || !this.renderer || !this.litegraphCanvas) { - this.state.isRenderScheduled = false; - return; - } - - // Try virtual zoom capture for high-res magnification - // This temporarily sets LiteGraph to 100% zoom, captures clean pixels, - // then restores the original zoom level - let sourceCanvas: HTMLCanvasElement = this.litegraphCanvas; - if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) { - const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas); - if (highResCanvas) { - sourceCanvas = highResCanvas; - } - } - - // Render the magnified view - this.renderer.render(sourceCanvas); - - // Update debug visualization - - - // Render HTML overlays for text, video, and image widgets - this.renderHtmlOverlays(); - - // Send frame to pop-out tab if open - if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) { - this.popOutManager.sendFrame(this.ui.glassCanvas); - } - - this.state.isRenderScheduled = false; - }); + updateMagnifiedView(): void { + if (this.state.active) { + this.startAnimationLoop(); } } @@ -321,409 +241,48 @@ export class MagnifyGlass { } /** - * Calculate the source region for magnification. - * @param cachedRect - Optional pre-calculated bounding rect of the canvas to avoid layout thrashing + * Calculate the source region (in canvas backing pixels) for magnification. */ - calculateSourceRegion(cachedRect?: DOMRect): void { - const cursorPixelX = this.state.x; - const cursorPixelY = this.state.y; - const canvasScale = this.state.canvasScale; - const canvasOffsetX = this.state.canvasOffsetX; - const canvasOffsetY = this.state.canvasOffsetY; - - if (canvasScale === 0) return; + calculateSourceRegion(): void { if (!this.litegraphCanvas) return; - // Get DPR (Device Pixel Ratio) - relationship between Backing Store pixels and CSS pixels - // Optimization: Use cached rect if provided to avoid forcing a reflow (Read-Write-Read cycle) - const rect = cachedRect || this.litegraphCanvas.getBoundingClientRect(); - const dpr = rect.width > 0 ? this.litegraphCanvas.width / rect.width : 1; - - // Convert cursor Backing pixels to CSS pixels (Screen pixels) - // state.x/y are in Backing Pixels (set in EventHandler) - const cursorCssX = cursorPixelX / dpr; - const cursorCssY = cursorPixelY / dpr; - - // Convert cursor CSS pixels to LiteGraph graph coordinates - // ds.offset and ds.scale operate in CSS pixel space - const cursorGraphX = (cursorCssX - canvasOffsetX) / canvasScale; - const cursorGraphY = (cursorCssY - canvasOffsetY) / canvasScale; - - // Apply manual offset (in Graph Units - arbitrary units) - const targetGraphCenterX = cursorGraphX + this.config.offsetX; - const targetGraphCenterY = cursorGraphY + this.config.offsetY; - - // Calculate source dimensions in graph units - const sourceGraphWidth = (this.config.glassSize / this.config.zoomFactor) / canvasScale; - const sourceGraphHeight = (this.config.glassSize / this.config.zoomFactor) / canvasScale; - - // Calculate source top-left corner in Graph Coordinates - const sourceGraphX = targetGraphCenterX - (sourceGraphWidth / 2); - const sourceGraphY = targetGraphCenterY - (sourceGraphHeight / 2); - - // Convert back to Canvas Backing Pixels for the source rectangle - // 1. Convert Graph -> CSS: (Graph * Scale) + Offset - // 2. Convert CSS -> Backing: CSS * DPR - const sourceCssX = (sourceGraphX * canvasScale) + canvasOffsetX; - const sourceCssY = (sourceGraphY * canvasScale) + canvasOffsetY; - const sourceCssWidth = sourceGraphWidth * canvasScale; - const sourceCssHeight = sourceGraphHeight * canvasScale; - - this.state.sourceX = sourceCssX * dpr; - this.state.sourceY = sourceCssY * dpr; - this.state.sourceWidth = sourceCssWidth * dpr; - this.state.sourceHeight = sourceCssHeight * dpr; - } - - /** - * Render HTML overlays for text and media in the magnified view. - */ - renderHtmlOverlays(): void { - const graph = (app as any).graph; - if (!this.state.active || !this.ui.htmlOverlayContainer || !graph || !this.litegraphCanvas) { - if (this.ui.htmlOverlayContainer) this.ui.htmlOverlayContainer.innerHTML = ''; - return; - } - - // Removed early clear to batch reads before writes - // this.ui.htmlOverlayContainer.innerHTML = ''; - - // Track if any videos are found for animation loop management - let foundVideos = false; - - const magnifyRect: Rectangle = { - x: this.state.sourceX, - y: this.state.sourceY, - width: this.state.sourceWidth, - height: this.state.sourceHeight - }; - - const nodes: ComfyNode[] = graph._nodes; - if (!nodes) { - // Ensure any previous overlays are cleared if we exit early - this.ui.htmlOverlayContainer.innerHTML = ''; - return; - } - - // Calculate source region in graph coordinates for culling - // This mirrors calculateSourceRegion() but stops at graph coords - if (this.state.canvasScale === 0) { - // Ensure any previous overlays are cleared if we exit early - this.ui.htmlOverlayContainer.innerHTML = ''; - return; - } - const rect = this.litegraphCanvas.getBoundingClientRect(); const dpr = rect.width > 0 ? this.litegraphCanvas.width / rect.width : 1; + const cap = captureRegion(this.state.x / dpr, this.state.y / dpr, this.config.glassSize, this.config.zoomFactor); - // Pre-calculate invariant values for the frame - const currentScale = this.state.canvasScale; - const isVirtualZoomMode = currentScale < 0.7; - const canvasRect = rect; - // Pivot point (mouse position) in CSS coordinates - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; + this.state.sourceX = cap.left * dpr; + this.state.sourceY = cap.top * dpr; + this.state.sourceWidth = cap.size * dpr; + this.state.sourceHeight = cap.size * dpr; + } - const cursorCssX = pivotCssX; - const cursorCssY = pivotCssY; - const cursorGraphX = (cursorCssX - this.state.canvasOffsetX) / this.state.canvasScale; - const cursorGraphY = (cursorCssY - this.state.canvasOffsetY) / this.state.canvasScale; - const targetGraphCenterX = cursorGraphX + this.config.offsetX; - const targetGraphCenterY = cursorGraphY + this.config.offsetY; - const sourceGraphWidth = (this.config.glassSize / this.config.zoomFactor) / this.state.canvasScale; - const sourceGraphHeight = (this.config.glassSize / this.config.zoomFactor) / this.state.canvasScale; + /** + * Render one frame of the glass and forward it to the pop-out. + */ + private renderFrame(renderer: WebGLRenderer, canvas: HTMLCanvasElement): void { + this.updateCanvasTransformation(); + this.calculateSourceRegion(); - const sourceGraphRect = { - x: targetGraphCenterX - (sourceGraphWidth / 2), - y: targetGraphCenterY - (sourceGraphHeight / 2), - width: sourceGraphWidth, - height: sourceGraphHeight - }; + renderer.render(this.offscreenRenderer?.renderHighResRegion(canvas) ?? canvas); - // Batch optimization: Collect all render tasks first (Read Phase) - interface RenderTask { - element: HTMLElement; - type: 'text' | 'video' | 'image'; - // Store the calculated overlay geometry so we don't recalculate in loop 2 - overlayRect: Rectangle; - fontSize?: number; - } - const renderTasks: RenderTask[] = []; - - for (const node of nodes) { - // Optimization: Cull nodes that are not in the source region - if (node.pos && node.size) { - const nodeRect = { - x: node.pos[0], - y: node.pos[1], - width: node.size[0], - height: node.size[1] - }; - if (!rectsOverlap(sourceGraphRect, nodeRect)) { - continue; - } - } - - const widgets = (node as any).widgets; - if (!widgets) continue; - - for (const widget of widgets) { - let isTextElement = false; - let isVideoElement = false; - let isImageElement = false; - let elementToProcess: HTMLElement | null = null; - - // Check for VHS-style widgets with videoEl property (VideoHelperSuite pattern) - const widgetName = String(widget.name || '').toLowerCase(); - if (widgetName === 'videopreview' || widgetName === 'audiopreview') { - const videoEl = widget.videoEl as HTMLVideoElement | undefined; - if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) { - isVideoElement = true; - foundVideos = true; - elementToProcess = videoEl; - } - } - - // Check for standard DOM element widgets (existing logic) - if (!elementToProcess && widget.element) { - const element = widget.element as HTMLElement; - // Skip text elements - now rendered natively on canvas via OffscreenRenderer - if (widget.type === "text" || widget.type === "string" || element.tagName === 'TEXTAREA') { - // isTextElement = true; // DISABLED - using native canvas rendering - // elementToProcess = element; - } else if (element.tagName === 'VIDEO') { - isVideoElement = true; - foundVideos = true; - elementToProcess = element; - } else if (element.tagName === 'IMG') { - isImageElement = true; - elementToProcess = element; - } else { - const potentialVideo = element.querySelector('video'); - if (potentialVideo) { - isVideoElement = true; - foundVideos = true; - elementToProcess = potentialVideo; - } else { - const potentialImage = element.querySelector('img'); - if (potentialImage) { - isImageElement = true; - elementToProcess = potentialImage; - } - } - } - } - - if (elementToProcess && (isTextElement || isVideoElement || isImageElement)) { - // READ: Force layout calculation here, batched together - const widgetRect = elementToProcess.getBoundingClientRect(); - - // Widget position in CSS coordinates relative to canvas - const widgetCssX = widgetRect.left - canvasRect.left; - const widgetCssY = widgetRect.top - canvasRect.top; - const widgetCssWidth = widgetRect.width; - const widgetCssHeight = widgetRect.height; - - let finalWidgetCssX: number; - let finalWidgetCssY: number; - let finalWidgetCssWidth: number; - let finalWidgetCssHeight: number; - - if (isVirtualZoomMode) { - // Virtual Zoom: Transform widget position to match virtual 1.0 scale capture - finalWidgetCssX = (widgetCssX - pivotCssX) / currentScale + pivotCssX; - finalWidgetCssY = (widgetCssY - pivotCssY) / currentScale + pivotCssY; - finalWidgetCssWidth = widgetCssWidth / currentScale; - finalWidgetCssHeight = widgetCssHeight / currentScale; - } else { - // Direct Capture: Use actual positions - finalWidgetCssX = widgetCssX; - finalWidgetCssY = widgetCssY; - finalWidgetCssWidth = widgetCssWidth; - finalWidgetCssHeight = widgetCssHeight; - } - - // Convert to backing pixels for comparison with magnifyRect - const widgetCanvasX = finalWidgetCssX * dpr; - const widgetCanvasY = finalWidgetCssY * dpr; - const widgetCanvasWidth = finalWidgetCssWidth * dpr; - const widgetCanvasHeight = finalWidgetCssHeight * dpr; - - const widgetSourceRect: Rectangle = { - x: widgetCanvasX, - y: widgetCanvasY, - width: widgetCanvasWidth, - height: widgetCanvasHeight - }; - - // Only process further if it actually overlaps - if (rectsOverlap(magnifyRect, widgetSourceRect)) { - const task: RenderTask = { - element: elementToProcess, - type: isTextElement ? 'text' : (isVideoElement ? 'video' : 'image'), - overlayRect: widgetSourceRect, // Store calculated rect - fontSize: undefined - }; - - // READ: Get computed style for text elements (only if overlapping) - if (isTextElement) { - task.fontSize = parseFloat(window.getComputedStyle(elementToProcess).fontSize); - } - - if (isVideoElement) { - foundVideos = true; - } - - renderTasks.push(task); - } - } - } - } - - // WRITE Phase: Clear container and append new elements - this.ui.htmlOverlayContainer.innerHTML = ''; - const fragment = document.createDocumentFragment(); - - // Process collected tasks - for (const task of renderTasks) { - const elementToProcess = task.element; - const widgetSourceRect = task.overlayRect; // Use stored calculated rect - const isTextElement = task.type === 'text'; - const isVideoElement = task.type === 'video'; - const isImageElement = task.type === 'image'; - - const clonedElement = elementToProcess.cloneNode(true) as HTMLElement; - clonedElement.style.position = 'absolute'; - clonedElement.style.pointerEvents = 'none'; - - if (isTextElement) { - clonedElement.style.backgroundColor = elementToProcess.style.backgroundColor || '#222'; - clonedElement.style.color = elementToProcess.style.color || '#DDD'; - clonedElement.style.border = elementToProcess.style.border || '1px solid #555'; - // Hide scrollbar while keeping content visible - clonedElement.style.overflow = 'hidden'; - (clonedElement as HTMLInputElement).disabled = true; - - // Dual adaptive font sizing: - // - When zoomed OUT (below 50%): shrink text to fit all content - // - When zoomed IN (above 50%): grow text for better readability - const canvasScale = this.state.canvasScale; - // READ: Use stored font size - const originalFontSize = task.fontSize || 12; // Fallback - const threshold = 0.5; // 50% zoom threshold - - let adaptedFontSize: number; - if (canvasScale < threshold) { - // Zoomed out: shrink text aggressively to fit content - // Scale from threshold down to minimum - const shrinkFactor = canvasScale / threshold; - adaptedFontSize = Math.max(6, originalFontSize * shrinkFactor * 0.8); - } else { - // Zoomed in: grow text for readability - // Scale from 100% at threshold up to 150% at 100%+ zoom - const growFactor = 1 + ((canvasScale - threshold) / (1 - threshold)) * 0.5; - adaptedFontSize = originalFontSize * Math.min(1.5, growFactor); - } - - clonedElement.style.fontSize = `${adaptedFontSize}px`; - clonedElement.style.lineHeight = canvasScale < threshold ? '1.2' : '1.4'; - } else if (isVideoElement) { - const video = clonedElement as HTMLVideoElement; - const originalVideo = elementToProcess as HTMLVideoElement; - video.src = originalVideo.src; - video.autoplay = originalVideo.autoplay; - video.loop = originalVideo.loop; - video.preload = originalVideo.preload; - video.crossOrigin = originalVideo.crossOrigin; - video.muted = true; - if (!originalVideo.paused) { - video.play().catch(e => { - if (e.name !== 'AbortError') { - console.warn("Magnify Glass: Cloned video play failed", e); - } - }); - } - video.currentTime = originalVideo.currentTime; - } else if (isImageElement) { - const img = clonedElement as HTMLImageElement; - const originalImg = elementToProcess as HTMLImageElement; - img.src = originalImg.src; - img.alt = originalImg.alt; - } - - const relativeX = widgetSourceRect.x - magnifyRect.x; - const relativeY = widgetSourceRect.y - magnifyRect.y; - const magnifiedX = relativeX * this.config.zoomFactor; - const magnifiedY = relativeY * this.config.zoomFactor; - - clonedElement.style.left = `${magnifiedX}px`; - clonedElement.style.top = `${magnifiedY}px`; - clonedElement.style.width = `${widgetSourceRect.width}px`; - clonedElement.style.height = `${widgetSourceRect.height}px`; - clonedElement.style.transformOrigin = 'top left'; - clonedElement.style.transform = `scale(${this.config.zoomFactor})`; - - fragment.appendChild(clonedElement); - } - - this.ui.htmlOverlayContainer.appendChild(fragment); - - // Manage animation loop based on video presence - if (foundVideos && !this.hasVisibleVideos) { - this.hasVisibleVideos = true; - this.startAnimationLoop(); - } else if (!foundVideos && this.hasVisibleVideos) { - this.hasVisibleVideos = false; - // Loop will stop itself on next iteration + if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) { + this.popOutManager.sendFrame(this.ui.glassCanvas); } } /** - * Start the animation loop for continuous video rendering. - * Only runs when videos are visible in the magnified view. - * This loop directly renders without going through updateMagnifiedView's - * scheduling logic to ensure continuous frame updates for video playback. + * Start the render loop. It is the only render path and keeps running without + * mouse movement, so latent previews and videos update in the glass. */ private startAnimationLoop(): void { if (this.animationLoopId !== null) return; // Already running const animate = () => { - // Stop if glass is deactivated or renderer unavailable - if (!this.state.active || !this.renderer || !this.litegraphCanvas) { - this.stopAnimationLoop(); + if (!this.state.active || this.state.isPreviewHidden || !this.renderer || !this.litegraphCanvas) { + this.animationLoopId = null; return; } - - // Skip if glass preview is hidden - if (this.state.isPreviewHidden) { - this.animationLoopId = requestAnimationFrame(animate); - return; - } - - // Update canvas transformation and source region - this.updateCanvasTransformation(); - this.calculateSourceRegion(); - - // Direct render (bypass isRenderScheduled to ensure continuous updates) - let sourceCanvas: HTMLCanvasElement = this.litegraphCanvas; - if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) { - const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas); - if (highResCanvas) { - sourceCanvas = highResCanvas; - } - } - - // Render the magnified view - this.renderer.render(sourceCanvas); - - // Render HTML overlays for video widgets - this.renderHtmlOverlays(); - - // Send frame to pop-out tab if open - if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) { - this.popOutManager.sendFrame(this.ui.glassCanvas); - } - + this.renderFrame(this.renderer, this.litegraphCanvas); this.animationLoopId = requestAnimationFrame(animate); }; diff --git a/src/magnify-glass/OffscreenRenderer.ts b/src/magnify-glass/OffscreenRenderer.ts index b61dee4..8b0ab6e 100644 --- a/src/magnify-glass/OffscreenRenderer.ts +++ b/src/magnify-glass/OffscreenRenderer.ts @@ -1,16 +1,29 @@ /** * ComfyUI MagnifyGlass - Offscreen Renderer - * + * * Hybrid Renderer: * 1. Direct Capture (Nuclear) for near-100% zoom (Perfect Alignment, Good Quality). * 2. Virtual Zoom (High Res) for zoomed-out views (High Detail, sensitive alignment). + * + * Image and video previews are drawn on top in both modes from their full-resolution sources. */ import type { ConfigManager } from './ConfigManager'; import type { MagnifierState } from './MagnifierState'; +import { getSettingValue } from '../shared/utils'; +import { captureRegion, fitContent, graphBoxToCss, isSafeMediaSource, previewImageBoxes, scaleAbout, toGlass } from './mediaLayout'; +import type { Box, CaptureRegion } from './mediaLayout'; declare const app: any; +const TITLE_HEIGHT = 30; + +interface DomMedia { + el: HTMLImageElement | HTMLVideoElement; + box: Box; + objectFit: string; +} + export class OffscreenRenderer { private config: ConfigManager; private state: MagnifierState; @@ -20,11 +33,6 @@ export class OffscreenRenderer { private lastRenderHeight: number = 0; private isCapturing: boolean = false; - // Virtual Zoom Throttling (60 FPS = ~16ms per frame) - private static readonly VIRTUAL_ZOOM_MIN_INTERVAL_MS = 16; - private lastVirtualZoomTime: number = 0; - private cachedVirtualZoomResult: HTMLCanvasElement | null = null; - constructor(config: ConfigManager, state: MagnifierState) { this.config = config; this.state = state; @@ -53,199 +61,112 @@ export class OffscreenRenderer { this.lastRenderHeight = renderSize; } - const currentScale = lgCanvas.ds.scale; const rect = targetCanvas.getBoundingClientRect(); // Calculate DPR exactly as EventHandler does (Canvas Pixels / CSS Pixels) const dpr = rect.width > 0 ? targetCanvas.width / rect.width : 1; + // state.x/y are Backing Pixels; the pivot is in CSS pixels relative to the canvas + const pivotX = this.state.x / dpr; + const pivotY = this.state.y / dpr; + const cap = captureRegion(pivotX, pivotY, renderSize, this.config.zoomFactor); - // Threshold for Hybrid Mode - // If we are zoomed in enough (>= 100%), Direct Capture quality is perfect, - // and it guarantees perfect alignment with zero extra draws. - // If we are zoomed out (< 100%), we use High Res Virtual Zoom for crisp details. - // Note: Image preview nodes are handled by drawImagePreviewsNatively() during Virtual Zoom. - // If forceDirectCapture is enabled (via hotkey), always use Direct Capture for accurate cursor alignment. - const useDirectCapture = currentScale >= 1.0 || this.config.forceDirectCapture; + // Vue nodes are DOM, so re-rendering the canvas at scale 1 gains nothing and makes the nodes jitter. + // forceDirectCapture (hotkey) trades detail for exact cursor alignment. + const vueNodes = !!(window as any).LiteGraph?.vueNodesMode; + const currentScale = lgCanvas.ds.scale; + const media = this.collectDomMedia(rect, vueNodes); - if (useDirectCapture) { - // Direct Capture: Full speed, no extra draws - this.cachedVirtualZoomResult = null; // Invalidate cache when switching modes - return this.renderDirectCapture(targetCanvas, renderSize, dpr); - } else { - // Virtual Zoom: Throttled to 60 FPS to prevent FPS counter inflation - const now = performance.now(); - const elapsed = now - this.lastVirtualZoomTime; - - if (elapsed < OffscreenRenderer.VIRTUAL_ZOOM_MIN_INTERVAL_MS && this.cachedVirtualZoomResult) { - // Return cached result if within throttle window - return this.cachedVirtualZoomResult; - } - - // Perform the expensive virtual zoom render - const result = this.renderVirtualZoom(lgCanvas, targetCanvas, renderSize, currentScale, dpr); - this.lastVirtualZoomTime = now; - this.cachedVirtualZoomResult = result; - return result; + if (currentScale < 1.0 && !this.config.forceDirectCapture && !vueNodes) { + const domScale = 1 / currentScale; + const scaled = media.map(m => ({ ...m, box: scaleAbout(m.box, pivotX, pivotY, domScale) })); + return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, scaled); } + return this.renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes); } /** - * Get a list of nodes visible within the source rectangle. - * This avoids iterating over all nodes multiple times per frame. + * Read phase: measure loaded, same-origin DOM images and videos (DOM widgets, or Vue node bodies) + * before any canvas draw. Boxes are CSS pixels relative to the canvas at the real zoom. */ - private getVisibleNodes( - sourceCssX: number, - sourceCssY: number, - sourceCssWidth: number, - sourceCssHeight: number, - scale: number, - offset: [number, number] - ): any[] { - const graph = app?.graph; - if (!graph || !graph._nodes) return []; + private collectDomMedia(rect: DOMRect, vueNodes: boolean): DomMedia[] { + const selector = vueNodes ? '[data-node-id] img, [data-node-id] video' : '.dom-widget img, .dom-widget video'; + const media: DomMedia[] = []; + for (const el of document.querySelectorAll(selector)) { + const loaded = el instanceof HTMLVideoElement ? el.readyState >= 2 : el.complete && el.naturalWidth > 0; + if (!loaded || !isSafeMediaSource(el)) continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + media.push({ + el, + box: { x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, + objectFit: getComputedStyle(el).objectFit + }); + } + return media; + } + /** + * Nodes of the graph on screen (including subgraphs) that overlap the capture region, + * in canvas render order (back to front). + */ + private getVisibleNodes(cap: CaptureRegion, scale: number, offset: [number, number]): any[] { const visibleNodes = []; - for (const node of graph._nodes) { + for (const node of app.canvas.visible_nodes) { if (!node.pos || !node.size) continue; if (node.flags?.collapsed) continue; - // Calculate node position in CSS pixels - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; - const nodeCssWidth = node.size[0] * scale; - const nodeCssHeight = node.size[1] * scale; - - // Check overlap - if (nodeCssX + nodeCssWidth < sourceCssX || nodeCssX > sourceCssX + sourceCssWidth) continue; - if (nodeCssY + nodeCssHeight < sourceCssY || nodeCssY > sourceCssY + sourceCssHeight) continue; + const box = graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset); + if (box.x + box.w < cap.left || box.x > cap.left + cap.size) continue; + if (box.y + box.h < cap.top || box.y > cap.top + cap.size) continue; visibleNodes.push(node); } return visibleNodes; } - /** - * Detect if there are nodes with image previews in the capture region. - * These nodes (Save Image, Preview Image, etc.) have images that are drawn - * via onDrawBackground/onDrawForeground and cause artifacts during Virtual Zoom. - */ - private detectImagePreviewNodes(dpr: number, scale: number, offset: number[]): boolean { - const graph = app?.graph; - if (!graph || !graph._nodes) return false; + private accessibilityFilter(): string { + if (this.config.invertColors) return 'invert(1)'; + if (this.config.grayscaleMode) return 'grayscale(1)'; + return 'none'; + } + private drawCapture(targetCanvas: HTMLCanvasElement, dpr: number, cap: CaptureRegion): void { const renderSize = this.config.glassSize; - const sourceSizeCss = renderSize / this.config.zoomFactor; - - // Mouse position in CSS pixels - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; - - // Capture region in CSS pixels - const captureLeft = pivotCssX - (sourceSizeCss / 2); - const captureTop = pivotCssY - (sourceSizeCss / 2); - const captureRight = captureLeft + sourceSizeCss; - const captureBottom = captureTop + sourceSizeCss; - - // Node types that have image previews - const imagePreviewTypes = [ - 'SaveImage', 'PreviewImage', 'LoadImage', 'LoadImageMask', - 'VHS_LoadVideo', 'VHS_VideoCombine', // Video nodes - 'ImagePreview', 'ShowImage' // Common variants - ]; - - for (const node of graph._nodes) { - if (!node.pos || !node.size) continue; - if (node.flags?.collapsed) continue; - - // Check if this node type has image previews - const nodeType = node.type || node.comfyClass || ''; - const hasImagePreview = imagePreviewTypes.some(t => - nodeType.toLowerCase().includes(t.toLowerCase()) - ) || node.imgs?.length > 0; // Also check for imgs array directly - - if (!hasImagePreview) continue; - - // Calculate node position in CSS pixels - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; - const nodeCssWidth = node.size[0] * scale; - const nodeCssHeight = node.size[1] * scale; - - // Check if node overlaps with capture region - const nodeRight = nodeCssX + nodeCssWidth; - const nodeBottom = nodeCssY + nodeCssHeight; - - const overlaps = !(nodeRight < captureLeft || nodeCssX > captureRight || - nodeBottom < captureTop || nodeCssY > captureBottom); - - if (overlaps) { - return true; // Found an image preview node in capture region - } - } - - return false; + this.offscreenCtx!.save(); + this.offscreenCtx!.filter = this.accessibilityFilter(); + this.offscreenCtx!.drawImage( + targetCanvas, + cap.left * dpr, cap.top * dpr, cap.size * dpr, cap.size * dpr, + 0, 0, renderSize, renderSize + ); + this.offscreenCtx!.restore(); } /** * Mode 1: Direct Capture (Nuclear Fix) * Copies pixels directly from screen. Zero drift. */ - private renderDirectCapture(targetCanvas: HTMLCanvasElement, renderSize: number, dpr: number): HTMLCanvasElement | null { + private renderDirectCapture(targetCanvas: HTMLCanvasElement, dpr: number, cap: CaptureRegion, media: DomMedia[], vueNodes: boolean): HTMLCanvasElement | null { try { this.isCapturing = true; - - // state.x/y are Backing Pixels. pivotCss is Backing / DPR. - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; - - const sourceSizeCss = renderSize / this.config.zoomFactor; - - // sourceRect in Backing Pixels - const sourceWidth = sourceSizeCss * dpr; - const sourceHeight = sourceSizeCss * dpr; - const sourceX = (pivotCssX - (sourceSizeCss / 2)) * dpr; - const sourceY = (pivotCssY - (sourceSizeCss / 2)) * dpr; + const renderSize = this.config.glassSize; this.offscreenCtx!.clearRect(0, 0, renderSize, renderSize); + this.drawCapture(targetCanvas, dpr, cap); - // Apply accessibility filters - this.offscreenCtx!.save(); - if (this.config.invertColors) { - this.offscreenCtx!.filter = 'invert(1)'; - } else if (this.config.grayscaleMode) { - this.offscreenCtx!.filter = 'grayscale(1)'; - } + const ds = app.canvas.ds; + const scale = ds.scale; + const offset: [number, number] = [ds.offset[0], ds.offset[1]]; + // Vue nodes draw their own text and titles in the DOM + const visibleNodes = vueNodes ? [] : this.getVisibleNodes(cap, scale, offset); - this.offscreenCtx!.drawImage( - targetCanvas, - sourceX, sourceY, sourceWidth, sourceHeight, - 0, 0, renderSize, renderSize - ); - this.offscreenCtx!.restore(); + this.drawMedia(targetCanvas, dpr, visibleNodes, media, cap, scale, offset); - // Draw native widget text on top of the captured canvas - const lgCanvas = app?.canvas; - const currentScale = lgCanvas?.ds?.scale ?? 1.0; - const currentOffset: [number, number] = lgCanvas?.ds?.offset ? [lgCanvas.ds.offset[0], lgCanvas.ds.offset[1]] : [0, 0]; + this.drawWidgetTextNatively(visibleNodes, cap, scale, offset); - // OPTIMIZATION: Get visible nodes once - const sourceCssX = sourceX / dpr; - const sourceCssY = sourceY / dpr; - const sourceCssWidth = sourceWidth / dpr; - const sourceCssHeight = sourceHeight / dpr; - const visibleNodes = this.getVisibleNodes(sourceCssX, sourceCssY, sourceCssWidth, sourceCssHeight, currentScale, currentOffset); - - this.drawWidgetTextNatively(visibleNodes, sourceX, sourceY, sourceWidth, sourceHeight, renderSize, currentScale, currentOffset); - - // Draw node titles if emphasis is enabled if (this.config.accessibilityEnabled && this.config.nodeTitleEmphasis) { - this.drawNodeTitlesNatively(visibleNodes, sourceX, sourceY, sourceWidth, sourceHeight, renderSize, currentScale, currentOffset); + this.drawNodeTitlesNatively(visibleNodes, cap, scale, offset); } - // NOTE: We do NOT call drawImagePreviewsNatively() here. - // Direct Capture copies pixels from the screen which already has correctly-drawn images. - // Native image drawing is only needed during Virtual Zoom where ComfyUI's rendering fails. - - // Draw cursor preview overlay if enabled if (this.config.showCursorPreview) { this.drawCursorPreview(renderSize); } @@ -264,217 +185,187 @@ export class OffscreenRenderer { * Temporarily sets scale to 1.0 to render details. * Uses LiteGraph's setZoom() API for correct pivot handling. */ - private renderVirtualZoom(lgCanvas: any, targetCanvas: HTMLCanvasElement, renderSize: number, currentScale: number, dpr: number): HTMLCanvasElement | null { + private renderVirtualZoom( + lgCanvas: any, + targetCanvas: HTMLCanvasElement, + rect: DOMRect, + dpr: number, + pivotX: number, + pivotY: number, + cap: CaptureRegion, + media: DomMedia[] + ): HTMLCanvasElement | null { + const ds = lgCanvas.ds; + const origScale = ds.scale; + const origOffsetX = ds.offset[0]; + const origOffsetY = ds.offset[1]; + const renderSize = this.config.glassSize; + + this.isCapturing = true; + // Set global flag so node renderers can check and skip expensive operations + (window as any).__magnifyGlassCapturing = true; + try { - this.isCapturing = true; - // Set global flag so node renderers can check and skip expensive operations - (window as any).__magnifyGlassCapturing = true; + // setZoom expects the centre in client coordinates (changeScale subtracts the canvas rect) + lgCanvas.setZoom(1.0, [pivotX + rect.left, pivotY + rect.top]); - // Pivot = Mouse in CSS Pixels (relative to canvas element) - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; - - // Target Scale (1.0 for high-res capture) - const targetScale = Math.max(1.0, currentScale); - - // Store original state for restoration - const origScale = lgCanvas.ds.scale; - const origOffsetX = lgCanvas.ds.offset[0]; - const origOffsetY = lgCanvas.ds.offset[1]; - - // Use LiteGraph's setZoom API - this properly handles the pivot point - // The center parameter tells LiteGraph to zoom around that screen position - if (typeof lgCanvas.setZoom === 'function') { - lgCanvas.setZoom(targetScale, [pivotCssX, pivotCssY]); - } else { - // Fallback: manual calculation (legacy, may drift) - const pivotGraphX = (pivotCssX - origOffsetX) / currentScale; - const pivotGraphY = (pivotCssY - origOffsetY) / currentScale; - const newOffsetX = pivotCssX - (pivotGraphX * targetScale); - const newOffsetY = pivotCssY - (pivotGraphY * targetScale); - lgCanvas.ds.scale = targetScale; - lgCanvas.ds.offset[0] = newOffsetX; - lgCanvas.ds.offset[1] = newOffsetY; - } - - // Hide images AND preview widgets from nodes to prevent them from being drawn on the captured canvas - // This prevents "ghosting" artifacts and crashes (since some custom widgets fail in Virtual Zoom) + // Hide images and preview widgets for the scale-1 draw. The frontend draws previews in a + // deferred microtask, so they would miss this capture and ghost onto the screen afterwards. + // drawMedia() draws them from their sources instead. const hiddenNodeImages = new Map(); const hiddenNodeWidgets = new Map(); - if (app?.graph?._nodes) { - for (const node of app.graph._nodes) { - // 1. Hide node.imgs - if (node.imgs) { - hiddenNodeImages.set(node, node.imgs); - node.imgs = null; - } + for (const node of lgCanvas.graph._nodes) { + if (node.imgs) { + hiddenNodeImages.set(node, node.imgs); + node.imgs = null; + } - // 2. Hide preview widgets - if (node.widgets && Array.isArray(node.widgets)) { - const hasPreviewWidget = node.widgets.some((w: any) => { + if (node.widgets && Array.isArray(node.widgets)) { + const hasPreviewWidget = node.widgets.some((w: any) => { + const wName = String(w.name || '').toLowerCase(); + const wType = String(w.type || '').toLowerCase(); + return wName.includes('preview') || wName.includes('image') || wName.includes('gallery') || wName.includes('upload') || + wType.includes('preview') || wType.includes('image'); + }); + + if (hasPreviewWidget) { + hiddenNodeWidgets.set(node, node.widgets); + // Only hide explicit preview widgets. Inputs (image, upload) should remain visible. + node.widgets = node.widgets.filter((w: any) => { const wName = String(w.name || '').toLowerCase(); const wType = String(w.type || '').toLowerCase(); - return wName.includes('preview') || wName.includes('image') || wName.includes('gallery') || wName.includes('upload') || - wType.includes('preview') || wType.includes('image'); + const isPreview = wName.includes('preview') || wName.includes('gallery') || + wType.includes('preview'); + return !isPreview; }); - - if (hasPreviewWidget) { - // Backup original widgets array - hiddenNodeWidgets.set(node, node.widgets); - // Filter out the preview widgets for this draw call - node.widgets = node.widgets.filter((w: any) => { - const wName = String(w.name || '').toLowerCase(); - const wType = String(w.type || '').toLowerCase(); - // Only hide explicit preview widgets. Inputs (image, upload) should remain visible. - const isPreview = wName.includes('preview') || wName.includes('gallery') || - wType.includes('preview'); - return !isPreview; - }); - } } } } - const origUpdatePreviews = lgCanvas.updatePreviews; try { - // Temporarily disable ComfyUI's image preview updates during capture - // This prevents massive console spam if images are missing (404s) - if (typeof origUpdatePreviews === 'function') { - lgCanvas.updatePreviews = () => { }; - } - lgCanvas.draw(true, true); } finally { - // Restore immediately after draw (in finally to handle exceptions) - if (typeof origUpdatePreviews === 'function') { - lgCanvas.updatePreviews = origUpdatePreviews; - } - - // Restore images for (const [node, imgs] of hiddenNodeImages.entries()) { node.imgs = imgs; } - // Restore widgets for (const [node, widgets] of hiddenNodeWidgets.entries()) { node.widgets = widgets; } } - // Capture pixels around the pivot point - const sourceSizeCss = renderSize / this.config.zoomFactor; - const sourceWidth = sourceSizeCss * dpr; - const sourceHeight = sourceSizeCss * dpr; - const sourceX = (pivotCssX - (sourceSizeCss / 2)) * dpr; - const sourceY = (pivotCssY - (sourceSizeCss / 2)) * dpr; - this.offscreenCtx!.clearRect(0, 0, renderSize, renderSize); + this.drawCapture(targetCanvas, dpr, cap); - // Apply accessibility filters - this.offscreenCtx!.save(); - if (this.config.invertColors) { - this.offscreenCtx!.filter = 'invert(1)'; - } else if (this.config.grayscaleMode) { - this.offscreenCtx!.filter = 'grayscale(1)'; - } + // setZoom() moved the offset to zoom around the pivot; use the capture-time transform + const captureScale = ds.scale; + const captureOffset: [number, number] = [ds.offset[0], ds.offset[1]]; + const visibleNodes = this.getVisibleNodes(cap, captureScale, captureOffset); - this.offscreenCtx!.drawImage( - targetCanvas, - sourceX, sourceY, sourceWidth, sourceHeight, - 0, 0, renderSize, renderSize - ); - this.offscreenCtx!.restore(); + this.drawMedia(targetCanvas, dpr, visibleNodes, media, cap, captureScale, captureOffset); - // Draw native widget text on top of the captured canvas - // Note: For virtual zoom, we use targetScale and the CURRENT offset (after setZoom) - // because lgCanvas.setZoom() modified the offset to zoom around the pivot - const captureOffset: [number, number] = [lgCanvas.ds.offset[0], lgCanvas.ds.offset[1]]; + this.drawWidgetTextNatively(visibleNodes, cap, captureScale, captureOffset); - // OPTIMIZATION: Get visible nodes once - const sourceCssX = sourceX / dpr; - const sourceCssY = sourceY / dpr; - const sourceCssWidth = sourceWidth / dpr; - const sourceCssHeight = sourceHeight / dpr; - const visibleNodes = this.getVisibleNodes(sourceCssX, sourceCssY, sourceCssWidth, sourceCssHeight, targetScale, captureOffset); - - this.drawWidgetTextNatively(visibleNodes, sourceX, sourceY, sourceWidth, sourceHeight, renderSize, targetScale, captureOffset); - - // Draw node titles if emphasis is enabled if (this.config.accessibilityEnabled && this.config.nodeTitleEmphasis) { - this.drawNodeTitlesNatively(visibleNodes, sourceX, sourceY, sourceWidth, sourceHeight, renderSize, targetScale, captureOffset); + this.drawNodeTitlesNatively(visibleNodes, cap, captureScale, captureOffset); } - // Draw image/video previews natively on top of the captured canvas - this.drawImagePreviewsNatively(visibleNodes, sourceX, sourceY, sourceWidth, sourceHeight, renderSize, targetScale, captureOffset); - - // Draw cursor preview overlay if enabled if (this.config.showCursorPreview) { this.drawCursorPreview(renderSize); } - // Restore original zoom state and draw one clean frame - lgCanvas.ds.scale = origScale; - lgCanvas.ds.offset[0] = origOffsetX; - lgCanvas.ds.offset[1] = origOffsetY; - - const finalOrigUpdatePreviews = lgCanvas.updatePreviews; - try { - if (typeof finalOrigUpdatePreviews === 'function') { - lgCanvas.updatePreviews = () => { }; - } - - lgCanvas.draw(true, true); - } finally { - if (typeof finalOrigUpdatePreviews === 'function') { - lgCanvas.updatePreviews = finalOrigUpdatePreviews; - } - } - - this.isCapturing = false; - (window as any).__magnifyGlassCapturing = false; return this.offscreenCanvas; - } catch (e) { console.warn("VirtualZoom failed", e); - // Emergency restore - lgCanvas.ds.scale = currentScale; - lgCanvas.draw(true, true); + return null; + } finally { + // Restore original zoom state and draw one clean frame + ds.scale = origScale; + ds.offset[0] = origOffsetX; + ds.offset[1] = origOffsetY; + this.isCapturing = false; (window as any).__magnifyGlassCapturing = false; - return null; + lgCanvas.draw(true, true); } } + /** + * Draw canvas image previews (from full-resolution node.imgs) and DOM images/videos + * into the glass. Video is redrawn every tick, so it plays. + */ + private drawMedia( + targetCanvas: HTMLCanvasElement, + dpr: number, + visibleNodes: any[], + media: DomMedia[], + cap: CaptureRegion, + scale: number, + offset: [number, number] + ): void { + const ctx = this.offscreenCtx!; + const renderSize = this.config.glassSize; + const onGlass = (b: Box) => b.w > 0 && b.h > 0 && b.x < renderSize && b.y < renderSize && b.x + b.w > 0 && b.y + b.h > 0; + const overlaps = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + ctx.save(); + ctx.filter = this.accessibilityFilter(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + const sizeTextMargin = getSettingValue('Comfy.Node.AllowImageSizeDraw', true) ? 15 : 0; + for (const [i, node] of visibleNodes.entries()) { + const widget = node.imgs?.length && node.widgets?.find((w: any) => w.name === '$$canvas-image-preview'); + if (!widget) continue; + for (const { img, box } of previewImageBoxes(node.imgs, widget, node, sizeTextMargin)) { + const g = toGlass(graphBoxToCss(box, node.pos, scale, offset), cap); + if (onGlass(g) && isSafeMediaSource(img)) ctx.drawImage(img, g.x, g.y, g.w, g.h); + } + + // Nodes later in render order cover this preview on the canvas: put them back from the capture + const nodeBox = toGlass(graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset), cap); + for (const front of visibleNodes.slice(i + 1)) { + const f = toGlass(graphBoxToCss({ x: 0, y: -TITLE_HEIGHT, w: front.size[0], h: front.size[1] + TITLE_HEIGHT }, front.pos, scale, offset), cap); + if (!overlaps(f, nodeBox)) continue; + ctx.save(); + ctx.beginPath(); + ctx.rect(f.x, f.y, f.w, f.h); + ctx.clip(); + this.drawCapture(targetCanvas, dpr, cap); + ctx.restore(); + } + } + + for (const { el, box, objectFit } of media) { + const g = toGlass(box, cap); + if (!onGlass(g)) continue; + const content = el instanceof HTMLVideoElement + ? fitContent(g, el.videoWidth, el.videoHeight, objectFit) + : fitContent(g, el.naturalWidth, el.naturalHeight, objectFit); + ctx.save(); + ctx.beginPath(); + ctx.rect(g.x, g.y, g.w, g.h); + ctx.clip(); + ctx.drawImage(el, content.x, content.y, content.w, content.h); + ctx.restore(); + } + + ctx.restore(); + } /** * Draw node titles natively with accessibility styling. */ private drawNodeTitlesNatively( visibleNodes: any[], - sourceX: number, - sourceY: number, - sourceWidth: number, - sourceHeight: number, - renderSize: number, + cap: CaptureRegion, scale: number, offset: [number, number] ): void { if (!this.offscreenCtx) return; const ctx = this.offscreenCtx; - const TITLE_HEIGHT = 30; - - // Coordinate transforms - const sourceSizeCss = renderSize / this.config.zoomFactor; - const actualDpr = sourceWidth / sourceSizeCss; - const captureScale = renderSize / sourceWidth; - - // Source region in CSS pixels - const sourceCssX = sourceX / actualDpr; - const sourceCssY = sourceY / actualDpr; - const sourceCssWidth = sourceWidth / actualDpr; - const sourceCssHeight = sourceHeight / actualDpr; + const renderSize = this.config.glassSize; for (const node of visibleNodes) { if (!node.pos || !node.size) continue; @@ -483,20 +374,20 @@ export class OffscreenRenderer { const title = node.title || node.type || "Node"; // Node position in CSS pixels - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; + const nodeCssX = (node.pos[0] + offset[0]) * scale; + const nodeCssY = (node.pos[1] + offset[1]) * scale; const nodeCssWidth = node.size[0] * scale; // Check visibility - if (nodeCssX + nodeCssWidth < sourceCssX || nodeCssX > sourceCssX + sourceCssWidth) continue; - if (nodeCssY + (TITLE_HEIGHT * scale) < sourceCssY || nodeCssY > sourceCssY + sourceCssHeight) continue; + if (nodeCssX + nodeCssWidth < cap.left || nodeCssX > cap.left + cap.size) continue; + if (nodeCssY + (TITLE_HEIGHT * scale) < cap.top || nodeCssY > cap.top + cap.size) continue; // Draw - const canvasX = (nodeCssX - sourceCssX) * actualDpr * captureScale; - const canvasY = (nodeCssY - sourceCssY) * actualDpr * captureScale; + const canvasX = (nodeCssX - cap.left) * cap.k; + const canvasY = (nodeCssY - cap.top) * cap.k; // Limit width for title background - const canvasWidth = Math.min(nodeCssWidth, nodeCssWidth) * actualDpr * captureScale; - const canvasHeight = TITLE_HEIGHT * scale * actualDpr * captureScale; + const canvasWidth = Math.min(nodeCssWidth, nodeCssWidth) * cap.k; + const canvasHeight = TITLE_HEIGHT * scale * cap.k; if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { ctx.save(); @@ -518,7 +409,7 @@ export class OffscreenRenderer { // Boost title size a bit more scaleFactor *= 1.1; - const fontSize = Math.max(12, Math.min(32, baseFontSize * scale * actualDpr * captureScale * scaleFactor)); + const fontSize = Math.max(12, Math.min(32, baseFontSize * scale * cap.k * scaleFactor)); ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif`; ctx.textAlign = 'left'; @@ -536,7 +427,7 @@ export class OffscreenRenderer { ctx.shadowColor = this.config.textGlowColor; } - const padding = 10 * scale * actualDpr * captureScale; + const padding = 10 * scale * cap.k; ctx.fillText(title, canvasX + padding, canvasY + (canvasHeight / 2) - (5 * scale)); ctx.restore(); @@ -549,69 +440,40 @@ export class OffscreenRenderer { * This renders text content that would otherwise be lost since widgets are DOM elements. * * @param visibleNodes - List of nodes to check for text widgets - * @param sourceX - Source X position in backing pixels - * @param sourceY - Source Y position in backing pixels - * @param sourceWidth - Source width in backing pixels - * @param sourceHeight - Source height in backing pixels - * @param renderSize - Output render size in pixels + * @param cap - Capture region (CSS pixels) and glass scale * @param scale - Canvas scale used during capture * @param offset - Canvas offset [x, y] used during capture */ private drawWidgetTextNatively( visibleNodes: any[], - sourceX: number, - sourceY: number, - sourceWidth: number, - sourceHeight: number, - renderSize: number, + cap: CaptureRegion, scale: number, offset: [number, number] ): void { if (!this.offscreenCtx) return; const ctx = this.offscreenCtx; - const lgCanvas = app?.canvas; - if (!lgCanvas || !lgCanvas.ds) return; + const renderSize = this.config.glassSize; // LiteGraph layout constants - const TITLE_HEIGHT = 30; const WIDGET_HEIGHT = 20; const WIDGET_MARGIN = 4; const PADDING = 15; - // Use the passed offset (capture-time offset), not current lgCanvas offset - // const ds = lgCanvas.ds; - // const offset = ds.offset || [0, 0]; // OLD: This was wrong - - // The offset from LiteGraph is in CSS pixels - // The sourceX/Y/Width/Height are in backing pixels - // We need to convert properly - - // Calculate DPR from the source parameters - const sourceSizeCss = renderSize / this.config.zoomFactor; - const actualDpr = sourceWidth / sourceSizeCss; - const captureScale = renderSize / sourceWidth; - - // Source region in CSS pixels - const sourceCssX = sourceX / actualDpr; - const sourceCssY = sourceY / actualDpr; - const sourceCssWidth = sourceWidth / actualDpr; - const sourceCssHeight = sourceHeight / actualDpr; - for (const node of visibleNodes) { if (!node.widgets || !node.pos || !node.size) continue; if (node.flags?.collapsed) continue; // Calculate node position in CSS pixels - // node.pos is in graph coordinates, transform: screenCss = graphPos * scale + offset - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; + // node.pos is in graph coordinates, transform: screenCss = (graphPos + offset) * scale + const nodeCssX = (node.pos[0] + offset[0]) * scale; + const nodeCssY = (node.pos[1] + offset[1]) * scale; const nodeCssWidth = node.size[0] * scale; const nodeCssHeight = node.size[1] * scale; // Check if node is in captured region (CSS coordinates) - if (nodeCssX + nodeCssWidth < sourceCssX || nodeCssX > sourceCssX + sourceCssWidth) continue; - if (nodeCssY + nodeCssHeight < sourceCssY || nodeCssY > sourceCssY + sourceCssHeight) continue; + if (nodeCssX + nodeCssWidth < cap.left || nodeCssX > cap.left + cap.size) continue; + if (nodeCssY + nodeCssHeight < cap.top || nodeCssY > cap.top + cap.size) continue; // Calculate widget positions let widgetY = TITLE_HEIGHT; @@ -644,9 +506,9 @@ export class OffscreenRenderer { const widgetCssWidth = nodeCssWidth - (PADDING * 2 * scale); // Convert from CSS to offscreen canvas coordinates - const canvasX = (widgetCssX - sourceCssX) * actualDpr * captureScale; - const canvasY = (widgetCssY - sourceCssY) * actualDpr * captureScale; - const widgetWidth = widgetCssWidth * actualDpr * captureScale; + const canvasX = (widgetCssX - cap.left) * cap.k; + const canvasY = (widgetCssY - cap.top) * cap.k; + const widgetWidth = widgetCssWidth * cap.k; // Calculate font size // Calculate font size with accessibility scaling @@ -655,7 +517,7 @@ export class OffscreenRenderer { if (this.config.accessibilityEnabled) { scaleFactor = this.config.fontScaleFactor / 100; } - const fontSize = Math.max(10, Math.min(28 * scaleFactor, baseFontSize * scale * actualDpr * captureScale * scaleFactor)); + const fontSize = Math.max(10, Math.min(28 * scaleFactor, baseFontSize * scale * cap.k * scaleFactor)); const lineHeight = fontSize * 1.4; // ---------------------------------------------------------------- @@ -700,13 +562,13 @@ export class OffscreenRenderer { // Max height for this widget in CSS pixels (stay inside node, minus padding) const maxWidgetHeightCss = Math.max(0, nodeBottomCss - widgetCssY - (PADDING * scale)); // Convert to canvas pixels - const maxWidgetHeight = maxWidgetHeightCss * actualDpr * captureScale; + const maxWidgetHeight = maxWidgetHeightCss * cap.k; // Calculate widget height - Render full content for markdown up to node bounds const widgetHeight = (isMarkdownWidget) - ? Math.min(Math.max((widget as any).computedHeight * scale * captureScale || 0, contentHeight), maxWidgetHeight) + ? Math.min(Math.max((widget as any).computedHeight * scale * cap.k || 0, contentHeight), maxWidgetHeight) : ((widget as any).computedHeight - ? (widget as any).computedHeight * scale * captureScale + ? (widget as any).computedHeight * scale * cap.k : Math.max(80, fontSize * 5)); // Check if widget is at least partially visible (more lenient bounds check) @@ -754,277 +616,6 @@ export class OffscreenRenderer { } } - /** - * Draw image and video previews natively on the offscreen canvas. - * This renders preview content that would otherwise cause errors during Virtual Zoom, - * because ComfyUI's ImagePreviewWidget fails when the canvas scale is manipulated. - * - * Handles: - * - node.imgs[] array (standard ComfyUI SaveImage/PreviewImage nodes) - * - VHS-style DOM widget previews (video/image elements) - * - * @param visibleNodes - List of nodes to check for image previews - * @param sourceX - Source X position in backing pixels - * @param sourceY - Source Y position in backing pixels - * @param sourceWidth - Source width in backing pixels - * @param sourceHeight - Source height in backing pixels - * @param renderSize - Output render size in pixels - * @param scale - Canvas scale used during capture - * @param offset - Canvas offset [x, y] used during capture - */ - private drawImagePreviewsNatively( - visibleNodes: any[], - sourceX: number, - sourceY: number, - sourceWidth: number, - sourceHeight: number, - renderSize: number, - scale: number, - offset: [number, number] - ): void { - if (!this.offscreenCtx) return; - - const ctx = this.offscreenCtx; - - // Calculate coordinate transforms (same as drawWidgetTextNatively) - const sourceSizeCss = renderSize / this.config.zoomFactor; - const actualDpr = sourceWidth / sourceSizeCss; - const captureScale = renderSize / sourceWidth; - - // Source region in CSS pixels - const sourceCssX = sourceX / actualDpr; - const sourceCssY = sourceY / actualDpr; - const sourceCssWidth = sourceWidth / actualDpr; - const sourceCssHeight = sourceHeight / actualDpr; - - // LiteGraph constants - const TITLE_HEIGHT = 30; - const WIDGET_MARGIN = 4; - - for (const node of visibleNodes) { - if (!node.pos || !node.size) continue; - if (node.flags?.collapsed) continue; - - // Calculate node position in CSS pixels - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; - const nodeCssWidth = node.size[0] * scale; - const nodeCssHeight = node.size[1] * scale; - - // Check if node is in captured region - if (nodeCssX + nodeCssWidth < sourceCssX || nodeCssX > sourceCssX + sourceCssWidth) continue; - if (nodeCssY + nodeCssHeight < sourceCssY || nodeCssY > sourceCssY + sourceCssHeight) continue; - - // Method 1: Handle node.imgs[] array (standard ComfyUI preview) - if (node.imgs && Array.isArray(node.imgs) && node.imgs.length > 0) { - this.drawNodeImages( - ctx, node, nodeCssX, nodeCssY, nodeCssWidth, nodeCssHeight, - sourceCssX, sourceCssY, actualDpr, captureScale, renderSize, scale - ); - } - - // Method 2: Handle VHS-style DOM widget previews - if (node.widgets) { - for (const widget of node.widgets) { - const widgetName = String(widget.name || '').toLowerCase(); - if (widgetName === 'videopreview' || widgetName === 'audiopreview') { - this.drawDomWidgetPreview( - ctx, widget, node, nodeCssX, nodeCssY, nodeCssWidth, - sourceCssX, sourceCssY, actualDpr, captureScale, renderSize, scale - ); - } - } - } - } - } - - /** - * Draw images from node.imgs[] array onto the offscreen canvas. - * Supports batch images by drawing them in a grid layout. - */ - private drawNodeImages( - ctx: CanvasRenderingContext2D, - node: any, - nodeCssX: number, - nodeCssY: number, - nodeCssWidth: number, - nodeCssHeight: number, - sourceCssX: number, - sourceCssY: number, - actualDpr: number, - captureScale: number, - renderSize: number, - scale: number - ): void { - const TITLE_HEIGHT = 30; - const PADDING = 10; - const GAP = 4; // Gap between images in grid - - // Calculate image area (below title, with padding) - const imageAreaX = nodeCssX + (PADDING * scale); - const imageAreaY = nodeCssY + (TITLE_HEIGHT * scale); - const imageAreaWidth = nodeCssWidth - (PADDING * 2 * scale); - const imageAreaHeight = nodeCssHeight - (TITLE_HEIGHT * scale) - (PADDING * scale); - - if (imageAreaWidth <= 0 || imageAreaHeight <= 0) return; - - const imgs = node.imgs.filter((img: any) => - img && img instanceof HTMLImageElement && img.complete && img.naturalWidth > 0 - ); - const imgCount = imgs.length; - - if (imgCount === 0) return; - - // Calculate grid dimensions (match ComfyUI's layout) - // ComfyUI typically uses a square-ish grid - const cols = Math.ceil(Math.sqrt(imgCount)); - const rows = Math.ceil(imgCount / cols); - - // Calculate cell size with gap - const cellWidth = (imageAreaWidth - (GAP * scale * (cols - 1))) / cols; - const cellHeight = (imageAreaHeight - (GAP * scale * (rows - 1))) / rows; - - if (cellWidth <= 0 || cellHeight <= 0) return; - - // Draw each image in grid position - for (let i = 0; i < imgCount; i++) { - const img = imgs[i]; - const col = i % cols; - const row = Math.floor(i / cols); - - // Calculate cell position - const cellX = imageAreaX + col * (cellWidth + GAP * scale); - const cellY = imageAreaY + row * (cellHeight + GAP * scale); - - try { - // Calculate aspect-ratio-preserving dimensions within cell - const imgAspect = img.naturalWidth / img.naturalHeight; - const cellAspect = cellWidth / cellHeight; - - let drawWidth = cellWidth; - let drawHeight = cellHeight; - - if (imgAspect > cellAspect) { - // Image is wider - fit to width - drawHeight = drawWidth / imgAspect; - } else { - // Image is taller - fit to height - drawWidth = drawHeight * imgAspect; - } - - // Center the image in the cell - const drawX = cellX + (cellWidth - drawWidth) / 2; - const drawY = cellY + (cellHeight - drawHeight) / 2; - - // Convert to offscreen canvas coordinates - const canvasX = (drawX - sourceCssX) * actualDpr * captureScale; - const canvasY = (drawY - sourceCssY) * actualDpr * captureScale; - const canvasWidth = drawWidth * actualDpr * captureScale; - const canvasHeight = drawHeight * actualDpr * captureScale; - - // Check if at least partially visible - if (canvasX + canvasWidth > 0 && canvasX < renderSize && - canvasY + canvasHeight > 0 && canvasY < renderSize) { - - ctx.save(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - ctx.drawImage(img, canvasX, canvasY, canvasWidth, canvasHeight); - ctx.restore(); - } - } catch (e) { - // Silently fail for individual images - } - } - } - - /** - * Draw VHS-style DOM widget video/image preview onto the offscreen canvas. - */ - private drawDomWidgetPreview( - ctx: CanvasRenderingContext2D, - widget: any, - node: any, - nodeCssX: number, - nodeCssY: number, - nodeCssWidth: number, - sourceCssX: number, - sourceCssY: number, - actualDpr: number, - captureScale: number, - renderSize: number, - scale: number - ): void { - // VHS widgets have videoEl and imgEl properties - const videoEl = widget.videoEl as HTMLVideoElement | undefined; - const imgEl = widget.imgEl as HTMLImageElement | undefined; - - // Determine which element to draw - let sourceElement: HTMLVideoElement | HTMLImageElement | undefined; - let sourceWidth = 0; - let sourceHeight = 0; - - if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) { - sourceElement = videoEl; - sourceWidth = videoEl.videoWidth; - sourceHeight = videoEl.videoHeight; - } else if (imgEl && !imgEl.hidden && imgEl.naturalWidth > 0) { - sourceElement = imgEl; - sourceWidth = imgEl.naturalWidth; - sourceHeight = imgEl.naturalHeight; - } - - if (!sourceElement || sourceWidth === 0 || sourceHeight === 0) return; - - try { - // Widget positioning - VHS uses computedHeight and last_y - const widgetY = (widget as any).last_y ?? 30; - const widgetHeight = widget.computedHeight ?? 100; - - const widgetCssY = nodeCssY + (widgetY * scale); - const widgetCssWidth = nodeCssWidth - 20 * scale; // VHS padding - const widgetCssHeight = widgetHeight * scale; - - if (widgetCssWidth <= 0 || widgetCssHeight <= 0) return; - - // Calculate aspect-ratio-preserving dimensions - const srcAspect = sourceWidth / sourceHeight; - const areaAspect = widgetCssWidth / widgetCssHeight; - - let drawWidth = widgetCssWidth; - let drawHeight = widgetCssHeight; - - if (srcAspect > areaAspect) { - drawHeight = drawWidth / srcAspect; - } else { - drawWidth = drawHeight * srcAspect; - } - - // Center in widget area - const drawX = nodeCssX + 10 * scale + (widgetCssWidth - drawWidth) / 2; - const drawY = widgetCssY + (widgetCssHeight - drawHeight) / 2; - - // Convert to offscreen canvas coordinates - const canvasX = (drawX - sourceCssX) * actualDpr * captureScale; - const canvasY = (drawY - sourceCssY) * actualDpr * captureScale; - const canvasWidth = drawWidth * actualDpr * captureScale; - const canvasHeight = drawHeight * actualDpr * captureScale; - - // Check if at least partially visible - if (canvasX + canvasWidth > 0 && canvasX < renderSize && - canvasY + canvasHeight > 0 && canvasY < renderSize) { - - ctx.save(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = 'high'; - ctx.drawImage(sourceElement, canvasX, canvasY, canvasWidth, canvasHeight); - ctx.restore(); - } - } catch (e) { - // Silently fail for individual previews - } - } - /** * Render markdown text onto the context. * Supports basic headers (#) and lists (-). @@ -1118,58 +709,6 @@ export class OffscreenRenderer { } } - - - /** - * Render a button widget. - * Draws a rounded rectangle with the label centered. - */ - private drawButton( - ctx: CanvasRenderingContext2D, - label: string, - x: number, - y: number, - width: number, - height: number, - fontSize: number - ): void { - const radius = 6; - - ctx.save(); - - // Button background - ctx.beginPath(); - ctx.roundRect(x, y, width, height, radius); - ctx.fillStyle = '#222'; // Dark button background - ctx.fill(); - - // Button border - ctx.strokeStyle = '#555'; - ctx.lineWidth = 1; - ctx.stroke(); - - // Button label - ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - - let textColor = '#ccc'; // Light grey text - if (this.config.accessibilityEnabled && this.config.highContrastMode) { - textColor = '#ffffff'; - } - ctx.fillStyle = textColor; - - // Center text in button - const fileUploadLabel = "choose file to upload"; - // If it's the long upload label, we might need smaller font or wrapping, - // but typically buttons are single line. - // We'll just truncate if too long or let it clip via context clipping if we added any (we didn't yet). - - this.drawAccessibleText(ctx, label, x + width / 2, y + height / 2); - - ctx.restore(); - } - /** * Draw a mini cursor preview at the center of the glass. * The cursor is drawn as a classic arrow pointer with a contrasting outline. @@ -1218,14 +757,6 @@ export class OffscreenRenderer { ctx.restore(); } - isAvailable(): boolean { - return this.offscreenCanvas !== null && this.offscreenCtx !== null; - } - - getCanvas(): HTMLCanvasElement | null { - return this.offscreenCanvas; - } - /** * Draw text with accessibility enhancements (glow, outline). */ diff --git a/src/magnify-glass/UiManager.ts b/src/magnify-glass/UiManager.ts index a1c87cb..1c3bf5e 100644 --- a/src/magnify-glass/UiManager.ts +++ b/src/magnify-glass/UiManager.ts @@ -19,7 +19,6 @@ export class UiManager { state: MagnifierState; glassDiv: HTMLDivElement | null; glassCanvas: HTMLCanvasElement | null; - htmlOverlayContainer: HTMLDivElement | null; onToggle: (() => void) | undefined; constructor(config: ConfigManager, state: MagnifierState, onToggle?: () => void) { @@ -27,7 +26,6 @@ export class UiManager { this.state = state; this.glassDiv = null; this.glassCanvas = null; - this.htmlOverlayContainer = null; this.onToggle = onToggle; } @@ -60,20 +58,6 @@ export class UiManager { this.glassCanvas.id = "comfyui-magnify-canvas"; this.glassDiv.appendChild(this.glassCanvas); - // Create HTML overlay container - this.htmlOverlayContainer = document.createElement("div"); - this.htmlOverlayContainer.id = "comfyui-magnify-html-overlay"; - this.htmlOverlayContainer.style.cssText = ` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - overflow: hidden; - `; - this.glassDiv.appendChild(this.htmlOverlayContainer); - document.body.appendChild(this.glassDiv); // Inject menu button @@ -283,10 +267,6 @@ export class UiManager { if (this.glassDiv) { this.glassDiv.style.display = 'none'; } - // Clear HTML overlays when hiding - if (this.htmlOverlayContainer) { - this.htmlOverlayContainer.innerHTML = ''; - } } /** diff --git a/src/magnify-glass/WebGLRenderer.ts b/src/magnify-glass/WebGLRenderer.ts index 42d794f..cd32cd3 100644 --- a/src/magnify-glass/WebGLRenderer.ts +++ b/src/magnify-glass/WebGLRenderer.ts @@ -39,14 +39,6 @@ export class WebGLRenderer { attributeLocations: AttributeLocations | null; currentFilteringMode: number | null; - // Dirty flag optimization - lastCanvasVersion: number; - lastSourceX: number; - lastSourceY: number; - lastSourceWidth: number; - lastSourceHeight: number; - textureUploadSkipCount: number; - vertexShaderSource: string; fragmentShaderSource: string; @@ -64,14 +56,6 @@ export class WebGLRenderer { this.attributeLocations = null; this.currentFilteringMode = null; - // Initialize dirty flag tracking - this.lastCanvasVersion = -1; - this.lastSourceX = -1; - this.lastSourceY = -1; - this.lastSourceWidth = -1; - this.lastSourceHeight = -1; - this.textureUploadSkipCount = 0; - this.vertexShaderSource = ` attribute vec2 a_position; attribute vec2 a_texCoord; @@ -262,45 +246,13 @@ export class WebGLRenderer { render(sourceCanvas: HTMLCanvasElement): void { if (!this.gl || !this.program || !this.texture || !this.uniformLocations || !this.attributeLocations) return; - // Check if we need to update the texture - // We track: canvas version (via LiteGraph's change counter), source coordinates, and source dimensions - const app = (window as any).app; - const currentCanvasVersion = app?.graph?.change_counter ?? -1; - - const sourceChanged = - this.lastSourceX !== this.state.sourceX || - this.lastSourceY !== this.state.sourceY || - this.lastSourceWidth !== this.state.sourceWidth || - this.lastSourceHeight !== this.state.sourceHeight; - - const canvasChanged = currentCanvasVersion !== this.lastCanvasVersion; - - // Only upload texture if something changed - const needsTextureUpdate = canvasChanged || sourceChanged || this.lastCanvasVersion === -1; - - // Bind texture + // Upload every frame: the source changes with live previews and video even when the cursor is still this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture); - - if (needsTextureUpdate) { - try { - this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, sourceCanvas); - - // Update tracking variables - this.lastCanvasVersion = currentCanvasVersion; - this.lastSourceX = this.state.sourceX; - this.lastSourceY = this.state.sourceY; - this.lastSourceWidth = this.state.sourceWidth; - this.lastSourceHeight = this.state.sourceHeight; - - // Reset skip counter - this.textureUploadSkipCount = 0; - } catch (e) { - console.error("ComfyUI Magnifying Glass ERROR: Error in texImage2D:", e); - return; - } - } else { - // Skip texture upload - just increment counter - this.textureUploadSkipCount++; + try { + this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, sourceCanvas); + } catch (e) { + console.error("ComfyUI Magnifying Glass ERROR: Error in texImage2D:", e); + return; } // Calculate normalized texture coordinates (UV space: 0-1) diff --git a/src/magnify-glass/mediaLayout.ts b/src/magnify-glass/mediaLayout.ts new file mode 100644 index 0000000..cda289f --- /dev/null +++ b/src/magnify-glass/mediaLayout.ts @@ -0,0 +1,111 @@ +/** + * ComfyUI MagnifyGlass - Media Layout + * + * Pure geometry for drawing image/video previews into the glass. + * All values are CSS pixels relative to the canvas element. + */ + +export interface Box { + x: number; + y: number; + w: number; + h: number; +} + +export interface CaptureRegion { + left: number; + top: number; + size: number; + /** Glass pixels per CSS pixel */ + k: number; +} + +export function captureRegion(pivotX: number, pivotY: number, glassSize: number, zoomFactor: number): CaptureRegion { + const size = glassSize / zoomFactor; + return { left: pivotX - size / 2, top: pivotY - size / 2, size, k: glassSize / size }; +} + +export function toGlass(box: Box, cap: CaptureRegion): Box { + return { x: (box.x - cap.left) * cap.k, y: (box.y - cap.top) * cap.k, w: box.w * cap.k, h: box.h * cap.k }; +} + +/** Node-local graph box to canvas CSS pixels, matching LiteGraph's (pos + offset) * scale. */ +export function graphBoxToCss(box: Box, nodePos: ArrayLike, scale: number, offset: ArrayLike): Box { + return { + x: (nodePos[0] + box.x + offset[0]) * scale, + y: (nodePos[1] + box.y + offset[1]) * scale, + w: box.w * scale, + h: box.h * scale + }; +} + +/** Scale a box about a fixed point, e.g. a DOM rect measured at the real zoom into the Virtual Zoom frame. */ +export function scaleAbout(box: Box, px: number, py: number, s: number): Box { + return { x: px + (box.x - px) * s, y: py + (box.y - py) * s, w: box.w * s, h: box.h * s }; +} + +/** Where an element's content lands inside its box for the given CSS object-fit. */ +export function fitContent(box: Box, nw: number, nh: number, objectFit: string): Box { + if (objectFit !== 'contain' && objectFit !== 'scale-down' && objectFit !== 'cover') return box; + let s = objectFit === 'cover' ? Math.max(box.w / nw, box.h / nh) : Math.min(box.w / nw, box.h / nh); + if (objectFit === 'scale-down') s = Math.min(s, 1); + const w = nw * s; + const h = nh * s; + return { x: box.x + (box.w - w) / 2, y: box.y + (box.h - h) / 2, w, h }; +} + +/** + * Node-local boxes of the frontend's `$$canvas-image-preview` images + * (mirrors renderPreview in the ComfyUI frontend). + */ +export function previewImageBoxes( + imgs: HTMLImageElement[], + widget: { y: number; computedHeight?: number; width?: number }, + node: { size: ArrayLike; imageIndex?: number | null; imageRects?: number[][] }, + sizeTextMargin: number +): { img: HTMLImageElement; box: Box }[] { + const loaded = (img: HTMLImageElement | undefined) => !!img?.naturalWidth && !!img.naturalHeight; + const index = node.imageIndex ?? (imgs.length === 1 ? 0 : null); + + if (index != null) { + const img = imgs.at(index); + if (!img || !loaded(img)) return []; + const w = widget.width || node.size[0]; + const h = widget.computedHeight ? widget.computedHeight - sizeTextMargin : 0; + const s = Math.min(w / img.naturalWidth, h / img.naturalHeight, 1); + const dw = img.naturalWidth * s; + const dh = img.naturalHeight * s; + return [{ img, box: { x: (w - dw) / 2, y: widget.y + (h - dh) / 2, w: dw, h: dh } }]; + } + + const rects = node.imageRects ?? []; + const ratio = imgs[0]?.naturalWidth / imgs[0]?.naturalHeight; + const inset = imgs.every(img => img.naturalWidth / img.naturalHeight === ratio) ? 0 : 2; + const boxes: { img: HTMLImageElement; box: Box }[] = []; + imgs.forEach((img, i) => { + const cell = rects[i]; + if (!cell || !loaded(img)) return; + const [cx, cy, cw, ch] = cell; + const s = Math.min(cw / img.naturalWidth, ch / img.naturalHeight); + const dw = img.naturalWidth * s; + const dh = img.naturalHeight * s; + boxes.push({ + img, + box: { x: cx + (cw - dw) / 2 + inset, y: cy + (ch - dh) / 2 + inset, w: dw - inset * 2, h: dh - inset * 2 } + }); + }); + return boxes; +} + +/** + * Whether drawing this element keeps the canvas origin-clean. A cross-origin + * drawImage does not throw; it silently taints the canvas, which later breaks + * the WebGL upload and the pop-out. + */ +export function isSafeMediaSource(el: HTMLImageElement | HTMLVideoElement): boolean { + if (el.crossOrigin !== null) return true; + const src = el.currentSrc || el.src; + if (!src) return false; + const url = new URL(src, location.href); + return url.protocol === 'blob:' || url.protocol === 'data:' || url.origin === location.origin; +} diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 15eb4a2..0afd1af 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -10,16 +10,6 @@ import type { ComfyApp } from '../types/comfyui'; // @ts-ignore import { app } from "/scripts/app.js"; -/** - * Rectangle interface for overlap calculations - */ -export interface Rectangle { - x: number; - y: number; - width: number; - height: number; -} - /** * Debug logger interface */ @@ -90,19 +80,6 @@ export function isUserTyping(): boolean { return false; } -/** - * Check if two rectangles overlap. - * @param rect1 - First rectangle - * @param rect2 - Second rectangle - * @returns True if rectangles overlap - */ -export function rectsOverlap(rect1: Rectangle, rect2: Rectangle): boolean { - return rect1.x < rect2.x + rect2.width && - rect1.x + rect1.width > rect2.x && - rect1.y < rect2.y + rect2.height && - rect1.y + rect1.height > rect2.y; -} - /** * Find the LiteGraph canvas element in the DOM. * Tries multiple selectors for compatibility with different ComfyUI versions. diff --git a/tests/unit/MagnifierState.test.ts b/tests/unit/MagnifierState.test.ts index 031e397..2dfb3c2 100644 --- a/tests/unit/MagnifierState.test.ts +++ b/tests/unit/MagnifierState.test.ts @@ -39,10 +39,6 @@ describe('MagnifierState', () => { expect(state.canvasOffsetX).toBe(0); expect(state.canvasOffsetY).toBe(0); }); - - it('should not be scheduled for render initially', () => { - expect(state.isRenderScheduled).toBe(false); - }); }); describe('reset', () => { diff --git a/tests/unit/MagnifyGlassRenderOptim.test.ts b/tests/unit/MagnifyGlassRenderOptim.test.ts deleted file mode 100644 index 372b8fa..0000000 --- a/tests/unit/MagnifyGlassRenderOptim.test.ts +++ /dev/null @@ -1,131 +0,0 @@ - -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// Create hoisted mocks -const { appMock } = vi.hoisted(() => { - return { - appMock: { - registerExtension: vi.fn(), - ui: { - settings: { - getSettingValue: vi.fn(), - setSettingValue: vi.fn(), - } - }, - canvas: { - ds: { - scale: 1, - offset: [0, 0] - } - }, - graph: { - _nodes: [] as any[] - } - } - }; -}); - -// Attach to global for files that rely on global variable 'app' -// @ts-ignore -globalThis.app = appMock; - -vi.mock('/scripts/app.js', () => ({ - app: appMock -})); - -// Import after mocks are set up -import { MagnifyGlass } from '../../src/magnify-glass/MagnifyGlass'; - -describe('MagnifyGlass Rendering Optimization', () => { - let magnifyGlass: MagnifyGlass; - let canvasMock: HTMLCanvasElement; - let canvasGetBoundingClientRectSpy: any; - - beforeEach(() => { - // Reset mocks - vi.clearAllMocks(); - - // Reset appMock state - appMock.graph._nodes = []; - - // Setup document body - document.body.innerHTML = ''; - canvasMock = document.getElementById('graph-canvas') as HTMLCanvasElement; - - // Mock getBoundingClientRect - canvasGetBoundingClientRectSpy = vi.spyOn(canvasMock, 'getBoundingClientRect').mockReturnValue({ - x: 0, y: 0, width: 1000, height: 800, top: 0, left: 0, right: 1000, bottom: 800, toJSON: () => {} - }); - - // Initialize MagnifyGlass manually - magnifyGlass = new MagnifyGlass(); - magnifyGlass.litegraphCanvas = canvasMock; - - // Mock UI container - magnifyGlass.ui.htmlOverlayContainer = document.createElement('div'); - - // Setup state - magnifyGlass.state.active = true; - magnifyGlass.state.canvasScale = 1; - magnifyGlass.state.x = 500; - magnifyGlass.state.y = 400; - - // Setup config - magnifyGlass.config.glassSize = 300; - magnifyGlass.config.zoomFactor = 2; - - // Trigger calculateSourceRegion - magnifyGlass.calculateSourceRegion(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - document.body.innerHTML = ''; - }); - - it('should call getBoundingClientRect multiple times (inefficiently) before optimization', () => { - // Setup nodes and widgets - const numNodes = 5; - const widgetsPerNode = 3; - - const nodes = []; - for (let i = 0; i < numNodes; i++) { - const widgets = []; - for (let j = 0; j < widgetsPerNode; j++) { - const el = document.createElement('div'); - const videoEl = document.createElement('video'); - el.appendChild(videoEl); - - widgets.push({ - type: 'custom', - element: el - }); - } - - nodes.push({ - pos: [400, 300], - size: [200, 200], - widgets: widgets - }); - } - - appMock.graph._nodes = nodes; - - // Mock widget getBoundingClientRect - vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ - x: 450, y: 350, width: 100, height: 100, top: 350, left: 450, right: 550, bottom: 450, toJSON: () => {} - }); - - // Run renderHtmlOverlays - magnifyGlass.renderHtmlOverlays(); - - // Calculate expected calls - // 1 call in setup (calculateSourceRegion) - // 1 call in renderHtmlOverlays (top level) - // 0 calls inside loop (optimization) - const expectedCalls = 2; - - // After optimization, this should pass - expect(canvasGetBoundingClientRectSpy).toHaveBeenCalledTimes(expectedCalls); - }); -}); diff --git a/tests/unit/OffscreenRenderer.test.ts b/tests/unit/OffscreenRenderer.test.ts index 89cf8d2..df8c620 100644 --- a/tests/unit/OffscreenRenderer.test.ts +++ b/tests/unit/OffscreenRenderer.test.ts @@ -5,15 +5,19 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; const { appMock } = vi.hoisted(() => { return { appMock: { - graph: { - _nodes: [] as any[] + ui: { + settings: { + getSettingValue: (_key: string): any => undefined + } }, canvas: { ds: { scale: 1, offset: [0, 0] - } - } + }, + visible_nodes: [] as any[], + graph: { _nodes: [] as any[] } + } as any } }; }); @@ -38,8 +42,7 @@ describe('OffscreenRenderer Optimization', () => { beforeEach(() => { vi.clearAllMocks(); - appMock.graph._nodes = []; - appMock.canvas.ds = { scale: 1, offset: [0, 0] }; + appMock.canvas = { ds: { scale: 1, offset: [0, 0] }, visible_nodes: [], graph: { _nodes: [] } }; // Mock ConfigManager configMock = { @@ -88,6 +91,7 @@ describe('OffscreenRenderer Optimization', () => { lineTo: vi.fn(), closePath: vi.fn(), strokeText: vi.fn(), + rect: vi.fn(), }); return canvas; } @@ -97,6 +101,10 @@ describe('OffscreenRenderer Optimization', () => { renderer = new OffscreenRenderer(configMock, stateMock); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should identify visible nodes and render them', () => { // Setup nodes // Node 1: Visible (at 50, 50, size 100x100) @@ -119,7 +127,7 @@ describe('OffscreenRenderer Optimization', () => { ] }; - appMock.graph._nodes = [visibleNode, invisibleNode]; + appMock.canvas.visible_nodes = [visibleNode, invisibleNode]; // Mock target canvas canvasMock = document.createElement('canvas'); @@ -155,4 +163,187 @@ describe('OffscreenRenderer Optimization', () => { // Verify text was NOT drawn for invisible node expect(fillTextSpy).not.toHaveBeenCalledWith(expect.stringContaining('Invisible Text'), expect.any(Number), expect.any(Number)); }); + + describe('media in the glass', () => { + const PREVIEW_WIDGET = '$$canvas-image-preview'; + + const targetCanvas = (left: number, top: number, dpr: number) => { + const canvas = document.createElement('canvas'); + canvas.width = 1000 * dpr; + canvas.height = 800 * dpr; + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: left, y: top, left, top, width: 1000, height: 800, right: left + 1000, bottom: top + 800, toJSON: () => {} + } as DOMRect); + return canvas; + }; + + const image = (src: string, naturalWidth: number, naturalHeight: number) => { + const img = document.createElement('img'); + img.src = src; + Object.defineProperty(img, 'naturalWidth', { value: naturalWidth }); + Object.defineProperty(img, 'naturalHeight', { value: naturalHeight }); + Object.defineProperty(img, 'complete', { value: true }); + return img; + }; + + const domVideo = (readyState: number) => { + const wrapper = document.createElement('div'); + wrapper.className = 'dom-widget'; + const video = document.createElement('video'); + video.src = '/view?filename=clip.mp4&type=output'; + Object.defineProperty(video, 'readyState', { value: readyState }); + Object.defineProperty(video, 'videoWidth', { value: 40 }); + Object.defineProperty(video, 'videoHeight', { value: 30 }); + vi.spyOn(video, 'getBoundingClientRect').mockReturnValue({ + x: 280, y: 180, left: 280, top: 180, width: 40, height: 30, right: 320, bottom: 210, toJSON: () => {} + } as DOMRect); + wrapper.appendChild(video); + document.body.appendChild(wrapper); + return video; + }; + + const drawsOf = (el: unknown) => + ((renderer as any).offscreenCtx.drawImage as ReturnType).mock.calls.filter(c => c[0] === el); + + const previewNode = (img: HTMLImageElement) => ({ + pos: [150, 100], + size: [200, 300], + imgs: [img], + widgets: [{ name: PREVIEW_WIDGET, type: 'custom', y: 40, computedHeight: 215 }] + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete (window as any).LiteGraph; + }); + + it('draws a canvas preview image at its frontend box in Direct Capture (scale 1.5, panned)', () => { + appMock.canvas.ds = { scale: 1.5, offset: [-40, 20] }; + const img = image('/view?filename=a.png&type=output', 100, 50); + appMock.canvas.visible_nodes = [previewNode(img)]; + stateMock.x = 300; + stateMock.y = 380; + + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + + // Node-local box (50, 115, 100x50) -> CSS ((150+50-40)*1.5, (100+115+20)*1.5) = (240, 352.5) + // Capture region left 250, top 330, k 2 -> glass (-20, 45, 300x150) + expect(drawsOf(img)).toEqual([[img, -20, 45, 300, 150]]); + }); + + it('puts a node in front of a preview back over it from the capture', () => { + const img = image('/view?filename=a.png&type=output', 100, 50); + const front = { pos: [200, 250], size: [100, 100], widgets: [] }; + const behind = { pos: [190, 190], size: [40, 40], widgets: [] }; + appMock.canvas.visible_nodes = [behind, previewNode(img), front]; + stateMock.x = 250; + stateMock.y = 250; + const canvas = targetCanvas(0, 0, 1); + + renderer.renderHighResRegion(canvas); + + const ctx = (renderer as any).offscreenCtx; + // Capture, preview, then the capture again clipped to the node after the preview node only: + // with its title (200, 220, 100x130) -> glass (0, 40, 200x260) + expect(ctx.drawImage.mock.calls.map((c: unknown[]) => c[0])).toEqual([canvas, img, canvas]); + expect(ctx.rect.mock.calls).toEqual([[0, 40, 200, 260]]); + }); + + it('passes client coordinates to setZoom', () => { + appMock.canvas.ds = { scale: 0.5, offset: [0, 0] }; + appMock.canvas.setZoom = vi.fn((s: number) => { appMock.canvas.ds.scale = s; }); + appMock.canvas.draw = vi.fn(); + const img = image('/view?filename=a.png&type=output', 100, 50); + const node = previewNode(img); + appMock.canvas.graph._nodes = [node]; + stateMock.x = 300; + stateMock.y = 200; + + renderer.renderHighResRegion(targetCanvas(100, 50, 1)); + + expect(appMock.canvas.setZoom).toHaveBeenCalledWith(1, [400, 250]); + expect(appMock.canvas.ds.scale).toBe(0.5); + expect((window as any).__magnifyGlassCapturing).toBe(false); + expect(node.imgs).toEqual([img]); + }); + + it('maps a DOM video the same way at DPR 1 and DPR 2', () => { + const video = domVideo(4); + + stateMock.x = 300; + stateMock.y = 200; + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + + stateMock.x = 600; + stateMock.y = 400; + renderer.renderHighResRegion(targetCanvas(0, 0, 2)); + + expect(drawsOf(video)).toEqual([[video, 60, 60, 80, 60], [video, 60, 60, 80, 60]]); + }); + + it('skips a DOM video that has no current frame', () => { + const video = domVideo(1); + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + expect(drawsOf(video)).toHaveLength(0); + }); + + it('never draws a cross-origin image', () => { + const img = image('https://other.example/a.png', 100, 50); + appMock.canvas.visible_nodes = [previewNode(img)]; + const wrapper = document.createElement('div'); + wrapper.className = 'dom-widget'; + const domImg = image('https://other.example/b.png', 40, 30); + vi.spyOn(domImg, 'getBoundingClientRect').mockReturnValue({ + x: 280, y: 180, left: 280, top: 180, width: 40, height: 30, right: 320, bottom: 210, toJSON: () => {} + } as DOMRect); + wrapper.appendChild(domImg); + document.body.appendChild(wrapper); + stateMock.x = 300; + stateMock.y = 200; + + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + + expect(drawsOf(img)).toHaveLength(0); + expect(drawsOf(domImg)).toHaveLength(0); + }); + + it('uses Direct Capture with Vue node media when Vue nodes mode is on', () => { + (window as any).LiteGraph = { vueNodesMode: true }; + appMock.canvas.ds = { scale: 0.5, offset: [0, 0] }; + appMock.canvas.setZoom = vi.fn(); + appMock.canvas.draw = vi.fn(); + const node = document.createElement('div'); + node.setAttribute('data-node-id', '7'); + const img = image('/view?filename=a.png&type=output', 40, 30); + vi.spyOn(img, 'getBoundingClientRect').mockReturnValue({ + x: 280, y: 180, left: 280, top: 180, width: 40, height: 30, right: 320, bottom: 210, toJSON: () => {} + } as DOMRect); + node.appendChild(img); + document.body.appendChild(node); + stateMock.x = 300; + stateMock.y = 200; + + renderer.renderHighResRegion(targetCanvas(0, 0, 1)); + + expect(appMock.canvas.setZoom).not.toHaveBeenCalled(); + expect(appMock.canvas.draw).not.toHaveBeenCalled(); + expect(drawsOf(img)).toEqual([[img, 60, 60, 80, 60]]); + }); + + it('draws the video on every call in Virtual Zoom (no throttle)', () => { + appMock.canvas.ds = { scale: 0.5, offset: [0, 0] }; + appMock.canvas.setZoom = vi.fn((s: number) => { appMock.canvas.ds.scale = s; }); + appMock.canvas.draw = vi.fn(); + const video = domVideo(4); + stateMock.x = 300; + stateMock.y = 200; + const canvas = targetCanvas(0, 0, 1); + + renderer.renderHighResRegion(canvas); + renderer.renderHighResRegion(canvas); + + // Measured at 0.5, scaled x2 about the pivot (300, 200) -> (260, 160, 80x60) -> glass (20, 20, 160x120) + expect(drawsOf(video)).toEqual([[video, 20, 20, 160, 120], [video, 20, 20, 160, 120]]); + }); + }); }); diff --git a/tests/unit/mediaLayout.test.ts b/tests/unit/mediaLayout.test.ts new file mode 100644 index 0000000..bcbef91 --- /dev/null +++ b/tests/unit/mediaLayout.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { + captureRegion, + toGlass, + graphBoxToCss, + scaleAbout, + fitContent, + previewImageBoxes, + isSafeMediaSource +} from '../../src/magnify-glass/mediaLayout'; + +const fakeImg = (naturalWidth: number, naturalHeight: number) => + ({ naturalWidth, naturalHeight }) as HTMLImageElement; + +describe('mediaLayout', () => { + describe('captureRegion / toGlass', () => { + it('centres the region on the pivot and scales by glassSize / size', () => { + expect(captureRegion(300, 200, 200, 2)).toEqual({ left: 250, top: 150, size: 100, k: 2 }); + }); + + it('maps a CSS box into glass pixels', () => { + expect(toGlass({ x: 280, y: 180, w: 40, h: 30 }, captureRegion(300, 200, 200, 2))).toEqual({ x: 60, y: 60, w: 80, h: 60 }); + }); + }); + + describe('graphBoxToCss', () => { + it('uses (pos + offset) * scale like LiteGraph', () => { + const css = graphBoxToCss({ x: 10, y: 20, w: 100, h: 50 }, [150, 100], 1.5, [-40, 20]); + expect(css).toEqual({ x: (150 + 10 - 40) * 1.5, y: (100 + 20 + 20) * 1.5, w: 150, h: 75 }); + }); + }); + + describe('scaleAbout', () => { + it('keeps the pivot fixed and scales distances and sizes', () => { + expect(scaleAbout({ x: 280, y: 180, w: 40, h: 30 }, 300, 200, 2)).toEqual({ x: 260, y: 160, w: 80, h: 60 }); + expect(scaleAbout({ x: 300, y: 200, w: 10, h: 10 }, 300, 200, 4)).toEqual({ x: 300, y: 200, w: 40, h: 40 }); + }); + }); + + describe('fitContent', () => { + const box = { x: 0, y: 0, w: 200, h: 100 }; + + it('contain letterboxes and centres', () => { + expect(fitContent(box, 100, 100, 'contain')).toEqual({ x: 50, y: 0, w: 100, h: 100 }); + }); + + it('scale-down never enlarges', () => { + expect(fitContent(box, 50, 20, 'scale-down')).toEqual({ x: 75, y: 40, w: 50, h: 20 }); + expect(fitContent(box, 400, 400, 'scale-down')).toEqual({ x: 50, y: 0, w: 100, h: 100 }); + }); + + it('cover fills and overflows', () => { + expect(fitContent(box, 100, 100, 'cover')).toEqual({ x: 0, y: -50, w: 200, h: 200 }); + }); + + it('fill stretches to the box', () => { + expect(fitContent(box, 100, 100, 'fill')).toEqual(box); + }); + }); + + describe('previewImageBoxes', () => { + const widget = { y: 40, computedHeight: 215 }; + + it('never enlarges a single image and leaves the 15px size-text margin', () => { + const img = fakeImg(100, 50); + const [entry] = previewImageBoxes([img], widget, { size: [200, 300] }, 15); + // Area 200 x 200 (215 - 15); image fits at scale 1, centred + expect(entry.img).toBe(img); + expect(entry.box).toEqual({ x: 50, y: 40 + 75, w: 100, h: 50 }); + }); + + it('fits a large single image inside the area without the margin when size text is off', () => { + const [entry] = previewImageBoxes([fakeImg(800, 400)], widget, { size: [200, 300] }, 0); + // Area 200 x 215: scale min(0.25, 0.5375, 1) = 0.25 + expect(entry.box).toEqual({ x: 0, y: 40 + (215 - 100) / 2, w: 200, h: 100 }); + }); + + it('imageIndex picks the selected image of a batch', () => { + const imgs = [fakeImg(100, 100), fakeImg(64, 32), fakeImg(100, 100)]; + const boxes = previewImageBoxes(imgs, widget, { size: [200, 300], imageIndex: 1 }, 15); + expect(boxes).toHaveLength(1); + expect(boxes[0].img).toBe(imgs[1]); + expect(boxes[0].box).toEqual({ x: 68, y: 40 + 84, w: 64, h: 32 }); + }); + + it('lays a batch out in the node.imageRects cells, inset 2px when aspect ratios differ', () => { + const imageRects = [[0, 40, 100, 100], [100, 40, 100, 100]]; + const same = previewImageBoxes([fakeImg(50, 50), fakeImg(50, 50)], widget, { size: [200, 300], imageRects }, 15); + expect(same.map(b => b.box)).toEqual([ + { x: 0, y: 40, w: 100, h: 100 }, + { x: 100, y: 40, w: 100, h: 100 } + ]); + + const mixed = previewImageBoxes([fakeImg(100, 50), fakeImg(50, 50)], widget, { size: [200, 300], imageRects }, 15); + expect(mixed.map(b => b.box)).toEqual([ + { x: 2, y: 40 + 25 + 2, w: 96, h: 46 }, + { x: 102, y: 42, w: 96, h: 96 } + ]); + }); + + it('skips images that are not loaded', () => { + expect(previewImageBoxes([fakeImg(0, 0)], widget, { size: [200, 300] }, 15)).toEqual([]); + }); + }); + + describe('isSafeMediaSource', () => { + const media = (src: string, crossOrigin?: string) => { + const img = document.createElement('img'); + img.src = src; + if (crossOrigin) img.crossOrigin = crossOrigin; + return img; + }; + + it('accepts same-origin, blob and data sources', () => { + expect(isSafeMediaSource(media('/view?filename=a.png&type=output'))).toBe(true); + expect(isSafeMediaSource(media(`${location.origin}/view?filename=a.png`))).toBe(true); + expect(isSafeMediaSource(media(`blob:${location.origin}/1234`))).toBe(true); + expect(isSafeMediaSource(media('data:image/png;base64,AAAA'))).toBe(true); + }); + + it('rejects cross-origin sources unless requested with CORS', () => { + expect(isSafeMediaSource(media('https://other.example/a.png'))).toBe(false); + expect(isSafeMediaSource(media('https://other.example/a.png', 'anonymous'))).toBe(true); + }); + + it('rejects an element without a source', () => { + expect(isSafeMediaSource(document.createElement('video'))).toBe(false); + }); + }); +}); diff --git a/web/magnify-glass/EventHandler.js b/web/magnify-glass/EventHandler.js index 397e489..679a17f 100644 --- a/web/magnify-glass/EventHandler.js +++ b/web/magnify-glass/EventHandler.js @@ -190,7 +190,7 @@ class EventHandler { this.magnifyGlass.state.x = pixelX; this.magnifyGlass.state.y = pixelY; this.magnifyGlass.ui.positionGlass(clientX, clientY); - this.magnifyGlass.updateMagnifiedView(rect); + this.magnifyGlass.updateMagnifiedView(); } }); } @@ -223,7 +223,7 @@ class EventHandler { } else { this.magnifyGlass.ui.positionGlass(clientX, clientY); } - this.magnifyGlass.updateMagnifiedView(rect); + this.magnifyGlass.updateMagnifiedView(); } } } diff --git a/web/magnify-glass/MagnifierState.js b/web/magnify-glass/MagnifierState.js index 5585fd1..f732af2 100644 --- a/web/magnify-glass/MagnifierState.js +++ b/web/magnify-glass/MagnifierState.js @@ -25,8 +25,6 @@ class MagnifierState { __publicField(this, "canvasOffsetX"); /** Canvas translation Y */ __publicField(this, "canvasOffsetY"); - /** Flag to manage requestAnimationFrame */ - __publicField(this, "isRenderScheduled"); /** Whether glass drag mode is enabled (move icon on hover controls) */ __publicField(this, "isDragModeEnabled"); /** Whether glass preview is hidden via hover controls (but tool still active) */ @@ -52,7 +50,6 @@ class MagnifierState { this.canvasScale = 1; this.canvasOffsetX = 0; this.canvasOffsetY = 0; - this.isRenderScheduled = false; this.isDragModeEnabled = false; this.isPreviewHidden = false; this.virtualMouseCssX = 0; @@ -79,7 +76,6 @@ class MagnifierState { this.canvasScale = 1; this.canvasOffsetX = 0; this.canvasOffsetY = 0; - this.isRenderScheduled = false; } } export { diff --git a/web/magnify-glass/MagnifyGlass.js b/web/magnify-glass/MagnifyGlass.js index 4cc1121..9a7d7aa 100644 --- a/web/magnify-glass/MagnifyGlass.js +++ b/web/magnify-glass/MagnifyGlass.js @@ -1,7 +1,7 @@ var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); -import { findLiteGraphCanvas, rectsOverlap } from "../shared/utils.js"; +import { findLiteGraphCanvas } from "../shared/utils.js"; import { INFO_PANEL_ID, DEFAULT_PADDING, DEFAULT_GLASS_Y_OFFSET } from "../shared/constants.js"; import { registerGlassSettings } from "../shared/settings/glassSettings.js"; import "/scripts/app.js"; @@ -14,6 +14,7 @@ import { DebugManager } from "./DebugManager.js"; import { EventHandler } from "./EventHandler.js"; import { PopOutManager } from "./PopOutManager.js"; import { OffscreenRenderer } from "./OffscreenRenderer.js"; +import { captureRegion } from "./mediaLayout.js"; class MagnifyGlass { constructor() { __publicField(this, "config"); @@ -32,10 +33,8 @@ class MagnifyGlass { __publicField(this, "isOverMedia"); /** Current media element under cursor */ __publicField(this, "currentMediaElement"); - /** Animation loop ID for continuous video rendering */ + /** Render loop ID; the loop runs while the glass is active and visible */ __publicField(this, "animationLoopId"); - /** Whether videos are currently visible in magnified view */ - __publicField(this, "hasVisibleVideos"); this.config = new ConfigManager(); this.state = new MagnifierState(); this.popOutManager = new PopOutManager(); @@ -53,7 +52,6 @@ class MagnifyGlass { this.isOverMedia = false; this.currentMediaElement = null; this.animationLoopId = null; - this.hasVisibleVideos = false; } /** * Initialize the magnifying glass. @@ -109,7 +107,6 @@ class MagnifyGlass { forceHideAllComponents() { this.state.active = false; this.stopAnimationLoop(); - this.hasVisibleVideos = false; this.ui.hide(); const panelEl = document.getElementById(INFO_PANEL_ID); if (panelEl) { @@ -131,68 +128,25 @@ class MagnifyGlass { /** * Set glass preview visibility WITHOUT affecting info panel. * This is used by the toggle-glass hover control button. - * When hidden, the renderer stops completely to save performance. - * When shown, the renderer resumes. + * When hidden, the render loop stops completely to save performance. + * When shown, the render loop resumes. * * @param visible - true to show and enable rendering, false to hide and disable */ setGlassPreviewActive(visible) { this.state.isPreviewHidden = !visible; - if (visible) { - this.ui.setPreviewVisibility(true); - } else { - this.ui.setPreviewVisibility(false); + this.ui.setPreviewVisibility(visible); + if (visible && this.state.active) { + this.startAnimationLoop(); } } /** - * Check if the glass preview is visible. - * Returns false if hidden via hover controls. + * Update the magnified view. Rendering happens in the animation loop, + * so this only makes sure the loop is running. */ - isGlassPreviewVisible() { - var _a; - const extensions = window.comfyUIMagnifyGlassExtensions; - if (extensions && extensions.length > 0) { - const infoPanel = extensions[0]; - if ((_a = infoPanel == null ? void 0 : infoPanel.stateManager) == null ? void 0 : _a.state) { - return infoPanel.stateManager.state.isGlassPreviewVisible !== false; - } - } - return true; - } - /** - * Update the magnified view. - * @param rect - Optional pre-calculated bounding rect of the canvas to avoid layout thrashing - */ - updateMagnifiedView(rect) { - if (!this.state.active || !this.renderer || !this.litegraphCanvas) { - return; - } - if (this.state.isPreviewHidden) { - return; - } - this.updateCanvasTransformation(); - this.calculateSourceRegion(rect); - if (!this.state.isRenderScheduled) { - this.state.isRenderScheduled = true; - requestAnimationFrame(() => { - if (!this.state.active || !this.renderer || !this.litegraphCanvas) { - this.state.isRenderScheduled = false; - return; - } - let sourceCanvas = this.litegraphCanvas; - if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) { - const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas); - if (highResCanvas) { - sourceCanvas = highResCanvas; - } - } - this.renderer.render(sourceCanvas); - this.renderHtmlOverlays(); - if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) { - this.popOutManager.sendFrame(this.ui.glassCanvas); - } - this.state.isRenderScheduled = false; - }); + updateMagnifiedView() { + if (this.state.active) { + this.startAnimationLoop(); } } /** @@ -216,288 +170,42 @@ class MagnifyGlass { } } /** - * Calculate the source region for magnification. - * @param cachedRect - Optional pre-calculated bounding rect of the canvas to avoid layout thrashing + * Calculate the source region (in canvas backing pixels) for magnification. */ - calculateSourceRegion(cachedRect) { - const cursorPixelX = this.state.x; - const cursorPixelY = this.state.y; - const canvasScale = this.state.canvasScale; - const canvasOffsetX = this.state.canvasOffsetX; - const canvasOffsetY = this.state.canvasOffsetY; - if (canvasScale === 0) return; + calculateSourceRegion() { if (!this.litegraphCanvas) return; - const rect = cachedRect || this.litegraphCanvas.getBoundingClientRect(); - const dpr = rect.width > 0 ? this.litegraphCanvas.width / rect.width : 1; - const cursorCssX = cursorPixelX / dpr; - const cursorCssY = cursorPixelY / dpr; - const cursorGraphX = (cursorCssX - canvasOffsetX) / canvasScale; - const cursorGraphY = (cursorCssY - canvasOffsetY) / canvasScale; - const targetGraphCenterX = cursorGraphX + this.config.offsetX; - const targetGraphCenterY = cursorGraphY + this.config.offsetY; - const sourceGraphWidth = this.config.glassSize / this.config.zoomFactor / canvasScale; - const sourceGraphHeight = this.config.glassSize / this.config.zoomFactor / canvasScale; - const sourceGraphX = targetGraphCenterX - sourceGraphWidth / 2; - const sourceGraphY = targetGraphCenterY - sourceGraphHeight / 2; - const sourceCssX = sourceGraphX * canvasScale + canvasOffsetX; - const sourceCssY = sourceGraphY * canvasScale + canvasOffsetY; - const sourceCssWidth = sourceGraphWidth * canvasScale; - const sourceCssHeight = sourceGraphHeight * canvasScale; - this.state.sourceX = sourceCssX * dpr; - this.state.sourceY = sourceCssY * dpr; - this.state.sourceWidth = sourceCssWidth * dpr; - this.state.sourceHeight = sourceCssHeight * dpr; - } - /** - * Render HTML overlays for text and media in the magnified view. - */ - renderHtmlOverlays() { - const graph = app.graph; - if (!this.state.active || !this.ui.htmlOverlayContainer || !graph || !this.litegraphCanvas) { - if (this.ui.htmlOverlayContainer) this.ui.htmlOverlayContainer.innerHTML = ""; - return; - } - let foundVideos = false; - const magnifyRect = { - x: this.state.sourceX, - y: this.state.sourceY, - width: this.state.sourceWidth, - height: this.state.sourceHeight - }; - const nodes = graph._nodes; - if (!nodes) { - this.ui.htmlOverlayContainer.innerHTML = ""; - return; - } - if (this.state.canvasScale === 0) { - this.ui.htmlOverlayContainer.innerHTML = ""; - return; - } const rect = this.litegraphCanvas.getBoundingClientRect(); const dpr = rect.width > 0 ? this.litegraphCanvas.width / rect.width : 1; - const currentScale = this.state.canvasScale; - const isVirtualZoomMode = currentScale < 0.7; - const canvasRect = rect; - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; - const cursorCssX = pivotCssX; - const cursorCssY = pivotCssY; - const cursorGraphX = (cursorCssX - this.state.canvasOffsetX) / this.state.canvasScale; - const cursorGraphY = (cursorCssY - this.state.canvasOffsetY) / this.state.canvasScale; - const targetGraphCenterX = cursorGraphX + this.config.offsetX; - const targetGraphCenterY = cursorGraphY + this.config.offsetY; - const sourceGraphWidth = this.config.glassSize / this.config.zoomFactor / this.state.canvasScale; - const sourceGraphHeight = this.config.glassSize / this.config.zoomFactor / this.state.canvasScale; - const sourceGraphRect = { - x: targetGraphCenterX - sourceGraphWidth / 2, - y: targetGraphCenterY - sourceGraphHeight / 2, - width: sourceGraphWidth, - height: sourceGraphHeight - }; - const renderTasks = []; - for (const node of nodes) { - if (node.pos && node.size) { - const nodeRect = { - x: node.pos[0], - y: node.pos[1], - width: node.size[0], - height: node.size[1] - }; - if (!rectsOverlap(sourceGraphRect, nodeRect)) { - continue; - } - } - const widgets = node.widgets; - if (!widgets) continue; - for (const widget of widgets) { - let isVideoElement = false; - let isImageElement = false; - let elementToProcess = null; - const widgetName = String(widget.name || "").toLowerCase(); - if (widgetName === "videopreview" || widgetName === "audiopreview") { - const videoEl = widget.videoEl; - if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) { - isVideoElement = true; - foundVideos = true; - elementToProcess = videoEl; - } - } - if (!elementToProcess && widget.element) { - const element = widget.element; - if (widget.type === "text" || widget.type === "string" || element.tagName === "TEXTAREA") ; - else if (element.tagName === "VIDEO") { - isVideoElement = true; - foundVideos = true; - elementToProcess = element; - } else if (element.tagName === "IMG") { - isImageElement = true; - elementToProcess = element; - } else { - const potentialVideo = element.querySelector("video"); - if (potentialVideo) { - isVideoElement = true; - foundVideos = true; - elementToProcess = potentialVideo; - } else { - const potentialImage = element.querySelector("img"); - if (potentialImage) { - isImageElement = true; - elementToProcess = potentialImage; - } - } - } - } - if (elementToProcess && (isVideoElement || isImageElement)) { - const widgetRect = elementToProcess.getBoundingClientRect(); - const widgetCssX = widgetRect.left - canvasRect.left; - const widgetCssY = widgetRect.top - canvasRect.top; - const widgetCssWidth = widgetRect.width; - const widgetCssHeight = widgetRect.height; - let finalWidgetCssX; - let finalWidgetCssY; - let finalWidgetCssWidth; - let finalWidgetCssHeight; - if (isVirtualZoomMode) { - finalWidgetCssX = (widgetCssX - pivotCssX) / currentScale + pivotCssX; - finalWidgetCssY = (widgetCssY - pivotCssY) / currentScale + pivotCssY; - finalWidgetCssWidth = widgetCssWidth / currentScale; - finalWidgetCssHeight = widgetCssHeight / currentScale; - } else { - finalWidgetCssX = widgetCssX; - finalWidgetCssY = widgetCssY; - finalWidgetCssWidth = widgetCssWidth; - finalWidgetCssHeight = widgetCssHeight; - } - const widgetCanvasX = finalWidgetCssX * dpr; - const widgetCanvasY = finalWidgetCssY * dpr; - const widgetCanvasWidth = finalWidgetCssWidth * dpr; - const widgetCanvasHeight = finalWidgetCssHeight * dpr; - const widgetSourceRect = { - x: widgetCanvasX, - y: widgetCanvasY, - width: widgetCanvasWidth, - height: widgetCanvasHeight - }; - if (rectsOverlap(magnifyRect, widgetSourceRect)) { - const task = { - element: elementToProcess, - type: isVideoElement ? "video" : "image", - overlayRect: widgetSourceRect, - // Store calculated rect - fontSize: void 0 - }; - if (isVideoElement) { - foundVideos = true; - } - renderTasks.push(task); - } - } - } - } - this.ui.htmlOverlayContainer.innerHTML = ""; - const fragment = document.createDocumentFragment(); - for (const task of renderTasks) { - const elementToProcess = task.element; - const widgetSourceRect = task.overlayRect; - const isTextElement = task.type === "text"; - const isVideoElement = task.type === "video"; - const isImageElement = task.type === "image"; - const clonedElement = elementToProcess.cloneNode(true); - clonedElement.style.position = "absolute"; - clonedElement.style.pointerEvents = "none"; - if (isTextElement) { - clonedElement.style.backgroundColor = elementToProcess.style.backgroundColor || "#222"; - clonedElement.style.color = elementToProcess.style.color || "#DDD"; - clonedElement.style.border = elementToProcess.style.border || "1px solid #555"; - clonedElement.style.overflow = "hidden"; - clonedElement.disabled = true; - const canvasScale = this.state.canvasScale; - const originalFontSize = task.fontSize || 12; - const threshold = 0.5; - let adaptedFontSize; - if (canvasScale < threshold) { - const shrinkFactor = canvasScale / threshold; - adaptedFontSize = Math.max(6, originalFontSize * shrinkFactor * 0.8); - } else { - const growFactor = 1 + (canvasScale - threshold) / (1 - threshold) * 0.5; - adaptedFontSize = originalFontSize * Math.min(1.5, growFactor); - } - clonedElement.style.fontSize = `${adaptedFontSize}px`; - clonedElement.style.lineHeight = canvasScale < threshold ? "1.2" : "1.4"; - } else if (isVideoElement) { - const video = clonedElement; - const originalVideo = elementToProcess; - video.src = originalVideo.src; - video.autoplay = originalVideo.autoplay; - video.loop = originalVideo.loop; - video.preload = originalVideo.preload; - video.crossOrigin = originalVideo.crossOrigin; - video.muted = true; - if (!originalVideo.paused) { - video.play().catch((e) => { - if (e.name !== "AbortError") { - console.warn("Magnify Glass: Cloned video play failed", e); - } - }); - } - video.currentTime = originalVideo.currentTime; - } else if (isImageElement) { - const img = clonedElement; - const originalImg = elementToProcess; - img.src = originalImg.src; - img.alt = originalImg.alt; - } - const relativeX = widgetSourceRect.x - magnifyRect.x; - const relativeY = widgetSourceRect.y - magnifyRect.y; - const magnifiedX = relativeX * this.config.zoomFactor; - const magnifiedY = relativeY * this.config.zoomFactor; - clonedElement.style.left = `${magnifiedX}px`; - clonedElement.style.top = `${magnifiedY}px`; - clonedElement.style.width = `${widgetSourceRect.width}px`; - clonedElement.style.height = `${widgetSourceRect.height}px`; - clonedElement.style.transformOrigin = "top left"; - clonedElement.style.transform = `scale(${this.config.zoomFactor})`; - fragment.appendChild(clonedElement); - } - this.ui.htmlOverlayContainer.appendChild(fragment); - if (foundVideos && !this.hasVisibleVideos) { - this.hasVisibleVideos = true; - this.startAnimationLoop(); - } else if (!foundVideos && this.hasVisibleVideos) { - this.hasVisibleVideos = false; + const cap = captureRegion(this.state.x / dpr, this.state.y / dpr, this.config.glassSize, this.config.zoomFactor); + this.state.sourceX = cap.left * dpr; + this.state.sourceY = cap.top * dpr; + this.state.sourceWidth = cap.size * dpr; + this.state.sourceHeight = cap.size * dpr; + } + /** + * Render one frame of the glass and forward it to the pop-out. + */ + renderFrame(renderer, canvas) { + var _a; + this.updateCanvasTransformation(); + this.calculateSourceRegion(); + renderer.render(((_a = this.offscreenRenderer) == null ? void 0 : _a.renderHighResRegion(canvas)) ?? canvas); + if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) { + this.popOutManager.sendFrame(this.ui.glassCanvas); } } /** - * Start the animation loop for continuous video rendering. - * Only runs when videos are visible in the magnified view. - * This loop directly renders without going through updateMagnifiedView's - * scheduling logic to ensure continuous frame updates for video playback. + * Start the render loop. It is the only render path and keeps running without + * mouse movement, so latent previews and videos update in the glass. */ startAnimationLoop() { if (this.animationLoopId !== null) return; const animate = () => { - if (!this.state.active || !this.renderer || !this.litegraphCanvas) { - this.stopAnimationLoop(); + if (!this.state.active || this.state.isPreviewHidden || !this.renderer || !this.litegraphCanvas) { + this.animationLoopId = null; return; } - if (this.state.isPreviewHidden) { - this.animationLoopId = requestAnimationFrame(animate); - return; - } - this.updateCanvasTransformation(); - this.calculateSourceRegion(); - let sourceCanvas = this.litegraphCanvas; - if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) { - const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas); - if (highResCanvas) { - sourceCanvas = highResCanvas; - } - } - this.renderer.render(sourceCanvas); - this.renderHtmlOverlays(); - if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) { - this.popOutManager.sendFrame(this.ui.glassCanvas); - } + this.renderFrame(this.renderer, this.litegraphCanvas); this.animationLoopId = requestAnimationFrame(animate); }; this.animationLoopId = requestAnimationFrame(animate); diff --git a/web/magnify-glass/OffscreenRenderer.js b/web/magnify-glass/OffscreenRenderer.js index 59db24c..9964499 100644 --- a/web/magnify-glass/OffscreenRenderer.js +++ b/web/magnify-glass/OffscreenRenderer.js @@ -1,7 +1,10 @@ var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); -const _OffscreenRenderer = class _OffscreenRenderer { +import { getSettingValue } from "../shared/utils.js"; +import { captureRegion, scaleAbout, isSafeMediaSource, graphBoxToCss, previewImageBoxes, toGlass, fitContent } from "./mediaLayout.js"; +const TITLE_HEIGHT = 30; +class OffscreenRenderer { constructor(config, state) { __publicField(this, "config"); __publicField(this, "state"); @@ -10,8 +13,6 @@ const _OffscreenRenderer = class _OffscreenRenderer { __publicField(this, "lastRenderWidth", 0); __publicField(this, "lastRenderHeight", 0); __publicField(this, "isCapturing", false); - __publicField(this, "lastVirtualZoomTime", 0); - __publicField(this, "cachedVirtualZoomResult", null); this.config = config; this.state = state; this.initOffscreenCanvas(); @@ -21,6 +22,7 @@ const _OffscreenRenderer = class _OffscreenRenderer { this.offscreenCtx = this.offscreenCanvas.getContext("2d"); } renderHighResRegion(targetCanvas) { + var _a; if (!this.offscreenCanvas || !this.offscreenCtx) return null; if (this.isCapturing) return null; const lgCanvas = app == null ? void 0 : app.canvas; @@ -32,115 +34,98 @@ const _OffscreenRenderer = class _OffscreenRenderer { this.lastRenderWidth = renderSize; this.lastRenderHeight = renderSize; } - const currentScale = lgCanvas.ds.scale; const rect = targetCanvas.getBoundingClientRect(); const dpr = rect.width > 0 ? targetCanvas.width / rect.width : 1; - const useDirectCapture = currentScale >= 1 || this.config.forceDirectCapture; - if (useDirectCapture) { - this.cachedVirtualZoomResult = null; - return this.renderDirectCapture(targetCanvas, renderSize, dpr); - } else { - const now = performance.now(); - const elapsed = now - this.lastVirtualZoomTime; - if (elapsed < _OffscreenRenderer.VIRTUAL_ZOOM_MIN_INTERVAL_MS && this.cachedVirtualZoomResult) { - return this.cachedVirtualZoomResult; - } - const result = this.renderVirtualZoom(lgCanvas, targetCanvas, renderSize, currentScale, dpr); - this.lastVirtualZoomTime = now; - this.cachedVirtualZoomResult = result; - return result; + const pivotX = this.state.x / dpr; + const pivotY = this.state.y / dpr; + const cap = captureRegion(pivotX, pivotY, renderSize, this.config.zoomFactor); + const vueNodes = !!((_a = window.LiteGraph) == null ? void 0 : _a.vueNodesMode); + const currentScale = lgCanvas.ds.scale; + const media = this.collectDomMedia(rect, vueNodes); + if (currentScale < 1 && !this.config.forceDirectCapture && !vueNodes) { + const domScale = 1 / currentScale; + const scaled = media.map((m) => ({ ...m, box: scaleAbout(m.box, pivotX, pivotY, domScale) })); + return this.renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, scaled); } + return this.renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes); } /** - * Detect if there are nodes with image previews in the capture region. - * These nodes (Save Image, Preview Image, etc.) have images that are drawn - * via onDrawBackground/onDrawForeground and cause artifacts during Virtual Zoom. + * Read phase: measure loaded, same-origin DOM images and videos (DOM widgets, or Vue node bodies) + * before any canvas draw. Boxes are CSS pixels relative to the canvas at the real zoom. */ - detectImagePreviewNodes(dpr, scale, offset) { - var _a, _b; - const graph = app == null ? void 0 : app.graph; - if (!graph || !graph._nodes) return false; - const renderSize = this.config.glassSize; - const sourceSizeCss = renderSize / this.config.zoomFactor; - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; - const captureLeft = pivotCssX - sourceSizeCss / 2; - const captureTop = pivotCssY - sourceSizeCss / 2; - const captureRight = captureLeft + sourceSizeCss; - const captureBottom = captureTop + sourceSizeCss; - const imagePreviewTypes = [ - "SaveImage", - "PreviewImage", - "LoadImage", - "LoadImageMask", - "VHS_LoadVideo", - "VHS_VideoCombine", - // Video nodes - "ImagePreview", - "ShowImage" - // Common variants - ]; - for (const node of graph._nodes) { + collectDomMedia(rect, vueNodes) { + const selector = vueNodes ? "[data-node-id] img, [data-node-id] video" : ".dom-widget img, .dom-widget video"; + const media = []; + for (const el of document.querySelectorAll(selector)) { + const loaded = el instanceof HTMLVideoElement ? el.readyState >= 2 : el.complete && el.naturalWidth > 0; + if (!loaded || !isSafeMediaSource(el)) continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 || r.height === 0) continue; + media.push({ + el, + box: { x: r.left - rect.left, y: r.top - rect.top, w: r.width, h: r.height }, + objectFit: getComputedStyle(el).objectFit + }); + } + return media; + } + /** + * Nodes of the graph on screen (including subgraphs) that overlap the capture region, + * in canvas render order (back to front). + */ + getVisibleNodes(cap, scale, offset) { + var _a; + const visibleNodes = []; + for (const node of app.canvas.visible_nodes) { if (!node.pos || !node.size) continue; if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; - const nodeType = node.type || node.comfyClass || ""; - const hasImagePreview = imagePreviewTypes.some( - (t) => nodeType.toLowerCase().includes(t.toLowerCase()) - ) || ((_b = node.imgs) == null ? void 0 : _b.length) > 0; - if (!hasImagePreview) continue; - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; - const nodeCssWidth = node.size[0] * scale; - const nodeCssHeight = node.size[1] * scale; - const nodeRight = nodeCssX + nodeCssWidth; - const nodeBottom = nodeCssY + nodeCssHeight; - const overlaps = !(nodeRight < captureLeft || nodeCssX > captureRight || nodeBottom < captureTop || nodeCssY > captureBottom); - if (overlaps) { - return true; - } + const box = graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset); + if (box.x + box.w < cap.left || box.x > cap.left + cap.size) continue; + if (box.y + box.h < cap.top || box.y > cap.top + cap.size) continue; + visibleNodes.push(node); } - return false; + return visibleNodes; + } + accessibilityFilter() { + if (this.config.invertColors) return "invert(1)"; + if (this.config.grayscaleMode) return "grayscale(1)"; + return "none"; + } + drawCapture(targetCanvas, dpr, cap) { + const renderSize = this.config.glassSize; + this.offscreenCtx.save(); + this.offscreenCtx.filter = this.accessibilityFilter(); + this.offscreenCtx.drawImage( + targetCanvas, + cap.left * dpr, + cap.top * dpr, + cap.size * dpr, + cap.size * dpr, + 0, + 0, + renderSize, + renderSize + ); + this.offscreenCtx.restore(); } /** * Mode 1: Direct Capture (Nuclear Fix) * Copies pixels directly from screen. Zero drift. */ - renderDirectCapture(targetCanvas, renderSize, dpr) { - var _a, _b; + renderDirectCapture(targetCanvas, dpr, cap, media, vueNodes) { try { this.isCapturing = true; - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; - const sourceSizeCss = renderSize / this.config.zoomFactor; - const sourceWidth = sourceSizeCss * dpr; - const sourceHeight = sourceSizeCss * dpr; - const sourceX = (pivotCssX - sourceSizeCss / 2) * dpr; - const sourceY = (pivotCssY - sourceSizeCss / 2) * dpr; + const renderSize = this.config.glassSize; this.offscreenCtx.clearRect(0, 0, renderSize, renderSize); - this.offscreenCtx.save(); - if (this.config.invertColors) { - this.offscreenCtx.filter = "invert(1)"; - } else if (this.config.grayscaleMode) { - this.offscreenCtx.filter = "grayscale(1)"; - } - this.offscreenCtx.drawImage( - targetCanvas, - sourceX, - sourceY, - sourceWidth, - sourceHeight, - 0, - 0, - renderSize, - renderSize - ); - this.offscreenCtx.restore(); - const lgCanvas = app == null ? void 0 : app.canvas; - const currentScale = ((_a = lgCanvas == null ? void 0 : lgCanvas.ds) == null ? void 0 : _a.scale) ?? 1; - const currentOffset = ((_b = lgCanvas == null ? void 0 : lgCanvas.ds) == null ? void 0 : _b.offset) ? [lgCanvas.ds.offset[0], lgCanvas.ds.offset[1]] : [0, 0]; - this.drawWidgetTextNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, currentScale, currentOffset); + this.drawCapture(targetCanvas, dpr, cap); + const ds = app.canvas.ds; + const scale = ds.scale; + const offset = [ds.offset[0], ds.offset[1]]; + const visibleNodes = vueNodes ? [] : this.getVisibleNodes(cap, scale, offset); + this.drawMedia(targetCanvas, dpr, visibleNodes, media, cap, scale, offset); + this.drawWidgetTextNatively(visibleNodes, cap, scale, offset); if (this.config.accessibilityEnabled && this.config.nodeTitleEmphasis) { - this.drawNodeTitlesNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, currentScale, currentOffset); + this.drawNodeTitlesNatively(visibleNodes, cap, scale, offset); } if (this.config.showCursorPreview) { this.drawCursorPreview(renderSize); @@ -158,65 +143,43 @@ const _OffscreenRenderer = class _OffscreenRenderer { * Temporarily sets scale to 1.0 to render details. * Uses LiteGraph's setZoom() API for correct pivot handling. */ - renderVirtualZoom(lgCanvas, targetCanvas, renderSize, currentScale, dpr) { - var _a; + renderVirtualZoom(lgCanvas, targetCanvas, rect, dpr, pivotX, pivotY, cap, media) { + const ds = lgCanvas.ds; + const origScale = ds.scale; + const origOffsetX = ds.offset[0]; + const origOffsetY = ds.offset[1]; + const renderSize = this.config.glassSize; + this.isCapturing = true; + window.__magnifyGlassCapturing = true; try { - this.isCapturing = true; - window.__magnifyGlassCapturing = true; - const pivotCssX = this.state.x / dpr; - const pivotCssY = this.state.y / dpr; - const targetScale = Math.max(1, currentScale); - const origScale = lgCanvas.ds.scale; - const origOffsetX = lgCanvas.ds.offset[0]; - const origOffsetY = lgCanvas.ds.offset[1]; - if (typeof lgCanvas.setZoom === "function") { - lgCanvas.setZoom(targetScale, [pivotCssX, pivotCssY]); - } else { - const pivotGraphX = (pivotCssX - origOffsetX) / currentScale; - const pivotGraphY = (pivotCssY - origOffsetY) / currentScale; - const newOffsetX = pivotCssX - pivotGraphX * targetScale; - const newOffsetY = pivotCssY - pivotGraphY * targetScale; - lgCanvas.ds.scale = targetScale; - lgCanvas.ds.offset[0] = newOffsetX; - lgCanvas.ds.offset[1] = newOffsetY; - } + lgCanvas.setZoom(1, [pivotX + rect.left, pivotY + rect.top]); const hiddenNodeImages = /* @__PURE__ */ new Map(); const hiddenNodeWidgets = /* @__PURE__ */ new Map(); - if ((_a = app == null ? void 0 : app.graph) == null ? void 0 : _a._nodes) { - for (const node of app.graph._nodes) { - if (node.imgs) { - hiddenNodeImages.set(node, node.imgs); - node.imgs = null; - } - if (node.widgets && Array.isArray(node.widgets)) { - const hasPreviewWidget = node.widgets.some((w) => { + for (const node of lgCanvas.graph._nodes) { + if (node.imgs) { + hiddenNodeImages.set(node, node.imgs); + node.imgs = null; + } + if (node.widgets && Array.isArray(node.widgets)) { + const hasPreviewWidget = node.widgets.some((w) => { + const wName = String(w.name || "").toLowerCase(); + const wType = String(w.type || "").toLowerCase(); + return wName.includes("preview") || wName.includes("image") || wName.includes("gallery") || wName.includes("upload") || wType.includes("preview") || wType.includes("image"); + }); + if (hasPreviewWidget) { + hiddenNodeWidgets.set(node, node.widgets); + node.widgets = node.widgets.filter((w) => { const wName = String(w.name || "").toLowerCase(); const wType = String(w.type || "").toLowerCase(); - return wName.includes("preview") || wName.includes("image") || wName.includes("gallery") || wName.includes("upload") || wType.includes("preview") || wType.includes("image"); + const isPreview = wName.includes("preview") || wName.includes("gallery") || wType.includes("preview"); + return !isPreview; }); - if (hasPreviewWidget) { - hiddenNodeWidgets.set(node, node.widgets); - node.widgets = node.widgets.filter((w) => { - const wName = String(w.name || "").toLowerCase(); - const wType = String(w.type || "").toLowerCase(); - const isPreview = wName.includes("preview") || wName.includes("gallery") || wType.includes("preview"); - return !isPreview; - }); - } } } } - const origUpdatePreviews = lgCanvas.updatePreviews; try { - if (typeof origUpdatePreviews === "function") { - lgCanvas.updatePreviews = () => { - }; - } lgCanvas.draw(true, true); } finally { - if (typeof origUpdatePreviews === "function") { - lgCanvas.updatePreviews = origUpdatePreviews; - } for (const [node, imgs] of hiddenNodeImages.entries()) { node.imgs = imgs; } @@ -224,95 +187,100 @@ const _OffscreenRenderer = class _OffscreenRenderer { node.widgets = widgets; } } - const sourceSizeCss = renderSize / this.config.zoomFactor; - const sourceWidth = sourceSizeCss * dpr; - const sourceHeight = sourceSizeCss * dpr; - const sourceX = (pivotCssX - sourceSizeCss / 2) * dpr; - const sourceY = (pivotCssY - sourceSizeCss / 2) * dpr; this.offscreenCtx.clearRect(0, 0, renderSize, renderSize); - this.offscreenCtx.save(); - if (this.config.invertColors) { - this.offscreenCtx.filter = "invert(1)"; - } else if (this.config.grayscaleMode) { - this.offscreenCtx.filter = "grayscale(1)"; - } - this.offscreenCtx.drawImage( - targetCanvas, - sourceX, - sourceY, - sourceWidth, - sourceHeight, - 0, - 0, - renderSize, - renderSize - ); - this.offscreenCtx.restore(); - const captureOffset = [lgCanvas.ds.offset[0], lgCanvas.ds.offset[1]]; - this.drawWidgetTextNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, targetScale, captureOffset); + this.drawCapture(targetCanvas, dpr, cap); + const captureScale = ds.scale; + const captureOffset = [ds.offset[0], ds.offset[1]]; + const visibleNodes = this.getVisibleNodes(cap, captureScale, captureOffset); + this.drawMedia(targetCanvas, dpr, visibleNodes, media, cap, captureScale, captureOffset); + this.drawWidgetTextNatively(visibleNodes, cap, captureScale, captureOffset); if (this.config.accessibilityEnabled && this.config.nodeTitleEmphasis) { - this.drawNodeTitlesNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, targetScale, captureOffset); + this.drawNodeTitlesNatively(visibleNodes, cap, captureScale, captureOffset); } - this.drawImagePreviewsNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, targetScale, captureOffset); if (this.config.showCursorPreview) { this.drawCursorPreview(renderSize); } - lgCanvas.ds.scale = origScale; - lgCanvas.ds.offset[0] = origOffsetX; - lgCanvas.ds.offset[1] = origOffsetY; - const finalOrigUpdatePreviews = lgCanvas.updatePreviews; - try { - if (typeof finalOrigUpdatePreviews === "function") { - lgCanvas.updatePreviews = () => { - }; - } - lgCanvas.draw(true, true); - } finally { - if (typeof finalOrigUpdatePreviews === "function") { - lgCanvas.updatePreviews = finalOrigUpdatePreviews; - } - } - this.isCapturing = false; - window.__magnifyGlassCapturing = false; return this.offscreenCanvas; } catch (e) { console.warn("VirtualZoom failed", e); - lgCanvas.ds.scale = currentScale; - lgCanvas.draw(true, true); + return null; + } finally { + ds.scale = origScale; + ds.offset[0] = origOffsetX; + ds.offset[1] = origOffsetY; this.isCapturing = false; window.__magnifyGlassCapturing = false; - return null; + lgCanvas.draw(true, true); } } + /** + * Draw canvas image previews (from full-resolution node.imgs) and DOM images/videos + * into the glass. Video is redrawn every tick, so it plays. + */ + drawMedia(targetCanvas, dpr, visibleNodes, media, cap, scale, offset) { + var _a, _b; + const ctx = this.offscreenCtx; + const renderSize = this.config.glassSize; + const onGlass = (b) => b.w > 0 && b.h > 0 && b.x < renderSize && b.y < renderSize && b.x + b.w > 0 && b.y + b.h > 0; + const overlaps = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h; + ctx.save(); + ctx.filter = this.accessibilityFilter(); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + const sizeTextMargin = getSettingValue("Comfy.Node.AllowImageSizeDraw", true) ? 15 : 0; + for (const [i, node] of visibleNodes.entries()) { + const widget = ((_a = node.imgs) == null ? void 0 : _a.length) && ((_b = node.widgets) == null ? void 0 : _b.find((w) => w.name === "$$canvas-image-preview")); + if (!widget) continue; + for (const { img, box } of previewImageBoxes(node.imgs, widget, node, sizeTextMargin)) { + const g = toGlass(graphBoxToCss(box, node.pos, scale, offset), cap); + if (onGlass(g) && isSafeMediaSource(img)) ctx.drawImage(img, g.x, g.y, g.w, g.h); + } + const nodeBox = toGlass(graphBoxToCss({ x: 0, y: 0, w: node.size[0], h: node.size[1] }, node.pos, scale, offset), cap); + for (const front of visibleNodes.slice(i + 1)) { + const f = toGlass(graphBoxToCss({ x: 0, y: -TITLE_HEIGHT, w: front.size[0], h: front.size[1] + TITLE_HEIGHT }, front.pos, scale, offset), cap); + if (!overlaps(f, nodeBox)) continue; + ctx.save(); + ctx.beginPath(); + ctx.rect(f.x, f.y, f.w, f.h); + ctx.clip(); + this.drawCapture(targetCanvas, dpr, cap); + ctx.restore(); + } + } + for (const { el, box, objectFit } of media) { + const g = toGlass(box, cap); + if (!onGlass(g)) continue; + const content = el instanceof HTMLVideoElement ? fitContent(g, el.videoWidth, el.videoHeight, objectFit) : fitContent(g, el.naturalWidth, el.naturalHeight, objectFit); + ctx.save(); + ctx.beginPath(); + ctx.rect(g.x, g.y, g.w, g.h); + ctx.clip(); + ctx.drawImage(el, content.x, content.y, content.w, content.h); + ctx.restore(); + } + ctx.restore(); + } /** * Draw node titles natively with accessibility styling. */ - drawNodeTitlesNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, scale, offset) { + drawNodeTitlesNatively(visibleNodes, cap, scale, offset) { var _a; - const graph = app == null ? void 0 : app.graph; - if (!graph || !graph._nodes || !this.offscreenCtx) return; + if (!this.offscreenCtx) return; const ctx = this.offscreenCtx; - const TITLE_HEIGHT = 30; - const sourceSizeCss = renderSize / this.config.zoomFactor; - const actualDpr = sourceWidth / sourceSizeCss; - const captureScale = renderSize / sourceWidth; - const sourceCssX = sourceX / actualDpr; - const sourceCssY = sourceY / actualDpr; - const sourceCssWidth = sourceWidth / actualDpr; - const sourceCssHeight = sourceHeight / actualDpr; - for (const node of graph._nodes) { + const renderSize = this.config.glassSize; + for (const node of visibleNodes) { if (!node.pos || !node.size) continue; if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; const title = node.title || node.type || "Node"; - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; + const nodeCssX = (node.pos[0] + offset[0]) * scale; + const nodeCssY = (node.pos[1] + offset[1]) * scale; const nodeCssWidth = node.size[0] * scale; - if (nodeCssX + nodeCssWidth < sourceCssX || nodeCssX > sourceCssX + sourceCssWidth) continue; - if (nodeCssY + TITLE_HEIGHT * scale < sourceCssY || nodeCssY > sourceCssY + sourceCssHeight) continue; - const canvasX = (nodeCssX - sourceCssX) * actualDpr * captureScale; - const canvasY = (nodeCssY - sourceCssY) * actualDpr * captureScale; - const canvasWidth = Math.min(nodeCssWidth, nodeCssWidth) * actualDpr * captureScale; - const canvasHeight = TITLE_HEIGHT * scale * actualDpr * captureScale; + if (nodeCssX + nodeCssWidth < cap.left || nodeCssX > cap.left + cap.size) continue; + if (nodeCssY + TITLE_HEIGHT * scale < cap.top || nodeCssY > cap.top + cap.size) continue; + const canvasX = (nodeCssX - cap.left) * cap.k; + const canvasY = (nodeCssY - cap.top) * cap.k; + const canvasWidth = Math.min(nodeCssWidth, nodeCssWidth) * cap.k; + const canvasHeight = TITLE_HEIGHT * scale * cap.k; if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { ctx.save(); ctx.beginPath(); @@ -325,7 +293,7 @@ const _OffscreenRenderer = class _OffscreenRenderer { const baseFontSize = 14; let scaleFactor = this.config.fontScaleFactor / 100; scaleFactor *= 1.1; - const fontSize = Math.max(12, Math.min(32, baseFontSize * scale * actualDpr * captureScale * scaleFactor)); + const fontSize = Math.max(12, Math.min(32, baseFontSize * scale * cap.k * scaleFactor)); ctx.font = `bold ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; @@ -336,7 +304,7 @@ const _OffscreenRenderer = class _OffscreenRenderer { ctx.shadowBlur = this.config.textGlowIntensity; ctx.shadowColor = this.config.textGlowColor; } - const padding = 10 * scale * actualDpr * captureScale; + const padding = 10 * scale * cap.k; ctx.fillText(title, canvasX + padding, canvasY + canvasHeight / 2 - 5 * scale); ctx.restore(); } @@ -346,41 +314,28 @@ const _OffscreenRenderer = class _OffscreenRenderer { * Draw widget text natively on the offscreen canvas. * This renders text content that would otherwise be lost since widgets are DOM elements. * - * @param sourceX - Source X position in backing pixels - * @param sourceY - Source Y position in backing pixels - * @param sourceWidth - Source width in backing pixels - * @param sourceHeight - Source height in backing pixels - * @param renderSize - Output render size in pixels + * @param visibleNodes - List of nodes to check for text widgets + * @param cap - Capture region (CSS pixels) and glass scale * @param scale - Canvas scale used during capture * @param offset - Canvas offset [x, y] used during capture */ - drawWidgetTextNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, scale, offset) { + drawWidgetTextNatively(visibleNodes, cap, scale, offset) { var _a; - const graph = app == null ? void 0 : app.graph; - if (!graph || !graph._nodes || !this.offscreenCtx) return; + if (!this.offscreenCtx) return; const ctx = this.offscreenCtx; - const lgCanvas = app == null ? void 0 : app.canvas; - if (!lgCanvas || !lgCanvas.ds) return; - const TITLE_HEIGHT = 30; + const renderSize = this.config.glassSize; const WIDGET_HEIGHT = 20; const WIDGET_MARGIN = 4; const PADDING = 15; - const sourceSizeCss = renderSize / this.config.zoomFactor; - const actualDpr = sourceWidth / sourceSizeCss; - const captureScale = renderSize / sourceWidth; - const sourceCssX = sourceX / actualDpr; - const sourceCssY = sourceY / actualDpr; - const sourceCssWidth = sourceWidth / actualDpr; - const sourceCssHeight = sourceHeight / actualDpr; - for (const node of graph._nodes) { + for (const node of visibleNodes) { if (!node.widgets || !node.pos || !node.size) continue; if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; + const nodeCssX = (node.pos[0] + offset[0]) * scale; + const nodeCssY = (node.pos[1] + offset[1]) * scale; const nodeCssWidth = node.size[0] * scale; const nodeCssHeight = node.size[1] * scale; - if (nodeCssX + nodeCssWidth < sourceCssX || nodeCssX > sourceCssX + sourceCssWidth) continue; - if (nodeCssY + nodeCssHeight < sourceCssY || nodeCssY > sourceCssY + sourceCssHeight) continue; + if (nodeCssX + nodeCssWidth < cap.left || nodeCssX > cap.left + cap.size) continue; + if (nodeCssY + nodeCssHeight < cap.top || nodeCssY > cap.top + cap.size) continue; let widgetY = TITLE_HEIGHT; for (const widget of node.widgets) { const widgetLocalY = widget.last_y ?? widgetY; @@ -391,15 +346,15 @@ const _OffscreenRenderer = class _OffscreenRenderer { const widgetCssX = nodeCssX + PADDING * scale; const widgetCssY = nodeCssY + widgetLocalY * scale; const widgetCssWidth = nodeCssWidth - PADDING * 2 * scale; - const canvasX = (widgetCssX - sourceCssX) * actualDpr * captureScale; - const canvasY = (widgetCssY - sourceCssY) * actualDpr * captureScale; - const widgetWidth = widgetCssWidth * actualDpr * captureScale; + const canvasX = (widgetCssX - cap.left) * cap.k; + const canvasY = (widgetCssY - cap.top) * cap.k; + const widgetWidth = widgetCssWidth * cap.k; const baseFontSize = 13; let scaleFactor = 1; if (this.config.accessibilityEnabled) { scaleFactor = this.config.fontScaleFactor / 100; } - const fontSize = Math.max(10, Math.min(28 * scaleFactor, baseFontSize * scale * actualDpr * captureScale * scaleFactor)); + const fontSize = Math.max(10, Math.min(28 * scaleFactor, baseFontSize * scale * cap.k * scaleFactor)); const lineHeight = fontSize * 1.4; if (isMultiLineWidget && widget.value !== void 0 && widget.value !== null) { const textValue = String(widget.value); @@ -430,8 +385,8 @@ const _OffscreenRenderer = class _OffscreenRenderer { } const nodeBottomCss = nodeCssY + nodeCssHeight; const maxWidgetHeightCss = Math.max(0, nodeBottomCss - widgetCssY - PADDING * scale); - const maxWidgetHeight = maxWidgetHeightCss * actualDpr * captureScale; - const widgetHeight = isMarkdownWidget ? Math.min(Math.max(widget.computedHeight * scale * captureScale || 0, contentHeight), maxWidgetHeight) : widget.computedHeight ? widget.computedHeight * scale * captureScale : Math.max(80, fontSize * 5); + const maxWidgetHeight = maxWidgetHeightCss * cap.k; + const widgetHeight = isMarkdownWidget ? Math.min(Math.max(widget.computedHeight * scale * cap.k || 0, contentHeight), maxWidgetHeight) : widget.computedHeight ? widget.computedHeight * scale * cap.k : Math.max(80, fontSize * 5); const widgetRight = canvasX + widgetWidth; const widgetBottom = canvasY + widgetHeight; const isVisible = widgetRight > 0 && canvasX < renderSize && widgetBottom > 0 && canvasY < renderSize; @@ -461,190 +416,6 @@ const _OffscreenRenderer = class _OffscreenRenderer { } } } - /** - * Draw image and video previews natively on the offscreen canvas. - * This renders preview content that would otherwise cause errors during Virtual Zoom, - * because ComfyUI's ImagePreviewWidget fails when the canvas scale is manipulated. - * - * Handles: - * - node.imgs[] array (standard ComfyUI SaveImage/PreviewImage nodes) - * - VHS-style DOM widget previews (video/image elements) - * - * @param sourceX - Source X position in backing pixels - * @param sourceY - Source Y position in backing pixels - * @param sourceWidth - Source width in backing pixels - * @param sourceHeight - Source height in backing pixels - * @param renderSize - Output render size in pixels - * @param scale - Canvas scale used during capture - * @param offset - Canvas offset [x, y] used during capture - */ - drawImagePreviewsNatively(sourceX, sourceY, sourceWidth, sourceHeight, renderSize, scale, offset) { - var _a; - const graph = app == null ? void 0 : app.graph; - if (!graph || !graph._nodes || !this.offscreenCtx) return; - const ctx = this.offscreenCtx; - const sourceSizeCss = renderSize / this.config.zoomFactor; - const actualDpr = sourceWidth / sourceSizeCss; - const captureScale = renderSize / sourceWidth; - const sourceCssX = sourceX / actualDpr; - const sourceCssY = sourceY / actualDpr; - const sourceCssWidth = sourceWidth / actualDpr; - const sourceCssHeight = sourceHeight / actualDpr; - for (const node of graph._nodes) { - if (!node.pos || !node.size) continue; - if ((_a = node.flags) == null ? void 0 : _a.collapsed) continue; - const nodeCssX = node.pos[0] * scale + offset[0]; - const nodeCssY = node.pos[1] * scale + offset[1]; - const nodeCssWidth = node.size[0] * scale; - const nodeCssHeight = node.size[1] * scale; - if (nodeCssX + nodeCssWidth < sourceCssX || nodeCssX > sourceCssX + sourceCssWidth) continue; - if (nodeCssY + nodeCssHeight < sourceCssY || nodeCssY > sourceCssY + sourceCssHeight) continue; - if (node.imgs && Array.isArray(node.imgs) && node.imgs.length > 0) { - this.drawNodeImages( - ctx, - node, - nodeCssX, - nodeCssY, - nodeCssWidth, - nodeCssHeight, - sourceCssX, - sourceCssY, - actualDpr, - captureScale, - renderSize, - scale - ); - } - if (node.widgets) { - for (const widget of node.widgets) { - const widgetName = String(widget.name || "").toLowerCase(); - if (widgetName === "videopreview" || widgetName === "audiopreview") { - this.drawDomWidgetPreview( - ctx, - widget, - node, - nodeCssX, - nodeCssY, - nodeCssWidth, - sourceCssX, - sourceCssY, - actualDpr, - captureScale, - renderSize, - scale - ); - } - } - } - } - } - /** - * Draw images from node.imgs[] array onto the offscreen canvas. - * Supports batch images by drawing them in a grid layout. - */ - drawNodeImages(ctx, node, nodeCssX, nodeCssY, nodeCssWidth, nodeCssHeight, sourceCssX, sourceCssY, actualDpr, captureScale, renderSize, scale) { - const TITLE_HEIGHT = 30; - const PADDING = 10; - const GAP = 4; - const imageAreaX = nodeCssX + PADDING * scale; - const imageAreaY = nodeCssY + TITLE_HEIGHT * scale; - const imageAreaWidth = nodeCssWidth - PADDING * 2 * scale; - const imageAreaHeight = nodeCssHeight - TITLE_HEIGHT * scale - PADDING * scale; - if (imageAreaWidth <= 0 || imageAreaHeight <= 0) return; - const imgs = node.imgs.filter( - (img) => img && img instanceof HTMLImageElement && img.complete && img.naturalWidth > 0 - ); - const imgCount = imgs.length; - if (imgCount === 0) return; - const cols = Math.ceil(Math.sqrt(imgCount)); - const rows = Math.ceil(imgCount / cols); - const cellWidth = (imageAreaWidth - GAP * scale * (cols - 1)) / cols; - const cellHeight = (imageAreaHeight - GAP * scale * (rows - 1)) / rows; - if (cellWidth <= 0 || cellHeight <= 0) return; - for (let i = 0; i < imgCount; i++) { - const img = imgs[i]; - const col = i % cols; - const row = Math.floor(i / cols); - const cellX = imageAreaX + col * (cellWidth + GAP * scale); - const cellY = imageAreaY + row * (cellHeight + GAP * scale); - try { - const imgAspect = img.naturalWidth / img.naturalHeight; - const cellAspect = cellWidth / cellHeight; - let drawWidth = cellWidth; - let drawHeight = cellHeight; - if (imgAspect > cellAspect) { - drawHeight = drawWidth / imgAspect; - } else { - drawWidth = drawHeight * imgAspect; - } - const drawX = cellX + (cellWidth - drawWidth) / 2; - const drawY = cellY + (cellHeight - drawHeight) / 2; - const canvasX = (drawX - sourceCssX) * actualDpr * captureScale; - const canvasY = (drawY - sourceCssY) * actualDpr * captureScale; - const canvasWidth = drawWidth * actualDpr * captureScale; - const canvasHeight = drawHeight * actualDpr * captureScale; - if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { - ctx.save(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - ctx.drawImage(img, canvasX, canvasY, canvasWidth, canvasHeight); - ctx.restore(); - } - } catch (e) { - } - } - } - /** - * Draw VHS-style DOM widget video/image preview onto the offscreen canvas. - */ - drawDomWidgetPreview(ctx, widget, node, nodeCssX, nodeCssY, nodeCssWidth, sourceCssX, sourceCssY, actualDpr, captureScale, renderSize, scale) { - const videoEl = widget.videoEl; - const imgEl = widget.imgEl; - let sourceElement; - let sourceWidth = 0; - let sourceHeight = 0; - if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) { - sourceElement = videoEl; - sourceWidth = videoEl.videoWidth; - sourceHeight = videoEl.videoHeight; - } else if (imgEl && !imgEl.hidden && imgEl.naturalWidth > 0) { - sourceElement = imgEl; - sourceWidth = imgEl.naturalWidth; - sourceHeight = imgEl.naturalHeight; - } - if (!sourceElement || sourceWidth === 0 || sourceHeight === 0) return; - try { - const widgetY = widget.last_y ?? 30; - const widgetHeight = widget.computedHeight ?? 100; - const widgetCssY = nodeCssY + widgetY * scale; - const widgetCssWidth = nodeCssWidth - 20 * scale; - const widgetCssHeight = widgetHeight * scale; - if (widgetCssWidth <= 0 || widgetCssHeight <= 0) return; - const srcAspect = sourceWidth / sourceHeight; - const areaAspect = widgetCssWidth / widgetCssHeight; - let drawWidth = widgetCssWidth; - let drawHeight = widgetCssHeight; - if (srcAspect > areaAspect) { - drawHeight = drawWidth / srcAspect; - } else { - drawWidth = drawHeight * srcAspect; - } - const drawX = nodeCssX + 10 * scale + (widgetCssWidth - drawWidth) / 2; - const drawY = widgetCssY + (widgetCssHeight - drawHeight) / 2; - const canvasX = (drawX - sourceCssX) * actualDpr * captureScale; - const canvasY = (drawY - sourceCssY) * actualDpr * captureScale; - const canvasWidth = drawWidth * actualDpr * captureScale; - const canvasHeight = drawHeight * actualDpr * captureScale; - if (canvasX + canvasWidth > 0 && canvasX < renderSize && canvasY + canvasHeight > 0 && canvasY < renderSize) { - ctx.save(); - ctx.imageSmoothingEnabled = true; - ctx.imageSmoothingQuality = "high"; - ctx.drawImage(sourceElement, canvasX, canvasY, canvasWidth, canvasHeight); - ctx.restore(); - } - } catch (e) { - } - } /** * Render markdown text onto the context. * Supports basic headers (#) and lists (-). @@ -709,31 +480,6 @@ const _OffscreenRenderer = class _OffscreenRenderer { } } } - /** - * Render a button widget. - * Draws a rounded rectangle with the label centered. - */ - drawButton(ctx, label, x, y, width, height, fontSize) { - const radius = 6; - ctx.save(); - ctx.beginPath(); - ctx.roundRect(x, y, width, height, radius); - ctx.fillStyle = "#222"; - ctx.fill(); - ctx.strokeStyle = "#555"; - ctx.lineWidth = 1; - ctx.stroke(); - ctx.font = `600 ${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - let textColor = "#ccc"; - if (this.config.accessibilityEnabled && this.config.highContrastMode) { - textColor = "#ffffff"; - } - ctx.fillStyle = textColor; - this.drawAccessibleText(ctx, label, x + width / 2, y + height / 2); - ctx.restore(); - } /** * Draw a mini cursor preview at the center of the glass. * The cursor is drawn as a classic arrow pointer with a contrasting outline. @@ -765,12 +511,6 @@ const _OffscreenRenderer = class _OffscreenRenderer { ctx.stroke(); ctx.restore(); } - isAvailable() { - return this.offscreenCanvas !== null && this.offscreenCtx !== null; - } - getCanvas() { - return this.offscreenCanvas; - } /** * Draw text with accessibility enhancements (glow, outline). */ @@ -799,10 +539,7 @@ const _OffscreenRenderer = class _OffscreenRenderer { ctx.fillText(text, x, y); ctx.restore(); } -}; -// Virtual Zoom Throttling (60 FPS = ~16ms per frame) -__publicField(_OffscreenRenderer, "VIRTUAL_ZOOM_MIN_INTERVAL_MS", 16); -let OffscreenRenderer = _OffscreenRenderer; +} export { OffscreenRenderer }; diff --git a/web/magnify-glass/UiManager.js b/web/magnify-glass/UiManager.js index 4bca0df..e965019 100644 --- a/web/magnify-glass/UiManager.js +++ b/web/magnify-glass/UiManager.js @@ -10,13 +10,11 @@ class UiManager { __publicField(this, "state"); __publicField(this, "glassDiv"); __publicField(this, "glassCanvas"); - __publicField(this, "htmlOverlayContainer"); __publicField(this, "onToggle"); this.config = config; this.state = state; this.glassDiv = null; this.glassCanvas = null; - this.htmlOverlayContainer = null; this.onToggle = onToggle; } /** @@ -44,18 +42,6 @@ class UiManager { this.glassCanvas.height = this.config.glassSize; this.glassCanvas.id = "comfyui-magnify-canvas"; this.glassDiv.appendChild(this.glassCanvas); - this.htmlOverlayContainer = document.createElement("div"); - this.htmlOverlayContainer.id = "comfyui-magnify-html-overlay"; - this.htmlOverlayContainer.style.cssText = ` - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - overflow: hidden; - `; - this.glassDiv.appendChild(this.htmlOverlayContainer); document.body.appendChild(this.glassDiv); this.injectMenuButton(); } @@ -208,9 +194,6 @@ class UiManager { if (this.glassDiv) { this.glassDiv.style.display = "none"; } - if (this.htmlOverlayContainer) { - this.htmlOverlayContainer.innerHTML = ""; - } } /** * Position the glass relative to cursor. diff --git a/web/magnify-glass/WebGLRenderer.js b/web/magnify-glass/WebGLRenderer.js index 8702e7a..77509de 100644 --- a/web/magnify-glass/WebGLRenderer.js +++ b/web/magnify-glass/WebGLRenderer.js @@ -14,13 +14,6 @@ class WebGLRenderer { __publicField(this, "uniformLocations"); __publicField(this, "attributeLocations"); __publicField(this, "currentFilteringMode"); - // Dirty flag optimization - __publicField(this, "lastCanvasVersion"); - __publicField(this, "lastSourceX"); - __publicField(this, "lastSourceY"); - __publicField(this, "lastSourceWidth"); - __publicField(this, "lastSourceHeight"); - __publicField(this, "textureUploadSkipCount"); __publicField(this, "vertexShaderSource"); __publicField(this, "fragmentShaderSource"); this.config = config; @@ -34,12 +27,6 @@ class WebGLRenderer { this.uniformLocations = null; this.attributeLocations = null; this.currentFilteringMode = null; - this.lastCanvasVersion = -1; - this.lastSourceX = -1; - this.lastSourceY = -1; - this.lastSourceWidth = -1; - this.lastSourceHeight = -1; - this.textureUploadSkipCount = 0; this.vertexShaderSource = ` attribute vec2 a_position; attribute vec2 a_texCoord; @@ -190,29 +177,13 @@ class WebGLRenderer { * @param sourceCanvas - The source canvas to sample from */ render(sourceCanvas) { - var _a; if (!this.gl || !this.program || !this.texture || !this.uniformLocations || !this.attributeLocations) return; - const app = window.app; - const currentCanvasVersion = ((_a = app == null ? void 0 : app.graph) == null ? void 0 : _a.change_counter) ?? -1; - const sourceChanged = this.lastSourceX !== this.state.sourceX || this.lastSourceY !== this.state.sourceY || this.lastSourceWidth !== this.state.sourceWidth || this.lastSourceHeight !== this.state.sourceHeight; - const canvasChanged = currentCanvasVersion !== this.lastCanvasVersion; - const needsTextureUpdate = canvasChanged || sourceChanged || this.lastCanvasVersion === -1; this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture); - if (needsTextureUpdate) { - try { - this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, sourceCanvas); - this.lastCanvasVersion = currentCanvasVersion; - this.lastSourceX = this.state.sourceX; - this.lastSourceY = this.state.sourceY; - this.lastSourceWidth = this.state.sourceWidth; - this.lastSourceHeight = this.state.sourceHeight; - this.textureUploadSkipCount = 0; - } catch (e) { - console.error("ComfyUI Magnifying Glass ERROR: Error in texImage2D:", e); - return; - } - } else { - this.textureUploadSkipCount++; + try { + this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, sourceCanvas); + } catch (e) { + console.error("ComfyUI Magnifying Glass ERROR: Error in texImage2D:", e); + return; } const isOffscreenCanvas = sourceCanvas.width === this.config.glassSize && sourceCanvas.height === this.config.glassSize; let uvX, uvY, uvWidth, uvHeight; diff --git a/web/magnify-glass/mediaLayout.js b/web/magnify-glass/mediaLayout.js new file mode 100644 index 0000000..a90fca7 --- /dev/null +++ b/web/magnify-glass/mediaLayout.js @@ -0,0 +1,74 @@ +function captureRegion(pivotX, pivotY, glassSize, zoomFactor) { + const size = glassSize / zoomFactor; + return { left: pivotX - size / 2, top: pivotY - size / 2, size, k: glassSize / size }; +} +function toGlass(box, cap) { + return { x: (box.x - cap.left) * cap.k, y: (box.y - cap.top) * cap.k, w: box.w * cap.k, h: box.h * cap.k }; +} +function graphBoxToCss(box, nodePos, scale, offset) { + return { + x: (nodePos[0] + box.x + offset[0]) * scale, + y: (nodePos[1] + box.y + offset[1]) * scale, + w: box.w * scale, + h: box.h * scale + }; +} +function scaleAbout(box, px, py, s) { + return { x: px + (box.x - px) * s, y: py + (box.y - py) * s, w: box.w * s, h: box.h * s }; +} +function fitContent(box, nw, nh, objectFit) { + if (objectFit !== "contain" && objectFit !== "scale-down" && objectFit !== "cover") return box; + let s = objectFit === "cover" ? Math.max(box.w / nw, box.h / nh) : Math.min(box.w / nw, box.h / nh); + if (objectFit === "scale-down") s = Math.min(s, 1); + const w = nw * s; + const h = nh * s; + return { x: box.x + (box.w - w) / 2, y: box.y + (box.h - h) / 2, w, h }; +} +function previewImageBoxes(imgs, widget, node, sizeTextMargin) { + var _a, _b; + const loaded = (img) => !!(img == null ? void 0 : img.naturalWidth) && !!img.naturalHeight; + const index = node.imageIndex ?? (imgs.length === 1 ? 0 : null); + if (index != null) { + const img = imgs.at(index); + if (!img || !loaded(img)) return []; + const w = widget.width || node.size[0]; + const h = widget.computedHeight ? widget.computedHeight - sizeTextMargin : 0; + const s = Math.min(w / img.naturalWidth, h / img.naturalHeight, 1); + const dw = img.naturalWidth * s; + const dh = img.naturalHeight * s; + return [{ img, box: { x: (w - dw) / 2, y: widget.y + (h - dh) / 2, w: dw, h: dh } }]; + } + const rects = node.imageRects ?? []; + const ratio = ((_a = imgs[0]) == null ? void 0 : _a.naturalWidth) / ((_b = imgs[0]) == null ? void 0 : _b.naturalHeight); + const inset = imgs.every((img) => img.naturalWidth / img.naturalHeight === ratio) ? 0 : 2; + const boxes = []; + imgs.forEach((img, i) => { + const cell = rects[i]; + if (!cell || !loaded(img)) return; + const [cx, cy, cw, ch] = cell; + const s = Math.min(cw / img.naturalWidth, ch / img.naturalHeight); + const dw = img.naturalWidth * s; + const dh = img.naturalHeight * s; + boxes.push({ + img, + box: { x: cx + (cw - dw) / 2 + inset, y: cy + (ch - dh) / 2 + inset, w: dw - inset * 2, h: dh - inset * 2 } + }); + }); + return boxes; +} +function isSafeMediaSource(el) { + if (el.crossOrigin !== null) return true; + const src = el.currentSrc || el.src; + if (!src) return false; + const url = new URL(src, location.href); + return url.protocol === "blob:" || url.protocol === "data:" || url.origin === location.origin; +} +export { + captureRegion, + fitContent, + graphBoxToCss, + isSafeMediaSource, + previewImageBoxes, + scaleAbout, + toGlass +}; diff --git a/web/shared/utils.js b/web/shared/utils.js index 8b897ea..73be12b 100644 --- a/web/shared/utils.js +++ b/web/shared/utils.js @@ -32,9 +32,6 @@ function isUserTyping() { } return false; } -function rectsOverlap(rect1, rect2) { - return rect1.x < rect2.x + rect2.width && rect1.x + rect1.width > rect2.x && rect1.y < rect2.y + rect2.height && rect1.y + rect1.height > rect2.y; -} function findLiteGraphCanvas() { var _a, _b; const canvas = document.getElementById("graph-canvas"); @@ -64,6 +61,5 @@ export { findLiteGraphCanvas, getSettingValue, isUserTyping, - rectsOverlap, setSettingValue };