From fc4292ecb0ff70956d15e9633e9b8a093e2b7183 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 14 Aug 2026 15:56:31 +0200 Subject: [PATCH] ci: enforce generated frontend and coverage --- .github/workflows/ci.yml | 38 ++- .gitignore | 3 +- build.bat | 7 +- js/media/CanvasBlobUtils.js | 26 ++ js/media/CanvasExportUtils.js | 39 +++ js/media/ImageUploadUtils.js | 135 ++++++++++ js/media/ImageUtils.js | 354 ++++++++++++++++++++++++++ js/media/PreviewUtils.js | 203 +++++++++++++++ package.json | 3 +- src/media/CanvasBlobUtils.ts | 47 ++++ src/media/CanvasExportUtils.ts | 51 ++++ src/media/ImageUploadUtils.ts | 208 ++++++++++++++++ src/media/ImageUtils.ts | 436 +++++++++++++++++++++++++++++++++ src/media/PreviewUtils.ts | 298 ++++++++++++++++++++++ 14 files changed, 1839 insertions(+), 9 deletions(-) create mode 100644 js/media/CanvasBlobUtils.js create mode 100644 js/media/CanvasExportUtils.js create mode 100644 js/media/ImageUploadUtils.js create mode 100644 js/media/ImageUtils.js create mode 100644 js/media/PreviewUtils.js create mode 100644 src/media/CanvasBlobUtils.ts create mode 100644 src/media/CanvasExportUtils.ts create mode 100644 src/media/ImageUploadUtils.ts create mode 100644 src/media/ImageUtils.ts create mode 100644 src/media/PreviewUtils.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1ce0e6..c527505 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: CI on: + push: + branches: [main] pull_request: types: [opened, synchronize, reopened] @@ -14,7 +16,6 @@ concurrency: jobs: frontend: name: Frontend tests, lint, and typecheck - if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest steps: @@ -33,6 +34,12 @@ jobs: - name: Run JavaScript tests run: npm test + - name: Run architecture boundary tests + run: node --test tests/test_architecture_boundaries.mjs + + - name: Run critical frontend coverage + run: npm run test:coverage:critical + - name: Run ESLint run: npm run lint @@ -41,7 +48,6 @@ jobs: backend: name: Python tests, coverage, and static checks - if: github.event.pull_request.head.repo.full_name != github.repository runs-on: ubuntu-latest steps: @@ -73,7 +79,31 @@ jobs: if-no-files-found: error - name: Run Ruff - run: python -m ruff check __init__.py canvas_node.py python tests + run: python -m ruff check __init__.py python tests - name: Check Python syntax - run: python -m compileall -q __init__.py canvas_node.py python tests + run: python -m compileall -q __init__.py python tests + + generated-frontend: + name: Generated frontend is synchronized + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install JavaScript dependencies + run: npm ci + + - name: Build generated frontend + run: .\\build.bat + + - name: Verify generated frontend has no diff + shell: bash + run: git diff --exit-code -- js diff --git a/.gitignore b/.gitignore index 7a165c1..7b6b2d5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,8 +46,7 @@ venv/* tsconfig.json src_web src_web/* -Media -Media/* +/Media/ config.json js/config.js python/config.py diff --git a/build.bat b/build.bat index 7398e02..f006b06 100644 --- a/build.bat +++ b/build.bat @@ -4,9 +4,12 @@ robocopy src/css js/css /E /nfl /ndl /njh /njs > nul robocopy src/templates js/templates /E /nfl /ndl /njh /njs > nul echo Compiling TypeScript files... call .\\node_modules\\.bin\\tsc.cmd -if %errorlevel% equ 0 ( +set BUILD_ERROR=%errorlevel% +if %BUILD_ERROR% equ 0 ( echo Build completed successfully - no errors found. ) else ( - echo Build failed with error code %errorlevel%. + echo Build failed with error code %BUILD_ERROR%. + exit /b %BUILD_ERROR% ) echo Build process finished. +exit /b 0 diff --git a/js/media/CanvasBlobUtils.js b/js/media/CanvasBlobUtils.js new file mode 100644 index 0000000..f795ea9 --- /dev/null +++ b/js/media/CanvasBlobUtils.js @@ -0,0 +1,26 @@ +const CANVAS_BLOB_METHODS = { + plain: 'getFlattenedCanvasAsBlob', + 'with-mask': 'getFlattenedCanvasWithMaskAsBlob' +}; +export function supportsFlattenedCanvasBlob(canvas, variant) { + const canvasLayers = canvas?.canvasLayers; + return !!canvasLayers && typeof canvasLayers[CANVAS_BLOB_METHODS[variant]] === 'function'; +} +export function getFlattenedCanvasBlob(canvas, variant) { + return canvas.canvasLayers[CANVAS_BLOB_METHODS[variant]](); +} +export async function resolveCanvasBlob(canvas, variant, options = {}) { + if (supportsFlattenedCanvasBlob(canvas, variant)) { + return { + source: 'flattened', + blob: await getFlattenedCanvasBlob(canvas, variant), + }; + } + const isNativeCanvas = typeof HTMLCanvasElement !== 'undefined' + && canvas instanceof HTMLCanvasElement; + if (options.allowNativeCanvasFallback && isNativeCanvas) { + const blob = await new Promise(resolve => canvas.toBlob(resolve)); + return { source: 'native', blob }; + } + return { source: 'unsupported', blob: null }; +} diff --git a/js/media/CanvasExportUtils.js b/js/media/CanvasExportUtils.js new file mode 100644 index 0000000..4f730af --- /dev/null +++ b/js/media/CanvasExportUtils.js @@ -0,0 +1,39 @@ +import { getFlattenedCanvasBlob } from './CanvasBlobUtils.js'; +function openBlob(blob) { + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +async function copyBlob(blob) { + const item = new ClipboardItem({ 'image/png': blob }); + await navigator.clipboard.write([item]); +} +function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +export async function exportCanvasImage(canvas, options) { + const blob = await getFlattenedCanvasBlob(canvas, options.variant); + if (!blob) + return false; + switch (options.action) { + case 'open': + openBlob(blob); + return true; + case 'copy': + await copyBlob(blob); + return true; + case 'download': + if (!options.filename) { + throw new Error('Filename is required for canvas image download'); + } + downloadBlob(blob, options.filename); + return true; + } +} diff --git a/js/media/ImageUploadUtils.js b/js/media/ImageUploadUtils.js new file mode 100644 index 0000000..161a27c --- /dev/null +++ b/js/media/ImageUploadUtils.js @@ -0,0 +1,135 @@ +// @ts-ignore +import { api } from "../../../scripts/api.js"; +import { createModuleLogger } from "../log_system/log_funcs.js"; +import { withErrorHandling, createValidationError, createNetworkError } from "../shared/ErrorHandler.js"; +import { resolveCanvasBlob } from './CanvasBlobUtils.js'; +import { loadImage } from './ImageUtils.js'; +const log = createModuleLogger('ImageUploadUtils'); +export async function postImageBlob(request, transport = (input, init) => api.fetchApi(input, init)) { + const formData = new FormData(); + formData.append("image", request.blob, request.filename); + formData.append("overwrite", (request.overwrite ?? true).toString()); + if (request.type !== undefined) { + formData.append("type", request.type); + } + return transport("/upload/image", { + method: "POST", + body: formData, + }); +} +async function getCanvasBlobForUpload(canvas, uploadOptions, config) { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + const resolution = await resolveCanvasBlob(canvas, config.variant, { + allowNativeCanvasFallback: config.allowNativeCanvasFallback, + }); + if (resolution.source === 'unsupported') { + throw createValidationError(config.unsupportedCanvasMessage, { + canvas, + hasCanvasLayers: !!canvas.canvasLayers, + isHTMLCanvas: typeof HTMLCanvasElement !== 'undefined' + && canvas instanceof HTMLCanvasElement, + ...(config.variant === 'with-mask' + ? { hasMaskMethod: false } + : {}) + }); + } + if (!resolution.blob) { + throw createValidationError(config.emptyBlobMessage, { canvas, options: uploadOptions }); + } + return resolution.blob; +} +/** + * Uploads an image blob to ComfyUI server and returns image element + * @param blob - Image blob to upload + * @param options - Upload options + * @returns Promise with upload result + */ +export const uploadImageBlob = withErrorHandling(async function (blob, options = {}) { + if (!blob) { + throw createValidationError("Blob is required", { blob }); + } + if (blob.size === 0) { + throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); + } + const { filenamePrefix = 'layerforge', overwrite = true, type = 'temp', nodeId } = options; + // Generate unique filename + const timestamp = Date.now(); + const nodeIdSuffix = nodeId ? `-${nodeId}` : ''; + const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`; + log.debug('Uploading image blob:', { + filename, + blobSize: blob.size, + type, + overwrite + }); + // Upload to server + const response = await postImageBlob({ + blob, + filename, + overwrite, + type + }); + if (!response.ok) { + throw createNetworkError(`Failed to upload image: ${response.statusText}`, { + status: response.status, + statusText: response.statusText, + filename, + blobSize: blob.size + }); + } + const data = await response.json(); + log.debug('Image uploaded successfully:', data); + // Create image element with proper URL + const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); + let imageElement; + try { + imageElement = await loadImage(imageUrl, { crossOrigin: "anonymous" }); + } + catch (error) { + log.error("Failed to load uploaded image", error); + throw createNetworkError("Failed to load uploaded image", { error, imageUrl, filename }); + } + log.debug("Uploaded image loaded successfully", { + width: imageElement.width, + height: imageElement.height, + src: imageElement.src.substring(0, 100) + '...' + }); + return { + data, + filename, + imageUrl, + imageElement + }; +}, 'uploadImageBlob'); +/** + * Uploads canvas content as image blob + * @param canvas - Canvas element or Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export const uploadCanvasAsImage = withErrorHandling(async function (canvas, options = {}) { + const blob = await getCanvasBlobForUpload(canvas, options, { + variant: 'plain', + allowNativeCanvasFallback: true, + unsupportedCanvasMessage: "Unsupported canvas type", + emptyBlobMessage: "Failed to generate canvas blob" + }); + return uploadImageBlob(blob, options); +}, 'uploadCanvasAsImage'); +/** + * Uploads canvas with mask as image blob + * @param canvas - Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export const uploadCanvasWithMaskAsImage = withErrorHandling(async function (canvas, options = {}) { + const blob = await getCanvasBlobForUpload(canvas, options, { + variant: 'with-mask', + allowNativeCanvasFallback: false, + unsupportedCanvasMessage: "Canvas does not support mask operations", + emptyBlobMessage: "Failed to generate canvas with mask blob" + }); + return uploadImageBlob(blob, options); +}, 'uploadCanvasWithMaskAsImage'); diff --git a/js/media/ImageUtils.js b/js/media/ImageUtils.js new file mode 100644 index 0000000..7d685f1 --- /dev/null +++ b/js/media/ImageUtils.js @@ -0,0 +1,354 @@ +import { createModuleLogger } from "../log_system/log_funcs.js"; +import { withErrorHandling, createValidationError } from "../shared/ErrorHandler.js"; +import { createCanvas } from "../utils/CommonUtils.js"; +const log = createModuleLogger('ImageUtils'); +function tensorToRgbaPixels(tensor, dimensionsLog) { + const shape = tensor.shape; + const height = shape[1]; + const width = shape[2]; + const channels = shape[3]; + const floatData = new Float32Array(tensor.data); + log.debug(dimensionsLog, { height, width, channels }); + const rgbaData = new Uint8ClampedArray(width * height * 4); + for (let h = 0; h < height; h++) { + for (let w = 0; w < width; w++) { + const pixelIndex = (h * width + w) * 4; + const tensorIndex = (h * width + w) * channels; + for (let c = 0; c < channels; c++) { + const value = floatData[tensorIndex + c]; + rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); + } + rgbaData[pixelIndex + 3] = 255; + } + } + return { + data: rgbaData, + width: width, + height: height + }; +} +export function validateImageData(data) { + log.debug("Validating data structure:", { + hasData: !!data, + type: typeof data, + isArray: Array.isArray(data), + keys: data ? Object.keys(data) : null, + shape: data?.shape, + dataType: data?.data ? data.data.constructor.name : null, + fullData: data + }); + if (!data) { + log.info("Data is null or undefined"); + return false; + } + if (Array.isArray(data)) { + log.debug("Data is array, getting first element"); + data = data[0]; + } + if (!data || typeof data !== 'object') { + log.info("Invalid data type"); + return false; + } + if (!data.data) { + log.info("Missing data property"); + return false; + } + if (!(data.data instanceof Float32Array)) { + try { + data.data = new Float32Array(data.data); + } + catch (e) { + log.error("Failed to convert data to Float32Array:", e); + return false; + } + } + return true; +} +export function convertImageData(data) { + log.info("Converting image data:", data); + if (Array.isArray(data)) { + data = data[0]; + } + return tensorToRgbaPixels(data, "Processing dimensions:"); +} +export function applyMaskToImageData(imageData, maskData) { + log.info("Applying mask to image data"); + const rgbaData = new Uint8ClampedArray(imageData.data); + const width = imageData.width; + const height = imageData.height; + const maskShape = maskData.shape; + const maskFloatData = new Float32Array(maskData.data); + log.debug(`Applying mask of shape: ${maskShape}`); + for (let h = 0; h < height; h++) { + for (let w = 0; w < width; w++) { + const pixelIndex = (h * width + w) * 4; + const maskIndex = h * width + w; + const alpha = maskFloatData[maskIndex]; + rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255))); + } + } + log.info("Mask application completed"); + return { + data: rgbaData, + width: width, + height: height + }; +} +export const prepareImageForCanvas = withErrorHandling(function (inputImage) { + log.info("Preparing image for canvas:", inputImage); + if (Array.isArray(inputImage)) { + inputImage = inputImage[0]; + } + if (!inputImage || !inputImage.shape || !inputImage.data) { + throw createValidationError("Invalid input image format", { inputImage }); + } + return tensorToRgbaPixels(inputImage, "Image dimensions:"); +}, 'prepareImageForCanvas'); +export const imageToTensor = withErrorHandling(async function (image) { + if (!image) { + throw createValidationError("Image is required"); + } + const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true }); + if (ctx) { + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = new Float32Array(canvas.width * canvas.height * 3); + for (let i = 0; i < imageData.data.length; i += 4) { + const pixelIndex = i / 4; + data[pixelIndex * 3] = imageData.data[i] / 255; + data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255; + data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255; + } + return { + data: data, + shape: [1, canvas.height, canvas.width, 3], + width: canvas.width, + height: canvas.height + }; + } + throw new Error("Canvas context not available"); +}, 'imageToTensor'); +export const tensorToImage = withErrorHandling(async function (tensor) { + if (!tensor || !tensor.data || !tensor.shape) { + throw createValidationError("Invalid tensor format", { tensor }); + } + const [, height, width, channels] = tensor.shape; + const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true }); + if (ctx) { + const imageData = ctx.createImageData(width, height); + const data = tensor.data; + for (let i = 0; i < width * height; i++) { + const pixelIndex = i * 4; + const tensorIndex = i * channels; + imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255); + imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255); + imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255); + imageData.data[pixelIndex + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); + return loadImage(canvas.toDataURL()); + } + throw new Error("Canvas context not available"); +}, 'tensorToImage'); +export const resizeImage = withErrorHandling(async function (image, maxWidth, maxHeight) { + if (!image) { + throw createValidationError("Image is required"); + } + const originalWidth = image.width; + const originalHeight = image.height; + const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); + const newWidth = Math.round(originalWidth * scale); + const newHeight = Math.round(originalHeight * scale); + const { canvas, ctx } = createCanvas(newWidth, newHeight, '2d', { willReadFrequently: true }); + if (ctx) { + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(image, 0, 0, newWidth, newHeight); + return loadImage(canvas.toDataURL()); + } + throw new Error("Canvas context not available"); +}, 'resizeImage'); +export const createThumbnail = withErrorHandling(async function (image, size = 128) { + return resizeImage(image, size, size); +}, 'createThumbnail'); +export const imageToBase64 = withErrorHandling(function (image, format = 'png', quality = 0.9) { + if (!image) { + throw createValidationError("Image is required"); + } + const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; + const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true }); + if (ctx) { + ctx.drawImage(image, 0, 0); + const mimeType = `image/${format}`; + return canvas.toDataURL(mimeType, quality); + } + throw new Error("Canvas context not available"); +}, 'imageToBase64'); +export const base64ToImage = withErrorHandling(function (base64) { + if (!base64) { + throw createValidationError("Base64 string is required"); + } + return loadImage(base64).catch(() => { + throw new Error("Failed to load image from base64"); + }); +}, 'base64ToImage'); +export function isValidImage(image) { + return image && + (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && + image.width > 0 && + image.height > 0; +} +export function getImageInfo(image) { + if (!isValidImage(image)) { + return null; + } + const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; + return { + width, + height, + aspectRatio: width / height, + area: width * height + }; +} +export function loadImage(source, options = {}) { + return new Promise((resolve, reject) => { + const img = new Image(); + if (options.crossOrigin !== undefined) { + img.crossOrigin = options.crossOrigin; + } + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = source; + }); +} +export function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} +export async function loadImageFromBlob(blob, options = {}) { + const dataUrl = await blobToDataUrl(blob); + return loadImage(dataUrl, options); +} +export const createEmptyImage = withErrorHandling(function (width, height, color = 'transparent') { + const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true }); + if (ctx) { + if (color !== 'transparent') { + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + } + return loadImage(canvas.toDataURL()); + } + throw new Error("Canvas context not available"); +}, 'createEmptyImage'); +/** + * Converts a canvas or image to an Image element + * Consolidated from MaskProcessingUtils.convertToImage() + * @param source - Source canvas or image + * @returns Promise with Image element + */ +export async function convertToImage(source) { + if (source instanceof HTMLImageElement) { + return source; // Already an image + } + return loadImage(source.toDataURL()); +} +/** + * Scales an image to fit within specified bounds while maintaining aspect ratio + * @param image - Image to scale + * @param targetWidth - Target width to fit within + * @param targetHeight - Target height to fit within + * @returns Promise with scaled Image element + */ +export async function scaleImageToFit(image, targetWidth, targetHeight) { + const scale = Math.min(targetWidth / image.width, targetHeight / image.height); + const scaledWidth = Math.max(1, Math.round(image.width * scale)); + const scaledHeight = Math.max(1, Math.round(image.height * scale)); + const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true }); + if (!ctx) + throw new Error("Could not create scaled image context"); + ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight); + return loadImage(canvas.toDataURL()); +} +/** + * Unified tensor to image data conversion + * Handles both RGB images and grayscale masks + * @param tensor - Input tensor data + * @param mode - 'rgb' for images or 'grayscale' for masks + * @returns ImageData object + */ +export function tensorToImageData(tensor, mode = 'rgb') { + try { + const shape = tensor.shape; + const height = shape[1]; + const width = shape[2]; + const channels = shape[3] || 1; // Default to 1 for masks + log.debug("Converting tensor:", { shape, channels, mode }); + const imageData = new ImageData(width, height); + const data = new Uint8ClampedArray(width * height * 4); + const flatData = tensor.data; + const pixelCount = width * height; + const min = tensor.min_val ?? 0; + const max = tensor.max_val ?? 1; + const denom = (max - min) || 1; + for (let i = 0; i < pixelCount; i++) { + const pixelIndex = i * 4; + const tensorIndex = i * channels; + let lum; + if (mode === 'grayscale' || channels === 1) { + lum = flatData[tensorIndex]; + } + else { + // Compute luminance for RGB + const r = flatData[tensorIndex + 0] ?? 0; + const g = flatData[tensorIndex + 1] ?? 0; + const b = flatData[tensorIndex + 2] ?? 0; + lum = 0.299 * r + 0.587 * g + 0.114 * b; + } + let norm = (lum - min) / denom; + if (!isFinite(norm)) + norm = 0; + norm = Math.max(0, Math.min(1, norm)); + const value = Math.round(norm * 255); + if (mode === 'grayscale') { + // For masks: RGB = value, A = 255 (MaskTool reads luminance) + data[pixelIndex] = value; + data[pixelIndex + 1] = value; + data[pixelIndex + 2] = value; + data[pixelIndex + 3] = 255; + } + else { + // For images: RGB from channels, A = 255 + for (let c = 0; c < Math.min(3, channels); c++) { + const channelValue = flatData[tensorIndex + c]; + const channelNorm = (channelValue - min) / denom; + data[pixelIndex + c] = Math.round(channelNorm * 255); + } + data[pixelIndex + 3] = 255; + } + } + imageData.data.set(data); + return imageData; + } + catch (error) { + log.error("Error converting tensor:", error); + return null; + } +} +/** + * Creates an HTMLImageElement from ImageData + * @param imageData - Input ImageData + * @returns Promise with HTMLImageElement + */ +export async function createImageFromImageData(imageData) { + const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true }); + if (!ctx) + throw new Error("Could not create canvas context"); + ctx.putImageData(imageData, 0, 0); + return await loadImage(canvas.toDataURL()); +} diff --git a/js/media/PreviewUtils.js b/js/media/PreviewUtils.js new file mode 100644 index 0000000..273b22a --- /dev/null +++ b/js/media/PreviewUtils.js @@ -0,0 +1,203 @@ +import { createModuleLogger } from "../log_system/log_funcs.js"; +import { withErrorHandling, createValidationError } from "../shared/ErrorHandler.js"; +import { resolveCanvasBlob, supportsFlattenedCanvasBlob } from './CanvasBlobUtils.js'; +import { loadImage, loadImageFromBlob } from './ImageUtils.js'; +const log = createModuleLogger('PreviewUtils'); +export async function loadPreviewImage(blob, options) { + const isCanvasSource = options.source === 'canvas'; + try { + 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" + : "Failed to load preview image from blob"; + log.error(errorMessage, error); + throw createValidationError(errorMessage, isCanvasSource + ? { 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, + height: previewImage.height, + nodeId: options.node?.id + } + : { + width: previewImage.width, + height: previewImage.height + }); + if (options.updateNodeImages && options.node) { + options.node.imgs = [previewImage]; + log.debug(isCanvasSource + ? "Node images updated with new preview" + : "Node images updated with blob preview"); + } + return previewImage; +} +/** + * Creates a preview image from canvas and updates node + * @param canvas - Canvas object with canvasLayers + * @param node - ComfyUI node to update + * @param options - Preview options + * @returns Promise with created Image element + */ +export const createPreviewFromCanvas = withErrorHandling(async function (canvas, node, options = {}) { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + if (!node) { + throw createValidationError("Node is required", { node }); + } + const { includeMask = true, updateNodeImages = true, customBlob } = options; + log.debug('Creating preview from canvas:', { + includeMask, + updateNodeImages, + hasCustomBlob: !!customBlob, + nodeId: node.id + }); + let blob = customBlob || null; + // Get blob from canvas if not provided + if (!blob) { + if (!canvas.canvasLayers) { + throw createValidationError("Canvas does not have canvasLayers", { canvas }); + } + let variant = 'plain'; + if (includeMask && supportsFlattenedCanvasBlob(canvas, 'with-mask')) { + variant = 'with-mask'; + } + const resolution = await resolveCanvasBlob(canvas, variant); + if (resolution.source === 'unsupported') { + throw createValidationError("Canvas does not support required blob generation methods", { + canvas, + availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers) + }); + } + blob = resolution.blob; + } + if (!blob) { + throw createValidationError("Failed to generate canvas blob for preview", { canvas, options }); + } + return loadPreviewForNode(blob, { + source: 'canvas', + node, + updateNodeImages + }); +}, 'createPreviewFromCanvas'); +/** + * Creates a preview image from a blob + * @param blob - Image blob + * @param node - ComfyUI node to update (optional) + * @param updateNodeImages - Whether to update node.imgs (default: false) + * @returns Promise with created Image element + */ +export const createPreviewFromBlob = withErrorHandling(async function (blob, node, updateNodeImages = false) { + if (!blob) { + throw createValidationError("Blob is required", { blob }); + } + if (blob.size === 0) { + throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); + } + log.debug('Creating preview from blob:', { + blobSize: blob.size, + updateNodeImages, + hasNode: !!node + }); + return loadPreviewForNode(blob, { + source: 'blob', + node, + updateNodeImages + }); +}, 'createPreviewFromBlob'); +/** + * Updates node preview after canvas changes + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param includeMask - Whether to include mask in preview + * @returns Promise with updated preview image + */ +export const updateNodePreview = withErrorHandling(async function (canvas, node, includeMask = true) { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + if (!node) { + throw createValidationError("Node is required", { node }); + } + log.info('Updating node preview:', { + nodeId: node.id, + includeMask + }); + // Trigger canvas render and save state + if (typeof canvas.render === 'function') { + canvas.render(); + } + if (typeof canvas.saveState === 'function') { + canvas.saveState(); + } + // Create new preview + const previewImage = await createPreviewFromCanvas(canvas, node, { + includeMask, + updateNodeImages: true + }); + log.info('Node preview updated successfully'); + return previewImage; +}, 'updateNodePreview'); +/** + * Clears node preview images + * @param node - ComfyUI node + */ +export function clearNodePreview(node) { + log.debug('Clearing node preview:', { nodeId: node.id }); + node.imgs = []; +} +/** + * Checks if node has preview images + * @param node - ComfyUI node + * @returns True if node has preview images + */ +export function hasNodePreview(node) { + return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src); +} +/** + * Gets the current preview image from node + * @param node - ComfyUI node + * @returns Current preview image or null + */ +export function getCurrentPreview(node) { + if (hasNodePreview(node) && node.imgs) { + return node.imgs[0]; + } + return null; +} +/** + * Creates a preview with custom processing + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param processor - Custom processing function that takes canvas and returns blob + * @returns Promise with processed preview image + */ +export const createCustomPreview = withErrorHandling(async function (canvas, node, processor) { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + if (!node) { + throw createValidationError("Node is required", { node }); + } + if (!processor || typeof processor !== 'function') { + throw createValidationError("Processor function is required", { processor }); + } + log.debug('Creating custom preview:', { nodeId: node.id }); + const blob = await processor(canvas); + return createPreviewFromBlob(blob, node, true); +}, 'createCustomPreview'); diff --git a/package.json b/package.json index feb2ade..96b994a 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,9 @@ "scripts": { "test": "node --test tests/*.mjs", "test:coverage": "node --experimental-test-coverage --test tests/*.mjs", - "lint": "eslint js/ tests/", + "lint": "eslint js/ tests/ --max-warnings=0", "lint:fix": "eslint js/ tests/ --fix", + "test:coverage:critical": "node --experimental-test-coverage --test-coverage-include=js/canvas/CanvasHistory.js --test-coverage-include=js/persistence/contracts.js --test-coverage-include=js/utils/WebSocketManager.js --test-coverage-lines=65 --test-coverage-functions=65 --test-coverage-branches=50 --test tests/test_canvas_history.mjs tests/test_persistence_contracts.mjs tests/test_persistence_resilience.mjs tests/test_async_lifecycle_resilience.mjs", "watch": ".\\node_modules\\.bin\\tsc.cmd --watch", "watch:build": "nodemon --watch src --ext ts,css,html --exec .\\build.bat" }, diff --git a/src/media/CanvasBlobUtils.ts b/src/media/CanvasBlobUtils.ts new file mode 100644 index 0000000..0f00802 --- /dev/null +++ b/src/media/CanvasBlobUtils.ts @@ -0,0 +1,47 @@ +export type CanvasBlobVariant = 'plain' | 'with-mask'; +export type CanvasBlobSource = 'flattened' | 'native' | 'unsupported'; + +export interface CanvasBlobResolution { + source: CanvasBlobSource; + blob: Blob | null; +} + +export interface CanvasBlobResolutionOptions { + allowNativeCanvasFallback?: boolean; +} + +const CANVAS_BLOB_METHODS: Record = { + plain: 'getFlattenedCanvasAsBlob', + 'with-mask': 'getFlattenedCanvasWithMaskAsBlob' +}; + +export function supportsFlattenedCanvasBlob(canvas: any, variant: CanvasBlobVariant): boolean { + const canvasLayers = canvas?.canvasLayers; + return !!canvasLayers && typeof canvasLayers[CANVAS_BLOB_METHODS[variant]] === 'function'; +} + +export function getFlattenedCanvasBlob(canvas: any, variant: CanvasBlobVariant): Promise { + return canvas.canvasLayers[CANVAS_BLOB_METHODS[variant]](); +} + +export async function resolveCanvasBlob( + canvas: any, + variant: CanvasBlobVariant, + options: CanvasBlobResolutionOptions = {}, +): Promise { + if (supportsFlattenedCanvasBlob(canvas, variant)) { + return { + source: 'flattened', + blob: await getFlattenedCanvasBlob(canvas, variant), + }; + } + + const isNativeCanvas = typeof HTMLCanvasElement !== 'undefined' + && canvas instanceof HTMLCanvasElement; + if (options.allowNativeCanvasFallback && isNativeCanvas) { + const blob = await new Promise(resolve => canvas.toBlob(resolve)); + return { source: 'native', blob }; + } + + return { source: 'unsupported', blob: null }; +} diff --git a/src/media/CanvasExportUtils.ts b/src/media/CanvasExportUtils.ts new file mode 100644 index 0000000..9875fea --- /dev/null +++ b/src/media/CanvasExportUtils.ts @@ -0,0 +1,51 @@ +import { getFlattenedCanvasBlob, type CanvasBlobVariant } from './CanvasBlobUtils.js'; + +export type CanvasExportAction = 'open' | 'copy' | 'download'; + +export interface CanvasExportOptions { + action: CanvasExportAction; + variant: CanvasBlobVariant; + filename?: string; +} + +function openBlob(blob: Blob): void { + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +async function copyBlob(blob: Blob): Promise { + const item = new ClipboardItem({'image/png': blob}); + await navigator.clipboard.write([item]); +} + +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export async function exportCanvasImage(canvas: any, options: CanvasExportOptions): Promise { + const blob = await getFlattenedCanvasBlob(canvas, options.variant); + if (!blob) return false; + + switch (options.action) { + case 'open': + openBlob(blob); + return true; + case 'copy': + await copyBlob(blob); + return true; + case 'download': + if (!options.filename) { + throw new Error('Filename is required for canvas image download'); + } + downloadBlob(blob, options.filename); + return true; + } +} diff --git a/src/media/ImageUploadUtils.ts b/src/media/ImageUploadUtils.ts new file mode 100644 index 0000000..b470c72 --- /dev/null +++ b/src/media/ImageUploadUtils.ts @@ -0,0 +1,208 @@ +// @ts-ignore +import { api } from "../../../scripts/api.js"; +import { createModuleLogger } from "../log_system/log_funcs.js"; +import { withErrorHandling, createValidationError, createNetworkError } from "../shared/ErrorHandler.js"; +import { resolveCanvasBlob, type CanvasBlobVariant } from './CanvasBlobUtils.js'; +import { loadImage } from './ImageUtils.js'; + +const log = createModuleLogger('ImageUploadUtils'); + +/** + * Utility functions for uploading images to ComfyUI server + */ + +export interface UploadImageOptions { + /** Custom filename prefix (default: 'layerforge') */ + filenamePrefix?: string; + /** Whether to overwrite existing files (default: true) */ + overwrite?: boolean; + /** Upload type (default: 'temp') */ + type?: string; + /** Node ID for unique filename generation */ + nodeId?: string | number; +} + +export interface UploadImageResult { + /** Server response data */ + data: any; + /** Generated filename */ + filename: string; + /** Full image URL */ + imageUrl: string; + /** Created Image element */ + imageElement: HTMLImageElement; +} + +interface CanvasBlobUploadConfig { + variant: CanvasBlobVariant; + allowNativeCanvasFallback: boolean; + unsupportedCanvasMessage: string; + emptyBlobMessage: string; +} + +interface ImageBlobUploadRequest { + blob: Blob; + filename: string; + overwrite?: boolean; + type?: string; +} + +type ImageUploadTransport = (input: string, init: RequestInit) => Promise; + +export async function postImageBlob( + request: ImageBlobUploadRequest, + transport: ImageUploadTransport = (input, init) => api.fetchApi(input, init) +): Promise { + const formData = new FormData(); + formData.append("image", request.blob, request.filename); + formData.append("overwrite", (request.overwrite ?? true).toString()); + if (request.type !== undefined) { + formData.append("type", request.type); + } + + return transport("/upload/image", { + method: "POST", + body: formData, + }); +} + +async function getCanvasBlobForUpload( + canvas: any, + uploadOptions: UploadImageOptions, + config: CanvasBlobUploadConfig +): Promise { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + + const resolution = await resolveCanvasBlob(canvas, config.variant, { + allowNativeCanvasFallback: config.allowNativeCanvasFallback, + }); + + if (resolution.source === 'unsupported') { + throw createValidationError(config.unsupportedCanvasMessage, { + canvas, + hasCanvasLayers: !!canvas.canvasLayers, + isHTMLCanvas: typeof HTMLCanvasElement !== 'undefined' + && canvas instanceof HTMLCanvasElement, + ...(config.variant === 'with-mask' + ? { hasMaskMethod: false } + : {}) + }); + } + + if (!resolution.blob) { + throw createValidationError(config.emptyBlobMessage, { canvas, options: uploadOptions }); + } + + return resolution.blob; +} + +/** + * Uploads an image blob to ComfyUI server and returns image element + * @param blob - Image blob to upload + * @param options - Upload options + * @returns Promise with upload result + */ +export const uploadImageBlob = withErrorHandling(async function(blob: Blob, options: UploadImageOptions = {}): Promise { + if (!blob) { + throw createValidationError("Blob is required", { blob }); + } + if (blob.size === 0) { + throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); + } + + const { + filenamePrefix = 'layerforge', + overwrite = true, + type = 'temp', + nodeId + } = options; + + // Generate unique filename + const timestamp = Date.now(); + const nodeIdSuffix = nodeId ? `-${nodeId}` : ''; + const filename = `${filenamePrefix}${nodeIdSuffix}-${timestamp}.png`; + + log.debug('Uploading image blob:', { + filename, + blobSize: blob.size, + type, + overwrite + }); + + // Upload to server + const response = await postImageBlob({ + blob, + filename, + overwrite, + type + }); + + if (!response.ok) { + throw createNetworkError(`Failed to upload image: ${response.statusText}`, { + status: response.status, + statusText: response.statusText, + filename, + blobSize: blob.size + }); + } + + const data = await response.json(); + log.debug('Image uploaded successfully:', data); + + // Create image element with proper URL + const imageUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=${data.type}&subfolder=${data.subfolder}`); + let imageElement: HTMLImageElement; + try { + imageElement = await loadImage(imageUrl, { crossOrigin: "anonymous" }); + } catch (error) { + log.error("Failed to load uploaded image", error); + throw createNetworkError("Failed to load uploaded image", { error, imageUrl, filename }); + } + + log.debug("Uploaded image loaded successfully", { + width: imageElement.width, + height: imageElement.height, + src: imageElement.src.substring(0, 100) + '...' + }); + + return { + data, + filename, + imageUrl, + imageElement + }; +}, 'uploadImageBlob'); + +/** + * Uploads canvas content as image blob + * @param canvas - Canvas element or Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export const uploadCanvasAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise { + const blob = await getCanvasBlobForUpload(canvas, options, { + variant: 'plain', + allowNativeCanvasFallback: true, + unsupportedCanvasMessage: "Unsupported canvas type", + emptyBlobMessage: "Failed to generate canvas blob" + }); + return uploadImageBlob(blob, options); +}, 'uploadCanvasAsImage'); + +/** + * Uploads canvas with mask as image blob + * @param canvas - Canvas object with canvasLayers + * @param options - Upload options + * @returns Promise with upload result + */ +export const uploadCanvasWithMaskAsImage = withErrorHandling(async function(canvas: any, options: UploadImageOptions = {}): Promise { + const blob = await getCanvasBlobForUpload(canvas, options, { + variant: 'with-mask', + allowNativeCanvasFallback: false, + unsupportedCanvasMessage: "Canvas does not support mask operations", + emptyBlobMessage: "Failed to generate canvas with mask blob" + }); + return uploadImageBlob(blob, options); +}, 'uploadCanvasWithMaskAsImage'); diff --git a/src/media/ImageUtils.ts b/src/media/ImageUtils.ts new file mode 100644 index 0000000..44c30f3 --- /dev/null +++ b/src/media/ImageUtils.ts @@ -0,0 +1,436 @@ +import {createModuleLogger} from "../log_system/log_funcs.js"; +import {withErrorHandling, createValidationError} from "../shared/ErrorHandler.js"; +import { createCanvas } from "../utils/CommonUtils.js"; +import type { Tensor, ImageDataPixel } from '../shared/types'; + +const log = createModuleLogger('ImageUtils'); + +interface TensorPixelData { + shape: number[]; + data: ArrayLike; +} + +function tensorToRgbaPixels(tensor: TensorPixelData, dimensionsLog: string): ImageDataPixel { + const shape = tensor.shape; + const height = shape[1]; + const width = shape[2]; + const channels = shape[3]; + const floatData = new Float32Array(tensor.data); + + log.debug(dimensionsLog, {height, width, channels}); + + const rgbaData = new Uint8ClampedArray(width * height * 4); + + for (let h = 0; h < height; h++) { + for (let w = 0; w < width; w++) { + const pixelIndex = (h * width + w) * 4; + const tensorIndex = (h * width + w) * channels; + + for (let c = 0; c < channels; c++) { + const value = floatData[tensorIndex + c]; + rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255))); + } + + rgbaData[pixelIndex + 3] = 255; + } + } + + return { + data: rgbaData, + width: width, + height: height + }; +} + +export function validateImageData(data: any): boolean { + log.debug("Validating data structure:", { + hasData: !!data, + type: typeof data, + isArray: Array.isArray(data), + keys: data ? Object.keys(data) : null, + shape: data?.shape, + dataType: data?.data ? data.data.constructor.name : null, + fullData: data + }); + + if (!data) { + log.info("Data is null or undefined"); + return false; + } + + if (Array.isArray(data)) { + log.debug("Data is array, getting first element"); + data = data[0]; + } + + if (!data || typeof data !== 'object') { + log.info("Invalid data type"); + return false; + } + + if (!data.data) { + log.info("Missing data property"); + return false; + } + + if (!(data.data instanceof Float32Array)) { + try { + data.data = new Float32Array(data.data); + } catch (e) { + log.error("Failed to convert data to Float32Array:", e); + return false; + } + } + + return true; +} + +export function convertImageData(data: any): ImageDataPixel { + log.info("Converting image data:", data); + + if (Array.isArray(data)) { + data = data[0]; + } + + return tensorToRgbaPixels(data, "Processing dimensions:"); +} + +export function applyMaskToImageData(imageData: ImageDataPixel, maskData: Tensor): ImageDataPixel { + log.info("Applying mask to image data"); + + const rgbaData = new Uint8ClampedArray(imageData.data); + const width = imageData.width; + const height = imageData.height; + + const maskShape = maskData.shape; + const maskFloatData = new Float32Array(maskData.data); + + log.debug(`Applying mask of shape: ${maskShape}`); + + for (let h = 0; h < height; h++) { + for (let w = 0; w < width; w++) { + const pixelIndex = (h * width + w) * 4; + const maskIndex = h * width + w; + + const alpha = maskFloatData[maskIndex]; + rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255))); + } + } + + log.info("Mask application completed"); + + return { + data: rgbaData, + width: width, + height: height + }; +} + +export const prepareImageForCanvas = withErrorHandling(function (inputImage: any): ImageDataPixel { + log.info("Preparing image for canvas:", inputImage); + + if (Array.isArray(inputImage)) { + inputImage = inputImage[0]; + } + + if (!inputImage || !inputImage.shape || !inputImage.data) { + throw createValidationError("Invalid input image format", {inputImage}); + } + + return tensorToRgbaPixels(inputImage, "Image dimensions:"); +}, 'prepareImageForCanvas'); + +export const imageToTensor = withErrorHandling(async function (image: HTMLImageElement | HTMLCanvasElement): Promise { + if (!image) { + throw createValidationError("Image is required"); + } + + const { canvas, ctx } = createCanvas(image.width, image.height, '2d', { willReadFrequently: true }); + + if (ctx) { + ctx.drawImage(image, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = new Float32Array(canvas.width * canvas.height * 3); + + for (let i = 0; i < imageData.data.length; i += 4) { + const pixelIndex = i / 4; + data[pixelIndex * 3] = imageData.data[i] / 255; + data[pixelIndex * 3 + 1] = imageData.data[i + 1] / 255; + data[pixelIndex * 3 + 2] = imageData.data[i + 2] / 255; + } + + return { + data: data, + shape: [1, canvas.height, canvas.width, 3], + width: canvas.width, + height: canvas.height + }; + } + throw new Error("Canvas context not available"); +}, 'imageToTensor'); + +export const tensorToImage = withErrorHandling(async function (tensor: Tensor): Promise { + if (!tensor || !tensor.data || !tensor.shape) { + throw createValidationError("Invalid tensor format", {tensor}); + } + + const [, height, width, channels] = tensor.shape; + const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true }); + + if (ctx) { + const imageData = ctx.createImageData(width, height); + const data = tensor.data; + + for (let i = 0; i < width * height; i++) { + const pixelIndex = i * 4; + const tensorIndex = i * channels; + + imageData.data[pixelIndex] = Math.round(data[tensorIndex] * 255); + imageData.data[pixelIndex + 1] = Math.round(data[tensorIndex + 1] * 255); + imageData.data[pixelIndex + 2] = Math.round(data[tensorIndex + 2] * 255); + imageData.data[pixelIndex + 3] = 255; + } + + ctx.putImageData(imageData, 0, 0); + + return loadImage(canvas.toDataURL()); + } + throw new Error("Canvas context not available"); +}, 'tensorToImage'); + +export const resizeImage = withErrorHandling(async function (image: HTMLImageElement, maxWidth: number, maxHeight: number): Promise { + if (!image) { + throw createValidationError("Image is required"); + } + + const originalWidth = image.width; + const originalHeight = image.height; + const scale = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); + const newWidth = Math.round(originalWidth * scale); + const newHeight = Math.round(originalHeight * scale); + + const { canvas, ctx } = createCanvas(newWidth, newHeight, '2d', { willReadFrequently: true }); + + if (ctx) { + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(image, 0, 0, newWidth, newHeight); + + return loadImage(canvas.toDataURL()); + } + throw new Error("Canvas context not available"); +}, 'resizeImage'); + +export const createThumbnail = withErrorHandling(async function (image: HTMLImageElement, size = 128): Promise { + return resizeImage(image, size, size); +}, 'createThumbnail'); + +export const imageToBase64 = withErrorHandling(function (image: HTMLImageElement | HTMLCanvasElement, format = 'png', quality = 0.9): string { + if (!image) { + throw createValidationError("Image is required"); + } + + const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; + const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true }); + + if (ctx) { + ctx.drawImage(image, 0, 0); + const mimeType = `image/${format}`; + return canvas.toDataURL(mimeType, quality); + } + throw new Error("Canvas context not available"); +}, 'imageToBase64'); + +export const base64ToImage = withErrorHandling(function (base64: string): Promise { + if (!base64) { + throw createValidationError("Base64 string is required"); + } + + return loadImage(base64).catch(() => { + throw new Error("Failed to load image from base64"); + }); +}, 'base64ToImage'); + +export function isValidImage(image: any): image is HTMLImageElement | HTMLCanvasElement { + return image && + (image instanceof HTMLImageElement || image instanceof HTMLCanvasElement) && + image.width > 0 && + image.height > 0; +} + +export function getImageInfo(image: HTMLImageElement | HTMLCanvasElement): {width: number, height: number, aspectRatio: number, area: number} | null { + if (!isValidImage(image)) { + return null; + } + + const width = image instanceof HTMLImageElement ? image.naturalWidth : image.width; + const height = image instanceof HTMLImageElement ? image.naturalHeight : image.height; + + return { + width, + height, + aspectRatio: width / height, + area: width * height + }; +} + +export function loadImage(source: string, options: { crossOrigin?: string } = {}): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + if (options.crossOrigin !== undefined) { + img.crossOrigin = options.crossOrigin; + } + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = source; + }); +} + +export function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +export async function loadImageFromBlob( + blob: Blob, + options: { crossOrigin?: string } = {} +): Promise { + const dataUrl = await blobToDataUrl(blob); + return loadImage(dataUrl, options); +} + +export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise { + const { canvas, ctx } = createCanvas(width, height, '2d', { willReadFrequently: true }); + + if (ctx) { + if (color !== 'transparent') { + ctx.fillStyle = color; + ctx.fillRect(0, 0, width, height); + } + + return loadImage(canvas.toDataURL()); + } + throw new Error("Canvas context not available"); +}, 'createEmptyImage'); + +/** + * Converts a canvas or image to an Image element + * Consolidated from MaskProcessingUtils.convertToImage() + * @param source - Source canvas or image + * @returns Promise with Image element + */ +export async function convertToImage(source: HTMLCanvasElement | HTMLImageElement): Promise { + if (source instanceof HTMLImageElement) { + return source; // Already an image + } + + return loadImage(source.toDataURL()); +} + +/** + * Scales an image to fit within specified bounds while maintaining aspect ratio + * @param image - Image to scale + * @param targetWidth - Target width to fit within + * @param targetHeight - Target height to fit within + * @returns Promise with scaled Image element + */ +export async function scaleImageToFit(image: HTMLImageElement, targetWidth: number, targetHeight: number): Promise { + const scale = Math.min(targetWidth / image.width, targetHeight / image.height); + const scaledWidth = Math.max(1, Math.round(image.width * scale)); + const scaledHeight = Math.max(1, Math.round(image.height * scale)); + + const { canvas, ctx } = createCanvas(scaledWidth, scaledHeight, '2d', { willReadFrequently: true }); + if (!ctx) throw new Error("Could not create scaled image context"); + + ctx.drawImage(image, 0, 0, scaledWidth, scaledHeight); + + return loadImage(canvas.toDataURL()); +} + +/** + * Unified tensor to image data conversion + * Handles both RGB images and grayscale masks + * @param tensor - Input tensor data + * @param mode - 'rgb' for images or 'grayscale' for masks + * @returns ImageData object + */ +export function tensorToImageData(tensor: any, mode: 'rgb' | 'grayscale' = 'rgb'): ImageData | null { + try { + const shape = tensor.shape; + const height = shape[1]; + const width = shape[2]; + const channels = shape[3] || 1; // Default to 1 for masks + + log.debug("Converting tensor:", { shape, channels, mode }); + + const imageData = new ImageData(width, height); + const data = new Uint8ClampedArray(width * height * 4); + + const flatData = tensor.data; + const pixelCount = width * height; + + const min = tensor.min_val ?? 0; + const max = tensor.max_val ?? 1; + const denom = (max - min) || 1; + + for (let i = 0; i < pixelCount; i++) { + const pixelIndex = i * 4; + const tensorIndex = i * channels; + + let lum: number; + if (mode === 'grayscale' || channels === 1) { + lum = flatData[tensorIndex]; + } else { + // Compute luminance for RGB + const r = flatData[tensorIndex + 0] ?? 0; + const g = flatData[tensorIndex + 1] ?? 0; + const b = flatData[tensorIndex + 2] ?? 0; + lum = 0.299 * r + 0.587 * g + 0.114 * b; + } + + let norm = (lum - min) / denom; + if (!isFinite(norm)) norm = 0; + norm = Math.max(0, Math.min(1, norm)); + const value = Math.round(norm * 255); + + if (mode === 'grayscale') { + // For masks: RGB = value, A = 255 (MaskTool reads luminance) + data[pixelIndex] = value; + data[pixelIndex + 1] = value; + data[pixelIndex + 2] = value; + data[pixelIndex + 3] = 255; + } else { + // For images: RGB from channels, A = 255 + for (let c = 0; c < Math.min(3, channels); c++) { + const channelValue = flatData[tensorIndex + c]; + const channelNorm = (channelValue - min) / denom; + data[pixelIndex + c] = Math.round(channelNorm * 255); + } + data[pixelIndex + 3] = 255; + } + } + + imageData.data.set(data); + return imageData; + } catch (error) { + log.error("Error converting tensor:", error); + return null; + } +} + +/** + * Creates an HTMLImageElement from ImageData + * @param imageData - Input ImageData + * @returns Promise with HTMLImageElement + */ +export async function createImageFromImageData(imageData: ImageData): Promise { + const { canvas, ctx } = createCanvas(imageData.width, imageData.height, '2d', { willReadFrequently: true }); + if (!ctx) throw new Error("Could not create canvas context"); + ctx.putImageData(imageData, 0, 0); + return await loadImage(canvas.toDataURL()); +} diff --git a/src/media/PreviewUtils.ts b/src/media/PreviewUtils.ts new file mode 100644 index 0000000..464ccd8 --- /dev/null +++ b/src/media/PreviewUtils.ts @@ -0,0 +1,298 @@ +import { createModuleLogger } from "../log_system/log_funcs.js"; +import { withErrorHandling, createValidationError } from "../shared/ErrorHandler.js"; +import { resolveCanvasBlob, supportsFlattenedCanvasBlob, type CanvasBlobVariant } from './CanvasBlobUtils.js'; +import { loadImage, loadImageFromBlob } from './ImageUtils.js'; +import type { ComfyNode } from '../shared/types'; + +const log = createModuleLogger('PreviewUtils'); + +/** + * Utility functions for creating and managing preview images + */ + +export interface PreviewOptions { + /** Whether to include mask in the preview (default: true) */ + includeMask?: boolean; + /** Whether to update node.imgs array (default: true) */ + updateNodeImages?: boolean; + /** Custom blob source instead of canvas */ + customBlob?: Blob; +} + +export type PreviewBlobSource = 'canvas' | 'blob'; + +export type PreviewUrlMode = 'data-url' | 'object-url'; + +export interface PreviewImageLoadOptions { + source: PreviewBlobSource; + urlMode?: PreviewUrlMode; +} + +interface PreviewBlobLoadOptions { + source: PreviewBlobSource; + node?: ComfyNode; + updateNodeImages?: boolean; +} + +export async function loadPreviewImage( + blob: Blob, + options: PreviewImageLoadOptions +): Promise { + const isCanvasSource = options.source === 'canvas'; + + try { + 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" + : "Failed to load preview image from blob"; + log.error(errorMessage, error); + throw createValidationError( + errorMessage, + isCanvasSource + ? { error, blob: blob.size } + : { 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", + isCanvasSource + ? { + width: previewImage.width, + height: previewImage.height, + nodeId: options.node?.id + } + : { + width: previewImage.width, + height: previewImage.height + } + ); + + if (options.updateNodeImages && options.node) { + options.node.imgs = [previewImage]; + log.debug( + isCanvasSource + ? "Node images updated with new preview" + : "Node images updated with blob preview" + ); + } + + return previewImage; +} + +/** + * Creates a preview image from canvas and updates node + * @param canvas - Canvas object with canvasLayers + * @param node - ComfyUI node to update + * @param options - Preview options + * @returns Promise with created Image element + */ +export const createPreviewFromCanvas = withErrorHandling(async function( + canvas: any, + node: ComfyNode, + options: PreviewOptions = {} +): Promise { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + if (!node) { + throw createValidationError("Node is required", { node }); + } + + const { + includeMask = true, + updateNodeImages = true, + customBlob + } = options; + + log.debug('Creating preview from canvas:', { + includeMask, + updateNodeImages, + hasCustomBlob: !!customBlob, + nodeId: node.id + }); + + let blob: Blob | null = customBlob || null; + + // Get blob from canvas if not provided + if (!blob) { + if (!canvas.canvasLayers) { + throw createValidationError("Canvas does not have canvasLayers", { canvas }); + } + + let variant: CanvasBlobVariant = 'plain'; + if (includeMask && supportsFlattenedCanvasBlob(canvas, 'with-mask')) { + variant = 'with-mask'; + } + + const resolution = await resolveCanvasBlob(canvas, variant); + if (resolution.source === 'unsupported') { + throw createValidationError("Canvas does not support required blob generation methods", { + canvas, + availableMethods: Object.getOwnPropertyNames(canvas.canvasLayers) + }); + } + + blob = resolution.blob; + } + + if (!blob) { + throw createValidationError("Failed to generate canvas blob for preview", { canvas, options }); + } + + return loadPreviewForNode(blob, { + source: 'canvas', + node, + updateNodeImages + }); +}, 'createPreviewFromCanvas'); + +/** + * Creates a preview image from a blob + * @param blob - Image blob + * @param node - ComfyUI node to update (optional) + * @param updateNodeImages - Whether to update node.imgs (default: false) + * @returns Promise with created Image element + */ +export const createPreviewFromBlob = withErrorHandling(async function( + blob: Blob, + node?: ComfyNode, + updateNodeImages: boolean = false +): Promise { + if (!blob) { + throw createValidationError("Blob is required", { blob }); + } + if (blob.size === 0) { + throw createValidationError("Blob cannot be empty", { blobSize: blob.size }); + } + + log.debug('Creating preview from blob:', { + blobSize: blob.size, + updateNodeImages, + hasNode: !!node + }); + + return loadPreviewForNode(blob, { + source: 'blob', + node, + updateNodeImages + }); +}, 'createPreviewFromBlob'); + +/** + * Updates node preview after canvas changes + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param includeMask - Whether to include mask in preview + * @returns Promise with updated preview image + */ +export const updateNodePreview = withErrorHandling(async function( + canvas: any, + node: ComfyNode, + includeMask: boolean = true +): Promise { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + if (!node) { + throw createValidationError("Node is required", { node }); + } + + log.info('Updating node preview:', { + nodeId: node.id, + includeMask + }); + + // Trigger canvas render and save state + if (typeof canvas.render === 'function') { + canvas.render(); + } + + if (typeof canvas.saveState === 'function') { + canvas.saveState(); + } + + // Create new preview + const previewImage = await createPreviewFromCanvas(canvas, node, { + includeMask, + updateNodeImages: true + }); + + log.info('Node preview updated successfully'); + return previewImage; +}, 'updateNodePreview'); + +/** + * Clears node preview images + * @param node - ComfyUI node + */ +export function clearNodePreview(node: ComfyNode): void { + log.debug('Clearing node preview:', { nodeId: node.id }); + node.imgs = []; +} + +/** + * Checks if node has preview images + * @param node - ComfyUI node + * @returns True if node has preview images + */ +export function hasNodePreview(node: ComfyNode): boolean { + return !!(node.imgs && node.imgs.length > 0 && node.imgs[0].src); +} + +/** + * Gets the current preview image from node + * @param node - ComfyUI node + * @returns Current preview image or null + */ +export function getCurrentPreview(node: ComfyNode): HTMLImageElement | null { + if (hasNodePreview(node) && node.imgs) { + return node.imgs[0]; + } + return null; +} + +/** + * Creates a preview with custom processing + * @param canvas - Canvas object + * @param node - ComfyUI node + * @param processor - Custom processing function that takes canvas and returns blob + * @returns Promise with processed preview image + */ +export const createCustomPreview = withErrorHandling(async function( + canvas: any, + node: ComfyNode, + processor: (canvas: any) => Promise +): Promise { + if (!canvas) { + throw createValidationError("Canvas is required", { canvas }); + } + if (!node) { + throw createValidationError("Node is required", { node }); + } + if (!processor || typeof processor !== 'function') { + throw createValidationError("Processor function is required", { processor }); + } + + log.debug('Creating custom preview:', { nodeId: node.id }); + + const blob = await processor(canvas); + return createPreviewFromBlob(blob, node, true); +}, 'createCustomPreview');