Files
Azornes-Comfyui-LayerForge/src/CanvasView.ts
T

3431 lines
148 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-ignore
import {app} from "../../scripts/app.js";
// @ts-ignore
import {api} from "../../scripts/api.js";
// @ts-ignore
import {ComfyApp} from "../../scripts/app.js";
// @ts-ignore
import {ChangeTracker} from "../../scripts/changeTracker.js";
// @ts-ignore
import {$el} from "../../scripts/ui.js";
import { addStylesheet, getUrl, loadTemplate } from "./utils/ResourceManager.js";
import {Canvas, configureCanvasImagePreviewWidget} from "./Canvas.js";
import {clearAllCanvasStates, getCanvasState, setCanvasState} from "./db.js";
import {getCanvasStateKey} from "./utils/CanvasStateKey.js";
import {generateUniqueFileName, createCanvas} from "./utils/CommonUtils.js";
import { loadImageFromBlob } from "./utils/ImageUtils.js";
import {createModuleLogger} from "./log_system/log_funcs.js";
import {showErrorNotification, showSuccessNotification, showInfoNotification, showWarningNotification} from "./utils/NotificationUtils.js";
import { iconLoader, LAYERFORGE_TOOLS } from "./utils/IconLoader.js";
import { exportCanvasImage, type CanvasExportAction } from "./utils/CanvasExportUtils.js";
import { getFlattenedCanvasBlob, type CanvasBlobVariant } from "./utils/CanvasBlobUtils.js";
import { loadPreviewImage } from "./utils/PreviewUtils.js";
import { getImageAddMode } from "./utils/CanvasInputUtils.js";
import {
addLayerForgeImageInputLink,
clearLayerForgeImageInputLinks,
getLayerForgeImageInputLinks,
getLayerForgeImageInputSlot,
getLayerForgeMaskInputSlot,
hasLayerForgeImageInput,
LAYERFORGE_MAX_IMAGE_INPUTS,
removeLayerForgeImageInputLink,
} from "./utils/MultiImageInputUtils.js";
import {
fetchMattingModelStatus,
fetchMattingSettings,
saveMattingSettings as saveMattingSettingsToServer,
type MattingServerSettings,
type MattingSettingsUpdate,
} from "./utils/MattingUtils.js";
import { registerImageInClipspace, startSAMDetectorMonitoring, setupSAMDetectorHook } from "./SAMDetectorIntegration.js";
import type { ComfyNode, Layer, AddMode } from './types';
const log = createModuleLogger('Canvas_view');
type MattingMode = 'remove_background' | 'remove_foreground' | 'mask_only' | 'mask_only_inverted';
interface MattingSettings {
modelPath: string;
mode: MattingMode;
threshold: number;
hfTokenConfigured: boolean;
}
interface MattingModelOption {
path: string;
label: string;
description?: string;
url?: string;
project_url?: string;
source?: 'local' | 'remote';
backend?: 'birefnet' | 'rmbg';
downloaded?: boolean;
}
const DEFAULT_MATTING_SETTINGS: MattingSettings = {
modelPath: '',
mode: 'remove_background',
threshold: 0.5,
hfTokenConfigured: false,
};
const isMattingMode = (value: unknown): value is MattingMode => {
return value === 'remove_background'
|| value === 'remove_foreground'
|| value === 'mask_only'
|| value === 'mask_only_inverted';
};
const normalizeMattingSettings = (settings: Partial<MattingSettings>): MattingSettings => {
const threshold = Number(settings.threshold);
return {
modelPath: typeof settings.modelPath === 'string' ? settings.modelPath : DEFAULT_MATTING_SETTINGS.modelPath,
mode: isMattingMode(settings.mode) ? settings.mode : DEFAULT_MATTING_SETTINGS.mode,
threshold: Number.isFinite(threshold) ? Math.min(1, Math.max(0, threshold)) : DEFAULT_MATTING_SETTINGS.threshold,
hfTokenConfigured: settings.hfTokenConfigured === true,
};
};
const fromServerMattingSettings = (settings: MattingServerSettings): MattingSettings => normalizeMattingSettings({
modelPath: settings.model_path,
mode: settings.mode as MattingMode,
threshold: settings.threshold,
hfTokenConfigured: settings.hf_token_configured,
});
const loadMattingSettings = async (): Promise<MattingSettings> => {
try {
const response = await fetchMattingSettings();
if (response.ok && response.data.settings) {
return fromServerMattingSettings(response.data.settings);
}
} catch (error) {
log.warn('Unable to load Matting settings from ComfyUI:', error);
}
return { ...DEFAULT_MATTING_SETTINGS };
};
const persistMattingSettings = async (
settings: MattingSettings,
token: string,
clearToken: boolean,
): Promise<MattingSettings> => {
const payload: MattingSettingsUpdate = {
model_path: settings.modelPath,
mode: settings.mode,
threshold: settings.threshold,
};
if (token.trim()) payload.hf_token = token.trim();
if (clearToken) payload.clear_hf_token = true;
const response = await saveMattingSettingsToServer(payload);
if (!response.ok || !response.data.settings) {
throw new Error(response.data.error || 'Unable to save Matting settings on the ComfyUI server.');
}
return fromServerMattingSettings(response.data.settings);
};
const getMattingModeLabel = (mode: MattingMode): string => {
switch (mode) {
case 'remove_foreground':
return 'Remove detected foreground / keep background';
case 'mask_only':
return 'Apply generated mask to Draw Mask';
case 'mask_only_inverted':
return 'Apply inverted mask to Draw Mask';
default:
return 'Remove background / keep foreground';
}
};
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('.lf-painter-main-container input, .lf-painter-main-container textarea, .lf-painter-main-container select, .lf-painter-main-container [contenteditable="true"]');
};
const isLayerForgeShortcutContextElement = (target: EventTarget | null): boolean => {
return target instanceof HTMLElement && !!target.closest('.lf-painter-main-container');
};
const isLayerForgeShortcutContextActive = (event?: KeyboardEvent): boolean => {
if (event && isLayerForgeShortcutContextElement(event.target)) {
return true;
}
if (isLayerForgeShortcutContextElement(document.activeElement)) {
return true;
}
return !!document.querySelector(`.lf-painter-main-container[${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 {
canvas: Canvas;
panel: HTMLDivElement;
destroy?: () => void;
}
async function createCanvasWidget(node: ComfyNode, widget: any, app: ComfyApp): Promise<CanvasWidget> {
const canvas = new Canvas(node, widget, {
onStateChange: () => updateOutput(node, canvas)
});
/**
* Helper function to update the icon of a switch component.
* @param knobIconEl The HTML element for the switch's knob icon.
* @param isChecked The current state of the switch (e.g., checkbox.checked).
* @param iconToolTrue The icon tool name for the 'true' state.
* @param iconToolFalse The icon tool name for the 'false' state.
* @param fallbackTrue The text fallback for the 'true' state.
* @param fallbackFalse The text fallback for the 'false' state.
*/
const updateSwitchIcon = (
knobIconEl: HTMLElement,
isChecked: boolean,
iconToolTrue: string,
iconToolFalse: string,
fallbackTrue: string,
fallbackFalse: string
) => {
if (!knobIconEl) return;
const iconTool = isChecked ? iconToolTrue : iconToolFalse;
const fallbackText = isChecked ? fallbackTrue : fallbackFalse;
const icon = iconLoader.getIcon(iconTool);
knobIconEl.innerHTML = ''; // Clear previous icon
if (icon instanceof HTMLImageElement) {
const clonedIcon = icon.cloneNode() as HTMLImageElement;
clonedIcon.style.width = '20px';
clonedIcon.style.height = '20px';
knobIconEl.appendChild(clonedIcon);
} else {
knobIconEl.textContent = fallbackText;
}
};
const helpTooltip = $el("div.lf-painter-tooltip", {
id: `painter-help-tooltip-${node.id}`,
}) as HTMLDivElement;
const [standardShortcuts, maskShortcuts, systemClipboardTooltip, clipspaceClipboardTooltip] = await Promise.all([
loadTemplate('./templates/standard_shortcuts.html'),
loadTemplate('./templates/mask_shortcuts.html'),
loadTemplate('./templates/system_clipboard_tooltip.html'),
loadTemplate('./templates/clipspace_clipboard_tooltip.html')
]);
document.body.appendChild(helpTooltip);
const showTooltip = (buttonElement: HTMLElement, content: string) => {
helpTooltip.innerHTML = content;
helpTooltip.style.visibility = 'hidden';
helpTooltip.style.display = 'block';
const buttonRect = buttonElement.getBoundingClientRect();
const tooltipRect = helpTooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let left = buttonRect.left;
let top = buttonRect.bottom + 5;
if (left + tooltipRect.width > viewportWidth) {
left = viewportWidth - tooltipRect.width - 10;
}
if (top + tooltipRect.height > viewportHeight) {
top = buttonRect.top - tooltipRect.height - 5;
}
if (left < 10) left = 10;
if (top < 10) top = 10;
helpTooltip.style.left = `${left}px`;
helpTooltip.style.top = `${top}px`;
helpTooltip.style.visibility = 'visible';
};
const hideTooltip = () => {
helpTooltip.style.display = 'none';
};
const showMattingTooltip = (target: HTMLElement, content: string): void => {
helpTooltip.classList.add('lf-matting-tooltip');
showTooltip(target, content);
};
const hideMattingTooltip = (): void => {
hideTooltip();
helpTooltip.classList.remove('lf-matting-tooltip');
};
const createMattingTooltipBadge = (labelText: string, tooltipText: string): HTMLSpanElement => {
const badge = document.createElement('span');
badge.className = 'lf-matting-tooltip-badge';
badge.textContent = '?';
badge.tabIndex = 0;
badge.dataset.tooltip = tooltipText;
badge.setAttribute('aria-label', `More information about ${labelText}`);
const show = (): void => {
const content = badge.dataset.tooltip;
if (content) showMattingTooltip(badge, content);
};
const hide = (): void => hideMattingTooltip();
badge.addEventListener('mouseenter', show);
badge.addEventListener('focus', show);
badge.addEventListener('mouseleave', hide);
badge.addEventListener('blur', hide);
badge.addEventListener('click', (event) => event.preventDefault());
return badge;
};
let mattingSettingsBackdrop: HTMLDivElement | null = null;
let mattingSettingsEscapeHandler: ((event: KeyboardEvent) => void) | null = null;
const closeMattingSettings = () => {
hideMattingTooltip();
if (mattingSettingsEscapeHandler) {
document.removeEventListener('keydown', mattingSettingsEscapeHandler);
mattingSettingsEscapeHandler = null;
}
mattingSettingsBackdrop?.remove();
mattingSettingsBackdrop = null;
};
const openMattingSettings = async (): Promise<void> => {
if (mattingSettingsBackdrop) return;
const settings = await loadMattingSettings();
let modelOptions: MattingModelOption[] = [];
let modelStatusMessage = 'Model options are loaded from ComfyUI background-removal storage.';
try {
const { ok, data: status } = await fetchMattingModelStatus<MattingModelOption>();
if (ok) {
if (Array.isArray(status.models)) {
modelOptions = status.models.filter((option) => (
option && typeof option.path === 'string' && typeof option.label === 'string'
));
}
} else {
modelStatusMessage = 'Unable to read installed model options. Automatic selection remains available.';
}
} catch (error) {
log.warn('Unable to load Matting model options:', error);
modelStatusMessage = 'Unable to read installed model options. Automatic selection remains available.';
}
const backdrop = document.createElement('div');
backdrop.className = 'lf-matting-settings-backdrop';
backdrop.setAttribute('role', 'presentation');
const dialog = document.createElement('div');
dialog.className = 'lf-matting-settings-dialog';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'lf-matting-settings-title');
const header = document.createElement('div');
header.className = 'lf-matting-settings-header';
const title = document.createElement('h2');
title.id = 'lf-matting-settings-title';
title.textContent = 'Matting Settings';
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'lf-matting-settings-close';
closeButton.textContent = '×';
closeButton.title = 'Close Matting settings';
closeButton.setAttribute('aria-label', 'Close Matting settings');
closeButton.onclick = closeMattingSettings;
header.append(title, closeButton);
const body = document.createElement('div');
body.className = 'lf-matting-settings-body';
const createRow = (labelText: string, control: HTMLElement, tooltipText?: string): HTMLLabelElement => {
const row = document.createElement('label');
row.className = 'lf-matting-settings-row';
const label = document.createElement('span');
label.className = 'lf-matting-settings-label';
label.appendChild(document.createTextNode(labelText));
if (tooltipText) {
label.appendChild(createMattingTooltipBadge(labelText, tooltipText));
}
row.appendChild(label);
row.appendChild(control);
return row;
};
const modelSelect = document.createElement('select');
modelSelect.className = 'lf-matting-settings-select';
modelSelect.appendChild(new Option('Automatic (recommended)', 'auto'));
const localModelOptions = modelOptions.filter((option) => option.source !== 'remote');
const remoteModelOptions = modelOptions.filter((option) => option.source === 'remote');
if (localModelOptions.length > 0) {
const localGroup = document.createElement('optgroup');
localGroup.label = 'Installed locally';
localModelOptions.forEach((option) => {
localGroup.appendChild(new Option(option.label, option.path));
});
modelSelect.appendChild(localGroup);
}
if (remoteModelOptions.length > 0) {
const remoteGroup = document.createElement('optgroup');
remoteGroup.label = 'Download on first use';
remoteModelOptions.forEach((option) => {
const suffix = option.downloaded ? ' (downloaded)' : '';
const remoteOption = new Option(`${option.label}${suffix}`, option.path);
if (option.description) remoteOption.title = option.description;
remoteGroup.appendChild(remoteOption);
});
modelSelect.appendChild(remoteGroup);
}
const selectedModel = settings.modelPath && modelOptions.some((option) => option.path === settings.modelPath)
? settings.modelPath
: 'auto';
modelSelect.value = selectedModel;
const modelDetails = document.createElement('div');
modelDetails.className = 'lf-matting-model-details';
const modelDescription = document.createElement('p');
modelDescription.className = 'lf-matting-model-description';
const modelLinks = document.createElement('div');
modelLinks.className = 'lf-matting-model-links';
const createModelLink = (label: string, url: string): HTMLAnchorElement => {
const link = document.createElement('a');
link.className = 'lf-matting-model-link';
link.href = url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.textContent = label;
return link;
};
const updateModelDetails = (): void => {
modelLinks.replaceChildren();
const selectedOption = modelOptions.find((option) => option.path === modelSelect.value);
if (modelSelect.value === 'auto') {
modelDescription.textContent = 'Automatic mode picks the best compatible installed model and downloads the default model if needed.';
modelDetails.hidden = false;
return;
}
if (!selectedOption) {
modelDescription.textContent = 'The selected checkpoint is not currently available.';
modelDetails.hidden = false;
return;
}
modelDescription.textContent = selectedOption.description || (
selectedOption.backend === 'rmbg'
? 'Local BRIA RMBG 2.0 model loaded through Transformers.'
: 'Installed checkpoint validated by ComfyUI\'s native BiRefNet loader.'
);
if (selectedOption.url) {
modelLinks.appendChild(createModelLink('Model page', selectedOption.url));
}
if (selectedOption.project_url) {
modelLinks.appendChild(createModelLink(
selectedOption.backend === 'rmbg' ? 'BRIA project' : 'BiRefNet project',
selectedOption.project_url,
));
}
modelDetails.hidden = false;
};
modelSelect.onchange = updateModelDetails;
modelDetails.append(modelDescription, modelLinks);
updateModelDetails();
const modeSelect = document.createElement('select');
modeSelect.className = 'lf-matting-settings-select';
(['remove_background', 'remove_foreground', 'mask_only', 'mask_only_inverted'] as MattingMode[]).forEach((mode) => {
modeSelect.appendChild(new Option(getMattingModeLabel(mode), mode));
});
modeSelect.value = settings.mode;
const thresholdContainer = document.createElement('div');
thresholdContainer.className = 'lf-matting-settings-threshold';
const thresholdInput = document.createElement('input');
thresholdInput.type = 'range';
thresholdInput.min = '0';
thresholdInput.max = '1';
thresholdInput.step = '0.01';
thresholdInput.value = String(settings.threshold);
const thresholdValue = document.createElement('output');
thresholdValue.className = 'lf-matting-settings-threshold-value';
thresholdValue.value = settings.threshold.toFixed(2);
thresholdValue.textContent = settings.threshold.toFixed(2);
thresholdInput.oninput = () => {
const value = Number(thresholdInput.value);
thresholdValue.value = value.toFixed(2);
thresholdValue.textContent = value.toFixed(2);
};
thresholdContainer.append(thresholdInput, thresholdValue);
const tokenContainer = document.createElement('div');
tokenContainer.className = 'lf-matting-settings-token';
const tokenInput = document.createElement('input');
tokenInput.type = 'password';
tokenInput.className = 'lf-matting-settings-input';
tokenInput.autocomplete = 'off';
tokenInput.placeholder = settings.hfTokenConfigured
? 'Token saved — leave blank to keep it'
: 'Paste a Hugging Face read token';
tokenInput.spellcheck = false;
const clearTokenLabel = document.createElement('label');
clearTokenLabel.className = 'lf-matting-settings-token-clear';
const clearTokenInput = document.createElement('input');
clearTokenInput.type = 'checkbox';
clearTokenInput.disabled = !settings.hfTokenConfigured;
const clearTokenText = document.createElement('span');
clearTokenText.textContent = 'Clear saved token';
clearTokenLabel.append(clearTokenInput, clearTokenText);
tokenInput.oninput = () => {
if (tokenInput.value.trim()) clearTokenInput.checked = false;
};
tokenContainer.append(tokenInput, clearTokenLabel);
const modelStatus = document.createElement('p');
modelStatus.className = 'lf-matting-settings-status';
const localCount = localModelOptions.length;
const remoteCount = remoteModelOptions.length;
const modelCounts = [
localCount > 0 ? `${localCount} installed local model(s)` : 'No compatible local model installed',
remoteCount > 0 ? `${remoteCount} official model(s) available for download` : '',
].filter(Boolean).join('; ');
modelStatus.textContent = `${modelCounts}. ${modelStatusMessage}`;
body.append(
createRow('Model', modelSelect, 'Choose a local BiRefNet checkpoint or BRIA RMBG 2.0, or download an official model on first use.'),
modelDetails,
createRow('Processing mode', modeSelect, 'The selected mode controls what the Matting button creates from the detected mask.'),
createRow('Mask threshold', thresholdContainer, 'Set to 0 for a soft alpha mask; higher values create a harder cutout.'),
createRow('Hugging Face token', tokenContainer, 'Optional read token for gated models such as BRIA RMBG 2.0. It is stored only in the ComfyUI custom node settings file.'),
modelStatus,
);
const actions = document.createElement('div');
actions.className = 'lf-matting-settings-actions';
const resetButton = document.createElement('button');
resetButton.type = 'button';
resetButton.className = 'lf-matting-settings-secondary';
resetButton.textContent = 'Reset';
resetButton.onclick = () => {
modelSelect.value = 'auto';
modeSelect.value = DEFAULT_MATTING_SETTINGS.mode;
thresholdInput.value = String(DEFAULT_MATTING_SETTINGS.threshold);
thresholdValue.value = DEFAULT_MATTING_SETTINGS.threshold.toFixed(2);
thresholdValue.textContent = DEFAULT_MATTING_SETTINGS.threshold.toFixed(2);
updateModelDetails();
};
const saveButton = document.createElement('button');
saveButton.type = 'button';
saveButton.className = 'lf-matting-settings-primary';
saveButton.textContent = 'Save settings';
saveButton.onclick = async () => {
saveButton.disabled = true;
try {
await persistMattingSettings(
{
modelPath: modelSelect.value === 'auto' ? '' : modelSelect.value,
mode: modeSelect.value as MattingMode,
threshold: Number(thresholdInput.value),
hfTokenConfigured: settings.hfTokenConfigured,
},
tokenInput.value,
clearTokenInput.checked,
);
closeMattingSettings();
showInfoNotification('Matting settings saved.', 2000);
} catch (error) {
log.error('Unable to save Matting settings:', error);
showErrorNotification(error instanceof Error ? error.message : 'Unable to save Matting settings.', 8000);
} finally {
saveButton.disabled = false;
}
};
actions.append(resetButton, saveButton);
dialog.append(header, body, actions);
backdrop.appendChild(dialog);
backdrop.addEventListener('click', (event) => {
if (event.target === backdrop) closeMattingSettings();
});
mattingSettingsEscapeHandler = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
closeMattingSettings();
}
};
document.addEventListener('keydown', mattingSettingsEscapeHandler);
mattingSettingsBackdrop = backdrop;
document.body.appendChild(backdrop);
closeButton.focus();
};
const controlPanel = $el("div.painterControlPanel", {}, [
$el("div.controls.lf-painter-controls", {
style: {
position: "absolute",
top: "0",
left: "0",
right: "0",
zIndex: "10",
},
}, [
$el("div.lf-painter-button-group", {}, [
$el("button.lf-painter-button.lf-icon-button", {
id: `open-editor-btn-${node.id}`,
textContent: "⛶",
title: "Open in Editor",
}),
$el("button.lf-painter-button.lf-icon-button", {
textContent: "?",
onmouseenter: (e: MouseEvent) => {
const content = canvas.maskTool.isActive ? maskShortcuts : standardShortcuts;
showTooltip(e.target as HTMLElement, content);
},
onmouseleave: hideTooltip
}),
$el("button.lf-painter-button.lf-primary", {
textContent: "Add Image",
title: "Add image from file",
onclick: () => {
const addMode: AddMode = getImageAddMode(node.widgets);
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.onchange = async (e) => {
const target = e.target as HTMLInputElement;
if (!target.files) return;
for (const file of target.files) {
void loadImageFromBlob(file).then(img => {
canvas.addLayer(img, {}, addMode);
}).catch(() => undefined);
}
};
input.click();
}
}),
$el("button.lf-painter-button.lf-primary", {
textContent: "Import Input",
title: "Import image from another node",
onclick: () => canvas.canvasIO.importLatestImage()
}),
$el("div.lf-painter-clipboard-group", {}, [
$el("button.lf-painter-button.lf-primary", {
textContent: "Paste Image",
title: "Paste image from clipboard",
onclick: () => {
const addMode: AddMode = getImageAddMode(node.widgets);
canvas.canvasLayers.handlePaste(addMode);
}
}),
(() => {
// Modern clipboard switch
// Initial state: checked = clipspace, unchecked = system
const isClipspace = canvas.canvasLayers.clipboardPreference === 'clipspace';
const switchId = `clipboard-switch-${node.id}`;
const switchEl = $el("label.lf-clipboard-switch", { id: switchId }, [
$el("input", {
type: "checkbox",
checked: isClipspace,
onchange: (e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
canvas.canvasLayers.clipboardPreference = checked ? 'clipspace' : 'system';
// For accessibility, update ARIA label
switchEl.setAttribute('aria-label', checked ? "Clipboard: Clipspace" : "Clipboard: System");
log.info(`Clipboard preference toggled to: ${canvas.canvasLayers.clipboardPreference}`);
}
}),
$el("span.lf-switch-track"),
$el("span.lf-switch-labels", {}, [
$el("span.lf-text-clipspace", {}, ["Clipspace"]),
$el("span.lf-text-system", {}, ["System"])
]),
$el("span.lf-switch-knob", {}, [
$el("span.lf-switch-icon")
])
]);
// Helper function to get current tooltip content based on switch state
const getCurrentTooltipContent = () => {
const checked = (switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement).checked;
return checked ? clipspaceClipboardTooltip : systemClipboardTooltip;
};
// Helper function to update tooltip content if it's currently visible
const updateTooltipIfVisible = () => {
// Only update if tooltip is currently visible
if (helpTooltip.style.display === 'block') {
const tooltipContent = getCurrentTooltipContent();
showTooltip(switchEl, tooltipContent);
}
};
// Tooltip logic
switchEl.addEventListener("mouseenter", (e: MouseEvent) => {
const tooltipContent = getCurrentTooltipContent();
showTooltip(switchEl, tooltipContent);
});
switchEl.addEventListener("mouseleave", hideTooltip);
// Dynamic icon update on toggle
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.lf-switch-knob .lf-switch-icon') as HTMLElement;
input.addEventListener('change', () => {
updateSwitchIcon(
knobIcon,
input.checked,
LAYERFORGE_TOOLS.CLIPSPACE,
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
"🗂️",
"📋"
);
// Update tooltip content immediately after state change
updateTooltipIfVisible();
});
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchIcon(
knobIcon,
isClipspace,
LAYERFORGE_TOOLS.CLIPSPACE,
LAYERFORGE_TOOLS.SYSTEM_CLIPBOARD,
"🗂️",
"📋"
);
});
return switchEl;
})()
]),
]),
$el("div.lf-painter-separator"),
$el("div.lf-painter-button-group", {}, [
$el("button.lf-painter-button.requires-selection", {
textContent: "Auto Adjust Output",
title: "Automatically adjust output area to fit selected layers",
onclick: () => {
const selectedLayers = canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) {
showWarningNotification("Please select one or more layers first");
return;
}
const success = canvas.canvasLayers.autoAdjustOutputToSelection();
if (success) {
const bounds = canvas.outputAreaBounds;
showSuccessNotification(`Output area adjusted to ${bounds.width}x${bounds.height}px`);
} else {
showErrorNotification("Cannot calculate valid output area dimensions");
}
}
}),
$el("button.lf-painter-button", {
textContent: "Output Area Size",
title: "Transform output area - drag handles to resize",
onclick: () => {
// Activate output area transform mode
canvas.canvasInteractions.activateOutputAreaTransform();
showInfoNotification("Click and drag the handles to resize the output area. Click anywhere else to exit.", 3000);
}
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Remove Layer",
title: "Remove selected layer(s)",
onclick: () => canvas.removeSelectedLayers()
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Layer Up",
title: "Move selected layer(s) up",
onclick: () => canvas.canvasLayers.moveLayerUp()
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Layer Down",
title: "Move selected layer(s) down",
onclick: () => canvas.canvasLayers.moveLayerDown()
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Fuse",
title: "Flatten and merge selected layers into a single layer",
onclick: () => canvas.canvasLayers.fuseLayers()
}),
]),
$el("div.lf-painter-separator"),
$el("div.lf-painter-button-group", {}, [
(() => {
const switchEl = $el("label.lf-clipboard-switch.requires-selection", {
id: `crop-transform-switch-${node.id}`,
title: "Toggle between Transform and Crop mode for selected layer(s)"
}, [
$el("input", {
type: "checkbox",
checked: false,
onchange: (e: Event) => {
const isCropMode = (e.target as HTMLInputElement).checked;
const selectedLayers = canvas.canvasSelection.selectedLayers;
if (selectedLayers.length === 0) return;
selectedLayers.forEach((layer: Layer) => {
layer.cropMode = isCropMode;
if (isCropMode && !layer.cropBounds) {
layer.cropBounds = { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight };
}
});
canvas.saveState();
canvas.render();
}
}),
$el("span.lf-switch-track"),
$el("span.lf-switch-labels", { style: { fontSize: "11px" } }, [
$el("span.lf-text-clipspace", {}, ["Crop"]),
$el("span.lf-text-system", {}, ["Transform"])
]),
$el("span.lf-switch-knob", {}, [
$el("span.lf-switch-icon", { id: `crop-transform-icon-${node.id}`})
])
]);
const input = switchEl.querySelector('input[type="checkbox"]') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.lf-switch-icon') as HTMLElement;
input.addEventListener('change', () => {
updateSwitchIcon(
knobIcon,
input.checked,
LAYERFORGE_TOOLS.CROP,
LAYERFORGE_TOOLS.TRANSFORM,
"✂️",
"✥"
);
});
// Initial state
iconLoader.preloadToolIcons().then(() => {
updateSwitchIcon(
knobIcon,
false, // Initial state is transform
LAYERFORGE_TOOLS.CROP,
LAYERFORGE_TOOLS.TRANSFORM,
"✂️",
"✥"
);
});
return switchEl;
})(),
$el("button.lf-painter-button.requires-selection", {
textContent: "Rotate +90°",
title: "Rotate selected layer(s) by +90 degrees",
onclick: () => canvas.canvasLayers.rotateLayer(90)
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Scale +5%",
title: "Increase size of selected layer(s) by 5%",
onclick: () => canvas.canvasLayers.resizeLayer(1.05)
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Scale -5%",
title: "Decrease size of selected layer(s) by 5%",
onclick: () => canvas.canvasLayers.resizeLayer(0.95)
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Mirror H",
title: "Mirror selected layer(s) horizontally",
onclick: () => canvas.canvasLayers.mirrorHorizontal()
}),
$el("button.lf-painter-button.requires-selection", {
textContent: "Mirror V",
title: "Mirror selected layer(s) vertically",
onclick: () => canvas.canvasLayers.mirrorVertical()
}),
]),
$el("div.lf-painter-separator"),
$el("div.lf-painter-button-group", {}, [
$el("button.lf-painter-button.requires-selection.lf-matting-button", {
textContent: "Matting",
title: "Perform background removal on the selected layer",
onclick: async (e: MouseEvent) => {
const button = (e.target as HTMLElement).closest('.lf-matting-button') as HTMLButtonElement;
if (button.classList.contains('lf-loading')) return;
const mattingSettings = await loadMattingSettings();
try {
// First check if model is available
const { data: modelStatus } = await fetchMattingModelStatus(mattingSettings.modelPath);
if (!modelStatus.available) {
switch (modelStatus.reason) {
case 'missing_dependency':
showErrorNotification(modelStatus.message, 8000);
return;
case 'unsupported_comfyui':
showErrorNotification(modelStatus.message, 8000);
return;
case 'unsupported_rmbg':
showErrorNotification(modelStatus.message, 8000);
return;
case 'not_downloaded':
showWarningNotification(modelStatus.message || "The selected background-removal model will be downloaded automatically.", 7000);
// Ask user if they want to proceed with download
if (!confirm(`${modelStatus.message || "The selected background-removal model needs to be downloaded."}\n\nThis is a one-time download and may be large. Do you want to proceed?`)) {
return;
}
showInfoNotification("Downloading the selected background-removal model... This may take a few minutes.", 10000);
break;
case 'selected_model_unavailable':
showErrorNotification(modelStatus.message, 8000);
return;
case 'corrupted':
showErrorNotification(modelStatus.message, 8000);
return;
case 'error':
showErrorNotification(`Error checking model: ${modelStatus.message}`, 5000);
return;
}
}
// Proceed with matting
const spinner = $el("div.lf-matting-spinner") as HTMLDivElement;
button.appendChild(spinner);
button.classList.add('lf-loading');
startMattingSpinner();
if (modelStatus.reason === 'not_downloaded') {
setMattingDownloadProgress(0);
startMattingProgressPolling();
}
if (modelStatus.available) {
showInfoNotification(`Starting ${getMattingModeLabel(mattingSettings.mode).toLowerCase()}...`, 2000);
}
if (canvas.canvasSelection.selectedLayers.length !== 1) {
throw new Error("Please select exactly one image layer for matting.");
}
const selectedLayer = canvas.canvasSelection.selectedLayers[0];
const selectedLayerIndex = canvas.layers.indexOf(selectedLayer);
const imageData = await canvas.canvasLayers.getLayerImageData(selectedLayer);
const response = await fetch("/matting", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
image: imageData,
model_path: mattingSettings.modelPath || "auto",
mode: mattingSettings.mode,
threshold: mattingSettings.threshold,
node_id: String(node.id),
})
});
const result = await response.json();
if (!response.ok) {
let errorMsg = `Server error: ${response.status} - ${response.statusText}`;
if (result && result.error) {
// Handle specific error types
if (result.error === "Network Connection Error") {
showErrorNotification("Failed to download the matting model. Please check your internet connection and try again.", 8000);
return;
} else if (result.error === "Matting Interrupted") {
showWarningNotification(result.details || "Matting was interrupted by ComfyUI.", 6000);
return;
} else if (result.error === "Matting Model Error") {
showErrorNotification(result.details || "Model loading error. Please check the console for details.", 8000);
return;
} else if (result.error === "Dependency Not Found") {
showErrorNotification(result.details || "Missing required dependencies.", 8000);
return;
}
errorMsg = `${result.error}: ${result.details || 'Check console'}`;
}
throw new Error(errorMsg);
}
if (mattingSettings.mode === 'mask_only' || mattingSettings.mode === 'mask_only_inverted') {
if (typeof result.draw_mask !== 'string') {
throw new Error('Matting response did not contain a Draw Mask image.');
}
const drawMaskImage = new Image();
drawMaskImage.src = result.draw_mask;
await drawMaskImage.decode();
canvas.maskTool.setMaskForLayer(drawMaskImage, selectedLayer);
showSuccessNotification(
mattingSettings.mode === 'mask_only_inverted'
? 'Inverted mask applied to Draw Mask.'
: 'Generated mask applied to Draw Mask.',
);
return;
}
const mattedImage = new Image();
mattedImage.src = result.matted_image;
await mattedImage.decode();
const newLayer = {...selectedLayer, image: mattedImage, flipH: false, flipV: false} as Layer;
delete (newLayer as any).imageId;
canvas.layers[selectedLayerIndex] = newLayer;
canvas.canvasSelection.updateSelection([newLayer]);
canvas.canvasLayersPanel?.onLayersChanged();
// Invalidate processed image cache when layer image changes (matting)
canvas.canvasLayers.invalidateProcessedImageCache(newLayer.id);
canvas.render();
canvas.saveState();
showSuccessNotification(`${getMattingModeLabel(mattingSettings.mode)} successfully!`);
} catch (error: any) {
log.error("Matting error:", error);
const errorMessage = error.message || "An unknown error occurred.";
if (!errorMessage.includes("Network Connection Error") &&
!errorMessage.includes("Matting Model Error") &&
!errorMessage.includes("Dependency Not Found")) {
showErrorNotification(`Matting Failed: ${errorMessage}`);
}
} finally {
stopMattingProgressPolling();
stopMattingSpinner();
setMattingDownloadProgress(null);
button.classList.remove('lf-loading');
const spinner = button.querySelector('.lf-matting-spinner');
if (spinner && button.contains(spinner)) {
button.removeChild(spinner);
}
}
}
}),
$el("button.lf-painter-button.lf-icon-button.lf-matting-settings-button", {
textContent: "⚙",
title: "Open Matting settings",
"aria-label": "Open Matting settings",
onclick: (e: MouseEvent) => {
e.stopPropagation();
void openMattingSettings();
}
}),
$el("button.lf-painter-button", {
id: `undo-button-${node.id}`,
textContent: "Undo",
title: "Undo last action",
disabled: true,
onclick: () => canvas.undo()
}),
$el("button.lf-painter-button", {
id: `redo-button-${node.id}`,
textContent: "Redo",
title: "Redo last undone action",
disabled: true,
onclick: () => canvas.redo()
}),
]),
$el("div.lf-painter-separator"),
$el("div.lf-painter-button-group", {id: "mask-controls"}, [
$el("label.lf-clipboard-switch.lf-mask-switch", {
id: `toggle-mask-switch-${node.id}`,
style: { minWidth: "56px", maxWidth: "56px", width: "56px", paddingLeft: "0", paddingRight: "0" },
title: "Toggle mask overlay visibility on canvas (mask still affects output when disabled)"
}, [
$el("input", {
type: "checkbox",
checked: canvas.maskTool.isOverlayVisible,
onchange: (e: Event) => {
const checked = (e.target as HTMLInputElement).checked;
canvas.maskTool.isOverlayVisible = checked;
canvas.render();
}
}),
$el("span.lf-switch-track"),
$el("span.lf-switch-labels", { style: { fontSize: "11px" } }, [
$el("span.lf-text-clipspace", { style: { paddingRight: "22px" } }, ["On"]),
$el("span.lf-text-system", { style: { paddingLeft: "20px" } }, ["Off"])
]),
$el("span.lf-switch-knob", {}, [
(() => {
// Ikona maski (SVG lub obrazek)
const iconContainer = document.createElement('span') as HTMLElement;
iconContainer.className = 'lf-switch-icon';
iconContainer.style.display = 'flex';
iconContainer.style.alignItems = 'center';
iconContainer.style.justifyContent = 'center';
iconContainer.style.width = '16px';
iconContainer.style.height = '16px';
// Pobierz ikonę maski z iconLoader
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.width = "16px";
img.style.height = "16px";
// Ustaw filtr w zależności od stanu checkboxa
setTimeout(() => {
const input = document.getElementById(`toggle-mask-switch-${node.id}`)?.querySelector('input[type="checkbox"]') as HTMLInputElement;
const updateIconFilter = () => {
if (input && img) {
img.style.filter = input.checked
? "brightness(0) invert(1)"
: "grayscale(1) brightness(0.7) opacity(0.6)";
}
};
if (input) {
input.addEventListener('change', updateIconFilter);
updateIconFilter();
}
}, 0);
iconContainer.appendChild(img);
} else {
iconContainer.textContent = "M";
iconContainer.style.fontSize = "12px";
iconContainer.style.color = "#fff";
}
return iconContainer;
})()
])
]),
$el("button.lf-painter-button", {
textContent: "Edit Mask",
title: "Open the current canvas view in the mask editor",
onclick: () => {
canvas.startMaskEditor(null, true);
}
}),
$el("button.lf-painter-button", {
id: "mask-mode-btn",
textContent: "Draw Mask",
title: "Toggle mask drawing mode",
onclick: () => {
const maskBtn = controlPanel.querySelector('#mask-mode-btn') as HTMLButtonElement;
const maskControls = controlPanel.querySelector('#mask-controls') as HTMLDivElement;
if (canvas.maskTool.isActive) {
canvas.maskTool.deactivate();
maskBtn.classList.remove('lf-primary');
maskControls.querySelectorAll('.mask-control').forEach((c) => (c as HTMLElement).style.display = 'none');
} else {
canvas.maskTool.activate();
maskBtn.classList.add('lf-primary');
maskControls.querySelectorAll('.mask-control').forEach((c) => (c as HTMLElement).style.display = 'flex');
}
setTimeout(() => canvas.render(), 0);
}
}),
$el("div.lf-painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "preview-opacity-slider", textContent: "Mask Opacity:"}),
$el("input", {
id: "preview-opacity-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setPreviewOpacity(parseFloat(value));
const valueEl = document.getElementById('preview-opacity-value');
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.lf-slider-value", {id: "preview-opacity-value"}, ["50%"])
]),
$el("div.lf-painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-size-slider", textContent: "Size:"}),
$el("input", {
id: "brush-size-slider",
type: "range",
min: "1",
max: "200",
value: "20",
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setBrushSize(parseInt(value));
const valueEl = document.getElementById('brush-size-value');
if (valueEl) valueEl.textContent = `${value}px`;
}
}),
$el("div.lf-slider-value", {id: "brush-size-value"}, ["20px"])
]),
$el("div.lf-painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-strength-slider", textContent: "Strength:"}),
$el("input", {
id: "brush-strength-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setBrushStrength(parseFloat(value));
const valueEl = document.getElementById('brush-strength-value');
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.lf-slider-value", {id: "brush-strength-value"}, ["50%"])
]),
$el("div.lf-painter-slider-container.mask-control", {style: {display: 'none'}}, [
$el("label", {for: "brush-hardness-slider", textContent: "Hardness:"}),
$el("input", {
id: "brush-hardness-slider",
type: "range",
min: "0",
max: "1",
step: "0.05",
value: "0.5",
oninput: (e: Event) => {
const value = (e.target as HTMLInputElement).value;
canvas.maskTool.setBrushHardness(parseFloat(value));
const valueEl = document.getElementById('brush-hardness-value');
if (valueEl) valueEl.textContent = `${Math.round(parseFloat(value) * 100)}%`;
}
}),
$el("div.lf-slider-value", {id: "brush-hardness-value"}, ["50%"])
]),
$el("button.lf-painter-button.mask-control", {
textContent: "Clear Mask",
title: "Clear the entire mask",
style: {display: 'none'},
onclick: () => {
if (confirm("Are you sure you want to clear the mask?")) {
canvas.maskTool.clear();
canvas.render();
}
}
})
]),
$el("div.lf-painter-separator"),
$el("div.lf-painter-button-group", {}, [
$el("button.lf-painter-button.lf-success", {
textContent: "Run GC",
title: "Run Garbage Collection to clean unused images",
onclick: async () => {
try {
const stats = canvas.imageReferenceManager.getStats();
log.info("GC Stats before cleanup:", stats);
await canvas.imageReferenceManager.manualGarbageCollection();
const newStats = canvas.imageReferenceManager.getStats();
log.info("GC Stats after cleanup:", newStats);
showSuccessNotification(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}\nOperations: ${canvas.imageReferenceManager.operationCount}/${canvas.imageReferenceManager.operationThreshold}`);
} catch (e) {
log.error("Failed to run garbage collection:", e);
showErrorNotification("Error running garbage collection. Check the console for details.");
}
}
}),
$el("button.lf-painter-button.lf-danger", {
textContent: "Clear Cache",
title: "Clear all saved canvas states from browser storage",
onclick: async () => {
if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) {
try {
await clearAllCanvasStates();
showSuccessNotification("Canvas cache cleared successfully!");
} catch (e) {
log.error("Failed to clear canvas cache:", e);
showErrorNotification("Error clearing canvas cache. Check the console for details.");
}
}
}
})
])
])
]);
const mattingButton = controlPanel.querySelector('.lf-matting-button') as HTMLButtonElement | null;
const mattingProgressTrack = document.createElement('span');
mattingProgressTrack.className = 'lf-matting-download-progress';
mattingProgressTrack.setAttribute('aria-hidden', 'true');
const mattingProgressFill = document.createElement('span');
mattingProgressFill.className = 'lf-matting-download-progress-fill';
mattingProgressTrack.appendChild(mattingProgressFill);
mattingButton?.appendChild(mattingProgressTrack);
let mattingSpinnerAnimationFrame: number | null = null;
let mattingProgressPollTimer: number | null = null;
let mattingProgressPolling = false;
const stopMattingSpinner = (): void => {
if (mattingSpinnerAnimationFrame !== null) {
window.cancelAnimationFrame(mattingSpinnerAnimationFrame);
mattingSpinnerAnimationFrame = null;
}
const spinner = mattingButton?.querySelector('.lf-matting-spinner') as HTMLDivElement | null;
if (spinner) {
spinner.style.transform = '';
}
};
const startMattingSpinner = (): void => {
stopMattingSpinner();
const spinner = mattingButton?.querySelector('.lf-matting-spinner') as HTMLDivElement | null;
if (!spinner) return;
const startedAt = performance.now();
const animate = (timestamp: number): void => {
if (!mattingButton?.classList.contains('lf-loading') || !spinner.isConnected) {
stopMattingSpinner();
return;
}
const rotation = ((timestamp - startedAt) * 0.45) % 360;
spinner.style.transform = `translateY(-50%) rotate(${rotation}deg)`;
mattingSpinnerAnimationFrame = window.requestAnimationFrame(animate);
};
mattingSpinnerAnimationFrame = window.requestAnimationFrame(animate);
};
const setMattingDownloadProgress = (progress: number | null): void => {
if (!mattingButton) return;
if (progress === null) {
mattingButton.classList.remove('lf-downloading');
mattingProgressFill.style.width = '0%';
return;
}
const normalizedProgress = Math.min(100, Math.max(0, Number(progress) || 0));
mattingButton.classList.add('lf-downloading');
mattingProgressFill.style.width = `${normalizedProgress}%`;
};
const handleMattingStatus = (event: any): void => {
const eventPayload = event?.detail && typeof event.detail === 'object' ? event.detail : event;
const payload = eventPayload?.data && typeof eventPayload.data === 'object'
? eventPayload.data
: eventPayload;
if (!payload || typeof payload.status !== 'string') return;
if (payload.node_id !== undefined && String(payload.node_id) !== String(node.id)) {
return;
}
if (payload.status === 'downloading') {
setMattingDownloadProgress(Number(payload.progress) || 0);
} else if (payload.status === 'completed' || payload.status === 'error') {
setMattingDownloadProgress(null);
}
};
const stopMattingProgressPolling = (): void => {
mattingProgressPolling = false;
if (mattingProgressPollTimer !== null) {
window.clearTimeout(mattingProgressPollTimer);
mattingProgressPollTimer = null;
}
};
const pollMattingProgress = async (): Promise<void> => {
if (!mattingProgressPolling) return;
try {
const response = await fetch(
`/matting/progress?node_id=${encodeURIComponent(String(node.id))}`,
{ cache: 'no-store' },
);
if (response.ok) {
handleMattingStatus({ detail: await response.json() });
}
} catch {
// WebSocket events remain the primary path; polling is a best-effort fallback.
} finally {
if (mattingProgressPolling) {
mattingProgressPollTimer = window.setTimeout(() => {
void pollMattingProgress();
}, 250);
}
}
};
const startMattingProgressPolling = (): void => {
stopMattingProgressPolling();
mattingProgressPolling = true;
void pollMattingProgress();
};
if (mattingButton) {
api.addEventListener('matting_status', handleMattingStatus);
}
// Function to create mask icon
const createMaskIcon = (): HTMLElement => {
const iconContainer = document.createElement('div');
iconContainer.className = 'mask-icon-container';
iconContainer.style.cssText = `
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
`;
const icon = iconLoader.getIcon(LAYERFORGE_TOOLS.MASK);
if (icon) {
if (icon instanceof HTMLImageElement) {
const img = icon.cloneNode() as HTMLImageElement;
img.style.cssText = `
width: 16px;
height: 16px;
filter: brightness(0) invert(1);
`;
iconContainer.appendChild(img);
} else if (icon instanceof HTMLCanvasElement) {
const { canvas, ctx } = createCanvas(16, 16);
if (ctx) {
ctx.drawImage(icon, 0, 0, 16, 16);
}
iconContainer.appendChild(canvas);
}
} else {
// Fallback text
iconContainer.textContent = 'M';
iconContainer.style.fontSize = '12px';
iconContainer.style.color = '#ffffff';
}
return iconContainer;
};
const updateButtonStates = () => {
const selectionCount = canvas.canvasSelection.selectedLayers.length;
const hasSelection = selectionCount > 0;
// --- Handle Standard Buttons ---
controlPanel.querySelectorAll('.requires-selection').forEach((el: any) => {
if (el.tagName === 'BUTTON') {
if (el.textContent === 'Fuse') {
el.disabled = selectionCount < 2;
} else {
el.disabled = !hasSelection;
}
}
});
const mattingBtn = controlPanel.querySelector('.lf-matting-button') as HTMLButtonElement;
if (mattingBtn && !mattingBtn.classList.contains('lf-loading')) {
mattingBtn.disabled = selectionCount !== 1;
}
// --- Handle Crop/Transform Switch ---
const switchEl = controlPanel.querySelector(`#crop-transform-switch-${node.id}`) as HTMLLabelElement;
if (switchEl) {
const input = switchEl.querySelector('input') as HTMLInputElement;
const knobIcon = switchEl.querySelector('.lf-switch-icon') as HTMLElement;
const isDisabled = !hasSelection;
switchEl.classList.toggle('lf-disabled', isDisabled);
input.disabled = isDisabled;
if (!isDisabled) {
const isCropMode = canvas.canvasSelection.selectedLayers[0].cropMode || false;
if (input.checked !== isCropMode) {
input.checked = isCropMode;
}
// Update icon view
updateSwitchIcon(
knobIcon,
isCropMode,
LAYERFORGE_TOOLS.CROP,
LAYERFORGE_TOOLS.TRANSFORM,
"✂️",
"✥"
);
}
}
};
canvas.canvasSelection.onSelectionChange = updateButtonStates;
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`) as HTMLButtonElement;
const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`) as HTMLButtonElement;
canvas.onHistoryChange = ({ canUndo, canRedo }: { canUndo: boolean, canRedo: boolean }) => {
if (undoButton) undoButton.disabled = !canUndo;
if (redoButton) redoButton.disabled = !canRedo;
};
updateButtonStates();
canvas.updateHistoryButtons();
// Add mask icon to toggle mask button after icons are loaded
setTimeout(async () => {
try {
await iconLoader.preloadToolIcons();
const toggleMaskBtn = controlPanel.querySelector(`#toggle-mask-btn-${node.id}`) as HTMLButtonElement;
if (toggleMaskBtn && !toggleMaskBtn.querySelector('.mask-icon-container')) {
// Clear fallback text
toggleMaskBtn.textContent = '';
const maskIcon = createMaskIcon();
toggleMaskBtn.appendChild(maskIcon);
// Set initial state based on mask visibility
if (canvas.maskTool.isOverlayVisible) {
toggleMaskBtn.classList.add('lf-primary');
maskIcon.style.opacity = '1';
} else {
toggleMaskBtn.classList.remove('lf-primary');
maskIcon.style.opacity = '0.5';
}
}
} catch (error) {
log.warn('Failed to load mask icon:', error);
}
}, 200);
// Debounce timer for updateOutput to prevent excessive updates
let updateOutputTimer: NodeJS.Timeout | null = null;
const updateOutput = async (node: ComfyNode, canvas: Canvas) => {
// Check if preview is disabled - if so, skip updateOutput entirely
const triggerWidget = node.widgets.find((w) => w.name === "trigger");
if (triggerWidget) {
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
}
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
if (showPreviewWidget && !showPreviewWidget.value) {
log.debug("Preview disabled, skipping updateOutput");
const PLACEHOLDER_IMAGE = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
const placeholder = new Image();
placeholder.src = PLACEHOLDER_IMAGE;
node.imgs = [placeholder];
return;
}
// Clear previous timer
if (updateOutputTimer) {
clearTimeout(updateOutputTimer);
}
// Debounce the update to prevent excessive processing during rapid changes
updateOutputTimer = setTimeout(async () => {
try {
const blob = await getFlattenedCanvasBlob(canvas, 'with-mask');
if (blob) {
// For large images, use blob URL for better performance
if (blob.size > 2 * 1024 * 1024) { // 2MB threshold
void loadPreviewImage(blob, {
source: 'canvas',
urlMode: 'object-url'
}).then(img => {
node.imgs = [img];
log.debug(`Using blob URL for large image (${(blob.size / 1024 / 1024).toFixed(1)}MB): ${img.src.substring(0, 50)}...`);
// Clean up old blob URLs to prevent memory leaks
if (node.imgs.length > 1) {
const oldImg = node.imgs[0];
if (oldImg.src.startsWith('blob:')) {
URL.revokeObjectURL(oldImg.src);
}
}
}).catch(() => undefined);
} else {
// For smaller images, use data URI as before
void loadPreviewImage(blob, {
source: 'canvas',
urlMode: 'data-url'
}).then(img => {
node.imgs = [img];
log.debug(`Using data URI for small image (${(blob.size / 1024).toFixed(1)}KB): ${img.src.substring(0, 50)}...`);
}).catch(() => undefined);
}
} else {
node.imgs = [];
}
} catch (error) {
console.error("Error updating node preview:", error);
}
}, 250); // 150ms debounce delay
};
// Store previous temp filenames for cleanup (make it globally accessible)
if (!(window as any).layerForgeTempFileTracker) {
(window as any).layerForgeTempFileTracker = new Map<string, string>();
}
const tempFileTracker = (window as any).layerForgeTempFileTracker;
const layersPanel = canvas.canvasLayersPanel.createPanelStructure();
const canvasContainer = $el("div.lf-painter-canvas-container.lf-painter-container", {
style: {
position: "absolute",
top: "60px",
left: "10px",
right: "270px",
bottom: "10px",
overflow: "hidden"
}
}, [canvas.canvas]) as HTMLDivElement;
canvas.canvasContainer = canvasContainer;
const layersPanelContainer = $el("div.painterLayersPanelContainer", {
style: {
position: "absolute",
top: "60px",
right: "10px",
width: "250px",
bottom: "10px",
overflow: "hidden"
}
}, [layersPanel]) as HTMLDivElement;
const resizeObserver = new ResizeObserver((entries) => {
const controlsHeight = (entries[0].target as HTMLElement).offsetHeight;
const newTop = (controlsHeight + 10) + "px";
canvasContainer.style.top = newTop;
layersPanelContainer.style.top = newTop;
});
const controlsElement = controlPanel.querySelector('.controls');
if (controlsElement) {
resizeObserver.observe(controlsElement);
}
// Watch the canvas container itself to detect size changes and fix canvas dimensions
const canvasContainerResizeObserver = new ResizeObserver(() => {
// Force re-read of canvas dimensions on next render
canvas.render();
});
canvasContainerResizeObserver.observe(canvasContainer);
canvas.canvas.addEventListener('focus', () => {
canvasContainer.classList.add('lf-has-focus');
});
canvas.canvas.addEventListener('blur', () => {
canvasContainer.classList.remove('lf-has-focus');
});
node.onResize = function () {
canvas.render();
};
const mainContainer = $el("div.lf-painter-main-container", {
style: {
position: "relative",
width: "100%",
height: "100%"
}
}, [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) {
const getEditorWidgetHeight = () => {
const controlsHeight = controlsElement instanceof HTMLElement ? controlsElement.offsetHeight : 0;
return Math.max(300, controlsHeight + 180);
};
(node.addDOMWidget as any)("mainContainer", "widget", mainContainer, {
getMinHeight: getEditorWidgetHeight,
getHeight: getEditorWidgetHeight,
});
}
const openEditorBtn = controlPanel.querySelector(`#open-editor-btn-${node.id}`) as HTMLButtonElement;
let backdrop: HTMLDivElement | null = null;
let originalParent: HTMLElement | null = null;
let isEditorOpen = false;
let viewportAdjustment = { x: 0, y: 0 };
/**
* Adjusts the viewport when entering fullscreen mode.
*/
const adjustViewportOnOpen = (originalRect: DOMRect) => {
const fullscreenRect = canvasContainer.getBoundingClientRect();
const widthDiff = fullscreenRect.width - originalRect.width;
const heightDiff = fullscreenRect.height - originalRect.height;
const adjustX = (widthDiff / 2) / canvas.viewport.zoom;
const adjustY = (heightDiff / 2) / canvas.viewport.zoom;
// Store the adjustment
viewportAdjustment = { x: adjustX, y: adjustY };
// Apply the adjustment
canvas.viewport.x -= viewportAdjustment.x;
canvas.viewport.y -= viewportAdjustment.y;
};
/**
* Restores the viewport when exiting fullscreen mode.
*/
const adjustViewportOnClose = () => {
// Apply the stored adjustment in reverse
canvas.viewport.x += viewportAdjustment.x;
canvas.viewport.y += viewportAdjustment.y;
// Reset adjustment
viewportAdjustment = { x: 0, y: 0 };
};
const closeEditor = () => {
if (originalParent && backdrop) {
originalParent.appendChild(mainContainer);
document.body.removeChild(backdrop);
}
isEditorOpen = false;
openEditorBtn.textContent = "⛶";
openEditorBtn.title = "Open in Editor";
// Remove ESC key listener when editor closes
document.removeEventListener('keydown', handleEscKey);
setTimeout(() => {
adjustViewportOnClose();
canvas.render();
if (node.onResize) {
node.onResize();
}
}, 0);
};
// ESC key handler for closing fullscreen editor
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isEditorOpen) {
e.preventDefault();
e.stopPropagation();
closeEditor();
}
};
openEditorBtn.onclick = () => {
if (isEditorOpen) {
closeEditor();
return;
}
const originalRect = canvasContainer.getBoundingClientRect();
originalParent = mainContainer.parentElement;
if (!originalParent) {
log.error("Could not find original parent of the canvas container!");
return;
}
backdrop = $el("div.lf-painter-modal-backdrop") as HTMLDivElement;
const modalContent = $el("div.lf-painter-modal-content") as HTMLDivElement;
modalContent.appendChild(mainContainer);
backdrop.appendChild(modalContent);
document.body.appendChild(backdrop);
isEditorOpen = true;
openEditorBtn.textContent = "X";
openEditorBtn.title = "Close Editor (ESC)";
// Add ESC key listener when editor opens
document.addEventListener('keydown', handleEscKey);
setTimeout(() => {
adjustViewportOnOpen(originalRect);
canvas.render();
if (node.onResize) {
node.onResize();
}
}, 0);
};
if (!(window as any).canvasExecutionStates) {
(window as any).canvasExecutionStates = new Map<string, any>();
}
// Store the entire widget object, not just the canvas
(node as any).canvasWidget = {
canvas: canvas,
panel: controlPanel
};
setTimeout(() => {
canvas.loadInitialState();
if (canvas.canvasLayersPanel) {
canvas.canvasLayersPanel.renderLayers();
}
}, 100);
const showPreviewWidget = node.widgets.find((w) => w.name === "show_preview");
if (showPreviewWidget) {
const originalCallback = showPreviewWidget.callback;
showPreviewWidget.callback = function (value: boolean) {
if (originalCallback) {
originalCallback.call(this, value);
}
if (canvas && canvas.setPreviewVisibility) {
canvas.setPreviewVisibility(value);
}
if ((node as any).graph && (node as any).graph.canvas && node.setDirtyCanvas) {
node.setDirtyCanvas(true, true);
}
};
// Inicjalizuj stan preview na podstawie aktualnej wartości widget'u
if (canvas && canvas.setPreviewVisibility) {
canvas.setPreviewVisibility(showPreviewWidget.value);
}
}
return {
canvas: canvas,
panel: controlPanel,
destroy: () => {
closeMattingSettings();
stopMattingProgressPolling();
stopMattingSpinner();
if (mattingButton) {
api.removeEventListener('matting_status', handleMattingStatus);
}
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<number, CanvasWidget>();
let layerForgeQuickCreateMenu: any = null;
let layerForgeQuickCreateCanvas: any = null;
let layerForgeQuickCreateCleanup: (() => void) | null = null;
let layerForgeQuickCreatePending = false;
let layerForgeQuickCreateToken = 0;
let layerForgeLastCapturedDropAt = 0;
let layerForgeSuppressNativeDropUntil = 0;
const isLayerForgeTransportInput = (name: unknown): boolean => /^input_image_\d+$/i.test(String(name || ""));
const getLayerForgeGraphLink = (node: any, linkId: any): any | null => {
const graph = node?.graph || app.graph;
if (!graph || linkId == null) return null;
for (const links of [graph.links, graph._links]) {
if (!links) continue;
if (typeof links.get === 'function') {
const link = links.get(linkId) ?? links.get(String(linkId));
if (link) return link;
}
const link = links[linkId] ?? links[String(linkId)];
if (link) return link;
}
return null;
};
const getLayerForgeSlotIndex = (slots: any[] | undefined, rawSlot: any): number => {
if (!Array.isArray(slots)) return -1;
if (typeof rawSlot === 'number') return slots[rawSlot] ? rawSlot : -1;
for (const key of ['slot_index', 'slot', 'index']) {
const value = rawSlot?.[key];
if (typeof value === 'number' && slots[value]) return value;
}
if (rawSlot) {
const directIndex = slots.indexOf(rawSlot);
if (directIndex >= 0) return directIndex;
const name = typeof rawSlot === 'string' ? rawSlot : rawSlot?.name;
if (name) return slots.findIndex(slot => slot?.name === name);
}
return -1;
};
const getLayerForgePendingConnectorLink = (canvas: any): {
direction: 'from_input' | 'from_output';
targetNode?: any;
targetSlot?: number;
sourceNode?: any;
sourceSlot?: number;
sourceType?: string;
} | null => {
const renderLinks = canvas?.linkConnector?.renderLinks;
const link = renderLinks?.[0] || renderLinks?.at?.(0);
if (!link) return null;
const endpointNode = link.node
|| link.fromNode
|| link.originNode
|| link.sourceNode
|| link.toNode
|| link.targetNode
|| link.inputNode
|| link.outputNode;
const endpointSlot = link.fromSlot ?? link.slot ?? link.output ?? link.input ?? link.toSlot ?? {};
if (!endpointNode) return null;
const inputIndex = getLayerForgeSlotIndex(endpointNode.inputs, endpointSlot);
const outputIndex = getLayerForgeSlotIndex(endpointNode.outputs, endpointSlot);
const toType = String(link.toType || link.targetType || link.targetSlotType || '').toLowerCase();
let direction: 'from_input' | 'from_output' = toType.includes('output') ? 'from_input' : 'from_output';
if (inputIndex >= 0 && outputIndex < 0) direction = 'from_input';
if (outputIndex >= 0 && inputIndex < 0) direction = 'from_output';
if (direction === 'from_input') {
const input = endpointNode.inputs?.[inputIndex] || endpointSlot;
const inputName = String(input?.name || '');
if (endpointNode?.comfyClass !== 'LayerForgeNode'
&& endpointNode?.type !== 'LayerForgeNode') return null;
if (inputName !== 'input_image' && !isLayerForgeTransportInput(inputName)) return null;
return {
direction,
targetNode: endpointNode,
targetSlot: inputIndex,
};
}
const output = endpointNode.outputs?.[outputIndex] || endpointSlot || {};
return {
direction,
sourceNode: endpointNode,
sourceSlot: Math.max(0, outputIndex),
sourceType: String(output?.type || output?.datatype || output?.name || 'IMAGE'),
};
};
const getLayerForgePointerGraphPosition = (canvas: any, event: any): [number, number] => {
if (Number.isFinite(event?.canvasX) && Number.isFinite(event?.canvasY)) {
return [event.canvasX, event.canvasY];
}
const rect = canvas?.canvas?.getBoundingClientRect?.();
const scale = canvas?.ds?.scale || 1;
const offset = canvas?.ds?.offset || [0, 0];
if (rect && Number.isFinite(event?.clientX) && Number.isFinite(event?.clientY)) {
return [
(event.clientX - rect.left) / scale - offset[0],
(event.clientY - rect.top) / scale - offset[1],
];
}
return [0, 0];
};
const getLayerForgeConnectionPosition = (node: any, isInput: boolean, slotIndex: number): [number, number] | null => {
const normalize = (point: any): [number, number] | null => {
if (!point || !Number.isFinite(Number(point[0])) || !Number.isFinite(Number(point[1]))) return null;
return [Number(point[0]), Number(point[1])];
};
const modernPosition = normalize(isInput
? node?.getInputPos?.(slotIndex)
: node?.getOutputPos?.(slotIndex));
if (modernPosition) return modernPosition;
try {
if (typeof node?.getConnectionPos === 'function') {
const output: [number, number] = [0, 0];
const legacyPosition = normalize(node.getConnectionPos(isInput, slotIndex, output)) || normalize(output);
if (legacyPosition) return legacyPosition;
}
} catch {
// Fall through to stable LiteGraph geometry for older frontend builds.
}
const position = node?.pos || [0, 0];
const size = node?.size || [160, 0];
const slotY = Number(position[1] || 0) + 40 + Math.max(0, slotIndex) * 20;
return [
Number(position[0] || 0) + (isInput ? 0 : Number(size[0] || 160)),
slotY,
];
};
const getLayerForgeVirtualLinkGeometry = (targetNode: any, link: any): {
source: [number, number];
target: [number, number];
midpoint: [number, number];
sourceNode: any;
} | null => {
const graph = targetNode?.graph || app.graph;
const sourceNode = graph?.getNodeById?.(Number(link?.source_id));
if (!sourceNode) return null;
const inputSlot = getLayerForgeImageInputSlot(targetNode);
const inputIndex = Math.max(0, targetNode?.inputs?.indexOf(inputSlot) ?? 0);
const source = getLayerForgeConnectionPosition(sourceNode, false, Number(link?.source_slot) || 0);
const target = getLayerForgeConnectionPosition(targetNode, true, inputIndex);
if (!source || !target) return null;
const midpoint: [number, number] = [
(source[0] + target[0]) / 2,
(source[1] + target[1]) / 2,
];
return { source, target, midpoint, sourceNode };
};
const getLayerForgeVirtualLinkColor = (link: any): string => {
const colors = (globalThis as any).LGraphCanvas?.link_type_colors || {};
const rawType = String(link?.source_type || 'IMAGE');
for (const candidate of [rawType, rawType.toUpperCase(), rawType.toLowerCase()]) {
if (colors[candidate]) return colors[candidate];
}
return '#5aa9f0';
};
const drawLayerForgeVirtualLinks = (canvas: any, context: CanvasRenderingContext2D): void => {
const graph = canvas?.graph || app.graph;
if (!graph?._nodes || canvas.links_render_mode === (globalThis as any).LiteGraph?.HIDDEN_LINK) return;
for (const targetNode of graph._nodes) {
if (targetNode?.comfyClass !== 'LayerForgeNode' && targetNode?.type !== 'LayerForgeNode') continue;
const links = getLayerForgeImageInputLinks(targetNode);
links.forEach((link, index) => {
const geometry = getLayerForgeVirtualLinkGeometry(targetNode, link);
if (!geometry) return;
const highlighted = Boolean(targetNode.selected || geometry.sourceNode.selected);
const color = highlighted ? '#ffffff' : getLayerForgeVirtualLinkColor(link);
const width = Number(canvas.connections_width) || 3;
const controlOffset = 80;
context.save();
context.lineJoin = 'round';
context.shadowBlur = 0;
context.shadowColor = 'transparent';
context.beginPath();
context.moveTo(geometry.source[0], geometry.source[1]);
context.bezierCurveTo(
geometry.source[0] + controlOffset,
geometry.source[1],
geometry.target[0] - controlOffset,
geometry.target[1],
geometry.target[0],
geometry.target[1],
);
context.lineWidth = width + 4;
context.strokeStyle = canvas.render_connections_border !== false && !canvas.low_quality
? 'rgba(0, 0, 0, 0.5)'
: 'transparent';
if (context.strokeStyle !== 'transparent') context.stroke();
context.beginPath();
context.moveTo(geometry.source[0], geometry.source[1]);
context.bezierCurveTo(
geometry.source[0] + controlOffset,
geometry.source[1],
geometry.target[0] - controlOffset,
geometry.target[1],
geometry.target[0],
geometry.target[1],
);
context.lineWidth = width;
context.strokeStyle = color;
context.stroke();
if (canvas.linkMarkerShape !== 0 && (canvas.ds?.scale ?? 1) >= 0.6 && canvas.highquality_render !== false) {
context.beginPath();
context.arc(geometry.midpoint[0], geometry.midpoint[1], 5, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
context.fillStyle = highlighted ? '#222' : '#fff';
context.font = 'bold 7px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
context.fillText(String(index + 1), geometry.midpoint[0], geometry.midpoint[1] + 0.3);
}
context.restore();
});
}
};
const getLayerForgeGraphPosition = (canvas: any, event: any): [number, number] => {
try {
canvas?.adjustMouseEvent?.(event);
} catch {
// Older LiteGraph builds may not expose adjustMouseEvent.
}
if (Array.isArray(canvas?.graph_mouse)) return [canvas.graph_mouse[0], canvas.graph_mouse[1]];
if (Number.isFinite(event?.canvasX) && Number.isFinite(event?.canvasY)) {
return [event.canvasX, event.canvasY];
}
const rect = canvas?.canvas?.getBoundingClientRect?.();
const scale = canvas?.ds?.scale || 1;
const offset = canvas?.ds?.offset || [0, 0];
if (rect && Number.isFinite(event?.clientX) && Number.isFinite(event?.clientY)) {
return [
(event.clientX - rect.left) / scale - offset[0],
(event.clientY - rect.top) / scale - offset[1],
];
}
return [0, 0];
};
const hitTestLayerForgeVirtualLinks = (graph: any, x: number, y: number): {
targetNode: any;
index: number;
point: [number, number];
distance: number;
} | null => {
let best: {
targetNode: any;
index: number;
point: [number, number];
distance: number;
} | null = null;
for (const targetNode of graph?._nodes || []) {
if (targetNode?.comfyClass !== 'LayerForgeNode' && targetNode?.type !== 'LayerForgeNode') continue;
getLayerForgeImageInputLinks(targetNode).forEach((link, index) => {
const geometry = getLayerForgeVirtualLinkGeometry(targetNode, link);
if (!geometry) return;
const distance = Math.hypot(x - geometry.midpoint[0], y - geometry.midpoint[1]);
if (distance <= 18 && (!best || distance < best.distance)) {
best = {
targetNode,
index,
point: geometry.midpoint,
distance,
};
}
});
}
return best;
};
const getLayerForgeClientPosition = (canvas: any, point: [number, number]): { x: number; y: number } | null => {
const rect = canvas?.canvas?.getBoundingClientRect?.();
if (!rect) return null;
const scale = canvas?.ds?.scale || 1;
const offset = canvas?.ds?.offset || [0, 0];
return {
x: rect.left + (point[0] + offset[0]) * scale,
y: rect.top + (point[1] + offset[1]) * scale,
};
};
const openLayerForgeVirtualLinkMenu = (canvas: any, hit: {
targetNode: any;
index: number;
point: [number, number];
}, event: any): void => {
const ContextMenu = (globalThis as any).LiteGraph?.ContextMenu;
if (typeof ContextMenu !== 'function') return;
const clientPoint = getLayerForgeClientPosition(canvas, hit.point);
const clientX = Number.isFinite(event?.clientX) ? event.clientX : clientPoint?.x || 0;
const clientY = Number.isFinite(event?.clientY) ? event.clientY : clientPoint?.y || 0;
const PointerEventConstructor = (globalThis as any).PointerEvent;
const MouseEventConstructor = (globalThis as any).MouseEvent;
let menuEvent: any;
try {
const EventConstructor = PointerEventConstructor || MouseEventConstructor;
menuEvent = EventConstructor
? new EventConstructor('pointerdown', {
clientX,
clientY,
bubbles: true,
cancelable: true,
})
: { clientX, clientY };
} catch {
menuEvent = { clientX, clientY };
}
let menuInstance: any = null;
const closeMenu = (): void => {
menuInstance?.close?.();
menuInstance?.remove?.();
};
menuInstance = new ContextMenu([
{
content: 'Remove connection',
callback: () => {
if (removeLayerForgeImageInputLink(hit.targetNode, hit.index)) {
hit.targetNode.setDirtyCanvas?.(true, true);
canvas?.setDirty?.(true, true);
canvas?.graph?.setDirtyCanvas?.(true, true);
app.graph?.change?.();
}
closeMenu();
},
},
], { event: menuEvent });
};
const clearLayerForgeTemporaryConnector = (canvas: any): void => {
const connector = canvas?.linkConnector;
connector?.reset?.();
if (Array.isArray(connector?.renderLinks)) connector.renderLinks.length = 0;
canvas?.setDirty?.(true, true);
(canvas?.graph || app.graph)?.setDirtyCanvas?.(true, true);
};
const createLayerForgeLoadImageNode = (canvas: any, targetNode: any, position: [number, number]): boolean => {
const graph = canvas?.graph || app.graph;
const LiteGraph = (globalThis as any).LiteGraph;
if (!graph || typeof LiteGraph?.createNode !== 'function' || !targetNode) return false;
if (getLayerForgeImageInputLinks(targetNode).length >= LAYERFORGE_MAX_IMAGE_INPUTS) return false;
const node = LiteGraph.createNode('LoadImage');
if (!node) return false;
node.pos = [position[0], position[1]];
graph.add(node);
const imageOutputIndex = Math.max(0, node.outputs?.findIndex((output: any) => {
const type = String(output?.type || output?.datatype || output?.name || '').toUpperCase();
return type.includes('IMAGE');
}) ?? 0);
const outputPosition = getLayerForgeConnectionPosition(node, false, imageOutputIndex);
if (outputPosition) {
node.pos = [
Number(node.pos?.[0] || 0) + position[0] - outputPosition[0],
Number(node.pos?.[1] || 0) + position[1] - outputPosition[1],
];
}
const output = node.outputs?.[imageOutputIndex];
addLayerForgeImageInputLink(targetNode, {
source_id: Number(node.id),
source_slot: imageOutputIndex,
source_type: String(output?.type || 'IMAGE'),
});
node.setDirtyCanvas?.(true, true);
targetNode.setDirtyCanvas?.(true, true);
graph.setDirtyCanvas?.(true, true);
app.graph?.change?.();
return true;
};
const getLayerForgeQuickCreateMenuEvent = (detail: any): any => {
const clientX = Number(detail?.clientX) || 0;
const clientY = Number(detail?.clientY) || 0;
const PointerEventConstructor = (globalThis as any).PointerEvent;
const MouseEventConstructor = (globalThis as any).MouseEvent;
try {
const EventConstructor = PointerEventConstructor || MouseEventConstructor;
return EventConstructor
? new EventConstructor('pointerdown', {
clientX,
clientY,
bubbles: true,
cancelable: true,
})
: { clientX, clientY };
} catch {
return { clientX, clientY };
}
};
const closeLayerForgeNativeNodeSearchSoon = (): void => {
const documentObject = (globalThis as any).document;
if (!documentObject) return;
const close = (): void => {
const container = documentObject.querySelector?.(
'.node-search-box-dialog-mask, .invisible-dialog-root, .comfy-vue-node-search-container',
);
const input = container?.querySelector?.('input')
|| documentObject.querySelector?.('input[id^="comfy-vue-node-search-box-input-"]');
if (!container && !input) return;
const KeyboardEventConstructor = (globalThis as any).KeyboardEvent;
if (typeof KeyboardEventConstructor !== 'function') return;
const init = {
key: 'Escape',
code: 'Escape',
keyCode: 27,
which: 27,
bubbles: true,
cancelable: true,
};
input?.dispatchEvent?.(new KeyboardEventConstructor('keydown', init));
container?.dispatchEvent?.(new KeyboardEventConstructor('keydown', init));
documentObject.dispatchEvent?.(new KeyboardEventConstructor('keydown', init));
};
for (const delay of [0, 16, 50, 120]) setTimeout(close, delay);
};
const openLayerForgeQuickCreateMenu = (canvas: any, targetNode: any, detail: any): void => {
const ContextMenu = (globalThis as any).LiteGraph?.ContextMenu;
if (typeof ContextMenu !== 'function' || !targetNode) return;
layerForgeQuickCreateMenu?.close?.();
layerForgeQuickCreateMenu?.remove?.();
layerForgeQuickCreateMenu = null;
const position: [number, number] = [
Number(detail?.canvasX) || 0,
Number(detail?.canvasY) || 0,
];
const finish = (): void => {
clearLayerForgeTemporaryConnector(canvas);
layerForgeQuickCreatePending = false;
layerForgeQuickCreateMenu?.close?.();
layerForgeQuickCreateMenu?.remove?.();
layerForgeQuickCreateMenu = null;
};
const menuInstance = new ContextMenu([
{
content: 'Load image',
callback: () => {
createLayerForgeLoadImageNode(canvas, targetNode, position);
finish();
},
},
], { event: getLayerForgeQuickCreateMenuEvent(detail) });
layerForgeQuickCreateMenu = menuInstance;
menuInstance.controller?.signal?.addEventListener?.('abort', () => {
if (layerForgeQuickCreateMenu !== menuInstance) return;
clearLayerForgeTemporaryConnector(canvas);
layerForgeQuickCreatePending = false;
layerForgeQuickCreateMenu = null;
}, { once: true });
};
const getLayerForgeTargetInputState = (pending: any): { inputLinkId: any; virtualLinkCount: number } => {
const targetNode = pending?.targetNode;
const input = getLayerForgeImageInputSlot(targetNode);
return {
inputLinkId: input?.link ?? null,
virtualLinkCount: getLayerForgeImageInputLinks(targetNode).length,
};
};
const scheduleLayerForgeQuickCreateMenu = (canvas: any, event: any, pending: any): boolean => {
if (pending?.direction !== 'from_input' || !pending.targetNode) return false;
const token = ++layerForgeQuickCreateToken;
const [canvasX, canvasY] = getLayerForgePointerGraphPosition(canvas, event);
const detail = {
clientX: event?.clientX,
clientY: event?.clientY,
canvasX,
canvasY,
originalEvent: event,
};
const linkSnapshot = { ...pending };
const before = getLayerForgeTargetInputState(linkSnapshot);
const graph = canvas?.graph || app.graph;
const beforeGraphVersion = Number(graph?._version) || 0;
const releaseConnectorHold = holdLayerForgeConnectorReset(canvas);
layerForgeQuickCreatePending = true;
layerForgeSuppressNativeDropUntil = performance.now() + 1000;
closeLayerForgeNativeNodeSearchSoon();
const checkConnected = (): boolean => {
if (token !== layerForgeQuickCreateToken) return true;
const current = getLayerForgeTargetInputState(linkSnapshot);
const graphVersion = Number(graph?._version) || 0;
return current.inputLinkId != null
|| current.virtualLinkCount > before.virtualLinkCount
|| graphVersion > beforeGraphVersion;
};
const openIfStillEmpty = (): void => {
if (token !== layerForgeQuickCreateToken) {
releaseConnectorHold?.();
return;
}
if (checkConnected()) {
releaseConnectorHold?.();
layerForgeQuickCreatePending = false;
clearLayerForgeTemporaryConnector(canvas);
return;
}
releaseConnectorHold?.();
layerForgeQuickCreatePending = false;
openLayerForgeQuickCreateMenu(canvas, linkSnapshot.targetNode, detail);
};
setTimeout(openIfStillEmpty, 70);
return true;
};
const holdLayerForgeConnectorReset = (canvas: any): (() => void) | null => {
const events = canvas?.linkConnector?.events;
if (!events) return null;
const preventReset = (event: any): void => event?.preventDefault?.();
events.addEventListener?.('reset', preventReset, { once: true });
return () => events.removeEventListener?.('reset', preventReset, { once: true });
};
const hasLayerForgePendingConnection = (canvas: any): boolean => Boolean(
canvas?.connecting_node
|| canvas?.connectingNode
|| canvas?.connecting_input
|| canvas?.connectingInput
|| canvas?.linkConnector?.renderLinks?.length,
);
const shouldSuppressLayerForgeNativeDrop = (type: string): boolean => (
type === 'dropped-on-canvas'
&& Boolean(layerForgeQuickCreateMenu || layerForgeQuickCreatePending)
&& performance.now() < layerForgeSuppressNativeDropUntil
);
const primeLayerForgeInputDropSuppression = (canvas: any): boolean => {
const pending = getLayerForgePendingConnectorLink(canvas);
if (pending?.direction !== 'from_input') return false;
layerForgeQuickCreatePending = true;
layerForgeSuppressNativeDropUntil = performance.now() + 1000;
return true;
};
const installLayerForgeQuickCreateCapture = (canvas: any): boolean => {
const events = canvas?.linkConnector?.events;
if (!canvas?.canvas || !events) return false;
if (canvas === layerForgeQuickCreateCanvas && canvas.__layerForgeQuickCreateInstalled) return true;
layerForgeQuickCreateCleanup?.();
layerForgeQuickCreateCleanup = null;
layerForgeQuickCreateCanvas = canvas;
canvas.__layerForgeQuickCreateInstalled = true;
const handler = (event: any): void => {
if (layerForgeQuickCreateMenu
|| event?.target?.closest?.('.litecontextmenu')) return;
if (event?.button > 0 || performance.now() - layerForgeLastCapturedDropAt < 80) return;
const pending = getLayerForgePendingConnectorLink(canvas);
if (!pending) return;
const [x, y] = getLayerForgePointerGraphPosition(canvas, event);
if (pending.direction === 'from_output') {
const target = (canvas.graph?._nodes || []).find((node: any) => {
if (node?.comfyClass !== 'LayerForgeNode' && node?.type !== 'LayerForgeNode') return false;
const inputSlot = getLayerForgeImageInputSlot(node);
const inputIndex = Math.max(0, node.inputs?.indexOf(inputSlot) ?? 0);
const dot = getLayerForgeConnectionPosition(node, true, inputIndex);
return dot && Math.hypot(x - dot[0], y - dot[1]) <= 18;
});
if (!target || !pending.sourceNode || target === pending.sourceNode) return;
const added = addLayerForgeImageInputLink(target, {
source_id: Number(pending.sourceNode.id),
source_slot: Number(pending.sourceSlot) || 0,
source_type: pending.sourceType || 'IMAGE',
});
if (!added) return;
layerForgeLastCapturedDropAt = performance.now();
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
clearLayerForgeTemporaryConnector(canvas);
return;
}
if (scheduleLayerForgeQuickCreateMenu(canvas, event, pending)) {
layerForgeLastCapturedDropAt = performance.now();
closeLayerForgeNativeNodeSearchSoon();
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
}
};
const pointerTargets = [
(globalThis as any).window,
(globalThis as any).document,
canvas.canvas,
];
for (const target of pointerTargets) {
target?.addEventListener?.('pointerup', handler, true);
target?.addEventListener?.('mouseup', handler, true);
}
const originalDispatch = typeof events.dispatch === 'function' ? events.dispatch : null;
const originalDispatchEvent = typeof events.dispatchEvent === 'function' ? events.dispatchEvent : null;
let wrappedDispatch: any = null;
let wrappedDispatchEvent: any = null;
if (originalDispatch) {
wrappedDispatch = function dispatchWithLayerForgeDropGuard(type: string, detail: any) {
if (type === 'before-drop-links') primeLayerForgeInputDropSuppression(canvas);
if (shouldSuppressLayerForgeNativeDrop(type)) return false;
return originalDispatch.call(events, type, detail);
};
events.dispatch = wrappedDispatch;
}
if (originalDispatchEvent) {
wrappedDispatchEvent = function dispatchEventWithLayerForgeDropGuard(event: any) {
if (event?.type === 'before-drop-links') primeLayerForgeInputDropSuppression(canvas);
if (shouldSuppressLayerForgeNativeDrop(event?.type)) {
event?.preventDefault?.();
event?.stopPropagation?.();
return false;
}
return originalDispatchEvent.call(events, event);
};
events.dispatchEvent = wrappedDispatchEvent;
}
const beforeDropLinksHandler = (): void => {
primeLayerForgeInputDropSuppression(canvas);
};
const droppedOnCanvasHandler = (event: any): void => {
if (shouldSuppressLayerForgeNativeDrop(event?.type)) {
event?.preventDefault?.();
event?.stopPropagation?.();
event?.stopImmediatePropagation?.();
}
};
events.addEventListener?.('before-drop-links', beforeDropLinksHandler, { capture: true });
events.addEventListener?.('dropped-on-canvas', droppedOnCanvasHandler, { capture: true });
layerForgeQuickCreateCleanup = () => {
for (const target of pointerTargets) {
target?.removeEventListener?.('pointerup', handler, true);
target?.removeEventListener?.('mouseup', handler, true);
}
events.removeEventListener?.('before-drop-links', beforeDropLinksHandler, { capture: true });
events.removeEventListener?.('dropped-on-canvas', droppedOnCanvasHandler, { capture: true });
if (wrappedDispatch && events.dispatch === wrappedDispatch) events.dispatch = originalDispatch;
if (wrappedDispatchEvent && events.dispatchEvent === wrappedDispatchEvent) {
events.dispatchEvent = originalDispatchEvent;
}
canvas.__layerForgeQuickCreateInstalled = false;
if (layerForgeQuickCreateCanvas === canvas) layerForgeQuickCreateCanvas = null;
layerForgeQuickCreatePending = false;
};
return true;
};
const installLayerForgeVirtualWirePatch = (): void => {
const canvas = (app as any).canvas;
if (!canvas) return;
installLayerForgeQuickCreateCapture(canvas);
if (canvas.__layerForgeVirtualWirePatched || typeof canvas.drawConnections !== 'function') return;
const originalDrawConnections = canvas.drawConnections;
canvas.__layerForgeVirtualWirePatched = true;
canvas.drawConnections = function drawConnectionsWithLayerForgeLinks(this: any, context: CanvasRenderingContext2D) {
const result = originalDrawConnections.apply(this, arguments);
const connectionContext = context || this.bgctx || this.ctx;
const onConnectionLayer = connectionContext?.canvas === this?.bgcanvas
|| connectionContext === this?.bgctx
|| !this?.bgcanvas;
if (connectionContext && onConnectionLayer) {
drawLayerForgeVirtualLinks(this, connectionContext);
}
return result;
};
const originalProcessMouseDown = canvas.processMouseDown;
canvas.processMouseDown = function processMouseDownWithLayerForgeLinks(this: any, event: any) {
if (!hasLayerForgePendingConnection(this)) {
const [x, y] = getLayerForgeGraphPosition(this, event);
const hit = hitTestLayerForgeVirtualLinks(this.graph || app.graph, x, y);
if (hit) {
openLayerForgeVirtualLinkMenu(this, hit, event);
event?.preventDefault?.();
event?.stopImmediatePropagation?.();
return true;
}
}
return originalProcessMouseDown?.apply(this, arguments);
};
const linkPointerHandler = (event: any): void => {
if (hasLayerForgePendingConnection(canvas)) return;
const [x, y] = getLayerForgeGraphPosition(canvas, event);
const hit = hitTestLayerForgeVirtualLinks(canvas.graph || app.graph, x, y);
if (!hit) return;
openLayerForgeVirtualLinkMenu(canvas, hit, event);
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
};
canvas.canvas?.addEventListener?.('pointerdown', linkPointerHandler, true);
};
const convertLayerForgeImageConnection = (node: any, inputIndex: number, linkInfo: any = null): boolean => {
if (!node || node.__layerForgeVirtualWireClearing) return false;
const input = node.inputs?.[inputIndex];
if (!input || (String(input.name || '') !== 'input_image' && !isLayerForgeTransportInput(input.name))) {
return false;
}
const graph = node.graph || app.graph;
const linkId = input.link ?? linkInfo?.id ?? linkInfo?.link_id ?? linkInfo?.linkId;
const nativeLink = getLayerForgeGraphLink(node, linkId) || linkInfo;
if (!nativeLink) return false;
const sourceId = Number(nativeLink.origin_id ?? nativeLink.originId ?? nativeLink.from_id ?? nativeLink.fromId);
const sourceSlot = Number(nativeLink.origin_slot ?? nativeLink.originSlot ?? nativeLink.from_slot ?? nativeLink.fromSlot ?? 0);
if (!Number.isFinite(sourceId) || !Number.isFinite(sourceSlot) || sourceId === Number(node.id)) return false;
const sourceNode = graph?.getNodeById?.(sourceId);
const sourceType = sourceNode?.outputs?.[sourceSlot]?.type
|| nativeLink.type
|| 'IMAGE';
addLayerForgeImageInputLink(node, {
source_id: sourceId,
source_slot: sourceSlot,
source_type: String(sourceType),
});
node.__layerForgeVirtualWireClearing = true;
try {
if (input.link != null && typeof node.disconnectInput === 'function') {
node.disconnectInput(inputIndex);
} else if (linkId != null && typeof graph?.removeLink === 'function') {
graph.removeLink(linkId);
}
if (node.inputs?.[inputIndex]) node.inputs[inputIndex].link = null;
} finally {
node.__layerForgeVirtualWireClearing = false;
}
node.setDirtyCanvas?.(true, true);
graph?.setDirtyCanvas?.(true, true);
app.graph?.change?.();
return true;
};
const scheduleLayerForgeImageConnectionConversion = (node: any, inputIndex: number, linkInfo: any = null): void => {
setTimeout(() => convertLayerForgeImageConnection(node, inputIndex, linkInfo), 0);
if (!linkInfo) setTimeout(() => convertLayerForgeImageConnection(node, inputIndex), 50);
};
const pruneLayerForgeTransportInputs = (node: any): void => {
if (!Array.isArray(node?.inputs)) return;
for (let index = node.inputs.length - 1; index >= 0; index -= 1) {
const input = node.inputs[index];
if (!isLayerForgeTransportInput(input?.name)) continue;
if (input.link != null) convertLayerForgeImageConnection(node, index);
if (input.link != null) continue;
if (typeof node.removeInput === 'function') node.removeInput(index);
else node.inputs.splice(index, 1);
}
};
const installLayerForgeMultiImagePromptPatch = (): void => {
const appWithPrompt = app as any;
const originalGraphToPrompt = appWithPrompt.graphToPrompt;
if (typeof originalGraphToPrompt !== 'function' || originalGraphToPrompt.__layerForgeMultiImagePatched) return;
const graphToPrompt = async function (this: any, ...args: any[]): Promise<any> {
const promptData = await originalGraphToPrompt.apply(this, args);
const output = promptData?.output;
if (!output) return promptData;
for (const node of app.graph?._nodes || []) {
if (node?.comfyClass !== 'LayerForgeNode' && node?.type !== 'LayerForgeNode') continue;
const promptNode = output[String(node.id)];
if (!promptNode) continue;
promptNode.inputs ||= {};
for (let index = 1; index <= LAYERFORGE_MAX_IMAGE_INPUTS; index += 1) {
delete promptNode.inputs[`input_image_${index}`];
}
const links = getLayerForgeImageInputLinks(node)
.filter(link => output[String(link.source_id)]);
if (links.length === 0) continue;
delete promptNode.inputs.input_image;
links.forEach((link, index) => {
promptNode.inputs[`input_image_${index + 1}`] = [String(link.source_id), link.source_slot];
});
}
return promptData;
};
graphToPrompt.__layerForgeMultiImagePatched = true;
appWithPrompt.graphToPrompt = graphToPrompt;
};
app.registerExtension({
name: "Comfy.LayerForgeNode",
init() {
addStylesheet(getUrl('./css/canvas_view.css'));
installLayerForgeMultiImagePromptPatch();
installLayerForgeVirtualWirePatch();
for (const delay of [0, 100, 500, 1200]) {
setTimeout(installLayerForgeVirtualWirePatch, delay);
}
const originalQueuePrompt = app.queuePrompt;
app.queuePrompt = async function (this: ComfyApp, number: number, prompt: any) {
installLayerForgeMultiImagePromptPatch();
installLayerForgeVirtualWirePatch();
log.info("Preparing to queue prompt...");
if (canvasNodeInstances.size > 0) {
log.info(`Found ${canvasNodeInstances.size} CanvasNode(s). Sending data via WebSocket...`);
const sendPromises: Promise<any>[] = [];
for (const [nodeId, canvasWidget] of canvasNodeInstances.entries()) {
const node = app.graph.getNodeById(nodeId);
if (!node) {
log.warn(`Node ${nodeId} not found in graph, removing from instances map.`);
canvasNodeInstances.delete(nodeId);
continue;
}
// Skip bypassed nodes
if (node.mode === 4) {
log.debug(`Node ${nodeId} is bypassed, skipping data send.`);
continue;
}
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
log.debug(`Sending data for canvas node ${nodeId}`);
sendPromises.push(canvasWidget.canvas.canvasIO.sendDataViaWebSocket(nodeId));
}
}
try {
await Promise.all(sendPromises);
log.info("All canvas data has been sent and acknowledged by the server.");
} catch (error: any) {
log.error("Failed to send canvas data for one or more nodes. Aborting prompt.", error);
showErrorNotification(`CanvasNode Error: ${error.message}`);
return;
}
}
log.info("All pre-prompt tasks complete. Proceeding with original queuePrompt.");
return originalQueuePrompt.apply(this, arguments as any);
};
},
async beforeRegisterNodeDef(nodeType: any, nodeData: any, app: ComfyApp) {
if (nodeType.comfyClass === "LayerForgeNode") {
// Map to track pending copy sources across node ID changes
const pendingCopySources = new Map<number, number>();
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function (this: ComfyNode) {
log.debug("CanvasNode onNodeCreated: Base widget setup.");
const r = onNodeCreated?.apply(this, arguments as any);
const nodeWithPreviewHook = this as any;
const originalAddCustomWidget = nodeWithPreviewHook.addCustomWidget;
if (typeof originalAddCustomWidget === "function" && !nodeWithPreviewHook.__layerForgePreviewWidgetHooked) {
nodeWithPreviewHook.addCustomWidget = function (customWidget: any, ...args: any[]) {
if (customWidget?.name === "$$canvas-image-preview" || customWidget?.type === "IMAGE_PREVIEW") {
const showPreviewWidget = this.widgets?.find((widget: any) => widget.name === "show_preview");
configureCanvasImagePreviewWidget(customWidget, showPreviewWidget?.value === true);
}
return originalAddCustomWidget.call(this, customWidget, ...args);
};
nodeWithPreviewHook.__layerForgePreviewWidgetHooked = true;
}
(this as any).properties ||= {};
pruneLayerForgeTransportInputs(this);
this.size = [1150, 1000];
return r;
};
nodeType.prototype.onAdded = async function (this: ComfyNode) {
log.info(`CanvasNode onAdded, ID: ${this.id}`);
log.debug(`Available widgets in onAdded:`, this.widgets.map((w) => w.name));
if ((this as any).canvasWidget) {
log.warn(`CanvasNode ${this.id} already initialized. Skipping onAdded setup.`);
return;
}
this.widgets.forEach((w) => {
log.debug(`Widget name: ${w.name}, type: ${w.type}, value: ${w.value}`);
});
const nodeIdWidget = this.widgets.find((w) => w.name === "node_id");
if (nodeIdWidget) {
nodeIdWidget.value = String(this.id);
log.debug(`Set hidden node_id widget to: ${nodeIdWidget.value}`);
} else {
log.error("Could not find the hidden node_id widget!");
}
const canvasWidget = await createCanvasWidget(this, null, app);
canvasNodeInstances.set(this.id, canvasWidget);
log.info(`Registered CanvasNode instance for ID: ${this.id}`);
// Store the canvas widget on the node
(this as any).canvasWidget = canvasWidget;
// Check if this node has a pending copy source (from onConfigure)
// Check both the current ID and -1 (temporary ID during paste)
let sourceNodeId = pendingCopySources.get(this.id);
if (!sourceNodeId) {
sourceNodeId = pendingCopySources.get(-1);
if (sourceNodeId) {
// Transfer from -1 to the real ID and clear -1
pendingCopySources.delete(-1);
}
}
if (sourceNodeId && sourceNodeId !== this.id) {
log.info(`Node ${this.id} will copy canvas state from node ${sourceNodeId}`);
// Clear the flag
pendingCopySources.delete(this.id);
// Copy the canvas state now that the widget is initialized
setTimeout(async () => {
try {
const sourceNode = (this as any).graph?.getNodeById?.(sourceNodeId);
let sourceState = sourceNode
? await getCanvasState(getCanvasStateKey(sourceNode))
: null;
// If source node doesn't exist (cross-workflow paste), try clipboard
if (!sourceState) {
log.debug(`No canvas state found for source node ${sourceNodeId}, checking clipboard`);
sourceState = await getCanvasState('__clipboard__');
}
if (!sourceState) {
log.debug(`No canvas state found in clipboard either`);
return;
}
await setCanvasState(getCanvasStateKey(this), sourceState);
await canvasWidget.canvas.loadInitialState();
log.info(`Canvas state copied successfully to node ${this.id}`);
} catch (error) {
log.error(`Error copying canvas state:`, error);
}
}, 100);
}
// Check if there are already connected inputs
setTimeout(() => {
if (this.inputs && this.inputs.length > 0) {
// Check if input_image is connected, including virtual
// links restored from a saved workflow.
if (hasLayerForgeImageInput(this)) {
log.info("Input image already connected on node creation, checking for data...");
if (canvasWidget.canvas && canvasWidget.canvas.canvasIO) {
canvasWidget.canvas.inputDataLoaded = false;
// Only allow images on init; mask should load only on mask connect or execution
canvasWidget.canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "init_image_connected" });
}
}
}
if (this.setDirtyCanvas) {
this.setDirtyCanvas(true, true);
}
}, 500);
};
// Add onConnectionsChange handler to detect when inputs are connected
nodeType.prototype.onConnectionsChange = function (this: ComfyNode, type: number, index: number, connected: boolean, link_info: any) {
log.info(`onConnectionsChange called: type=${type}, index=${index}, connected=${connected}`, link_info);
if ((this as any).__layerForgeVirtualWireClearing) return;
// Check if this is an input connection (type 1 = INPUT)
if (type === 1) {
const inputName = String((this.inputs as any)?.[index]?.name || '');
const isImageInput = inputName === 'input_image' || isLayerForgeTransportInput(inputName) || index === 0;
const isMaskInput = inputName === 'input_mask' || index === 1;
if (connected && link_info && (inputName === 'input_image' || isLayerForgeTransportInput(inputName))) {
scheduleLayerForgeImageConnectionConversion(this, index, link_info);
}
// Get the canvas widget - it might be in different places
const canvasWidget = (this as any).canvasWidget;
const canvas = canvasWidget?.canvas || canvasWidget;
if (!canvas || !canvas.canvasIO) {
log.warn("Canvas not ready in onConnectionsChange, scheduling retry...");
// Retry multiple times with increasing delays
const retryDelays = [500, 1000, 2000];
let retryCount = 0;
const tryAgain = () => {
const retryCanvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
if (retryCanvas && retryCanvas.canvasIO) {
log.info("Canvas now ready, checking for input data...");
if (connected) {
retryCanvas.inputDataLoaded = false;
// Respect which input triggered the connection:
const opts = isMaskInput
? { allowImage: false, allowMask: true, reason: "mask_connect" }
: { allowImage: true, allowMask: false, reason: "image_connect" };
retryCanvas.canvasIO.checkForInputData(opts);
}
} else if (retryCount < retryDelays.length) {
log.warn(`Canvas still not ready, retry ${retryCount + 1}/${retryDelays.length}...`);
setTimeout(tryAgain, retryDelays[retryCount++]);
} else {
log.error("Canvas failed to initialize after multiple retries");
}
};
setTimeout(tryAgain, retryDelays[retryCount++]);
return;
}
// Handle input_image connection (including the virtual multi-link port)
if (isImageInput) {
if (connected && link_info) {
log.info("Input image connected, marking for data check...");
// Reset the input data loaded flag to allow loading the new connection
canvas.inputDataLoaded = false;
// Also reset the last loaded image source and link ID to allow the new image
canvas.lastLoadedImageSrc = undefined;
canvas.lastLoadedLinkId = undefined;
// Mark that we have a pending input connection
canvas.hasPendingInputConnection = true;
// If mask input is not connected and a mask was auto-applied from input_mask before, clear it now
if (getLayerForgeMaskInputSlot(this)?.link == null) {
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
canvas.maskTool.clear();
canvas.render();
(canvas as any).maskAppliedFromInput = false;
canvas.lastLoadedMaskLinkId = undefined;
log.info("Cleared auto-applied mask because input_image connected without input_mask");
}
}
// Check for data immediately when connected
setTimeout(() => {
log.info("Checking for input data after connection...");
// Only load images here; masks should not auto-load on image connect
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: false, reason: "image_connect" });
}, 500);
} else {
log.info("Input image disconnected");
canvas.hasPendingInputConnection = false;
// Reset when disconnected so a new connection can load
canvas.inputDataLoaded = false;
canvas.lastLoadedImageSrc = undefined;
canvas.lastLoadedLinkId = undefined;
}
}
// Handle input_mask connection (index 1)
if (isMaskInput) {
if (connected && link_info) {
log.info("Input mask connected");
// DON'T clear existing mask when connecting a new input
// Reset the loaded mask link ID to allow loading from the new connection
canvas.lastLoadedMaskLinkId = undefined;
// Mark that we have a pending mask connection
canvas.hasPendingMaskConnection = true;
// Check for data immediately when connected
setTimeout(() => {
log.info("Checking for input data after mask connection...");
// Only load mask here if it's immediately available from the connected node
// Don't load stale masks from backend storage
canvas.canvasIO.checkForInputData({ allowImage: false, allowMask: true, reason: "mask_connect" });
}, 500);
} else {
log.info("Input mask disconnected");
canvas.hasPendingMaskConnection = false;
// If the current mask came from input_mask, clear it to avoid affecting images when mask is not connected
if ((canvas as any).maskAppliedFromInput && canvas.maskTool) {
(canvas as any).maskAppliedFromInput = false;
canvas.lastLoadedMaskLinkId = undefined;
log.info("Cleared auto-applied mask due to mask input disconnection");
}
}
}
}
};
// Add onExecuted handler to check for input data after workflow execution
const originalOnExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (this: ComfyNode, message: any) {
log.info("Node executed, checking for input data...");
const canvas = (this as any).canvasWidget?.canvas || (this as any).canvasWidget;
if (canvas && canvas.canvasIO) {
// Don't reset inputDataLoaded - just check for new data
// On execution we allow both image and mask to load
canvas.canvasIO.checkForInputData({ allowImage: true, allowMask: true, reason: "execution" });
}
// Call original if it exists
if (originalOnExecuted) {
originalOnExecuted.apply(this, arguments as any);
}
};
const onRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function (this: ComfyNode) {
log.info(`Cleaning up canvas node ${this.id}`);
// Clean up temp file tracker for this node (just remove from tracker)
const nodeKey = `node-${this.id}`;
const tempFileTracker = (window as any).layerForgeTempFileTracker;
if (tempFileTracker && tempFileTracker.has(nodeKey)) {
tempFileTracker.delete(nodeKey);
log.debug(`Removed temp file tracker for node ${this.id}`);
}
canvasNodeInstances.delete(this.id);
log.info(`Deregistered CanvasNode instance for ID: ${this.id}`);
if ((window as any).canvasExecutionStates) {
(window as any).canvasExecutionStates.delete(this.id);
}
const tooltip = document.getElementById(`painter-help-tooltip-${this.id}`);
if (tooltip) {
tooltip.remove();
}
const backdrop = document.querySelector('.lf-painter-modal-backdrop');
if (backdrop && (this as any).canvasWidget && backdrop.contains((this as any).canvasWidget.canvas.canvas)) {
document.body.removeChild(backdrop);
}
if ((this as any).canvasWidget && (this as any).canvasWidget.destroy) {
(this as any).canvasWidget.destroy();
}
return onRemoved?.apply(this, arguments as any);
};
// Handle copy/paste - save canvas state when copying
const originalSerialize = nodeType.prototype.serialize;
nodeType.prototype.serialize = function (this: ComfyNode) {
const data = originalSerialize ? originalSerialize.apply(this) : {};
// Store a reference to the source node ID so we can copy layer data
data.sourceNodeId = this.id;
log.debug(`Serializing node ${this.id} for copy`);
// Store canvas state in a clipboard entry for cross-workflow paste
// This happens async but that's fine since paste happens later
(async () => {
try {
const sourceState = await getCanvasState(getCanvasStateKey(this));
if (sourceState) {
// Store in a special "clipboard" entry
await setCanvasState('__clipboard__', sourceState);
log.debug(`Stored canvas state in clipboard for node ${this.id}`);
}
} catch (error) {
log.error('Error storing canvas state to clipboard:', error);
}
})();
return data;
};
// Handle copy/paste - load canvas state from source node when pasting
const originalConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = async function (this: ComfyNode, data: any) {
if (originalConfigure) {
originalConfigure.apply(this, [data]);
}
(this as any).properties ||= {};
pruneLayerForgeTransportInputs(this);
// Store the source node ID in the map (persists across node ID changes)
// This will be picked up later in onAdded when the canvas widget is ready
if (data.sourceNodeId && data.sourceNodeId !== this.id) {
const existingSource = pendingCopySources.get(this.id);
if (!existingSource) {
pendingCopySources.set(this.id, data.sourceNodeId);
log.debug(`Stored pending copy source: ${data.sourceNodeId} for node ${this.id}`);
}
}
};
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function (this: ComfyNode, _: any, options: any[]) {
// FIRST: Call original to let other extensions add their options
originalGetExtraMenuOptions?.apply(this, arguments as any);
const self = this;
// Debug: Log all menu options AFTER other extensions have added theirs
log.info("Available menu options AFTER original call:", options.map((opt, idx) => ({
index: idx,
content: opt?.content,
hasCallback: !!opt?.callback
})));
// Debug: Check node data to see what Impact Pack sees
const nodeData = (self as any).constructor.nodeData || {};
log.info("Node data for Impact Pack check:", {
output: nodeData.output,
outputType: typeof nodeData.output,
isArray: Array.isArray(nodeData.output),
nodeType: (self as any).type,
comfyClass: (self as any).comfyClass
});
// Additional debug: Check if any option contains common Impact Pack keywords
const impactOptions = options.filter((opt, idx) => {
if (!opt || !opt.content) return false;
const content = opt.content.toLowerCase();
return content.includes('impact') ||
content.includes('sam') ||
content.includes('detector') ||
content.includes('segment') ||
content.includes('mask') ||
content.includes('open in');
});
if (impactOptions.length > 0) {
log.info("Found potential Impact Pack options:", impactOptions.map(opt => opt.content));
} else {
log.info("No Impact Pack-related options found in menu");
}
// Debug: Check if Impact Pack extension is loaded
const impactExtensions = app.extensions.filter((ext: any) =>
ext.name && ext.name.toLowerCase().includes('impact')
);
log.info("Impact Pack extensions found:", impactExtensions.map((ext: any) => ext.name));
// Debug: Check menu options again after a delay to see if Impact Pack adds options later
setTimeout(() => {
log.info("Menu options after 100ms delay:", options.map((opt, idx) => ({
index: idx,
content: opt?.content,
hasCallback: !!opt?.callback
})));
// Try to find SAM Detector again
const delayedSamDetectorIndex = options.findIndex((option) =>
option && option.content && (
option.content.includes("SAM Detector") ||
option.content.includes("SAM") ||
option.content.includes("Detector") ||
option.content.toLowerCase().includes("sam") ||
option.content.toLowerCase().includes("detector")
)
);
if (delayedSamDetectorIndex !== -1) {
log.info(`Found SAM Detector after delay at index ${delayedSamDetectorIndex}: "${options[delayedSamDetectorIndex].content}"`);
} else {
log.info("SAM Detector still not found after delay");
}
}, 100);
// Debug: Let's also check what the Impact Pack extension actually does
const samExtension = app.extensions.find((ext: any) => ext.name === 'Comfy.Impact.SAMEditor');
if (samExtension) {
log.info("SAM Extension details:", {
name: samExtension.name,
hasBeforeRegisterNodeDef: !!samExtension.beforeRegisterNodeDef,
hasInit: !!samExtension.init
});
}
// Remove our old MaskEditor if it exists
const maskEditorIndex = options.findIndex((option) => option && option.content === "Open in MaskEditor");
if (maskEditorIndex !== -1) {
options.splice(maskEditorIndex, 1);
}
// Hook into "Open in SAM Detector" using the new integration module
setupSAMDetectorHook(self, options);
const runCanvasExport = async (
action: CanvasExportAction,
variant: CanvasBlobVariant,
filename?: string,
): Promise<void> => {
const canvas = (self as any).canvasWidget?.canvas;
if (!canvas) return;
const withMask = variant === 'with-mask';
const imageLabel = withMask ? 'image with mask' : 'image';
try {
const exported = await exportCanvasImage(canvas, { action, variant, filename });
if (exported && action === 'copy') {
log.info(`${withMask ? 'Image with mask alpha' : 'Image'} copied to clipboard.`);
}
} catch (error) {
log.error(`Error ${action === 'open' ? 'opening' : action === 'copy' ? 'copying' : 'saving'} ${imageLabel}:`, error);
if (action === 'copy') {
showErrorNotification(`Failed to copy ${withMask ? 'image with mask to clipboard.' : 'image to clipboard.'}`);
}
}
};
const virtualImageLinks = getLayerForgeImageInputLinks(self);
const newOptions = [
...(virtualImageLinks.length > 0 ? [{
content: `Clear ${virtualImageLinks.length} connected input image${virtualImageLinks.length === 1 ? '' : 's'}`,
callback: () => {
const cleared = clearLayerForgeImageInputLinks(self);
if (!cleared) return;
self.setDirtyCanvas?.(true, true);
app.graph?.change?.();
const canvas = (self as any).canvasWidget?.canvas;
if (canvas) {
canvas.inputDataLoaded = false;
canvas.lastLoadedImageSrc = undefined;
canvas.lastLoadedLinkId = undefined;
}
},
}] : []),
{
content: "Open in MaskEditor",
callback: async () => {
try {
log.info("Opening LayerForge canvas in MaskEditor");
if ((self as any).canvasWidget && (self as any).canvasWidget.canvas) {
await (self as any).canvasWidget.canvas.startMaskEditor(null, true);
} else {
log.error("Canvas widget not available");
showErrorNotification("Canvas not ready. Please try again.");
}
} catch (e: any) {
log.error("Error opening MaskEditor:", e);
showErrorNotification(`Failed to open MaskEditor: ${e.message}`);
}
},
},
{
content: "Open Image",
callback: () => runCanvasExport('open', 'plain'),
},
{
content: "Open Image with Mask Alpha",
callback: () => runCanvasExport('open', 'with-mask'),
},
{
content: "Copy Image",
callback: () => runCanvasExport('copy', 'plain'),
},
{
content: "Copy Image with Mask Alpha",
callback: () => runCanvasExport('copy', 'with-mask'),
},
{
content: "Save Image",
callback: () => runCanvasExport('download', 'plain', 'canvas_output.png'),
},
{
content: "Save Image with Mask Alpha",
callback: () => runCanvasExport('download', 'with-mask', 'canvas_output_with_mask.png'),
},
];
if (options.length > 0) {
options.unshift({content: "___", disabled: true});
}
options.unshift(...newOptions);
};
}
}
});