diff --git a/js/CanvasView.js b/js/CanvasView.js index 8ab327a..44a8e18 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -8,12 +8,13 @@ import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js" import { Canvas } from "./Canvas.js"; import { clearAllCanvasStates, getCanvasState, setCanvasState } from "./db.js"; import { createCanvas } from "./utils/CommonUtils.js"; -import { loadImage, loadImageFromBlob } from "./utils/ImageUtils.js"; +import { loadImageFromBlob } from "./utils/ImageUtils.js"; import { createModuleLogger } from "./log_system/log_funcs.js"; import { showErrorNotification, showSuccessNotification, showInfoNotification, showWarningNotification } from "./utils/NotificationUtils.js"; import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js"; import { exportCanvasImage } from "./utils/CanvasExportUtils.js"; import { getFlattenedCanvasBlob } from "./utils/CanvasBlobUtils.js"; +import { loadPreviewImage } from "./utils/PreviewUtils.js"; import { fetchMattingModelStatus } from "./utils/MattingUtils.js"; import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js"; const log = createModuleLogger('Canvas_view'); @@ -1140,10 +1141,12 @@ async function createCanvasWidget(node, widget, app) { if (blob) { // For large images, use blob URL for better performance if (blob.size > 2 * 1024 * 1024) { // 2MB threshold - const blobUrl = URL.createObjectURL(blob); - void loadImage(blobUrl).then(img => { + void loadPreviewImage(blob, { + source: 'canvas', + urlMode: 'object-url' + }).then(img => { node.imgs = [img]; - log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${blobUrl.substring(0, 50)}...`); + log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${img.src.substring(0, 50)}...`); // Clean up old blob URLs to prevent memory leaks if (node.imgs.length > 1) { const oldImg = node.imgs[0]; @@ -1155,7 +1158,10 @@ async function createCanvasWidget(node, widget, app) { } else { // For smaller images, use data URI as before - void loadImageFromBlob(blob).then(img => { + void loadPreviewImage(blob, { + source: 'canvas', + urlMode: 'data-url' + }).then(img => { node.imgs = [img]; log.debug(`Using data URI for small image (${(blob.size / 1024).toFixed(1)}KB): ${img.src.substring(0, 50)}...`); }).catch(() => undefined); diff --git a/js/utils/PreviewUtils.js b/js/utils/PreviewUtils.js index 5c125f2..617f7df 100644 --- a/js/utils/PreviewUtils.js +++ b/js/utils/PreviewUtils.js @@ -1,14 +1,16 @@ import { createModuleLogger } from "../log_system/log_funcs.js"; import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; import { resolveCanvasBlob, supportsFlattenedCanvasBlob } from './CanvasBlobUtils.js'; -import { loadImage } from './ImageUtils.js'; +import { loadImage, loadImageFromBlob } from './ImageUtils.js'; const log = createModuleLogger('PreviewUtils'); -async function loadPreviewImageFromBlob(blob, options) { +export async function loadPreviewImage(blob, options) { const isCanvasSource = options.source === 'canvas'; - const previewUrl = URL.createObjectURL(blob); - let previewImage; try { - previewImage = await loadImage(previewUrl); + if (options.urlMode === 'data-url') { + return await loadImageFromBlob(blob); + } + const previewUrl = URL.createObjectURL(blob); + return await loadImage(previewUrl); } catch (error) { const errorMessage = isCanvasSource @@ -19,6 +21,13 @@ async function loadPreviewImageFromBlob(blob, options) { ? { error, blob: blob.size } : { error, blobSize: blob.size }); } +} +async function loadPreviewForNode(blob, options) { + const isCanvasSource = options.source === 'canvas'; + const previewImage = await loadPreviewImage(blob, { + source: options.source, + urlMode: 'object-url' + }); log.debug(isCanvasSource ? "Preview image loaded successfully" : "Preview image from blob loaded successfully", isCanvasSource ? { width: previewImage.width, @@ -80,7 +89,7 @@ export const createPreviewFromCanvas = withErrorHandling(async function (canvas, if (!blob) { throw createValidationError("Failed to generate canvas blob for preview", { canvas, options }); } - return loadPreviewImageFromBlob(blob, { + return loadPreviewForNode(blob, { source: 'canvas', node, updateNodeImages @@ -105,7 +114,7 @@ export const createPreviewFromBlob = withErrorHandling(async function (blob, nod updateNodeImages, hasNode: !!node }); - return loadPreviewImageFromBlob(blob, { + return loadPreviewForNode(blob, { source: 'blob', node, updateNodeImages diff --git a/src/CanvasView.ts b/src/CanvasView.ts index 8223d90..6721f34 100644 --- a/src/CanvasView.ts +++ b/src/CanvasView.ts @@ -14,12 +14,13 @@ import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js" import {Canvas} from "./Canvas.js"; import {clearAllCanvasStates, getCanvasState, setCanvasState} from "./db.js"; import {generateUniqueFileName, createCanvas} from "./utils/CommonUtils.js"; -import { loadImage, loadImageFromBlob } from "./utils/ImageUtils.js"; +import { loadImageFromBlob } from "./utils/ImageUtils.js"; import {createModuleLogger} from "./log_system/log_funcs.js"; import {showErrorNotification, showSuccessNotification, showInfoNotification, showWarningNotification} from "./utils/NotificationUtils.js"; import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js"; import { exportCanvasImage, type CanvasExportAction } from "./utils/CanvasExportUtils.js"; import { getFlattenedCanvasBlob, type CanvasBlobVariant } from "./utils/CanvasBlobUtils.js"; +import { loadPreviewImage } from "./utils/PreviewUtils.js"; import { fetchMattingModelStatus } from "./utils/MattingUtils.js"; import { registerImageInClipspace, startSAMDetectorMonitoring, setupSAMDetectorHook } from "./SAMDetectorIntegration.js"; import type { ComfyNode, Layer, AddMode } from './types'; @@ -1340,10 +1341,12 @@ $el("label.lf-clipboard-switch.lf-mask-switch", { if (blob) { // For large images, use blob URL for better performance if (blob.size > 2 * 1024 * 1024) { // 2MB threshold - const blobUrl = URL.createObjectURL(blob); - void loadImage(blobUrl).then(img => { + void loadPreviewImage(blob, { + source: 'canvas', + urlMode: 'object-url' + }).then(img => { node.imgs = [img]; - log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${blobUrl.substring(0, 50)}...`); + log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${img.src.substring(0, 50)}...`); // Clean up old blob URLs to prevent memory leaks if (node.imgs.length > 1) { const oldImg = node.imgs[0]; @@ -1354,7 +1357,10 @@ $el("label.lf-clipboard-switch.lf-mask-switch", { }).catch(() => undefined); } else { // For smaller images, use data URI as before - void loadImageFromBlob(blob).then(img => { + void loadPreviewImage(blob, { + source: 'canvas', + urlMode: 'data-url' + }).then(img => { node.imgs = [img]; log.debug(`Using data URI for small image (${(blob.size / 1024).toFixed(1)}KB): ${img.src.substring(0, 50)}...`); }).catch(() => undefined); diff --git a/src/utils/PreviewUtils.ts b/src/utils/PreviewUtils.ts index b805662..ac61a11 100644 --- a/src/utils/PreviewUtils.ts +++ b/src/utils/PreviewUtils.ts @@ -1,7 +1,7 @@ import { createModuleLogger } from "../log_system/log_funcs.js"; import { withErrorHandling, createValidationError } from "../ErrorHandler.js"; import { resolveCanvasBlob, supportsFlattenedCanvasBlob, type CanvasBlobVariant } from './CanvasBlobUtils.js'; -import { loadImage } from './ImageUtils.js'; +import { loadImage, loadImageFromBlob } from './ImageUtils.js'; import type { ComfyNode } from '../types'; const log = createModuleLogger('PreviewUtils'); @@ -19,7 +19,14 @@ export interface PreviewOptions { customBlob?: Blob; } -type PreviewBlobSource = 'canvas' | 'blob'; +export type PreviewBlobSource = 'canvas' | 'blob'; + +export type PreviewUrlMode = 'data-url' | 'object-url'; + +export interface PreviewImageLoadOptions { + source: PreviewBlobSource; + urlMode?: PreviewUrlMode; +} interface PreviewBlobLoadOptions { source: PreviewBlobSource; @@ -27,15 +34,19 @@ interface PreviewBlobLoadOptions { updateNodeImages?: boolean; } -async function loadPreviewImageFromBlob( +export async function loadPreviewImage( blob: Blob, - options: PreviewBlobLoadOptions + options: PreviewImageLoadOptions ): Promise { const isCanvasSource = options.source === 'canvas'; - const previewUrl = URL.createObjectURL(blob); - let previewImage: HTMLImageElement; + try { - previewImage = await loadImage(previewUrl); + if (options.urlMode === 'data-url') { + return await loadImageFromBlob(blob); + } + + const previewUrl = URL.createObjectURL(blob); + return await loadImage(previewUrl); } catch (error) { const errorMessage = isCanvasSource ? "Failed to load preview image" @@ -48,6 +59,17 @@ async function loadPreviewImageFromBlob( : { error, blobSize: blob.size } ); } +} + +async function loadPreviewForNode( + blob: Blob, + options: PreviewBlobLoadOptions +): Promise { + const isCanvasSource = options.source === 'canvas'; + const previewImage = await loadPreviewImage(blob, { + source: options.source, + urlMode: 'object-url' + }); log.debug( isCanvasSource ? "Preview image loaded successfully" : "Preview image from blob loaded successfully", @@ -135,7 +157,7 @@ export const createPreviewFromCanvas = withErrorHandling(async function( throw createValidationError("Failed to generate canvas blob for preview", { canvas, options }); } - return loadPreviewImageFromBlob(blob, { + return loadPreviewForNode(blob, { source: 'canvas', node, updateNodeImages @@ -167,7 +189,7 @@ export const createPreviewFromBlob = withErrorHandling(async function( hasNode: !!node }); - return loadPreviewImageFromBlob(blob, { + return loadPreviewForNode(blob, { source: 'blob', node, updateNodeImages diff --git a/tests/test_blob_image_loading_characterization.mjs b/tests/test_blob_image_loading_characterization.mjs index cdc64b7..4f236da 100644 --- a/tests/test_blob_image_loading_characterization.mjs +++ b/tests/test_blob_image_loading_characterization.mjs @@ -24,7 +24,7 @@ test('Blob and File image entry points use the shared loading helpers', () => { assert.match(clipboardSource, /loadImageFromBlob\(file\)/); assert.match(interactionsSource, /loadImageFromBlob\(file\)/); assert.match(viewSource, /loadImageFromBlob\(file\)/); - assert.match(viewSource, /loadImageFromBlob\(blob\)/); + assert.match(viewSource, /loadPreviewImage\(blob,/); assert.match(layersSource, /blobToDataUrl\(blob\)/); assert.match(ioSource, /blobToDataUrl\(imageBlob\)/); assert.match(ioSource, /blobToDataUrl\(maskBlob\)/); diff --git a/tests/test_canvas_blob_characterization.mjs b/tests/test_canvas_blob_characterization.mjs index 58dc1a7..8a96f87 100644 --- a/tests/test_canvas_blob_characterization.mjs +++ b/tests/test_canvas_blob_characterization.mjs @@ -176,3 +176,11 @@ test('canvas blob callers preserve plain and mask method responsibilities', () = assert.match(blobUtilsSource, /getFlattenedCanvasWithMaskAsBlob/); assert.match(blobUtilsSource, /export async function resolveCanvasBlob/); }); + +test('CanvasView preserves size-dependent preview loading strategies', () => { + const canvasViewSource = sourceFiles[3]; + + assert.match(canvasViewSource, /blob\.size > 2 \* 1024 \* 1024/); + assert.match(canvasViewSource, /loadPreviewImage\(blob, \{[\s\S]*urlMode: 'object-url'/); + assert.match(canvasViewSource, /loadPreviewImage\(blob, \{[\s\S]*urlMode: 'data-url'/); +}); diff --git a/tests/test_manual_image_loading_characterization.mjs b/tests/test_manual_image_loading_characterization.mjs index 9c6e76e..4758e84 100644 --- a/tests/test_manual_image_loading_characterization.mjs +++ b/tests/test_manual_image_loading_characterization.mjs @@ -23,5 +23,5 @@ test('targeted image consumers use the shared Image loading lifecycle', () => { assert.match(previewSource, /loadImage\(previewUrl\)/); assert.doesNotMatch(viewSource, /const img = new Image\(\)/); - assert.match(viewSource, /loadImage\(blobUrl\)/); + assert.match(viewSource, /loadPreviewImage\(blob,/); }); diff --git a/tests/test_preview_characterization.mjs b/tests/test_preview_characterization.mjs index 05bf428..bfc300b 100644 --- a/tests/test_preview_characterization.mjs +++ b/tests/test_preview_characterization.mjs @@ -4,11 +4,13 @@ import test from 'node:test'; import { createPreviewFromBlob, createPreviewFromCanvas, + loadPreviewImage, } from '../js/utils/PreviewUtils.js'; function installPreviewStubs({ fail = false } = {}) { const originalImage = Object.getOwnPropertyDescriptor(globalThis, 'Image'); const originalUrl = Object.getOwnPropertyDescriptor(globalThis, 'URL'); + const originalFileReader = Object.getOwnPropertyDescriptor(globalThis, 'FileReader'); const createdUrls = []; Object.defineProperty(globalThis, 'Image', { @@ -47,6 +49,20 @@ function installPreviewStubs({ fail = false } = {}) { }, }); + Object.defineProperty(globalThis, 'FileReader', { + configurable: true, + value: class TestFileReader { + result = null; + onload = null; + onerror = null; + + readAsDataURL() { + this.result = 'data:image/png;base64,encoded'; + setTimeout(() => this.onload?.(), 0); + } + }, + }); + return { createdUrls, restore() { @@ -60,6 +76,11 @@ function installPreviewStubs({ fail = false } = {}) { } else { delete globalThis.URL; } + if (originalFileReader) { + Object.defineProperty(globalThis, 'FileReader', originalFileReader); + } else { + delete globalThis.FileReader; + } }, }; } @@ -95,6 +116,23 @@ test('canvas and Blob preview functions preserve loading and node update behavio } }); +test('shared preview loader supports data URLs without creating an object URL', async () => { + const stubs = installPreviewStubs(); + const blob = new Blob(['preview'], { type: 'image/png' }); + + try { + const image = await loadPreviewImage(blob, { + source: 'canvas', + urlMode: 'data-url', + }); + + assert.equal(image.src, 'data:image/png;base64,encoded'); + assert.equal(stubs.createdUrls.length, 0); + } finally { + stubs.restore(); + } +}); + test('Blob preview validation and image load failures preserve current errors', async () => { const stubs = installPreviewStubs({ fail: true }); const blob = new Blob(['preview'], { type: 'image/png' });