ci: enforce generated frontend and coverage
This commit is contained in:
@@ -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
@@ -46,8 +46,7 @@ venv/*
|
||||
tsconfig.json
|
||||
src_web
|
||||
src_web/*
|
||||
Media
|
||||
Media/*
|
||||
/Media/
|
||||
config.json
|
||||
js/config.js
|
||||
python/config.py
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user