mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-05-06 16:36:44 -03:00
Compare commits
10 Commits
835d94a11d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c2249ded | ||
|
|
eae1c2d2c3 | ||
|
|
abbc78c1d0 | ||
|
|
539be6fbdf | ||
|
|
c7239ae9d9 | ||
|
|
65f3dcc5d5 | ||
|
|
4bce819918 | ||
|
|
5cf10e5e5c | ||
|
|
72ef62e642 | ||
|
|
1edde25d75 |
261
canvas_node.py
261
canvas_node.py
@@ -92,7 +92,7 @@ class BiRefNet(torch.nn.Module):
|
|||||||
return [output]
|
return [output]
|
||||||
|
|
||||||
|
|
||||||
class LayerForgeNode:
|
class LayerForgeNode:
|
||||||
_canvas_data_storage = {}
|
_canvas_data_storage = {}
|
||||||
_storage_lock = threading.Lock()
|
_storage_lock = threading.Lock()
|
||||||
|
|
||||||
@@ -731,42 +731,139 @@ class LayerForgeNode:
|
|||||||
else:
|
else:
|
||||||
self.cached_image = image_data
|
self.cached_image = image_data
|
||||||
|
|
||||||
def get_cached_image(self):
|
def get_cached_image(self):
|
||||||
|
|
||||||
if self.cached_image:
|
if self.cached_image:
|
||||||
buffered = io.BytesIO()
|
buffered = io.BytesIO()
|
||||||
self.cached_image.save(buffered, format="PNG")
|
self.cached_image.save(buffered, format="PNG")
|
||||||
img_str = base64.b64encode(buffered.getvalue()).decode()
|
img_str = base64.b64encode(buffered.getvalue()).decode()
|
||||||
return f"data:image/png;base64,{img_str}"
|
return f"data:image/png;base64,{img_str}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class BiRefNetMatting:
|
def _get_birefnet_base_paths():
|
||||||
def __init__(self):
|
paths = []
|
||||||
self.model = None
|
|
||||||
self.model_path = None
|
comfy_models_dir = getattr(folder_paths, "models_dir", None)
|
||||||
self.model_cache = {}
|
if comfy_models_dir:
|
||||||
|
paths.append(os.path.join(comfy_models_dir, "BiRefNet"))
|
||||||
self.base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
|
||||||
"models")
|
legacy_models_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||||
def load_model(self, model_path):
|
"models",
|
||||||
from json.decoder import JSONDecodeError
|
"BiRefNet"
|
||||||
try:
|
)
|
||||||
if model_path not in self.model_cache:
|
paths.append(legacy_models_dir)
|
||||||
full_model_path = os.path.join(self.base_path, "BiRefNet")
|
|
||||||
log_info(f"Loading BiRefNet model from {full_model_path}...")
|
unique_paths = []
|
||||||
try:
|
seen = set()
|
||||||
# Try loading with additional configuration to handle compatibility issues
|
for path in paths:
|
||||||
self.model = AutoModelForImageSegmentation.from_pretrained(
|
normalized = os.path.normpath(path)
|
||||||
"ZhengPeng7/BiRefNet",
|
if normalized not in seen:
|
||||||
trust_remote_code=True,
|
seen.add(normalized)
|
||||||
cache_dir=full_model_path,
|
unique_paths.append(path)
|
||||||
# Add force_download=False to use cached version if available
|
|
||||||
force_download=False,
|
return unique_paths
|
||||||
# Add local_files_only=False to allow downloading if needed
|
|
||||||
local_files_only=False
|
|
||||||
)
|
def _is_valid_birefnet_model_dir(path):
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
files = os.listdir(path)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_config = "config.json" in files
|
||||||
|
has_model = "model.safetensors" in files or "pytorch_model.bin" in files
|
||||||
|
has_backbone = "backbone_swin.pth" in files or "swin_base_patch4_window12_384_22kto1k.pth" in files
|
||||||
|
has_birefnet = "birefnet.pth" in files or any(f.endswith(".pth") for f in files)
|
||||||
|
|
||||||
|
return has_config and (has_model or has_backbone or has_birefnet)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_local_birefnet_model():
|
||||||
|
for base_path in _get_birefnet_base_paths():
|
||||||
|
if not os.path.isdir(base_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _is_valid_birefnet_model_dir(base_path):
|
||||||
|
return base_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing_items = os.listdir(base_path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
model_subdirs = [
|
||||||
|
d for d in existing_items
|
||||||
|
if os.path.isdir(os.path.join(base_path, d)) and
|
||||||
|
(d.startswith("models--") or d == "ZhengPeng7--BiRefNet")
|
||||||
|
]
|
||||||
|
|
||||||
|
for subdir in model_subdirs:
|
||||||
|
snapshots_path = os.path.join(base_path, subdir, "snapshots")
|
||||||
|
if not os.path.isdir(snapshots_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
snapshot_dirs = os.listdir(snapshots_path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for snapshot in snapshot_dirs:
|
||||||
|
snapshot_path = os.path.join(snapshots_path, snapshot)
|
||||||
|
if _is_valid_birefnet_model_dir(snapshot_path):
|
||||||
|
return snapshot_path
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BiRefNetMatting:
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.model_path = None
|
||||||
|
self.model_cache = {}
|
||||||
|
self.base_paths = _get_birefnet_base_paths()
|
||||||
|
|
||||||
|
def load_model(self, model_path):
|
||||||
|
from json.decoder import JSONDecodeError
|
||||||
|
try:
|
||||||
|
if model_path not in self.model_cache:
|
||||||
|
local_model_path = _find_local_birefnet_model()
|
||||||
|
cache_dir = self.base_paths[0] if self.base_paths else None
|
||||||
|
|
||||||
|
if local_model_path:
|
||||||
|
log_info(f"Loading BiRefNet model from local path {local_model_path}...")
|
||||||
|
try:
|
||||||
|
self.model = AutoModelForImageSegmentation.from_pretrained(
|
||||||
|
local_model_path,
|
||||||
|
trust_remote_code=True
|
||||||
|
)
|
||||||
|
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 local disk")
|
||||||
|
return
|
||||||
|
except Exception as local_error:
|
||||||
|
log_warn(f"Failed to load local BiRefNet model from {local_model_path}: {str(local_error)}")
|
||||||
|
log_info("Falling back to Hugging Face model loading")
|
||||||
|
|
||||||
|
full_model_path = cache_dir or "BiRefNet"
|
||||||
|
log_info(f"Loading BiRefNet model from Hugging Face cache {full_model_path}...")
|
||||||
|
try:
|
||||||
|
# Try loading with additional configuration to handle compatibility issues
|
||||||
|
self.model = AutoModelForImageSegmentation.from_pretrained(
|
||||||
|
"ZhengPeng7/BiRefNet",
|
||||||
|
trust_remote_code=True,
|
||||||
|
cache_dir=cache_dir,
|
||||||
|
# Add force_download=False to use cached version if available
|
||||||
|
force_download=False,
|
||||||
|
# Add local_files_only=False to allow downloading if needed
|
||||||
|
local_files_only=False
|
||||||
|
)
|
||||||
self.model.eval()
|
self.model.eval()
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
self.model = self.model.cuda()
|
self.model = self.model.cuda()
|
||||||
@@ -923,75 +1020,27 @@ async def check_matting_model(request):
|
|||||||
"message": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
|
"message": "The 'transformers' library is required for the matting feature. Please install it by running: pip install transformers"
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check if model exists in cache
|
# Check if model exists in cache
|
||||||
base_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "models")
|
local_model_path = _find_local_birefnet_model()
|
||||||
model_path = os.path.join(base_path, "BiRefNet")
|
|
||||||
|
if local_model_path:
|
||||||
# Look for the actual BiRefNet model structure
|
# Model files exist, assume it's ready
|
||||||
model_files_exist = False
|
log_info(f"BiRefNet model files detected at {local_model_path}")
|
||||||
if os.path.exists(model_path):
|
return web.json_response({
|
||||||
# BiRefNet model from Hugging Face has a specific structure
|
"available": True,
|
||||||
# Check for subdirectories that indicate the model is downloaded
|
"reason": "ready",
|
||||||
existing_items = os.listdir(model_path) if os.path.isdir(model_path) else []
|
"message": "Model is ready to use",
|
||||||
|
"model_path": local_model_path
|
||||||
# Look for the model subdirectory (usually named with the model ID)
|
})
|
||||||
model_subdirs = [d for d in existing_items if os.path.isdir(os.path.join(model_path, d)) and
|
else:
|
||||||
(d.startswith("models--") or d == "ZhengPeng7--BiRefNet")]
|
searched_paths = _get_birefnet_base_paths()
|
||||||
|
log_info(f"BiRefNet model not found in any of: {searched_paths}")
|
||||||
if model_subdirs:
|
return web.json_response({
|
||||||
# Found model subdirectory, check inside for actual model files
|
"available": False,
|
||||||
for subdir in model_subdirs:
|
"reason": "not_downloaded",
|
||||||
subdir_path = os.path.join(model_path, subdir)
|
"message": "The matting model needs to be downloaded. This will happen automatically when you first use the matting feature (requires internet connection).",
|
||||||
# Navigate through the cache structure
|
"model_path": searched_paths[0] if searched_paths else None
|
||||||
if os.path.exists(os.path.join(subdir_path, "snapshots")):
|
})
|
||||||
snapshots_path = os.path.join(subdir_path, "snapshots")
|
|
||||||
snapshot_dirs = os.listdir(snapshots_path) if os.path.isdir(snapshots_path) else []
|
|
||||||
|
|
||||||
for snapshot in snapshot_dirs:
|
|
||||||
snapshot_path = os.path.join(snapshots_path, snapshot)
|
|
||||||
snapshot_files = os.listdir(snapshot_path) if os.path.isdir(snapshot_path) else []
|
|
||||||
|
|
||||||
# Check for essential files - BiRefNet uses model.safetensors
|
|
||||||
has_config = "config.json" in snapshot_files
|
|
||||||
has_model = "model.safetensors" in snapshot_files or "pytorch_model.bin" in snapshot_files
|
|
||||||
has_backbone = "backbone_swin.pth" in snapshot_files or "swin_base_patch4_window12_384_22kto1k.pth" in snapshot_files
|
|
||||||
has_birefnet = "birefnet.pth" in snapshot_files or any(f.endswith(".pth") for f in snapshot_files)
|
|
||||||
|
|
||||||
# Model is valid if it has config and either model.safetensors or other model files
|
|
||||||
if has_config and (has_model or has_backbone or has_birefnet):
|
|
||||||
model_files_exist = True
|
|
||||||
log_info(f"Found model files in: {snapshot_path} (config: {has_config}, model: {has_model})")
|
|
||||||
break
|
|
||||||
|
|
||||||
if model_files_exist:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Also check if there are .pth files directly in the model_path
|
|
||||||
if not model_files_exist:
|
|
||||||
direct_files = existing_items
|
|
||||||
has_config = "config.json" in direct_files
|
|
||||||
has_model_files = any(f.endswith((".pth", ".bin", ".safetensors")) for f in direct_files)
|
|
||||||
model_files_exist = has_config and has_model_files
|
|
||||||
|
|
||||||
if model_files_exist:
|
|
||||||
log_info(f"Found model files directly in: {model_path}")
|
|
||||||
|
|
||||||
if model_files_exist:
|
|
||||||
# Model files exist, assume it's ready
|
|
||||||
log_info("BiRefNet model files detected")
|
|
||||||
return web.json_response({
|
|
||||||
"available": True,
|
|
||||||
"reason": "ready",
|
|
||||||
"message": "Model is ready to use"
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
log_info(f"BiRefNet model not found in {model_path}")
|
|
||||||
return web.json_response({
|
|
||||||
"available": False,
|
|
||||||
"reason": "not_downloaded",
|
|
||||||
"message": "The matting model needs to be downloaded. This will happen automatically when you first use the matting feature (requires internet connection).",
|
|
||||||
"model_path": model_path
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log_error(f"Error checking matting model: {str(e)}")
|
log_error(f"Error checking matting model: {str(e)}")
|
||||||
|
|||||||
@@ -94,6 +94,16 @@ export class CanvasInteractions {
|
|||||||
this.canvas.canvas.style.border = '';
|
this.canvas.canvas.style.border = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isEditableElement(target) {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const editableSelector = 'input, textarea, select, [contenteditable="true"]';
|
||||||
|
return !!target.closest(editableSelector);
|
||||||
|
}
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
|
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown);
|
||||||
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
|
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove);
|
||||||
@@ -538,6 +548,9 @@ export class CanvasInteractions {
|
|||||||
this.interaction.isShiftPressed = true;
|
this.interaction.isShiftPressed = true;
|
||||||
if (e.key === 'Alt')
|
if (e.key === 'Alt')
|
||||||
this.interaction.isAltPressed = true;
|
this.interaction.isAltPressed = true;
|
||||||
|
if (this.isEditableElement(e.target) || this.isEditableElement(document.activeElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Check if canvas is focused before handling any shortcuts
|
// Check if canvas is focused before handling any shortcuts
|
||||||
const shouldHandle = this.canvas.isMouseOver ||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
this.canvas.canvas.contains(document.activeElement) ||
|
this.canvas.canvas.contains(document.activeElement) ||
|
||||||
@@ -586,7 +599,8 @@ export class CanvasInteractions {
|
|||||||
// so handlePasteEvent can access e.clipboardData for system images.
|
// so handlePasteEvent can access e.clipboardData for system images.
|
||||||
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
if (this.canvas.canvasLayers.internalClipboard.length > 0) {
|
||||||
this.canvas.canvasLayers.pasteLayers();
|
this.canvas.canvasLayers.pasteLayers();
|
||||||
} else {
|
}
|
||||||
|
else {
|
||||||
// Don't preventDefault - let paste event fire for system clipboard
|
// Don't preventDefault - let paste event fire for system clipboard
|
||||||
handled = false;
|
handled = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,19 @@ export class CanvasLayersPanel {
|
|||||||
this.setupControlButtons();
|
this.setupControlButtons();
|
||||||
this.setupMasterVisibilityToggle();
|
this.setupMasterVisibilityToggle();
|
||||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||||
|
const isEditableTarget = (target) => {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!target.closest('input, textarea, select, [contenteditable="true"]');
|
||||||
|
};
|
||||||
this.container.addEventListener('keydown', (e) => {
|
this.container.addEventListener('keydown', (e) => {
|
||||||
|
if (isEditableTarget(e.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
125
js/CanvasView.js
125
js/CanvasView.js
@@ -1,6 +1,8 @@
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { app } from "../../scripts/app.js";
|
import { app } from "../../scripts/app.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
import { ChangeTracker } from "../../scripts/changeTracker.js";
|
||||||
|
// @ts-ignore
|
||||||
import { $el } from "../../scripts/ui.js";
|
import { $el } from "../../scripts/ui.js";
|
||||||
import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js";
|
import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js";
|
||||||
import { Canvas } from "./Canvas.js";
|
import { Canvas } from "./Canvas.js";
|
||||||
@@ -12,6 +14,52 @@ import { showErrorNotification, showSuccessNotification, showInfoNotification, s
|
|||||||
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
|
||||||
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
import { setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
|
||||||
const log = createModuleLogger('Canvas_view');
|
const log = createModuleLogger('Canvas_view');
|
||||||
|
const LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG = '__layerForgeUndoRedoPatched';
|
||||||
|
const LAYERFORGE_SHORTCUT_ACTIVE_ATTR = 'data-layerforge-shortcuts-active';
|
||||||
|
const isLayerForgeEditableElement = (target) => {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!target.closest('.painterMainContainer input, .painterMainContainer textarea, .painterMainContainer select, .painterMainContainer [contenteditable="true"]');
|
||||||
|
};
|
||||||
|
const isLayerForgeShortcutContextElement = (target) => {
|
||||||
|
return target instanceof HTMLElement && !!target.closest('.painterMainContainer');
|
||||||
|
};
|
||||||
|
const isLayerForgeShortcutContextActive = (event) => {
|
||||||
|
if (event && isLayerForgeShortcutContextElement(event.target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isLayerForgeShortcutContextElement(document.activeElement)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!document.querySelector(`.painterMainContainer[${LAYERFORGE_SHORTCUT_ACTIVE_ATTR}="true"]`);
|
||||||
|
};
|
||||||
|
const isLayerForgeEditableFocused = () => {
|
||||||
|
return isLayerForgeEditableElement(document.activeElement);
|
||||||
|
};
|
||||||
|
const patchLayerForgeChangeTrackerUndoRedo = () => {
|
||||||
|
const prototype = ChangeTracker?.prototype;
|
||||||
|
if (!prototype || prototype[LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG] || typeof prototype.undoRedo !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const originalUndoRedo = prototype.undoRedo;
|
||||||
|
prototype.undoRedo = async function (event) {
|
||||||
|
if (isLayerForgeShortcutContextActive(event)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await originalUndoRedo.call(this, event);
|
||||||
|
};
|
||||||
|
Object.defineProperty(prototype, LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG, {
|
||||||
|
value: true,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
writable: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
patchLayerForgeChangeTrackerUndoRedo();
|
||||||
async function createCanvasWidget(node, widget, app) {
|
async function createCanvasWidget(node, widget, app) {
|
||||||
const canvas = new Canvas(node, widget, {
|
const canvas = new Canvas(node, widget, {
|
||||||
onStateChange: () => updateOutput(node, canvas)
|
onStateChange: () => updateOutput(node, canvas)
|
||||||
@@ -906,6 +954,70 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
height: "100%"
|
height: "100%"
|
||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
}, [controlPanel, canvasContainer, layersPanelContainer]);
|
||||||
|
const stopEditableClipboardLeak = (event) => {
|
||||||
|
if (isLayerForgeEditableElement(event.target) || isLayerForgeEditableFocused()) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mainContainer.addEventListener('copy', stopEditableClipboardLeak);
|
||||||
|
mainContainer.addEventListener('cut', stopEditableClipboardLeak);
|
||||||
|
mainContainer.addEventListener('paste', stopEditableClipboardLeak);
|
||||||
|
const setShortcutContextActive = (active) => {
|
||||||
|
if (active) {
|
||||||
|
mainContainer.setAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR, 'true');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleShortcutContextFocusIn = () => {
|
||||||
|
setShortcutContextActive(true);
|
||||||
|
};
|
||||||
|
const handleShortcutContextFocusOut = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!mainContainer.contains(document.activeElement)) {
|
||||||
|
setShortcutContextActive(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleShortcutContextPointerEnter = () => {
|
||||||
|
setShortcutContextActive(true);
|
||||||
|
};
|
||||||
|
const handleShortcutContextPointerLeave = () => {
|
||||||
|
if (!mainContainer.contains(document.activeElement)) {
|
||||||
|
setShortcutContextActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleRootUndoRedo = (event) => {
|
||||||
|
if (isLayerForgeEditableElement(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isPrimaryModifier = (event.ctrlKey || event.metaKey) && !event.altKey;
|
||||||
|
if (!isPrimaryModifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
const isUndo = key === 'z' && !event.shiftKey;
|
||||||
|
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
|
||||||
|
if (!isUndo && !isRedo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
if (isRedo) {
|
||||||
|
canvas.redo();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
canvas.undo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mainContainer.addEventListener('focusin', handleShortcutContextFocusIn);
|
||||||
|
mainContainer.addEventListener('focusout', handleShortcutContextFocusOut);
|
||||||
|
mainContainer.addEventListener('pointerenter', handleShortcutContextPointerEnter);
|
||||||
|
mainContainer.addEventListener('pointerleave', handleShortcutContextPointerLeave);
|
||||||
|
mainContainer.addEventListener('keydown', handleRootUndoRedo, true);
|
||||||
if (node.addDOMWidget) {
|
if (node.addDOMWidget) {
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
}
|
}
|
||||||
@@ -1029,7 +1141,18 @@ async function createCanvasWidget(node, widget, app) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
panel: controlPanel
|
panel: controlPanel,
|
||||||
|
destroy: () => {
|
||||||
|
mainContainer.removeEventListener('copy', stopEditableClipboardLeak);
|
||||||
|
mainContainer.removeEventListener('cut', stopEditableClipboardLeak);
|
||||||
|
mainContainer.removeEventListener('paste', stopEditableClipboardLeak);
|
||||||
|
mainContainer.removeEventListener('focusin', handleShortcutContextFocusIn);
|
||||||
|
mainContainer.removeEventListener('focusout', handleShortcutContextFocusOut);
|
||||||
|
mainContainer.removeEventListener('pointerenter', handleShortcutContextPointerEnter);
|
||||||
|
mainContainer.removeEventListener('pointerleave', handleShortcutContextPointerLeave);
|
||||||
|
mainContainer.removeEventListener('keydown', handleRootUndoRedo, true);
|
||||||
|
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const canvasNodeInstances = new Map();
|
const canvasNodeInstances = new Map();
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h4>Undo & Redo</h4>
|
||||||
|
<table>
|
||||||
|
<tr><td><kbd>Ctrl + Z</kbd></td><td>Undo last action</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl + Y</kbd> or <kbd>Ctrl + Shift + Z</kbd></td><td>Redo last action</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<h4>Layer Interaction</h4>
|
<h4>Layer Interaction</h4>
|
||||||
<table>
|
<table>
|
||||||
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
||||||
|
|||||||
@@ -140,5 +140,6 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsUrl = `${protocol}//${location.host}/layerforge/canvas_ws`;
|
||||||
export const webSocketManager = new WebSocketManager(wsUrl);
|
export const webSocketManager = new WebSocketManager(wsUrl);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "layerforge"
|
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."
|
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.5.11"
|
version = "1.5.13"
|
||||||
license = { text = "MIT License" }
|
license = { text = "MIT License" }
|
||||||
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
dependencies = ["torch", "torchvision", "transformers", "aiohttp", "numpy", "tqdm", "Pillow"]
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,19 @@ export class CanvasInteractions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isEditableElement(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableSelector = 'input, textarea, select, [contenteditable="true"]';
|
||||||
|
return !!target.closest(editableSelector);
|
||||||
|
}
|
||||||
|
|
||||||
setupEventListeners(): void {
|
setupEventListeners(): void {
|
||||||
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
|
this.canvas.canvas.addEventListener('mousedown', this.onMouseDown as EventListener);
|
||||||
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
|
this.canvas.canvas.addEventListener('mousemove', this.onMouseMove as EventListener);
|
||||||
@@ -666,6 +679,10 @@ export class CanvasInteractions {
|
|||||||
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
if (e.key === 'Shift') this.interaction.isShiftPressed = true;
|
||||||
if (e.key === 'Alt') this.interaction.isAltPressed = true;
|
if (e.key === 'Alt') this.interaction.isAltPressed = true;
|
||||||
|
|
||||||
|
if (this.isEditableElement(e.target) || this.isEditableElement(document.activeElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if canvas is focused before handling any shortcuts
|
// Check if canvas is focused before handling any shortcuts
|
||||||
const shouldHandle = this.canvas.isMouseOver ||
|
const shouldHandle = this.canvas.isMouseOver ||
|
||||||
this.canvas.canvas.contains(document.activeElement) ||
|
this.canvas.canvas.contains(document.activeElement) ||
|
||||||
|
|||||||
@@ -139,7 +139,23 @@ export class CanvasLayersPanel {
|
|||||||
this.setupMasterVisibilityToggle();
|
this.setupMasterVisibilityToggle();
|
||||||
|
|
||||||
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
// Dodaj listener dla klawiatury, aby usuwanie działało z panelu
|
||||||
|
const isEditableTarget = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!target.closest('input, textarea, select, [contenteditable="true"]');
|
||||||
|
};
|
||||||
|
|
||||||
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
this.container.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
if (isEditableTarget(e.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {api} from "../../scripts/api.js";
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import {ComfyApp} from "../../scripts/app.js";
|
import {ComfyApp} from "../../scripts/app.js";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
import {ChangeTracker} from "../../scripts/changeTracker.js";
|
||||||
|
// @ts-ignore
|
||||||
import {$el} from "../../scripts/ui.js";
|
import {$el} from "../../scripts/ui.js";
|
||||||
|
|
||||||
import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js";
|
import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js";
|
||||||
@@ -21,6 +23,66 @@ import type { ComfyNode, Layer, AddMode } from './types';
|
|||||||
|
|
||||||
const log = createModuleLogger('Canvas_view');
|
const log = createModuleLogger('Canvas_view');
|
||||||
|
|
||||||
|
const LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG = '__layerForgeUndoRedoPatched';
|
||||||
|
const LAYERFORGE_SHORTCUT_ACTIVE_ATTR = 'data-layerforge-shortcuts-active';
|
||||||
|
|
||||||
|
const isLayerForgeEditableElement = (target: EventTarget | null): boolean => {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!target.closest('.painterMainContainer input, .painterMainContainer textarea, .painterMainContainer select, .painterMainContainer [contenteditable="true"]');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLayerForgeShortcutContextElement = (target: EventTarget | null): boolean => {
|
||||||
|
return target instanceof HTMLElement && !!target.closest('.painterMainContainer');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLayerForgeShortcutContextActive = (event?: KeyboardEvent): boolean => {
|
||||||
|
if (event && isLayerForgeShortcutContextElement(event.target)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLayerForgeShortcutContextElement(document.activeElement)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !!document.querySelector(`.painterMainContainer[${LAYERFORGE_SHORTCUT_ACTIVE_ATTR}="true"]`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLayerForgeEditableFocused = (): boolean => {
|
||||||
|
return isLayerForgeEditableElement(document.activeElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchLayerForgeChangeTrackerUndoRedo = (): void => {
|
||||||
|
const prototype = ChangeTracker?.prototype as any;
|
||||||
|
if (!prototype || prototype[LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG] || typeof prototype.undoRedo !== 'function') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalUndoRedo = prototype.undoRedo;
|
||||||
|
prototype.undoRedo = async function (event: KeyboardEvent) {
|
||||||
|
if (isLayerForgeShortcutContextActive(event)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await originalUndoRedo.call(this, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(prototype, LAYERFORGE_CHANGE_TRACKER_PATCH_FLAG, {
|
||||||
|
value: true,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
writable: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
patchLayerForgeChangeTrackerUndoRedo();
|
||||||
|
|
||||||
interface CanvasWidget {
|
interface CanvasWidget {
|
||||||
canvas: Canvas;
|
canvas: Canvas;
|
||||||
panel: HTMLDivElement;
|
panel: HTMLDivElement;
|
||||||
@@ -1027,6 +1089,81 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
}
|
}
|
||||||
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
}, [controlPanel, canvasContainer, layersPanelContainer]) as HTMLDivElement;
|
||||||
|
|
||||||
|
const stopEditableClipboardLeak = (event: ClipboardEvent) => {
|
||||||
|
if (isLayerForgeEditableElement(event.target) || isLayerForgeEditableFocused()) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mainContainer.addEventListener('copy', stopEditableClipboardLeak);
|
||||||
|
mainContainer.addEventListener('cut', stopEditableClipboardLeak);
|
||||||
|
mainContainer.addEventListener('paste', stopEditableClipboardLeak);
|
||||||
|
|
||||||
|
const setShortcutContextActive = (active: boolean) => {
|
||||||
|
if (active) {
|
||||||
|
mainContainer.setAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR, 'true');
|
||||||
|
} else {
|
||||||
|
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShortcutContextFocusIn = () => {
|
||||||
|
setShortcutContextActive(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShortcutContextFocusOut = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!mainContainer.contains(document.activeElement)) {
|
||||||
|
setShortcutContextActive(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShortcutContextPointerEnter = () => {
|
||||||
|
setShortcutContextActive(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShortcutContextPointerLeave = () => {
|
||||||
|
if (!mainContainer.contains(document.activeElement)) {
|
||||||
|
setShortcutContextActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRootUndoRedo = (event: KeyboardEvent) => {
|
||||||
|
if (isLayerForgeEditableElement(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPrimaryModifier = (event.ctrlKey || event.metaKey) && !event.altKey;
|
||||||
|
if (!isPrimaryModifier) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
const isUndo = key === 'z' && !event.shiftKey;
|
||||||
|
const isRedo = key === 'y' || (key === 'z' && event.shiftKey);
|
||||||
|
if (!isUndo && !isRedo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
if (isRedo) {
|
||||||
|
canvas.redo();
|
||||||
|
} else {
|
||||||
|
canvas.undo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mainContainer.addEventListener('focusin', handleShortcutContextFocusIn);
|
||||||
|
mainContainer.addEventListener('focusout', handleShortcutContextFocusOut);
|
||||||
|
mainContainer.addEventListener('pointerenter', handleShortcutContextPointerEnter);
|
||||||
|
mainContainer.addEventListener('pointerleave', handleShortcutContextPointerLeave);
|
||||||
|
mainContainer.addEventListener('keydown', handleRootUndoRedo, true);
|
||||||
|
|
||||||
if (node.addDOMWidget) {
|
if (node.addDOMWidget) {
|
||||||
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
node.addDOMWidget("mainContainer", "widget", mainContainer);
|
||||||
}
|
}
|
||||||
@@ -1181,7 +1318,18 @@ $el("label.clipboard-switch.mask-switch", {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
canvas: canvas,
|
canvas: canvas,
|
||||||
panel: controlPanel
|
panel: controlPanel,
|
||||||
|
destroy: () => {
|
||||||
|
mainContainer.removeEventListener('copy', stopEditableClipboardLeak);
|
||||||
|
mainContainer.removeEventListener('cut', stopEditableClipboardLeak);
|
||||||
|
mainContainer.removeEventListener('paste', stopEditableClipboardLeak);
|
||||||
|
mainContainer.removeEventListener('focusin', handleShortcutContextFocusIn);
|
||||||
|
mainContainer.removeEventListener('focusout', handleShortcutContextFocusOut);
|
||||||
|
mainContainer.removeEventListener('pointerenter', handleShortcutContextPointerEnter);
|
||||||
|
mainContainer.removeEventListener('pointerleave', handleShortcutContextPointerLeave);
|
||||||
|
mainContainer.removeEventListener('keydown', handleRootUndoRedo, true);
|
||||||
|
mainContainer.removeAttribute(LAYERFORGE_SHORTCUT_ACTIVE_ATTR);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
<tr><td><kbd>Drag & Drop Image File</kbd></td><td>Add image as a new layer</td></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<h4>Undo & Redo</h4>
|
||||||
|
<table>
|
||||||
|
<tr><td><kbd>Ctrl + Z</kbd></td><td>Undo last action</td></tr>
|
||||||
|
<tr><td><kbd>Ctrl + Y</kbd> or <kbd>Ctrl + Shift + Z</kbd></td><td>Redo last action</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<h4>Layer Interaction</h4>
|
<h4>Layer Interaction</h4>
|
||||||
<table>
|
<table>
|
||||||
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
<tr><td><kbd>Click + Drag</kbd></td><td>Move selected layer(s)</td></tr>
|
||||||
|
|||||||
@@ -168,5 +168,6 @@ class WebSocketManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsUrl = `ws://${window.location.host}/layerforge/canvas_ws`;
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const wsUrl = `${protocol}//${location.host}/layerforge/canvas_ws`;
|
||||||
export const webSocketManager = new WebSocketManager(wsUrl);
|
export const webSocketManager = new WebSocketManager(wsUrl);
|
||||||
|
|||||||
Reference in New Issue
Block a user