ci: enforce generated frontend and coverage

This commit is contained in:
Dariusz L
2026-08-14 15:56:31 +02:00
parent 248810ae87
commit fc4292ecb0
14 changed files with 1839 additions and 9 deletions
+34 -4
View File
@@ -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
+1 -2
View File
@@ -46,8 +46,7 @@ venv/*
tsconfig.json
src_web
src_web/*
Media
Media/*
/Media/
config.json
js/config.js
python/config.py
+5 -2
View File
@@ -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
+26
View File
@@ -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 };
}
+39
View File
@@ -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;
}
}
+135
View File
@@ -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');
+354
View File
@@ -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());
}
+203
View File
@@ -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');
+2 -1
View File
@@ -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"
},
+47
View File
@@ -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<CanvasBlobVariant, string> = {
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<Blob | null> {
return canvas.canvasLayers[CANVAS_BLOB_METHODS[variant]]();
}
export async function resolveCanvasBlob(
canvas: any,
variant: CanvasBlobVariant,
options: CanvasBlobResolutionOptions = {},
): Promise<CanvasBlobResolution> {
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<Blob | null>(resolve => canvas.toBlob(resolve));
return { source: 'native', blob };
}
return { source: 'unsupported', blob: null };
}
+51
View File
@@ -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<void> {
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<boolean> {
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;
}
}
+208
View File
@@ -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<Response>;
export async function postImageBlob(
request: ImageBlobUploadRequest,
transport: ImageUploadTransport = (input, init) => api.fetchApi(input, init)
): Promise<Response> {
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<Blob> {
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<UploadImageResult> {
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<UploadImageResult> {
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<UploadImageResult> {
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');
+436
View File
@@ -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<number>;
}
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<Tensor> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<string> {
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<HTMLImageElement> {
const dataUrl = await blobToDataUrl(blob);
return loadImage(dataUrl, options);
}
export const createEmptyImage = withErrorHandling(function (width: number, height: number, color = 'transparent'): Promise<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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());
}
+298
View File
@@ -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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<HTMLImageElement> {
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<Blob>
): Promise<HTMLImageElement> {
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');