mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 13:12:10 -03:00
Excluded blendedImageCache and blendedImageDirty properties from layer serialization in CanvasState.ts to prevent DataCloneError when saving state. This ensures that only serializable data is sent to Web Workers, while runtime caches are regenerated as needed. Blend area performance optimization remains functional without serialization issues.
476 lines
19 KiB
JavaScript
476 lines
19 KiB
JavaScript
import { getCanvasState, setCanvasState, saveImage, getImage } from "./db.js";
|
|
import { createModuleLogger } from "./utils/LoggerUtils.js";
|
|
import { showAlertNotification } from "./utils/NotificationUtils.js";
|
|
import { generateUUID, cloneLayers, getStateSignature, debounce, createCanvas } from "./utils/CommonUtils.js";
|
|
const log = createModuleLogger('CanvasState');
|
|
export class CanvasState {
|
|
constructor(canvas) {
|
|
this.canvas = canvas;
|
|
this.layersUndoStack = [];
|
|
this.layersRedoStack = [];
|
|
this.maskUndoStack = [];
|
|
this.maskRedoStack = [];
|
|
this.historyLimit = 100;
|
|
this.saveTimeout = null;
|
|
this.lastSavedStateSignature = null;
|
|
this._loadInProgress = null;
|
|
this._debouncedSave = null;
|
|
try {
|
|
// @ts-ignore
|
|
this.stateSaverWorker = new Worker(new URL('./state-saver.worker.js', import.meta.url), { type: 'module' });
|
|
log.info("State saver worker initialized successfully.");
|
|
this.stateSaverWorker.onmessage = (e) => {
|
|
log.info("Message from state saver worker:", e.data);
|
|
};
|
|
this.stateSaverWorker.onerror = (e) => {
|
|
log.error("Error in state saver worker:", e.message, e.filename, e.lineno);
|
|
this.stateSaverWorker = null;
|
|
};
|
|
}
|
|
catch (e) {
|
|
log.error("Failed to initialize state saver worker:", e);
|
|
this.stateSaverWorker = null;
|
|
}
|
|
}
|
|
async loadStateFromDB() {
|
|
if (this._loadInProgress) {
|
|
log.warn("Load already in progress, waiting...");
|
|
return this._loadInProgress;
|
|
}
|
|
log.info("Attempting to load state from IndexedDB for node:", this.canvas.node.id);
|
|
const loadPromise = this._performLoad();
|
|
this._loadInProgress = loadPromise;
|
|
try {
|
|
const result = await loadPromise;
|
|
this._loadInProgress = null;
|
|
return result;
|
|
}
|
|
catch (error) {
|
|
this._loadInProgress = null;
|
|
throw error;
|
|
}
|
|
}
|
|
async _performLoad() {
|
|
try {
|
|
if (!this.canvas.node.id) {
|
|
log.error("Node ID is not available for loading state from DB.");
|
|
return false;
|
|
}
|
|
const savedState = await getCanvasState(String(this.canvas.node.id));
|
|
if (!savedState) {
|
|
log.info("No saved state found in IndexedDB for node:", this.canvas.node.id);
|
|
return false;
|
|
}
|
|
log.info("Found saved state in IndexedDB.");
|
|
this.canvas.width = savedState.width || 512;
|
|
this.canvas.height = savedState.height || 512;
|
|
this.canvas.viewport = savedState.viewport || {
|
|
x: -(this.canvas.width / 4),
|
|
y: -(this.canvas.height / 4),
|
|
zoom: 0.8
|
|
};
|
|
// Restore outputAreaBounds if saved, otherwise use default
|
|
if (savedState.outputAreaBounds) {
|
|
this.canvas.outputAreaBounds = savedState.outputAreaBounds;
|
|
log.debug(`Output Area bounds restored: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
|
|
}
|
|
else {
|
|
// Fallback to default positioning for legacy saves
|
|
this.canvas.outputAreaBounds = {
|
|
x: -(this.canvas.width / 4),
|
|
y: -(this.canvas.height / 4),
|
|
width: this.canvas.width,
|
|
height: this.canvas.height
|
|
};
|
|
log.debug(`Output Area bounds set to default: x=${this.canvas.outputAreaBounds.x}, y=${this.canvas.outputAreaBounds.y}, w=${this.canvas.outputAreaBounds.width}, h=${this.canvas.outputAreaBounds.height}`);
|
|
}
|
|
this.canvas.canvasLayers.updateOutputAreaSize(this.canvas.width, this.canvas.height, false);
|
|
log.debug(`Output Area resized to ${this.canvas.width}x${this.canvas.height} and viewport set.`);
|
|
const loadedLayers = await this._loadLayers(savedState.layers);
|
|
this.canvas.layers = loadedLayers.filter((l) => l !== null);
|
|
log.info(`Loaded ${this.canvas.layers.length} layers.`);
|
|
if (this.canvas.layers.length === 0) {
|
|
log.warn("No valid layers loaded, state may be corrupted.");
|
|
return false;
|
|
}
|
|
this.canvas.updateSelectionAfterHistory();
|
|
this.canvas.render();
|
|
log.info("Canvas state loaded successfully from IndexedDB for node", this.canvas.node.id);
|
|
return true;
|
|
}
|
|
catch (error) {
|
|
log.error("Error during state load:", error);
|
|
return false;
|
|
}
|
|
}
|
|
/**
|
|
* Ładuje warstwy z zapisanego stanu
|
|
* @param {any[]} layersData - Dane warstw do załadowania
|
|
* @returns {Promise<(Layer | null)[]>} Załadowane warstwy
|
|
*/
|
|
async _loadLayers(layersData) {
|
|
const imagePromises = layersData.map((layerData, index) => this._loadSingleLayer(layerData, index));
|
|
return Promise.all(imagePromises);
|
|
}
|
|
/**
|
|
* Ładuje pojedynczą warstwę
|
|
* @param {any} layerData - Dane warstwy
|
|
* @param {number} index - Indeks warstwy
|
|
* @returns {Promise<Layer | null>} Załadowana warstwa lub null
|
|
*/
|
|
async _loadSingleLayer(layerData, index) {
|
|
return new Promise((resolve) => {
|
|
if (layerData.imageId) {
|
|
this._loadLayerFromImageId(layerData, index, resolve);
|
|
}
|
|
else if (layerData.imageSrc) {
|
|
this._convertLegacyLayer(layerData, index, resolve);
|
|
}
|
|
else {
|
|
log.error(`Layer ${index}: No imageId or imageSrc found, skipping layer.`);
|
|
resolve(null);
|
|
}
|
|
});
|
|
}
|
|
/**
|
|
* Ładuje warstwę z imageId
|
|
* @param {any} layerData - Dane warstwy
|
|
* @param {number} index - Indeks warstwy
|
|
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
|
*/
|
|
_loadLayerFromImageId(layerData, index, resolve) {
|
|
log.debug(`Layer ${index}: Loading image with id: ${layerData.imageId}`);
|
|
if (this.canvas.imageCache.has(layerData.imageId)) {
|
|
log.debug(`Layer ${index}: Image found in cache.`);
|
|
const imageData = this.canvas.imageCache.get(layerData.imageId);
|
|
if (imageData) {
|
|
const imageSrc = URL.createObjectURL(new Blob([imageData.data]));
|
|
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
|
}
|
|
else {
|
|
resolve(null);
|
|
}
|
|
}
|
|
else {
|
|
getImage(layerData.imageId)
|
|
.then(imageSrc => {
|
|
if (imageSrc) {
|
|
log.debug(`Layer ${index}: Loading image from data:URL...`);
|
|
this._createLayerFromSrc(layerData, imageSrc, index, resolve);
|
|
}
|
|
else {
|
|
log.error(`Layer ${index}: Image not found in IndexedDB.`);
|
|
resolve(null);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
log.error(`Layer ${index}: Error loading image from IndexedDB:`, err);
|
|
resolve(null);
|
|
});
|
|
}
|
|
}
|
|
/**
|
|
* Konwertuje starą warstwę z imageSrc na nowy format
|
|
* @param {any} layerData - Dane warstwy
|
|
* @param {number} index - Indeks warstwy
|
|
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
|
*/
|
|
_convertLegacyLayer(layerData, index, resolve) {
|
|
log.info(`Layer ${index}: Found imageSrc, converting to new format with imageId.`);
|
|
const imageId = generateUUID();
|
|
saveImage(imageId, layerData.imageSrc)
|
|
.then(() => {
|
|
log.info(`Layer ${index}: Image saved to IndexedDB with id: ${imageId}`);
|
|
const newLayerData = { ...layerData, imageId };
|
|
delete newLayerData.imageSrc;
|
|
this._createLayerFromSrc(newLayerData, layerData.imageSrc, index, resolve);
|
|
})
|
|
.catch(err => {
|
|
log.error(`Layer ${index}: Error saving image to IndexedDB:`, err);
|
|
resolve(null);
|
|
});
|
|
}
|
|
/**
|
|
* Tworzy warstwę z src obrazu
|
|
* @param {any} layerData - Dane warstwy
|
|
* @param {string} imageSrc - Źródło obrazu
|
|
* @param {number} index - Indeks warstwy
|
|
* @param {(value: Layer | null) => void} resolve - Funkcja resolve
|
|
*/
|
|
_createLayerFromSrc(layerData, imageSrc, index, resolve) {
|
|
if (typeof imageSrc === 'string') {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
log.debug(`Layer ${index}: Image loaded successfully.`);
|
|
const newLayer = { ...layerData, image: img };
|
|
resolve(newLayer);
|
|
};
|
|
img.onerror = () => {
|
|
log.error(`Layer ${index}: Failed to load image from src.`);
|
|
resolve(null);
|
|
};
|
|
img.src = imageSrc;
|
|
}
|
|
else {
|
|
const { canvas, ctx } = createCanvas(imageSrc.width, imageSrc.height);
|
|
if (ctx) {
|
|
ctx.drawImage(imageSrc, 0, 0);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
log.debug(`Layer ${index}: Image loaded successfully from ImageBitmap.`);
|
|
const newLayer = { ...layerData, image: img };
|
|
resolve(newLayer);
|
|
};
|
|
img.onerror = () => {
|
|
log.error(`Layer ${index}: Failed to load image from ImageBitmap.`);
|
|
resolve(null);
|
|
};
|
|
img.src = canvas.toDataURL();
|
|
}
|
|
else {
|
|
log.error(`Layer ${index}: Failed to get 2d context from canvas.`);
|
|
resolve(null);
|
|
}
|
|
}
|
|
}
|
|
async saveStateToDB() {
|
|
if (!this.canvas.node.id) {
|
|
log.error("Node ID is not available for saving state to DB.");
|
|
return;
|
|
}
|
|
// Auto-correct node_id widget if needed before saving state
|
|
if (this.canvas.node && this.canvas.node.widgets) {
|
|
const nodeIdWidget = this.canvas.node.widgets.find((w) => w.name === "node_id");
|
|
if (nodeIdWidget) {
|
|
const correctId = String(this.canvas.node.id);
|
|
if (nodeIdWidget.value !== correctId) {
|
|
const prevValue = nodeIdWidget.value;
|
|
nodeIdWidget.value = correctId;
|
|
log.warn(`[CanvasState] node_id widget value (${prevValue}) did not match node.id (${correctId}) - auto-corrected (saveStateToDB).`);
|
|
showAlertNotification(`The value of node_id (${prevValue}) did not match the node number (${correctId}) and was automatically corrected.
|
|
If you see dark images or masks in the output, make sure node_id is set to ${correctId}.`);
|
|
}
|
|
}
|
|
}
|
|
log.info("Preparing state to be sent to worker...");
|
|
const layers = await this._prepareLayers();
|
|
const state = {
|
|
layers: layers.filter(layer => layer !== null),
|
|
viewport: this.canvas.viewport,
|
|
width: this.canvas.width,
|
|
height: this.canvas.height,
|
|
outputAreaBounds: this.canvas.outputAreaBounds,
|
|
};
|
|
if (state.layers.length === 0) {
|
|
log.warn("No valid layers to save, skipping.");
|
|
return;
|
|
}
|
|
if (this.stateSaverWorker) {
|
|
log.info("Posting state to worker for background saving.");
|
|
this.stateSaverWorker.postMessage({
|
|
nodeId: String(this.canvas.node.id),
|
|
state: state
|
|
});
|
|
this.canvas.render();
|
|
}
|
|
else {
|
|
log.warn("State saver worker not available. Saving on main thread.");
|
|
await setCanvasState(String(this.canvas.node.id), state);
|
|
}
|
|
}
|
|
/**
|
|
* Przygotowuje warstwy do zapisu
|
|
* @returns {Promise<(Omit<Layer, 'image'> & { imageId: string })[]>} Przygotowane warstwy
|
|
*/
|
|
async _prepareLayers() {
|
|
const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => {
|
|
const newLayer = { ...layer, imageId: layer.imageId || '' };
|
|
delete newLayer.image;
|
|
// Remove cache properties that cannot be serialized for the worker
|
|
delete newLayer.blendedImageCache;
|
|
delete newLayer.blendedImageDirty;
|
|
if (layer.image instanceof HTMLImageElement) {
|
|
if (layer.imageId) {
|
|
newLayer.imageId = layer.imageId;
|
|
}
|
|
else {
|
|
log.debug(`Layer ${index}: No imageId found, generating new one and saving image.`);
|
|
newLayer.imageId = generateUUID();
|
|
const imageBitmap = await createImageBitmap(layer.image);
|
|
await saveImage(newLayer.imageId, imageBitmap);
|
|
}
|
|
}
|
|
else if (!layer.imageId) {
|
|
log.error(`Layer ${index}: No image or imageId found, skipping layer.`);
|
|
return null;
|
|
}
|
|
return newLayer;
|
|
}));
|
|
return preparedLayers.filter((layer) => layer !== null);
|
|
}
|
|
saveState(replaceLast = false) {
|
|
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
|
this.saveMaskState(replaceLast);
|
|
}
|
|
else {
|
|
this.saveLayersState(replaceLast);
|
|
}
|
|
}
|
|
saveLayersState(replaceLast = false) {
|
|
if (replaceLast && this.layersUndoStack.length > 0) {
|
|
this.layersUndoStack.pop();
|
|
}
|
|
const currentState = cloneLayers(this.canvas.layers);
|
|
const currentStateSignature = getStateSignature(currentState);
|
|
if (this.layersUndoStack.length > 0) {
|
|
const lastState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
|
if (getStateSignature(lastState) === currentStateSignature) {
|
|
return;
|
|
}
|
|
}
|
|
this.layersUndoStack.push(currentState);
|
|
if (this.layersUndoStack.length > this.historyLimit) {
|
|
this.layersUndoStack.shift();
|
|
}
|
|
this.layersRedoStack = [];
|
|
this.canvas.updateHistoryButtons();
|
|
if (!this._debouncedSave) {
|
|
this._debouncedSave = debounce(this.saveStateToDB.bind(this), 1000);
|
|
}
|
|
this._debouncedSave();
|
|
}
|
|
saveMaskState(replaceLast = false) {
|
|
if (!this.canvas.maskTool)
|
|
return;
|
|
if (replaceLast && this.maskUndoStack.length > 0) {
|
|
this.maskUndoStack.pop();
|
|
}
|
|
const maskCanvas = this.canvas.maskTool.getMask();
|
|
const { canvas: clonedCanvas, ctx: clonedCtx } = createCanvas(maskCanvas.width, maskCanvas.height, '2d', { willReadFrequently: true });
|
|
if (clonedCtx) {
|
|
clonedCtx.drawImage(maskCanvas, 0, 0);
|
|
}
|
|
this.maskUndoStack.push(clonedCanvas);
|
|
if (this.maskUndoStack.length > this.historyLimit) {
|
|
this.maskUndoStack.shift();
|
|
}
|
|
this.maskRedoStack = [];
|
|
this.canvas.updateHistoryButtons();
|
|
}
|
|
undo() {
|
|
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
|
this.undoMaskState();
|
|
}
|
|
else {
|
|
this.undoLayersState();
|
|
}
|
|
}
|
|
redo() {
|
|
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
|
this.redoMaskState();
|
|
}
|
|
else {
|
|
this.redoLayersState();
|
|
}
|
|
}
|
|
undoLayersState() {
|
|
if (this.layersUndoStack.length <= 1)
|
|
return;
|
|
const currentState = this.layersUndoStack.pop();
|
|
if (currentState) {
|
|
this.layersRedoStack.push(currentState);
|
|
}
|
|
const prevState = this.layersUndoStack[this.layersUndoStack.length - 1];
|
|
this.canvas.layers = cloneLayers(prevState);
|
|
this.canvas.updateSelectionAfterHistory();
|
|
this.canvas.render();
|
|
this.canvas.updateHistoryButtons();
|
|
}
|
|
redoLayersState() {
|
|
if (this.layersRedoStack.length === 0)
|
|
return;
|
|
const nextState = this.layersRedoStack.pop();
|
|
if (nextState) {
|
|
this.layersUndoStack.push(nextState);
|
|
this.canvas.layers = cloneLayers(nextState);
|
|
this.canvas.updateSelectionAfterHistory();
|
|
this.canvas.render();
|
|
this.canvas.updateHistoryButtons();
|
|
}
|
|
}
|
|
undoMaskState() {
|
|
if (!this.canvas.maskTool || this.maskUndoStack.length <= 1)
|
|
return;
|
|
const currentState = this.maskUndoStack.pop();
|
|
if (currentState) {
|
|
this.maskRedoStack.push(currentState);
|
|
}
|
|
if (this.maskUndoStack.length > 0) {
|
|
const prevState = this.maskUndoStack[this.maskUndoStack.length - 1];
|
|
const maskCanvas = this.canvas.maskTool.getMask();
|
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
|
if (maskCtx) {
|
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
maskCtx.drawImage(prevState, 0, 0);
|
|
}
|
|
this.canvas.render();
|
|
}
|
|
this.canvas.updateHistoryButtons();
|
|
}
|
|
redoMaskState() {
|
|
if (!this.canvas.maskTool || this.maskRedoStack.length === 0)
|
|
return;
|
|
const nextState = this.maskRedoStack.pop();
|
|
if (nextState) {
|
|
this.maskUndoStack.push(nextState);
|
|
const maskCanvas = this.canvas.maskTool.getMask();
|
|
const maskCtx = maskCanvas.getContext('2d', { willReadFrequently: true });
|
|
if (maskCtx) {
|
|
maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
maskCtx.drawImage(nextState, 0, 0);
|
|
}
|
|
this.canvas.render();
|
|
}
|
|
this.canvas.updateHistoryButtons();
|
|
}
|
|
/**
|
|
* Czyści historię undo/redo
|
|
*/
|
|
clearHistory() {
|
|
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
|
this.maskUndoStack = [];
|
|
this.maskRedoStack = [];
|
|
}
|
|
else {
|
|
this.layersUndoStack = [];
|
|
this.layersRedoStack = [];
|
|
}
|
|
this.canvas.updateHistoryButtons();
|
|
log.info("History cleared");
|
|
}
|
|
/**
|
|
* Zwraca informacje o historii
|
|
* @returns {HistoryInfo} Informacje o historii
|
|
*/
|
|
getHistoryInfo() {
|
|
if (this.canvas.maskTool && this.canvas.maskTool.isActive) {
|
|
return {
|
|
undoCount: this.maskUndoStack.length,
|
|
redoCount: this.maskRedoStack.length,
|
|
canUndo: this.maskUndoStack.length > 1,
|
|
canRedo: this.maskRedoStack.length > 0,
|
|
historyLimit: this.historyLimit
|
|
};
|
|
}
|
|
else {
|
|
return {
|
|
undoCount: this.layersUndoStack.length,
|
|
redoCount: this.layersRedoStack.length,
|
|
canUndo: this.layersUndoStack.length > 1,
|
|
canRedo: this.layersRedoStack.length > 0,
|
|
historyLimit: this.historyLimit
|
|
};
|
|
}
|
|
}
|
|
}
|