1364 lines
40 KiB
JavaScript
1364 lines
40 KiB
JavaScript
/**
|
|
* ComfyUI Annotations Extension
|
|
*
|
|
* A pure annotation/markup overlay system for ComfyUI's node editor.
|
|
* Allows freehand drawing directly on the canvas without modifying workflow data.
|
|
*
|
|
* @version 1.0.0
|
|
* @license MIT
|
|
*
|
|
* Features:
|
|
* - Canvas overlay that exactly matches the node editor viewport
|
|
* - High-DPI crisp rendering support
|
|
* - Toggle mode that doesn't block node interactions when OFF
|
|
* - Freehand drawing with smooth strokes
|
|
* - Pen tool with color, size, opacity settings
|
|
* - Eraser tool with hit-testing
|
|
* - Undo/redo with configurable history
|
|
* - Persistence (save/load with workflow)
|
|
* - Performance optimized (RAF batching, stroke caching)
|
|
*/
|
|
|
|
import { app } from "../../scripts/app.js";
|
|
import {
|
|
createOverlay,
|
|
destroyOverlay,
|
|
getCanvas,
|
|
getContext,
|
|
getOverlay,
|
|
setPointerEventsMode,
|
|
setCursor,
|
|
showOverlay,
|
|
hideOverlay,
|
|
isOverlayVisible,
|
|
clearCanvas,
|
|
isInitialized,
|
|
onResize,
|
|
getCanvasDimensions
|
|
} from "./modules/annotationOverlay.js";
|
|
import {
|
|
getTransform,
|
|
screenToCanvas,
|
|
canvasToScreen,
|
|
applyTransformToContext,
|
|
resetContextTransform,
|
|
createTransformTracker,
|
|
drawDebugOverlay,
|
|
setDebugVisualizerEnabled,
|
|
isDebugVisualizerEnabled,
|
|
runCoordinateTests,
|
|
screenDistanceToCanvas
|
|
} from "./modules/coordinateUtils.js";
|
|
import {
|
|
findHitStrokes,
|
|
removeStrokesById
|
|
} from "./modules/hitTesting.js";
|
|
import {
|
|
initHistory,
|
|
recordState,
|
|
undo as historyUndo,
|
|
redo as historyRedo,
|
|
canUndo,
|
|
canRedo,
|
|
clearHistory,
|
|
getHistoryState,
|
|
setMaxHistory,
|
|
getMaxHistory,
|
|
recordInitialState
|
|
} from "./modules/historyManager.js";
|
|
import {
|
|
createToolbar,
|
|
updateToolbarState,
|
|
toggleCollapse as toggleToolbarCollapse,
|
|
setCollapsed as setToolbarCollapsed,
|
|
isToolbarCollapsed,
|
|
showToolbar,
|
|
hideToolbar,
|
|
destroyToolbar
|
|
} from "./modules/toolbarUI.js";
|
|
import {
|
|
setupPersistence,
|
|
serializeAnnotations,
|
|
deserializeAnnotations,
|
|
getExtensionKey
|
|
} from "./modules/persistence.js";
|
|
import {
|
|
scheduleRender,
|
|
cancelPendingRender,
|
|
forceRender,
|
|
initStrokeCache,
|
|
invalidateCache,
|
|
drawStrokesWithCache,
|
|
drawStrokeCached,
|
|
invalidateStrokePath,
|
|
startRenderTiming,
|
|
endRenderTiming,
|
|
getPerformanceMetrics,
|
|
resetPerformanceMetrics,
|
|
runBenchmark
|
|
} from "./modules/renderOptimizer.js";
|
|
import {
|
|
detectFeatures,
|
|
checkRequiredFeatures,
|
|
warnIfUnsupported,
|
|
hasFeature,
|
|
getFeatures,
|
|
createOptimizedContext,
|
|
createOffscreenCanvasWithFallback,
|
|
getCoalescedEventsWithFallback,
|
|
safariWorkarounds,
|
|
timing,
|
|
generateCompatibilityReport
|
|
} from "./modules/browserCompat.js";
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const VERSION = "1.0.0";
|
|
const EXTENSION_NAME = "comfyui.annotations";
|
|
const LOG_PREFIX = "[Annotations]";
|
|
|
|
// ============================================================================
|
|
// State
|
|
// ============================================================================
|
|
|
|
let isAnnotationModeActive = false;
|
|
let transformTrackerCleanup = null;
|
|
|
|
// Tool state: 'pen' or 'eraser'
|
|
let currentTool = 'pen';
|
|
|
|
// Strokes array - completed strokes
|
|
let strokes = [];
|
|
|
|
// Current drawing state
|
|
let isDrawing = false;
|
|
let currentStroke = null;
|
|
let lastPoint = null;
|
|
|
|
// Pen settings
|
|
const penSettings = {
|
|
color: '#FF5555',
|
|
size: 4,
|
|
opacity: 1.0
|
|
};
|
|
|
|
// Eraser settings
|
|
const eraserSettings = {
|
|
size: 20 // Eraser radius in canvas coordinates
|
|
};
|
|
|
|
// Eraser cursor position (for visualization)
|
|
let eraserCursorPos = null;
|
|
|
|
// Minimum distance between points (in canvas coords) for performance
|
|
const MIN_POINT_DISTANCE = 2;
|
|
|
|
/**
|
|
* Returns the current annotation state for the toolbar.
|
|
* @returns {Object} Current state
|
|
*/
|
|
function getAnnotationState() {
|
|
return {
|
|
isActive: isAnnotationModeActive,
|
|
currentTool,
|
|
penSettings: { ...penSettings },
|
|
eraserSettings: { ...eraserSettings },
|
|
canUndo: canUndo(),
|
|
canRedo: canRedo(),
|
|
strokeCount: strokes.length
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Initialization
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Waits for the canvas to be ready and initializes the overlay.
|
|
*/
|
|
function waitForCanvasAndInitialize() {
|
|
// Prevent duplicate initialization
|
|
if (isInitialized()) {
|
|
console.warn(LOG_PREFIX, 'Already initialized');
|
|
return;
|
|
}
|
|
|
|
let attempts = 0;
|
|
const maxAttempts = 300; // 30 seconds
|
|
const pollInterval = 100;
|
|
|
|
function tryInitialize() {
|
|
try {
|
|
const canvas = document.querySelector('canvas');
|
|
|
|
// Check all required conditions
|
|
if (canvas && canvas.parentElement && app?.canvas?.ds) {
|
|
initializeAnnotationSystem(canvas);
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (error) {
|
|
console.error(LOG_PREFIX, 'Init check error:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Try immediately
|
|
if (tryInitialize()) return;
|
|
|
|
// Poll until ready
|
|
const intervalId = setInterval(() => {
|
|
attempts++;
|
|
|
|
if (tryInitialize()) {
|
|
clearInterval(intervalId);
|
|
console.log(LOG_PREFIX, `Initialized after ${attempts * pollInterval}ms`);
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(intervalId);
|
|
console.error(LOG_PREFIX, 'Initialization timeout - canvas not ready');
|
|
}
|
|
}, pollInterval);
|
|
}
|
|
|
|
/**
|
|
* Main initialization function.
|
|
* @param {HTMLCanvasElement} litegraphCanvas
|
|
*/
|
|
function initializeAnnotationSystem(litegraphCanvas) {
|
|
const container = litegraphCanvas.parentElement;
|
|
|
|
// Ensure container supports absolute positioning
|
|
container.style.position = 'relative';
|
|
|
|
// Create overlay and canvas
|
|
createOverlay(container);
|
|
|
|
// Setup resize callback to re-render on size change
|
|
onResize((width, height, dpr) => {
|
|
renderAnnotations();
|
|
});
|
|
|
|
// Start transform tracking (re-render when zoom/pan changes)
|
|
// Only re-render when transform actually changes (not on every frame)
|
|
let lastTransformStr = '';
|
|
transformTrackerCleanup = createTransformTracker((transform) => {
|
|
const transformStr = `${transform.scale},${transform.offsetX},${transform.offsetY}`;
|
|
if (transformStr !== lastTransformStr) {
|
|
lastTransformStr = transformStr;
|
|
renderAnnotations();
|
|
}
|
|
});
|
|
|
|
// Setup keyboard shortcut for toggle
|
|
setupKeyboardShortcut();
|
|
|
|
// Setup pointer events for drawing
|
|
setupPointerEvents();
|
|
|
|
// Initialize history manager
|
|
initHistory({
|
|
maxHistory: 50,
|
|
onRestore: (restoredStrokes) => {
|
|
strokes = restoredStrokes;
|
|
invalidateCache(); // Strokes changed, invalidate cache
|
|
renderAnnotations();
|
|
updateToolbarState();
|
|
}
|
|
});
|
|
recordInitialState(); // Record empty state as starting point
|
|
|
|
// Create toolbar UI
|
|
createToolbar(container, {
|
|
onToggleMode: toggleAnnotationMode,
|
|
onSetTool: setTool,
|
|
onSetColor: (color) => { penSettings.color = color; },
|
|
onSetSize: (size) => { penSettings.size = Math.max(1, Math.min(50, size)); },
|
|
onSetOpacity: (opacity) => { penSettings.opacity = Math.max(0.1, Math.min(1, opacity)); },
|
|
onSetEraserSize: (size) => {
|
|
eraserSettings.size = Math.max(5, Math.min(100, size));
|
|
renderAnnotations();
|
|
},
|
|
onUndo: performUndo,
|
|
onRedo: performRedo,
|
|
onClear: clearAllStrokes,
|
|
getState: getAnnotationState
|
|
});
|
|
|
|
// Setup persistence (save/load annotations with workflow)
|
|
setupPersistence({
|
|
getStrokes: () => strokes,
|
|
setStrokes: (newStrokes) => {
|
|
strokes = newStrokes;
|
|
invalidateCache(); // New strokes loaded, invalidate cache
|
|
// Reset history when loading new strokes
|
|
clearHistory();
|
|
recordInitialState();
|
|
if (newStrokes.length > 0) {
|
|
recordState(strokes, 'load');
|
|
}
|
|
renderAnnotations();
|
|
updateToolbarState();
|
|
},
|
|
onLoad: () => {
|
|
console.log(LOG_PREFIX, 'Annotations loaded from workflow');
|
|
}
|
|
});
|
|
|
|
// Initial state: overlay visible but pass-through (annotations OFF)
|
|
showOverlay();
|
|
setPointerEventsMode('none');
|
|
renderAnnotations();
|
|
updateToolbarState();
|
|
|
|
// Debug: log canvas info
|
|
const { width, height, dpr } = getCanvasDimensions();
|
|
console.log(LOG_PREFIX, `Canvas dimensions: ${width}x${height}, DPR: ${dpr}`);
|
|
console.log(LOG_PREFIX, 'Annotation system initialized');
|
|
console.log(LOG_PREFIX, 'Press "A" to toggle annotation mode');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Rendering
|
|
// ============================================================================
|
|
|
|
// Performance optimization: use stroke cache for large drawings
|
|
let useStrokeCache = true; // Enable by default
|
|
|
|
/**
|
|
* Renders all annotations on the overlay canvas.
|
|
* Applies LiteGraph transform so strokes follow zoom/pan.
|
|
* Uses stroke caching for better performance with many strokes.
|
|
*/
|
|
function renderAnnotations() {
|
|
const ctx = getContext();
|
|
const canvas = getCanvas();
|
|
if (!ctx || !canvas) {
|
|
console.warn(LOG_PREFIX, 'No context or canvas available');
|
|
return;
|
|
}
|
|
|
|
const startTime = startRenderTiming();
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const { width, height } = getCanvasDimensions();
|
|
|
|
// Initialize stroke cache if needed
|
|
if (useStrokeCache) {
|
|
initStrokeCache(canvas.width, canvas.height);
|
|
}
|
|
|
|
// Clear canvas
|
|
clearCanvas();
|
|
|
|
// Get current transform
|
|
const transform = getTransform();
|
|
|
|
// === Draw strokes (canvas coordinates with LiteGraph transform) ===
|
|
// LiteGraph formula: screenPos = (canvasPos + offset) * scale
|
|
ctx.setTransform(
|
|
transform.scale * dpr,
|
|
0,
|
|
0,
|
|
transform.scale * dpr,
|
|
transform.offsetX * transform.scale * dpr,
|
|
transform.offsetY * transform.scale * dpr
|
|
);
|
|
|
|
// Draw all strokes using cache for performance
|
|
if (useStrokeCache && strokes.length > 10) {
|
|
// Use cached layer for many strokes
|
|
drawStrokesWithCache(ctx, strokes, transform, dpr);
|
|
// Restore transform after cache composite
|
|
ctx.setTransform(
|
|
transform.scale * dpr,
|
|
0,
|
|
0,
|
|
transform.scale * dpr,
|
|
transform.offsetX * transform.scale * dpr,
|
|
transform.offsetY * transform.scale * dpr
|
|
);
|
|
} else {
|
|
// Direct rendering for few strokes
|
|
for (const stroke of strokes) {
|
|
drawStrokeCached(ctx, stroke, false);
|
|
}
|
|
}
|
|
|
|
// === Draw eraser cursor (in canvas coordinates, under transform) ===
|
|
if (isAnnotationModeActive && currentTool === 'eraser' && eraserCursorPos) {
|
|
drawEraserCursor(ctx, eraserCursorPos.x, eraserCursorPos.y, eraserSettings.size);
|
|
}
|
|
|
|
// === Draw UI elements (screen coordinates, always visible) ===
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
|
|
// Draw mode indicator only when annotation mode is active
|
|
if (isAnnotationModeActive) {
|
|
drawModeIndicator(ctx, width, height);
|
|
}
|
|
|
|
// Draw debug overlay if enabled
|
|
drawDebugOverlay(ctx, width, height, dpr);
|
|
|
|
endRenderTiming(startTime);
|
|
}
|
|
|
|
/**
|
|
* Draws the eraser cursor circle at the given canvas position.
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {number} x - Canvas X coordinate
|
|
* @param {number} y - Canvas Y coordinate
|
|
* @param {number} radius - Eraser radius in canvas units
|
|
*/
|
|
function drawEraserCursor(ctx, x, y, radius) {
|
|
// Draw outer circle (eraser boundary)
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.strokeStyle = 'rgba(85, 170, 255, 0.8)';
|
|
ctx.lineWidth = 2 / getTransform().scale; // Keep line width consistent on screen
|
|
ctx.stroke();
|
|
|
|
// Draw inner fill (semi-transparent)
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
|
ctx.fillStyle = 'rgba(85, 170, 255, 0.15)';
|
|
ctx.fill();
|
|
|
|
// Draw center crosshair
|
|
const crossSize = radius * 0.3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x - crossSize, y);
|
|
ctx.lineTo(x + crossSize, y);
|
|
ctx.moveTo(x, y - crossSize);
|
|
ctx.lineTo(x, y + crossSize);
|
|
ctx.strokeStyle = 'rgba(85, 170, 255, 0.6)';
|
|
ctx.lineWidth = 1 / getTransform().scale;
|
|
ctx.stroke();
|
|
}
|
|
|
|
/**
|
|
* Draws a single stroke with straight line segments.
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {Object} stroke
|
|
*/
|
|
function drawStroke(ctx, stroke) {
|
|
if (!stroke.points || stroke.points.length < 2) return;
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = stroke.color;
|
|
ctx.lineWidth = stroke.size;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.globalAlpha = stroke.opacity;
|
|
|
|
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
|
|
|
|
for (let i = 1; i < stroke.points.length; i++) {
|
|
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
|
|
}
|
|
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
/**
|
|
* Draws a stroke with quadratic curve smoothing for a smoother appearance.
|
|
* Uses midpoints between consecutive points as control points.
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {Object} stroke
|
|
*/
|
|
function drawStrokeSmooth(ctx, stroke) {
|
|
if (!stroke.points || stroke.points.length < 2) return;
|
|
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = stroke.color;
|
|
ctx.lineWidth = stroke.size;
|
|
ctx.lineCap = 'round';
|
|
ctx.lineJoin = 'round';
|
|
ctx.globalAlpha = stroke.opacity;
|
|
|
|
const points = stroke.points;
|
|
|
|
// Start at first point
|
|
ctx.moveTo(points[0].x, points[0].y);
|
|
|
|
// If only 2 points, draw a line
|
|
if (points.length === 2) {
|
|
ctx.lineTo(points[1].x, points[1].y);
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
return;
|
|
}
|
|
|
|
// For 3+ points, use quadratic curves through midpoints
|
|
// This creates smooth curves that pass through all midpoints
|
|
for (let i = 1; i < points.length - 1; i++) {
|
|
const current = points[i];
|
|
const next = points[i + 1];
|
|
|
|
// Calculate midpoint to next point
|
|
const midX = (current.x + next.x) / 2;
|
|
const midY = (current.y + next.y) / 2;
|
|
|
|
// Draw quadratic curve to midpoint, using current point as control
|
|
ctx.quadraticCurveTo(current.x, current.y, midX, midY);
|
|
}
|
|
|
|
// Draw final segment to last point
|
|
const lastPoint = points[points.length - 1];
|
|
const secondLast = points[points.length - 2];
|
|
ctx.quadraticCurveTo(secondLast.x, secondLast.y, lastPoint.x, lastPoint.y);
|
|
|
|
ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
/**
|
|
* Draws a status indicator in the corner showing mode and current tool.
|
|
* @param {CanvasRenderingContext2D} ctx
|
|
* @param {number} width
|
|
* @param {number} height
|
|
*/
|
|
function drawModeIndicator(ctx, width, height) {
|
|
const padding = 12;
|
|
const toolName = currentTool === 'pen' ? 'PEN' : 'ERASER';
|
|
const text = `ANNOTATION: ${toolName}`;
|
|
const bgColor = currentTool === 'pen'
|
|
? 'rgba(255, 85, 85, 0.9)'
|
|
: 'rgba(85, 170, 255, 0.9)';
|
|
|
|
ctx.font = 'bold 13px -apple-system, BlinkMacSystemFont, sans-serif';
|
|
const textWidth = ctx.measureText(text).width;
|
|
|
|
const boxWidth = textWidth + padding * 2;
|
|
const boxHeight = 28;
|
|
const x = width - boxWidth - 10;
|
|
const y = 10;
|
|
|
|
// Background with rounded corners
|
|
ctx.fillStyle = bgColor;
|
|
ctx.beginPath();
|
|
ctx.roundRect(x, y, boxWidth, boxHeight, 6);
|
|
ctx.fill();
|
|
|
|
// Text
|
|
ctx.fillStyle = '#FFFFFF';
|
|
ctx.fillText(text, x + padding, y + 19);
|
|
|
|
// Show shortcut hints below
|
|
ctx.font = '11px -apple-system, BlinkMacSystemFont, sans-serif';
|
|
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
|
const hintText1 = 'P: Pen E: Eraser Esc: Exit';
|
|
const hintWidth1 = ctx.measureText(hintText1).width;
|
|
ctx.fillText(hintText1, width - hintWidth1 - 10 - padding, y + 45);
|
|
|
|
// Show undo/redo hint with status
|
|
const undoStatus = canUndo() ? '●' : '○';
|
|
const redoStatus = canRedo() ? '●' : '○';
|
|
const hintText2 = `${undoStatus} Ctrl+Z: Undo ${redoStatus} Ctrl+Shift+Z: Redo`;
|
|
const hintWidth2 = ctx.measureText(hintText2).width;
|
|
ctx.fillText(hintText2, width - hintWidth2 - 10 - padding, y + 60);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Pointer Events (Drawing)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Sets up pointer event handlers for drawing.
|
|
*/
|
|
function setupPointerEvents() {
|
|
const canvas = getCanvas();
|
|
if (!canvas) return;
|
|
|
|
canvas.addEventListener('pointerdown', handlePointerDown);
|
|
canvas.addEventListener('pointermove', handlePointerMove);
|
|
canvas.addEventListener('pointerup', handlePointerUp);
|
|
canvas.addEventListener('pointerleave', handlePointerLeave);
|
|
canvas.addEventListener('pointercancel', handlePointerCancel);
|
|
|
|
// Handle lost pointer capture (e.g., when another element captures)
|
|
canvas.addEventListener('lostpointercapture', handleLostPointerCapture);
|
|
|
|
// Prevent default touch behaviors (scrolling, zooming while drawing)
|
|
canvas.addEventListener('touchstart', (e) => {
|
|
if (isAnnotationModeActive) {
|
|
e.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('touchmove', (e) => {
|
|
if (isAnnotationModeActive && isDrawing) {
|
|
e.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
|
|
// Handle window blur - abort any in-progress stroke
|
|
window.addEventListener('blur', handleWindowBlur);
|
|
|
|
// Handle visibility change (tab switch)
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
}
|
|
|
|
/**
|
|
* Handles window blur - abort in-progress operations.
|
|
*/
|
|
function handleWindowBlur() {
|
|
if (isDrawing) {
|
|
abortCurrentStroke();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles visibility change (tab switch).
|
|
*/
|
|
function handleVisibilityChange() {
|
|
if (document.hidden && isDrawing) {
|
|
abortCurrentStroke();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles lost pointer capture event.
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function handleLostPointerCapture(e) {
|
|
if (isDrawing) {
|
|
console.log(LOG_PREFIX, 'Pointer capture lost, completing stroke');
|
|
// Complete the stroke gracefully instead of aborting
|
|
finalizeCurrentStroke();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles pointer cancel event (system interruption).
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function handlePointerCancel(e) {
|
|
console.log(LOG_PREFIX, 'Pointer cancelled');
|
|
abortCurrentStroke();
|
|
eraserCursorPos = null;
|
|
scheduleRender(renderAnnotations);
|
|
}
|
|
|
|
/**
|
|
* Aborts the current stroke without saving.
|
|
*/
|
|
function abortCurrentStroke() {
|
|
if (!isDrawing) return;
|
|
|
|
isDrawing = false;
|
|
currentStroke = null;
|
|
lastPoint = null;
|
|
eraserActionPending = false;
|
|
|
|
console.log(LOG_PREFIX, 'Stroke aborted');
|
|
scheduleRender(renderAnnotations);
|
|
}
|
|
|
|
/**
|
|
* Finalizes the current stroke (complete gracefully).
|
|
*/
|
|
function finalizeCurrentStroke() {
|
|
if (!isDrawing) return;
|
|
|
|
isDrawing = false;
|
|
|
|
if (currentTool === 'pen' && currentStroke && currentStroke.points.length >= 2) {
|
|
strokes.push(currentStroke);
|
|
invalidateCache();
|
|
recordState(strokes, 'draw');
|
|
updateToolbarState();
|
|
console.log(LOG_PREFIX, `Stroke finalized: ${currentStroke.points.length} points`);
|
|
} else if (currentTool === 'eraser') {
|
|
commitEraserAction();
|
|
}
|
|
|
|
currentStroke = null;
|
|
lastPoint = null;
|
|
scheduleRender(renderAnnotations);
|
|
}
|
|
|
|
/**
|
|
* Handles pointer leaving the canvas.
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function handlePointerLeave(e) {
|
|
handlePointerUp(e);
|
|
eraserCursorPos = null;
|
|
renderAnnotations();
|
|
}
|
|
|
|
/**
|
|
* Handles pointer down - start drawing or erasing.
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function handlePointerDown(e) {
|
|
// Only act with primary button (left click) in annotation mode
|
|
if (!isAnnotationModeActive || e.button !== 0) return;
|
|
|
|
isDrawing = true;
|
|
|
|
// Get position relative to canvas
|
|
const rect = e.target.getBoundingClientRect();
|
|
const screenX = e.clientX - rect.left;
|
|
const screenY = e.clientY - rect.top;
|
|
|
|
// Convert to canvas coordinates
|
|
const canvasPos = screenToCanvas(screenX, screenY);
|
|
|
|
if (currentTool === 'pen') {
|
|
// Create new stroke
|
|
currentStroke = {
|
|
id: generateStrokeId(),
|
|
points: [{ x: canvasPos.x, y: canvasPos.y }],
|
|
color: penSettings.color,
|
|
size: penSettings.size,
|
|
opacity: penSettings.opacity
|
|
};
|
|
lastPoint = canvasPos;
|
|
} else if (currentTool === 'eraser') {
|
|
// Perform eraser action at this point
|
|
performErase(canvasPos.x, canvasPos.y);
|
|
lastPoint = canvasPos;
|
|
}
|
|
|
|
// Capture pointer for reliable tracking
|
|
e.target.setPointerCapture(e.pointerId);
|
|
|
|
e.preventDefault();
|
|
}
|
|
|
|
// RAF batching state for pointer move
|
|
let pendingPointerRender = false;
|
|
|
|
/**
|
|
* Processes a single pointer position for drawing.
|
|
* @param {number} screenX
|
|
* @param {number} screenY
|
|
* @param {DOMRect} rect
|
|
* @returns {boolean} Whether a point was added
|
|
*/
|
|
function processPointerPosition(screenX, screenY, rect) {
|
|
const canvasPos = screenToCanvas(screenX, screenY);
|
|
|
|
if (currentTool === 'pen') {
|
|
if (!currentStroke || !lastPoint) return false;
|
|
|
|
const dx = canvasPos.x - lastPoint.x;
|
|
const dy = canvasPos.y - lastPoint.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance >= MIN_POINT_DISTANCE) {
|
|
currentStroke.points.push({ x: canvasPos.x, y: canvasPos.y });
|
|
lastPoint = canvasPos;
|
|
return true;
|
|
}
|
|
} else if (currentTool === 'eraser') {
|
|
if (!lastPoint) return false;
|
|
|
|
const dx = canvasPos.x - lastPoint.x;
|
|
const dy = canvasPos.y - lastPoint.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance >= MIN_POINT_DISTANCE) {
|
|
performErase(canvasPos.x, canvasPos.y);
|
|
lastPoint = canvasPos;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Handles pointer move - continue drawing or erasing, and update eraser cursor.
|
|
* Uses RAF batching to coalesce multiple pointer events per frame.
|
|
* Supports getCoalescedEvents() for smoother fast strokes.
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function handlePointerMove(e) {
|
|
const rect = e.target.getBoundingClientRect();
|
|
|
|
// Get the final position for eraser cursor
|
|
const screenX = e.clientX - rect.left;
|
|
const screenY = e.clientY - rect.top;
|
|
const canvasPos = screenToCanvas(screenX, screenY);
|
|
|
|
// Update eraser cursor position for visualization (even when not drawing)
|
|
if (isAnnotationModeActive && currentTool === 'eraser') {
|
|
eraserCursorPos = { x: canvasPos.x, y: canvasPos.y };
|
|
if (!isDrawing) {
|
|
scheduleRender(renderAnnotations);
|
|
}
|
|
}
|
|
|
|
if (!isDrawing) return;
|
|
|
|
// Use coalesced events for smoother fast strokes (with browser fallback)
|
|
// This captures all intermediate points that were batched by the browser
|
|
let pointsAdded = false;
|
|
|
|
if (currentTool === 'pen') {
|
|
// Use browser-compatible helper that falls back gracefully
|
|
const coalescedEvents = getCoalescedEventsWithFallback(e);
|
|
if (coalescedEvents.length > 1) {
|
|
// Process all coalesced events for smooth curves
|
|
for (const ce of coalescedEvents) {
|
|
const ceX = ce.clientX - rect.left;
|
|
const ceY = ce.clientY - rect.top;
|
|
if (processPointerPosition(ceX, ceY, rect)) {
|
|
pointsAdded = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no coalesced events (or eraser tool), process the main event
|
|
if (!pointsAdded) {
|
|
pointsAdded = processPointerPosition(screenX, screenY, rect);
|
|
}
|
|
|
|
// Update eraser cursor and request render
|
|
if (pointsAdded) {
|
|
if (currentTool === 'pen') {
|
|
scheduleRender(renderWithCurrentStroke);
|
|
}
|
|
// Eraser already schedules render in performErase
|
|
}
|
|
|
|
e.preventDefault();
|
|
}
|
|
|
|
/**
|
|
* Handles pointer up - finish drawing or erasing.
|
|
* @param {PointerEvent} e
|
|
*/
|
|
function handlePointerUp(e) {
|
|
if (!isDrawing) return;
|
|
|
|
isDrawing = false;
|
|
|
|
if (currentTool === 'pen' && currentStroke && currentStroke.points.length >= 2) {
|
|
// Add completed stroke to array
|
|
strokes.push(currentStroke);
|
|
invalidateCache(); // New stroke added, invalidate cache
|
|
console.log(LOG_PREFIX, `Stroke added: ${currentStroke.points.length} points`);
|
|
|
|
// Record state for undo
|
|
recordState(strokes, 'draw');
|
|
updateToolbarState();
|
|
} else if (currentTool === 'eraser') {
|
|
// Commit all eraser changes as one undo state
|
|
commitEraserAction();
|
|
}
|
|
|
|
currentStroke = null;
|
|
lastPoint = null;
|
|
|
|
// Release pointer capture
|
|
if (e.target.hasPointerCapture?.(e.pointerId)) {
|
|
e.target.releasePointerCapture(e.pointerId);
|
|
}
|
|
|
|
// Final render
|
|
renderAnnotations();
|
|
}
|
|
|
|
/**
|
|
* Performs eraser action at the given canvas coordinates.
|
|
* Removes any strokes that intersect with the eraser circle.
|
|
* @param {number} canvasX - X position in canvas coordinates
|
|
* @param {number} canvasY - Y position in canvas coordinates
|
|
*/
|
|
// Track eraser state for batching multiple erases into one undo state
|
|
let eraserActionPending = false;
|
|
|
|
/**
|
|
* Performs eraser action at the given canvas coordinates.
|
|
* Removes any strokes that intersect with the eraser circle.
|
|
* @param {number} canvasX - X position in canvas coordinates
|
|
* @param {number} canvasY - Y position in canvas coordinates
|
|
*/
|
|
function performErase(canvasX, canvasY) {
|
|
const hitIds = findHitStrokes(canvasX, canvasY, eraserSettings.size, strokes);
|
|
|
|
if (hitIds.length > 0) {
|
|
// Invalidate path cache for removed strokes
|
|
for (const stroke of strokes) {
|
|
if (hitIds.includes(stroke.id)) {
|
|
invalidateStrokePath(stroke);
|
|
}
|
|
}
|
|
strokes = removeStrokesById(strokes, hitIds);
|
|
eraserActionPending = true; // Mark that we have pending changes
|
|
invalidateCache(); // Invalidate stroke layer cache
|
|
console.log(LOG_PREFIX, `Erased ${hitIds.length} stroke(s)`);
|
|
scheduleRender(renderAnnotations);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Commits eraser action to history.
|
|
* Called when eraser pointer up to batch all erases in one undo state.
|
|
*/
|
|
function commitEraserAction() {
|
|
if (eraserActionPending) {
|
|
recordState(strokes, 'erase');
|
|
eraserActionPending = false;
|
|
updateToolbarState();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates a unique stroke ID.
|
|
* @returns {string}
|
|
*/
|
|
function generateStrokeId() {
|
|
return `stroke_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Renders all strokes plus the current in-progress stroke.
|
|
* Optimized for real-time drawing feedback using stroke cache.
|
|
*/
|
|
function renderWithCurrentStroke() {
|
|
const ctx = getContext();
|
|
const canvas = getCanvas();
|
|
if (!ctx || !canvas) return;
|
|
|
|
const startTime = startRenderTiming();
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const { width, height } = getCanvasDimensions();
|
|
|
|
// Initialize stroke cache if needed
|
|
if (useStrokeCache) {
|
|
initStrokeCache(canvas.width, canvas.height);
|
|
}
|
|
|
|
// Clear canvas
|
|
clearCanvas();
|
|
|
|
// Get current transform
|
|
const transform = getTransform();
|
|
|
|
// Apply LiteGraph transform
|
|
ctx.setTransform(
|
|
transform.scale * dpr,
|
|
0,
|
|
0,
|
|
transform.scale * dpr,
|
|
transform.offsetX * transform.scale * dpr,
|
|
transform.offsetY * transform.scale * dpr
|
|
);
|
|
|
|
// Draw all completed strokes using cache for performance
|
|
if (useStrokeCache && strokes.length > 10) {
|
|
drawStrokesWithCache(ctx, strokes, transform, dpr);
|
|
// Restore transform after cache composite
|
|
ctx.setTransform(
|
|
transform.scale * dpr,
|
|
0,
|
|
0,
|
|
transform.scale * dpr,
|
|
transform.offsetX * transform.scale * dpr,
|
|
transform.offsetY * transform.scale * dpr
|
|
);
|
|
} else {
|
|
for (const stroke of strokes) {
|
|
drawStrokeCached(ctx, stroke, false);
|
|
}
|
|
}
|
|
|
|
// Draw current stroke in progress (always direct, not cached)
|
|
if (currentStroke && currentStroke.points.length >= 2) {
|
|
drawStrokeSmooth(ctx, currentStroke);
|
|
}
|
|
|
|
// Draw UI elements
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
if (isAnnotationModeActive) {
|
|
drawModeIndicator(ctx, width, height);
|
|
}
|
|
|
|
endRenderTiming(startTime);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Mode Toggle
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Toggles annotation mode on/off.
|
|
*/
|
|
function toggleAnnotationMode() {
|
|
isAnnotationModeActive = !isAnnotationModeActive;
|
|
|
|
if (isAnnotationModeActive) {
|
|
// Enable annotation mode
|
|
showOverlay();
|
|
setPointerEventsMode('auto');
|
|
updateCursor();
|
|
console.log(LOG_PREFIX, `Annotation mode ON - ${currentTool} tool active`);
|
|
} else {
|
|
// Disable annotation mode - pass through to canvas
|
|
showOverlay(); // Keep visible to show strokes
|
|
setPointerEventsMode('none');
|
|
setCursor('default');
|
|
console.log(LOG_PREFIX, 'Annotation mode OFF - node interactions enabled');
|
|
}
|
|
|
|
renderAnnotations();
|
|
updateToolbarState();
|
|
}
|
|
|
|
/**
|
|
* Performs undo action.
|
|
* @returns {boolean} True if undo was performed
|
|
*/
|
|
function performUndo() {
|
|
if (!canUndo()) {
|
|
console.log(LOG_PREFIX, 'Nothing to undo');
|
|
return false;
|
|
}
|
|
|
|
historyUndo(); // This will trigger onRestore callback
|
|
console.log(LOG_PREFIX, 'Undo performed');
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Performs redo action.
|
|
* @returns {boolean} True if redo was performed
|
|
*/
|
|
function performRedo() {
|
|
if (!canRedo()) {
|
|
console.log(LOG_PREFIX, 'Nothing to redo');
|
|
return false;
|
|
}
|
|
|
|
historyRedo(); // This will trigger onRestore callback
|
|
console.log(LOG_PREFIX, 'Redo performed');
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clears all strokes with undo support.
|
|
*/
|
|
function clearAllStrokes() {
|
|
if (strokes.length === 0) {
|
|
console.log(LOG_PREFIX, 'No strokes to clear');
|
|
return;
|
|
}
|
|
|
|
strokes = [];
|
|
invalidateCache(); // All strokes cleared, invalidate cache
|
|
recordState(strokes, 'clear');
|
|
renderAnnotations();
|
|
updateToolbarState();
|
|
console.log(LOG_PREFIX, 'All strokes cleared');
|
|
}
|
|
|
|
/**
|
|
* Sets up the keyboard shortcut for toggling annotation mode and tools.
|
|
*/
|
|
function setupKeyboardShortcut() {
|
|
document.addEventListener('keydown', (e) => {
|
|
// Don't intercept when typing in inputs
|
|
const activeEl = document.activeElement;
|
|
const isTyping = activeEl && (
|
|
activeEl.tagName === 'INPUT' ||
|
|
activeEl.tagName === 'TEXTAREA' ||
|
|
activeEl.isContentEditable
|
|
);
|
|
|
|
if (isTyping) return;
|
|
|
|
// 'A' key toggles annotation mode
|
|
if (e.key === 'a' || e.key === 'A') {
|
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
toggleAnnotationMode();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
// 'T' key toggles toolbar collapse
|
|
if (e.key === 't' || e.key === 'T') {
|
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
toggleToolbarCollapse();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
// Tool switching (only when annotation mode is active)
|
|
if (isAnnotationModeActive) {
|
|
// 'P' key switches to pen tool
|
|
if (e.key === 'p' || e.key === 'P') {
|
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
setTool('pen');
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
// 'E' key switches to eraser tool
|
|
if (e.key === 'e' || e.key === 'E') {
|
|
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
setTool('eraser');
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
// Ctrl+Z / Cmd+Z for undo
|
|
if (e.key === 'z' || e.key === 'Z') {
|
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
|
performUndo();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
// Ctrl+Shift+Z / Cmd+Shift+Z for redo
|
|
if (e.key === 'z' || e.key === 'Z') {
|
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey) {
|
|
performRedo();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
|
|
// Ctrl+Y / Cmd+Y for redo (alternative)
|
|
if (e.key === 'y' || e.key === 'Y') {
|
|
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
|
performRedo();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Escape exits annotation mode
|
|
if (e.key === 'Escape' && isAnnotationModeActive) {
|
|
toggleAnnotationMode();
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}
|
|
}, true); // Capture phase for priority
|
|
}
|
|
|
|
/**
|
|
* Sets the current tool and updates cursor.
|
|
* @param {'pen' | 'eraser'} tool
|
|
*/
|
|
function setTool(tool) {
|
|
if (currentTool === tool) return;
|
|
|
|
currentTool = tool;
|
|
updateCursor();
|
|
renderAnnotations();
|
|
updateToolbarState();
|
|
console.log(LOG_PREFIX, `Tool switched to: ${tool}`);
|
|
}
|
|
|
|
/**
|
|
* Updates the cursor based on current tool.
|
|
*/
|
|
function updateCursor() {
|
|
if (!isAnnotationModeActive) {
|
|
setCursor('default');
|
|
return;
|
|
}
|
|
|
|
if (currentTool === 'pen') {
|
|
setCursor('crosshair');
|
|
} else if (currentTool === 'eraser') {
|
|
// Use a custom cursor or a circle cursor
|
|
setCursor('cell');
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Cleanup
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Stops the transform tracker (for debugging).
|
|
*/
|
|
function stopTracker() {
|
|
if (transformTrackerCleanup) {
|
|
transformTrackerCleanup();
|
|
transformTrackerCleanup = null;
|
|
console.log(LOG_PREFIX, 'Transform tracker stopped');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleans up resources when extension is unloaded.
|
|
*/
|
|
function cleanup() {
|
|
stopTracker();
|
|
destroyToolbar();
|
|
destroyOverlay();
|
|
}
|
|
|
|
// ============================================================================
|
|
// CSS Loading
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Loads the extension's stylesheet.
|
|
*/
|
|
function loadStyles() {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.type = 'text/css';
|
|
|
|
// Get base path from current script
|
|
let basePath = '/extensions/comfyui-annotations';
|
|
|
|
const currentScript = document.currentScript;
|
|
if (currentScript && currentScript.src) {
|
|
basePath = currentScript.src.substring(0, currentScript.src.lastIndexOf('/'));
|
|
}
|
|
|
|
link.href = `${basePath}/style.css`;
|
|
document.head.appendChild(link);
|
|
|
|
console.log(LOG_PREFIX, 'Styles loaded from', link.href);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Extension Registration
|
|
// ============================================================================
|
|
|
|
app.registerExtension({
|
|
name: EXTENSION_NAME,
|
|
|
|
async setup() {
|
|
console.log(LOG_PREFIX, 'Setting up extension...');
|
|
|
|
// Check browser compatibility first
|
|
const { supported, missing } = checkRequiredFeatures();
|
|
if (!supported) {
|
|
console.error(LOG_PREFIX, 'Browser not supported. Missing features:', missing.join(', '));
|
|
return;
|
|
}
|
|
|
|
// Log browser compatibility status and any warnings
|
|
warnIfUnsupported();
|
|
|
|
// Load CSS
|
|
loadStyles();
|
|
|
|
// Initialize overlay when canvas is ready
|
|
waitForCanvasAndInitialize();
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// Debug API (exposed on window for testing)
|
|
// ============================================================================
|
|
|
|
window.comfyAnnotations = {
|
|
// Version info
|
|
version: VERSION,
|
|
|
|
// Mode & rendering
|
|
toggle: toggleAnnotationMode,
|
|
isActive: () => isAnnotationModeActive,
|
|
getTransform,
|
|
screenToCanvas,
|
|
canvasToScreen,
|
|
render: renderAnnotations,
|
|
stopTracker,
|
|
cleanup,
|
|
getCanvas,
|
|
getContext,
|
|
// Tool management
|
|
getTool: () => currentTool,
|
|
setTool,
|
|
// Stroke management
|
|
getStrokes: () => strokes,
|
|
clearStrokes: clearAllStrokes, // Now with undo support
|
|
// Pen settings
|
|
getPenSettings: () => ({ ...penSettings }),
|
|
setColor: (color) => { penSettings.color = color; },
|
|
setSize: (size) => { penSettings.size = Math.max(1, Math.min(50, size)); },
|
|
setOpacity: (opacity) => { penSettings.opacity = Math.max(0.1, Math.min(1, opacity)); },
|
|
// Eraser settings
|
|
getEraserSettings: () => ({ ...eraserSettings }),
|
|
setEraserSize: (size) => {
|
|
eraserSettings.size = Math.max(5, Math.min(100, size));
|
|
renderAnnotations();
|
|
},
|
|
// History (undo/redo)
|
|
undo: performUndo,
|
|
redo: performRedo,
|
|
canUndo,
|
|
canRedo,
|
|
getHistoryState,
|
|
setMaxHistory: (max) => {
|
|
setMaxHistory(max);
|
|
console.log(LOG_PREFIX, `Max history set to: ${getMaxHistory()}`);
|
|
},
|
|
getMaxHistory,
|
|
// Toolbar
|
|
toolbar: {
|
|
toggle: toggleToolbarCollapse,
|
|
collapse: () => setToolbarCollapsed(true),
|
|
expand: () => setToolbarCollapsed(false),
|
|
isCollapsed: isToolbarCollapsed,
|
|
show: showToolbar,
|
|
hide: hideToolbar
|
|
},
|
|
// Debug & coordinate testing
|
|
debug: {
|
|
enable: () => { setDebugVisualizerEnabled(true, renderAnnotations); },
|
|
disable: () => { setDebugVisualizerEnabled(false, renderAnnotations); },
|
|
toggle: () => {
|
|
setDebugVisualizerEnabled(!isDebugVisualizerEnabled(), renderAnnotations);
|
|
},
|
|
isEnabled: isDebugVisualizerEnabled,
|
|
runTests: runCoordinateTests
|
|
},
|
|
// Persistence
|
|
persistence: {
|
|
getSerializedData: () => serializeAnnotations(strokes),
|
|
getExtensionKey
|
|
},
|
|
// Performance
|
|
performance: {
|
|
getMetrics: getPerformanceMetrics,
|
|
resetMetrics: resetPerformanceMetrics,
|
|
runBenchmark: (options) => {
|
|
const ctx = getContext();
|
|
const transform = getTransform();
|
|
const dpr = window.devicePixelRatio || 1;
|
|
return runBenchmark(ctx, transform, dpr, options);
|
|
},
|
|
setUseCache: (enabled) => {
|
|
useStrokeCache = enabled;
|
|
invalidateCache();
|
|
renderAnnotations();
|
|
console.log(LOG_PREFIX, `Stroke cache ${enabled ? 'enabled' : 'disabled'}`);
|
|
},
|
|
isUsingCache: () => useStrokeCache,
|
|
invalidateCache
|
|
},
|
|
// Browser compatibility
|
|
browser: {
|
|
getFeatures,
|
|
hasFeature,
|
|
checkSupport: checkRequiredFeatures,
|
|
generateReport: generateCompatibilityReport,
|
|
printReport: () => console.log(generateCompatibilityReport())
|
|
}
|
|
};
|
|
|
|
console.log(LOG_PREFIX, `Extension v${VERSION} loaded. Debug API: window.comfyAnnotations`);
|