12 Commits

Author SHA1 Message Date
Dariusz L
dfa7309132 Update pyproject.toml 2025-07-03 12:05:04 +02:00
Dariusz L
2ab406ebfd Improve error handling in BiRefNetMatting model loading
Refines exception handling in the load_model method to provide more informative error messages and re-raise exceptions for upstream handling. Removes boolean return values in favor of exception-based flow, and updates execute to rely on exceptions for error detection.
2025-07-03 12:04:28 +02:00
Dariusz L
d40f68b8c6 Preserve batch generation area during canvas changes
Introduces a 'generationArea' context for batch image generation, ensuring that batch preview outlines and image placement remain accurate when the canvas is moved or resized. Updates related logic in Canvas, CanvasInteractions, CanvasLayers, and CanvasRenderer to track and render the correct area, and synchronizes context updates across user interactions.
2025-07-03 11:52:16 +02:00
Dariusz L
e5060fd8c3 Support multiple batch preview menus on canvas
Refactored batch preview management to allow multiple BatchPreviewManager instances per canvas. Updated positioning logic to use an initial spawn position, adjusted UI updates, and ensured batch preview menus move correctly with canvas panning. Removed single-instance references and updated related event handling.
2025-07-03 03:55:04 +02:00
Dariusz L
f8eb91c4ad Make batch preview menu draggable and position-aware
Added draggable functionality to the batch preview menu, allowing users to reposition it within the canvas using world coordinates. The menu's position now updates with viewport changes, and its initial placement is centered below the output area. Also refactored logic to show the menu with new layers instead of adding to an existing batch.
2025-07-03 03:40:43 +02:00
Dariusz L
c4af745b2a Add addLayers method to BatchPreviewManager
Introduces an addLayers method to BatchPreviewManager for adding new layers to an active batch preview or showing the UI if inactive. Updates Canvas to use addLayers instead of show, and fixes a bug where new layers were only added if more than one was present.
2025-07-03 02:44:07 +02:00
Dariusz L
c9c0babf3c Add mask drawing mode to canvas interactions
Introduces support for a 'drawingMask' interaction mode in CanvasInteractions. Mouse events are now delegated to the maskTool when in this mode, and the canvas is re-rendered after each relevant event.
2025-07-03 02:40:07 +02:00
Dariusz L
152a3f7dff Auto-hide and restore mask overlay in batch preview
BatchPreviewManager now automatically hides the mask overlay when batch preview starts and restores its previous state when preview ends. The mask toggle button's state and label are updated accordingly. Also, mask toggle button IDs are now unique per canvas node.
2025-07-03 02:26:44 +02:00
Dariusz L
9f9a733731 Add batch preview manager and mask overlay toggle
Introduces BatchPreviewManager for reviewing and confirming multiple imported layers after auto-refresh. Adds a toggle button for mask overlay visibility in the UI and updates mask rendering logic to respect overlay visibility. Also refactors image import to return new layers and adds a utility for removing layers by ID.
2025-07-03 02:22:51 +02:00
Dariusz L
3419061b6c Add support for importing multiple latest images
Introduces a new backend route and method to fetch all images created since a given timestamp, and updates the frontend to import all new images as layers on auto-refresh. This improves workflow by allowing multiple images generated in a single execution to be imported at once, rather than only the most recent image.
2025-07-03 01:54:50 +02:00
Dariusz L
9e4da30b59 Add auto-refresh toggle after image generation
Introduces an 'Auto-refresh after generation' toggle to the Canvas. When enabled, the latest image is automatically imported after a successful execution event. Also ensures event listeners are properly cleaned up when the node is removed.
2025-07-03 01:02:35 +02:00
Dariusz L
2f730c87fa Change default log level and bump version
Set the default log level to NONE in createModuleLogger to reduce logging output. Incremented the project version to 1.3.3.1 in pyproject.toml.
2025-07-02 11:18:47 +02:00
11 changed files with 614 additions and 40 deletions

View File

@@ -333,6 +333,24 @@ class CanvasNode:
latest_image_path = max(image_files, key=os.path.getctime)
return latest_image_path
@classmethod
def get_latest_images(cls, since_timestamp=0):
output_dir = folder_paths.get_output_directory()
files = []
for f_name in os.listdir(output_dir):
file_path = os.path.join(output_dir, f_name)
if os.path.isfile(file_path) and file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp', '.gif')):
try:
mtime = os.path.getmtime(file_path)
if mtime > since_timestamp:
files.append((mtime, file_path))
except OSError:
continue
files.sort(key=lambda x: x[0])
return [f[1] for f in files]
@classmethod
def get_flow_status(cls, flow_id=None):
@@ -454,6 +472,30 @@ class CanvasNode:
'error': str(e)
})
@PromptServer.instance.routes.get("/layerforge/get-latest-images/{since}")
async def get_latest_images_route(request):
try:
since_timestamp = float(request.match_info.get('since', 0))
# JS Timestamps are in milliseconds, Python's are in seconds
latest_image_paths = cls.get_latest_images(since_timestamp / 1000.0)
images_data = []
for image_path in latest_image_paths:
with open(image_path, "rb") as f:
encoded_string = base64.b64encode(f.read()).decode('utf-8')
images_data.append(f"data:image/png;base64,{encoded_string}")
return web.json_response({
'success': True,
'images': images_data
})
except Exception as e:
log_error(f"Error in get_latest_images_route: {str(e)}")
return web.json_response({
'success': False,
'error': str(e)
}, status=500)
@PromptServer.instance.routes.get("/ycnode/get_latest_image")
async def get_latest_image_route(request):
try:
@@ -571,42 +613,38 @@ class BiRefNetMatting:
def load_model(self, model_path):
try:
if model_path not in self.model_cache:
full_model_path = os.path.join(self.base_path, "BiRefNet")
log_info(f"Loading BiRefNet model from {full_model_path}...")
try:
self.model = AutoModelForImageSegmentation.from_pretrained(
"ZhengPeng7/BiRefNet",
trust_remote_code=True,
cache_dir=full_model_path
)
self.model.eval()
if torch.cuda.is_available():
self.model = self.model.cuda()
self.model_cache[model_path] = self.model
log_info("Model loaded successfully from Hugging Face")
log_debug(f"Model type: {type(self.model)}")
log_debug(f"Model device: {next(self.model.parameters()).device}")
except Exception as e:
log_error(f"Failed to load model: {str(e)}")
raise
log_error(f"Failed to load model from Hugging Face: {str(e)}")
# Re-raise with a more informative message
raise RuntimeError(
"Failed to download or load the matting model. "
"This could be due to a network issue, file permissions, or a corrupted model cache. "
f"Please check your internet connection and the model cache path: {full_model_path}. "
f"Original error: {str(e)}"
) from e
else:
self.model = self.model_cache[model_path]
log_debug("Using cached model")
return True
except Exception as e:
# Catch the re-raised exception or any other error
log_error(f"Error loading model: {str(e)}")
log_exception("Model loading failed")
return False
raise # Re-raise the exception to be caught by the execute method
def preprocess_image(self, image):
@@ -636,11 +674,9 @@ class BiRefNetMatting:
def execute(self, image, model_path, threshold=0.5, refinement=1):
try:
PromptServer.instance.send_sync("matting_status", {"status": "processing"})
if not self.load_model(model_path):
raise RuntimeError("Failed to load model")
self.load_model(model_path)
if isinstance(image, torch.Tensor):
original_size = image.shape[-2:] if image.dim() == 4 else image.shape[-2:]

258
js/BatchPreviewManager.js Normal file
View File

@@ -0,0 +1,258 @@
import {createModuleLogger} from "./utils/LoggerUtils.js";
const log = createModuleLogger('BatchPreviewManager');
export class BatchPreviewManager {
constructor(canvas, initialPosition = { x: 0, y: 0 }, generationArea = null) {
this.canvas = canvas;
this.active = false;
this.layers = [];
this.currentIndex = 0;
this.element = null;
this.uiInitialized = false;
this.maskWasVisible = false;
// Position in canvas world coordinates
this.worldX = initialPosition.x;
this.worldY = initialPosition.y;
this.isDragging = false;
this.generationArea = generationArea; // Store the generation area
}
updateScreenPosition(viewport) {
if (!this.active || !this.element) return;
// Translate world coordinates to screen coordinates
const screenX = (this.worldX - viewport.x) * viewport.zoom;
const screenY = (this.worldY - viewport.y) * viewport.zoom;
// We can also scale the menu with zoom, but let's keep it constant for now for readability
const scale = 1; // viewport.zoom;
// Use transform for performance
this.element.style.transform = `translate(${screenX}px, ${screenY}px) scale(${scale})`;
}
_createUI() {
if (this.uiInitialized) return;
this.element = document.createElement('div');
this.element.id = 'layerforge-batch-preview';
this.element.style.cssText = `
position: absolute;
top: 0;
left: 0;
background-color: #333;
color: white;
padding: 8px 15px;
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0,0,0,0.5);
display: none;
align-items: center;
gap: 15px;
font-family: sans-serif;
z-index: 1001;
border: 1px solid #555;
cursor: move;
user-select: none;
`;
this.element.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
const handleMouseMove = (moveEvent) => {
if (this.isDragging) {
// Convert screen pixel movement to world coordinate movement
const deltaX = moveEvent.movementX / this.canvas.viewport.zoom;
const deltaY = moveEvent.movementY / this.canvas.viewport.zoom;
this.worldX += deltaX;
this.worldY += deltaY;
// The render loop will handle updating the screen position, but we need to trigger it.
this.canvas.render();
}
};
const handleMouseUp = () => {
this.isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
const prevButton = this._createButton('◀', 'Previous'); // Left arrow
const nextButton = this._createButton('▶', 'Next'); // Right arrow
const confirmButton = this._createButton('✔', 'Confirm'); // Checkmark
const cancelButton = this._createButton('✖', 'Cancel All'); // X mark
const closeButton = this._createButton('➲', 'Close'); // Door icon
this.counterElement = document.createElement('span');
this.counterElement.style.minWidth = '40px';
this.counterElement.style.textAlign = 'center';
this.counterElement.style.fontWeight = 'bold';
prevButton.onclick = () => this.navigate(-1);
nextButton.onclick = () => this.navigate(1);
confirmButton.onclick = () => this.confirm();
cancelButton.onclick = () => this.cancelAndRemoveAll();
closeButton.onclick = () => this.hide();
this.element.append(prevButton, this.counterElement, nextButton, confirmButton, cancelButton, closeButton);
if (this.canvas.canvas.parentNode) {
this.canvas.canvas.parentNode.appendChild(this.element);
} else {
log.error("Could not find parent node to attach batch preview UI.");
}
this.uiInitialized = true;
}
_createButton(innerHTML, title) {
const button = document.createElement('button');
button.innerHTML = innerHTML;
button.title = title;
button.style.cssText = `
background: #555;
color: white;
border: 1px solid #777;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
`;
button.onmouseover = () => button.style.background = '#666';
button.onmouseout = () => button.style.background = '#555';
return button;
}
show(layers) {
if (!layers || layers.length <= 1) {
return;
}
this._createUI();
// Auto-hide mask logic
this.maskWasVisible = this.canvas.maskTool.isOverlayVisible;
if (this.maskWasVisible) {
this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
if (toggleBtn) {
toggleBtn.classList.remove('primary');
toggleBtn.textContent = "Hide Mask";
}
this.canvas.render();
}
log.info(`Showing batch preview for ${layers.length} layers.`);
this.layers = layers;
this.currentIndex = 0;
// Make the element visible BEFORE calculating its size
this.element.style.display = 'flex';
this.active = true;
// Now that it's visible, we can get its dimensions and adjust the position.
const menuWidthInWorld = this.element.offsetWidth / this.canvas.viewport.zoom;
const paddingInWorld = 20 / this.canvas.viewport.zoom;
this.worldX -= menuWidthInWorld / 2; // Center horizontally
this.worldY += paddingInWorld; // Add padding below the output area
this._update();
}
hide() {
log.info('Hiding batch preview.');
if (this.element) {
this.element.remove();
}
this.active = false;
const index = this.canvas.batchPreviewManagers.indexOf(this);
if (index > -1) {
this.canvas.batchPreviewManagers.splice(index, 1);
}
// Trigger a final render to ensure the generation area outline is removed
this.canvas.render();
// Restore mask visibility if it was hidden by this manager
if (this.maskWasVisible && !this.canvas.maskTool.isOverlayVisible) {
this.canvas.maskTool.toggleOverlayVisibility();
const toggleBtn = document.getElementById(`toggle-mask-btn-${this.canvas.node.id}`);
if (toggleBtn) {
toggleBtn.classList.add('primary');
toggleBtn.textContent = "Show Mask";
}
}
this.maskWasVisible = false; // Reset state
// Make all layers visible again upon closing
this.canvas.layers.forEach(l => l.visible = true);
this.canvas.render();
}
navigate(direction) {
this.currentIndex += direction;
if (this.currentIndex < 0) {
this.currentIndex = this.layers.length - 1;
} else if (this.currentIndex >= this.layers.length) {
this.currentIndex = 0;
}
this._update();
}
confirm() {
const layerToKeep = this.layers[this.currentIndex];
log.info(`Confirming selection: Keeping layer ${layerToKeep.id}.`);
const layersToDelete = this.layers.filter(l => l.id !== layerToKeep.id);
const layerIdsToDelete = layersToDelete.map(l => l.id);
this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted ${layersToDelete.length} other layers.`);
this.hide();
}
cancelAndRemoveAll() {
log.info('Cancel clicked. Removing all new layers.');
const layerIdsToDelete = this.layers.map(l => l.id);
this.canvas.removeLayersByIds(layerIdsToDelete);
log.info(`Deleted all ${layerIdsToDelete.length} new layers.`);
this.hide();
}
_update() {
this.counterElement.textContent = `${this.currentIndex + 1} / ${this.layers.length}`;
this._focusOnLayer(this.layers[this.currentIndex]);
}
_focusOnLayer(layer) {
if (!layer) return;
log.debug(`Focusing on layer ${layer.id}`);
// Move the selected layer to the top of the layer stack
this.canvas.canvasLayers.moveLayers([layer], { toIndex: 0 });
this.canvas.updateSelection([layer]);
// Render is called by moveLayers, but we call it again to be safe
this.canvas.render();
}
}

View File

@@ -9,10 +9,22 @@ import {CanvasLayersPanel} from "./CanvasLayersPanel.js";
import {CanvasRenderer} from "./CanvasRenderer.js";
import {CanvasIO} from "./CanvasIO.js";
import {ImageReferenceManager} from "./ImageReferenceManager.js";
import {BatchPreviewManager} from "./BatchPreviewManager.js";
import {createModuleLogger} from "./utils/LoggerUtils.js";
import {mask_editor_showing, mask_editor_listen_for_cancel} from "./utils/mask_utils.js";
import { debounce } from "./utils/CommonUtils.js";
const useChainCallback = (original, next) => {
if (original === undefined || original === null) {
return next;
}
return function(...args) {
const originalReturn = original.apply(this, args);
const nextReturn = next.apply(this, args);
return nextReturn === undefined ? originalReturn : nextReturn;
};
};
const log = createModuleLogger('Canvas');
/**
@@ -146,6 +158,7 @@ export class Canvas {
// Stwórz opóźnioną wersję funkcji zapisu stanu
this.requestSaveState = debounce(this.saveState.bind(this), 500);
this._addAutoRefreshToggle();
this.maskTool = new MaskTool(this, {onStateChange: this.onStateChange});
this.canvasState = new CanvasState(this);
this.canvasInteractions = new CanvasInteractions(this);
@@ -154,6 +167,8 @@ export class Canvas {
this.canvasRenderer = new CanvasRenderer(this);
this.canvasIO = new CanvasIO(this);
this.imageReferenceManager = new ImageReferenceManager(this);
this.batchPreviewManagers = [];
this.pendingBatchContext = null;
log.debug('Canvas modules initialized successfully');
}
@@ -274,6 +289,26 @@ export class Canvas {
/**
* Usuwa wybrane warstwy
*/
removeLayersByIds(layerIds) {
if (!layerIds || layerIds.length === 0) return;
const initialCount = this.layers.length;
this.saveState();
this.layers = this.layers.filter(l => !layerIds.includes(l.id));
// If the current selection was part of the removal, clear it
const newSelection = this.selectedLayers.filter(l => !layerIds.includes(l.id));
this.updateSelection(newSelection);
this.render();
this.saveState();
if (this.canvasLayersPanel) {
this.canvasLayersPanel.onLayersChanged();
}
log.info(`Removed ${initialCount - this.layers.length} layers by ID.`);
}
removeSelectedLayers() {
if (this.selectedLayers.length > 0) {
log.info('Removing selected layers', {
@@ -445,6 +480,87 @@ export class Canvas {
return this.canvasIO.importLatestImage();
}
_addAutoRefreshToggle() {
let autoRefreshEnabled = false;
let lastExecutionStartTime = 0;
const handleExecutionStart = () => {
if (autoRefreshEnabled) {
lastExecutionStartTime = Date.now();
// Store a snapshot of the context for the upcoming batch
this.pendingBatchContext = {
// For the menu position
spawnPosition: {
x: this.width / 2,
y: this.height
},
// For the image placement
outputArea: {
x: 0,
y: 0,
width: this.width,
height: this.height
}
};
log.debug(`Execution started, pending batch context captured:`, this.pendingBatchContext);
this.render(); // Trigger render to show the pending outline immediately
}
};
const handleExecutionSuccess = async () => {
if (autoRefreshEnabled) {
log.info('Auto-refresh triggered, importing latest images.');
if (!this.pendingBatchContext) {
log.warn("execution_start did not fire, cannot process batch. Awaiting next execution.");
return;
}
// Use the captured output area for image import
const newLayers = await this.canvasIO.importLatestImages(
lastExecutionStartTime,
this.pendingBatchContext.outputArea
);
if (newLayers && newLayers.length > 1) {
const newManager = new BatchPreviewManager(
this,
this.pendingBatchContext.spawnPosition,
this.pendingBatchContext.outputArea
);
this.batchPreviewManagers.push(newManager);
newManager.show(newLayers);
}
// Consume the context
this.pendingBatchContext = null;
// Final render to clear the outline if it was the last one
this.render();
}
};
this.node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value) => {
autoRefreshEnabled = value;
log.debug('Auto-refresh toggled:', value);
}, {
serialize: false
}
);
api.addEventListener('execution_start', handleExecutionStart);
api.addEventListener('execution_success', handleExecutionSuccess);
this.node.onRemoved = useChainCallback(this.node.onRemoved, () => {
log.info('Node removed, cleaning up auto-refresh listeners.');
api.removeEventListener('execution_start', handleExecutionStart);
api.removeEventListener('execution_success', handleExecutionSuccess);
});
}
/**
* Uruchamia edytor masek

View File

@@ -744,12 +744,7 @@ export class CanvasIO {
img.src = result.image_data;
});
await this.canvas.canvasLayers.addLayerWithImage(img, {
x: 0,
y: 0,
width: this.canvas.width,
height: this.canvas.height,
});
await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit');
log.info("Latest image imported and placed on canvas successfully.");
return true;
} else {
@@ -761,4 +756,41 @@ export class CanvasIO {
return false;
}
}
async importLatestImages(sinceTimestamp, targetArea = null) {
try {
log.info(`Fetching latest images since ${sinceTimestamp}...`);
const response = await fetch(`/layerforge/get-latest-images/${sinceTimestamp}`);
const result = await response.json();
if (result.success && result.images && result.images.length > 0) {
log.info(`Received ${result.images.length} new images, adding to canvas.`);
const newLayers = [];
for (const imageData of result.images) {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = imageData;
});
const newLayer = await this.canvas.canvasLayers.addLayerWithImage(img, {}, 'fit', targetArea);
newLayers.push(newLayer);
}
log.info("All new images imported and placed on canvas successfully.");
return newLayers;
} else if (result.success) {
log.info("No new images found since last generation.");
return [];
}
else {
throw new Error(result.error || "Failed to fetch latest images.");
}
} catch (error) {
log.error("Error importing latest images:", error);
alert(`Failed to import latest images: ${error.message}`);
return [];
}
}
}

View File

@@ -70,6 +70,12 @@ export class CanvasInteractions {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseDown(worldCoords, viewCoords);
this.canvas.render();
return;
}
// --- Ostateczna, poprawna kolejność sprawdzania ---
// 1. Akcje globalne z modyfikatorami (mają najwyższy priorytet)
@@ -115,6 +121,7 @@ export class CanvasInteractions {
handleMouseMove(e) {
const worldCoords = this.canvas.getMouseWorldCoordinates(e);
const viewCoords = this.canvas.getMouseViewCoordinates(e);
this.canvas.lastMousePosition = worldCoords; // Zawsze aktualizuj ostatnią pozycję myszy
// Sprawdź, czy rozpocząć przeciąganie
@@ -131,6 +138,10 @@ export class CanvasInteractions {
}
switch (this.interaction.mode) {
case 'drawingMask':
this.canvas.maskTool.handleMouseMove(worldCoords, viewCoords);
this.canvas.render();
break;
case 'panning':
this.panViewport(e);
break;
@@ -156,6 +167,13 @@ export class CanvasInteractions {
}
handleMouseUp(e) {
const viewCoords = this.canvas.getMouseViewCoordinates(e);
if (this.interaction.mode === 'drawingMask') {
this.canvas.maskTool.handleMouseUp(viewCoords);
this.canvas.render();
return;
}
if (this.interaction.mode === 'resizingCanvas') {
this.finalizeCanvasResize();
}
@@ -514,6 +532,29 @@ export class CanvasInteractions {
this.canvas.maskTool.updatePosition(-finalX, -finalY);
// If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas move:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
}
@@ -683,20 +724,43 @@ export class CanvasInteractions {
if (this.interaction.canvasResizeRect && this.interaction.canvasResizeRect.width > 1 && this.interaction.canvasResizeRect.height > 1) {
const newWidth = Math.round(this.interaction.canvasResizeRect.width);
const newHeight = Math.round(this.interaction.canvasResizeRect.height);
const rectX = this.interaction.canvasResizeRect.x;
const rectY = this.interaction.canvasResizeRect.y;
const finalX = this.interaction.canvasResizeRect.x;
const finalY = this.interaction.canvasResizeRect.y;
this.canvas.updateOutputAreaSize(newWidth, newHeight);
this.canvas.layers.forEach(layer => {
layer.x -= rectX;
layer.y -= rectY;
layer.x -= finalX;
layer.y -= finalY;
});
this.canvas.maskTool.updatePosition(-rectX, -rectY);
this.canvas.maskTool.updatePosition(-finalX, -finalY);
this.canvas.viewport.x -= rectX;
this.canvas.viewport.y -= rectY;
// If a batch generation is in progress, update the captured context as well
if (this.canvas.pendingBatchContext) {
this.canvas.pendingBatchContext.outputArea.x -= finalX;
this.canvas.pendingBatchContext.outputArea.y -= finalY;
// Also update the menu spawn position to keep it relative
this.canvas.pendingBatchContext.spawnPosition.x -= finalX;
this.canvas.pendingBatchContext.spawnPosition.y -= finalY;
log.debug("Updated pending batch context during canvas resize:", this.canvas.pendingBatchContext);
}
// Also move any active batch preview menus
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
manager.worldX -= finalX;
manager.worldY -= finalY;
if (manager.generationArea) {
manager.generationArea.x -= finalX;
manager.generationArea.y -= finalY;
}
});
}
this.canvas.viewport.x -= finalX;
this.canvas.viewport.y -= finalY;
}
}

View File

@@ -149,12 +149,12 @@ export class CanvasLayers {
}
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default') => {
addLayerWithImage = withErrorHandling(async (image, layerProps = {}, addMode = 'default', targetArea = null) => {
if (!image) {
throw createValidationError("Image is required for layer creation");
}
log.debug("Adding layer with image:", image, "with mode:", addMode);
log.debug("Adding layer with image:", image, "with mode:", addMode, "targetArea:", targetArea);
const imageId = generateUUID();
await saveImage(imageId, image.src);
this.canvas.imageCache.set(imageId, image.src);
@@ -163,21 +163,25 @@ export class CanvasLayers {
let finalHeight = image.height;
let finalX, finalY;
// Use the targetArea if provided, otherwise default to the current canvas dimensions
const area = targetArea || { width: this.canvas.width, height: this.canvas.height, x: 0, y: 0 };
if (addMode === 'fit') {
const scale = Math.min(this.canvas.width / image.width, this.canvas.height / image.height);
const scale = Math.min(area.width / image.width, area.height / image.height);
finalWidth = image.width * scale;
finalHeight = image.height * scale;
finalX = (this.canvas.width - finalWidth) / 2;
finalY = (this.canvas.height - finalHeight) / 2;
finalX = area.x + (area.width - finalWidth) / 2;
finalY = area.y + (area.height - finalHeight) / 2;
} else if (addMode === 'mouse') {
finalX = this.canvas.lastMousePosition.x - finalWidth / 2;
finalY = this.canvas.lastMousePosition.y - finalHeight / 2;
} else { // 'center' or 'default'
finalX = (this.canvas.width - finalWidth) / 2;
finalY = (this.canvas.height - finalHeight) / 2;
finalX = area.x + (area.width - finalWidth) / 2;
finalY = area.y + (area.height - finalHeight) / 2;
}
const layer = {
id: generateUUID(),
image: image,
imageId: imageId,
x: finalX,

View File

@@ -82,8 +82,9 @@ export class CanvasRenderer {
});
this.drawCanvasOutline(ctx);
this.drawPendingGenerationAreas(ctx); // Draw snapshot outlines
const maskImage = this.canvas.maskTool.getMask();
if (maskImage) {
if (maskImage && this.canvas.maskTool.isOverlayVisible) {
ctx.save();
@@ -112,6 +113,13 @@ export class CanvasRenderer {
this.canvas.canvas.height = this.canvas.offscreenCanvas.height;
}
this.canvas.ctx.drawImage(this.canvas.offscreenCanvas, 0, 0);
// Update Batch Preview UI positions
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
manager.updateScreenPosition(this.canvas.viewport);
});
}
}
renderInteractionElements(ctx) {
@@ -321,4 +329,36 @@ export class CanvasRenderer {
ctx.stroke();
}
}
drawPendingGenerationAreas(ctx) {
const areasToDraw = [];
// 1. Get areas from active managers
if (this.canvas.batchPreviewManagers && this.canvas.batchPreviewManagers.length > 0) {
this.canvas.batchPreviewManagers.forEach(manager => {
if (manager.generationArea) {
areasToDraw.push(manager.generationArea);
}
});
}
// 2. Get the area from the pending context (if it exists)
if (this.canvas.pendingBatchContext && this.canvas.pendingBatchContext.outputArea) {
areasToDraw.push(this.canvas.pendingBatchContext.outputArea);
}
if (areasToDraw.length === 0) {
return;
}
// 3. Draw all collected areas
areasToDraw.forEach(area => {
ctx.save();
ctx.strokeStyle = 'rgba(0, 150, 255, 0.9)'; // Blue color
ctx.lineWidth = 3 / this.canvas.viewport.zoom;
ctx.setLineDash([12 / this.canvas.viewport.zoom, 6 / this.canvas.viewport.zoom]);
ctx.strokeRect(area.x, area.y, area.width, area.height);
ctx.restore();
});
}
}

View File

@@ -870,6 +870,24 @@ async function createCanvasWidget(node, widget, app) {
]),
$el("div.painter-separator"),
$el("div.painter-button-group", {id: "mask-controls"}, [
$el("button.painter-button.primary", {
id: `toggle-mask-btn-${node.id}`,
textContent: "Show Mask",
title: "Toggle mask overlay visibility",
onclick: (e) => {
const button = e.target;
canvas.maskTool.toggleOverlayVisibility();
canvas.render();
if (canvas.maskTool.isOverlayVisible) {
button.classList.add('primary');
button.textContent = "Show Mask";
} else {
button.classList.remove('primary');
button.textContent = "Hide Mask";
}
}
}),
$el("button.painter-button", {
textContent: "Edit Mask",
title: "Open the current canvas view in the mask editor",

View File

@@ -13,6 +13,7 @@ export class MaskTool {
this.x = 0;
this.y = 0;
this.isOverlayVisible = true;
this.isActive = false;
this.brushSize = 20;
this.brushStrength = 0.5;
@@ -280,6 +281,11 @@ export class MaskTool {
log.info(`Mask position updated to (${this.x}, ${this.y})`);
}
toggleOverlayVisibility() {
this.isOverlayVisible = !this.isOverlayVisible;
log.info(`Mask overlay visibility toggled to: ${this.isOverlayVisible}`);
}
setMask(image) {

View File

@@ -11,7 +11,7 @@ import {logger, LogLevel} from "../logger.js";
* @param {LogLevel} level - Poziom logowania (domyślnie DEBUG)
* @returns {Object} Obiekt z metodami logowania
*/
export function createModuleLogger(moduleName, level = LogLevel.DEBUG) {
export function createModuleLogger(moduleName, level = LogLevel.NONE) {
logger.setModuleLevel(moduleName, level);
return {

View File

@@ -1,7 +1,7 @@
[project]
name = "layerforge"
description = "Photoshop-like layered canvas editor to your ComfyUI workflow. This node is perfect for complex compositing, inpainting, and outpainting, featuring multi-layer support, masking, blend modes, and precise transformations. Includes optional AI-powered background removal for streamlined image editing."
version = "1.3.3"
version = "1.3.5"
license = {file = "LICENSE"}
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]