Fix image and video previews in the magnify glass
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f4f55f8ad0
commit
818248facc
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<number>, scale: number, offset: ArrayLike<number>): 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<number>; 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 = '<canvas id="graph-canvas" width="1000" height="800"></canvas>';
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof vi.fn>).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]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user