mirror of
https://github.com/Azornes/Comfyui-LayerForge.git
synced 2026-03-22 05:02:11 -03:00
Added calls to saveState() after scaling, rotating, and removing layers to ensure changes are properly recorded in the undo/redo history. Also refactored the matting button UI for better layout and ensured saveState is called after matting results are applied.
1193 lines
43 KiB
JavaScript
1193 lines
43 KiB
JavaScript
import {app} from "../../scripts/app.js";
|
|
import {api} from "../../scripts/api.js";
|
|
import {$el} from "../../scripts/ui.js";
|
|
import {Canvas} from "./Canvas.js";
|
|
import { clearAllCanvasStates } from "./db.js";
|
|
|
|
async function createCanvasWidget(node, widget, app) {
|
|
const canvas = new Canvas(node, widget);
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.painter-button {
|
|
background: linear-gradient(to bottom, #4a4a4a, #3a3a3a);
|
|
border: 1px solid #2a2a2a;
|
|
border-radius: 4px;
|
|
color: #ffffff;
|
|
padding: 6px 12px;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
min-width: 80px;
|
|
text-align: center;
|
|
margin: 2px;
|
|
text-shadow: 0 1px 1px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
.painter-button:hover {
|
|
background: linear-gradient(to bottom, #5a5a5a, #4a4a4a);
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
|
}
|
|
|
|
.painter-button:active {
|
|
background: linear-gradient(to bottom, #3a3a3a, #4a4a4a);
|
|
transform: translateY(1px);
|
|
}
|
|
|
|
.painter-button:disabled,
|
|
.painter-button:disabled:hover {
|
|
background: #555;
|
|
color: #888;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
box-shadow: none;
|
|
border-color: #444;
|
|
}
|
|
|
|
.painter-button.primary {
|
|
background: linear-gradient(to bottom, #4a6cd4, #3a5cc4);
|
|
border-color: #2a4cb4;
|
|
}
|
|
|
|
.painter-button.primary:hover {
|
|
background: linear-gradient(to bottom, #5a7ce4, #4a6cd4);
|
|
}
|
|
|
|
.painter-controls {
|
|
background: linear-gradient(to bottom, #404040, #383838);
|
|
border-bottom: 1px solid #2a2a2a;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
padding: 8px;
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
}
|
|
|
|
.painter-container {
|
|
background: #607080; /* 带蓝色的灰色背景 */
|
|
border: 1px solid #4a5a6a;
|
|
border-radius: 6px;
|
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.1);
|
|
transition: border-color 0.3s ease; /* Dodano dla płynnej zmiany ramki */
|
|
}
|
|
|
|
.painter-container.drag-over {
|
|
border-color: #00ff00; /* Zielona ramka podczas przeciągania */
|
|
border-style: dashed;
|
|
}
|
|
|
|
.painter-dialog {
|
|
background: #404040;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
padding: 20px;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.painter-dialog input {
|
|
background: #303030;
|
|
border: 1px solid #505050;
|
|
border-radius: 4px;
|
|
color: #ffffff;
|
|
padding: 4px 8px;
|
|
margin: 4px;
|
|
width: 80px;
|
|
}
|
|
|
|
.painter-dialog button {
|
|
background: #505050;
|
|
border: 1px solid #606060;
|
|
border-radius: 4px;
|
|
color: #ffffff;
|
|
padding: 4px 12px;
|
|
margin: 4px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.painter-dialog button:hover {
|
|
background: #606060;
|
|
}
|
|
|
|
.blend-opacity-slider {
|
|
width: 100%;
|
|
margin: 5px 0;
|
|
display: none;
|
|
}
|
|
|
|
.blend-mode-active .blend-opacity-slider {
|
|
display: block;
|
|
}
|
|
|
|
.blend-mode-item {
|
|
padding: 5px;
|
|
cursor: pointer;
|
|
position: relative;
|
|
}
|
|
|
|
.blend-mode-item.active {
|
|
background-color: rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.blend-mode-item.active {
|
|
background-color: rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.painter-tooltip {
|
|
position: fixed; /* Pozycjonowanie względem okna przeglądarki */
|
|
display: none; /* Domyślnie ukryty */
|
|
background: #3a3a3a;
|
|
color: #f0f0f0;
|
|
border: 1px solid #555;
|
|
border-radius: 8px;
|
|
padding: 12px 18px;
|
|
z-index: 1001; /* Wyżej niż inne elementy UI */
|
|
font-size: 13px;
|
|
line-height: 1.7;
|
|
max-width: 400px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
pointer-events: none; /* Zapobiega interakcji myszy z dymkiem */
|
|
}
|
|
|
|
.painter-tooltip h4 {
|
|
margin-top: 10px;
|
|
margin-bottom: 5px;
|
|
color: #4a90e2; /* Jasnoniebieski akcent */
|
|
border-bottom: 1px solid #555;
|
|
padding-bottom: 4px;
|
|
}
|
|
|
|
.painter-tooltip ul {
|
|
list-style: none;
|
|
padding-left: 10px;
|
|
margin: 0;
|
|
}
|
|
|
|
.painter-tooltip kbd {
|
|
background-color: #2a2a2a;
|
|
border: 1px solid #1a1a1a;
|
|
border-radius: 3px;
|
|
padding: 2px 6px;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
color: #d0d0d0;
|
|
}
|
|
|
|
.painter-container.has-focus {
|
|
/* Używamy box-shadow, aby stworzyć efekt zewnętrznej ramki,
|
|
która nie wpłynie na rozmiar ani pozycję elementu. */
|
|
box-shadow: 0 0 0 2px white;
|
|
/* Możesz też zmienić kolor istniejącej ramki, ale box-shadow jest bardziej wyrazisty */
|
|
/* border-color: white; */
|
|
}
|
|
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
const helpTooltip = $el("div.painter-tooltip", {
|
|
id: `painter-help-tooltip-${node.id}`,
|
|
innerHTML: `
|
|
<h4>Canvas Control</h4>
|
|
<ul>
|
|
<li><kbd>Click + Drag</kbd> - Pan canvas view</li>
|
|
<li><kbd>Mouse Wheel</kbd> - Zoom view in/out</li>
|
|
<li><kbd>Shift + Click (background)</kbd> - Start resizing canvas area</li>
|
|
<li><kbd>Shift + Ctrl + Click</kbd> - Start moving entire canvas</li>
|
|
<li><kbd>Double Click (background)</kbd> - Deselect all layers</li>
|
|
</ul>
|
|
<h4>Clipboard & I/O</h4>
|
|
<ul>
|
|
<li><kbd>Ctrl + C</kbd> - Copy selected layer(s)</li>
|
|
<li><kbd>Ctrl + V</kbd> - Paste from clipboard (image or internal layers)</li>
|
|
<li><kbd>Drag & Drop Image File</kbd> - Add image as a new layer</li>
|
|
</ul>
|
|
<h4>Layer Interaction</h4>
|
|
<ul>
|
|
<li><kbd>Click + Drag</kbd> - Move selected layer(s)</li>
|
|
<li><kbd>Ctrl + Click</kbd> - Add/Remove layer from selection</li>
|
|
<li><kbd>Alt + Drag</kbd> - Clone selected layer(s)</li>
|
|
<li><kbd>Shift + Click</kbd> - Show blend mode & opacity menu</li>
|
|
<li><kbd>Mouse Wheel</kbd> - Scale layer (snaps to grid)</li>
|
|
<li><kbd>Ctrl + Mouse Wheel</kbd> - Fine-scale layer</li>
|
|
<li><kbd>Shift + Mouse Wheel</kbd> - Rotate layer by 5°</li>
|
|
<li><kbd>Arrow Keys</kbd> - Nudge layer by 1px</li>
|
|
<li><kbd>Shift + Arrow Keys</kbd> - Nudge layer by 10px</li>
|
|
<li><kbd>[</kbd> or <kbd>]</kbd> - Rotate by 1°</li>
|
|
<li><kbd>Shift + [</kbd> or <kbd>]</kbd> - Rotate by 10°</li>
|
|
<li><kbd>Delete</kbd> - Delete selected layer(s)</li>
|
|
</ul>
|
|
<h4>Transform Handles (on selected layer)</h4>
|
|
<ul>
|
|
<li><kbd>Drag Corner/Side</kbd> - Resize layer</li>
|
|
<li><kbd>Drag Rotation Handle</kbd> - Rotate layer</li>
|
|
<li><kbd>Hold Shift</kbd> - Keep aspect ratio / Snap rotation to 15°</li>
|
|
<li><kbd>Hold Ctrl</kbd> - Snap to grid</li>
|
|
</ul>
|
|
`
|
|
});
|
|
|
|
document.body.appendChild(helpTooltip);
|
|
const controlPanel = $el("div.painterControlPanel", {}, [
|
|
$el("div.controls.painter-controls", {
|
|
style: {
|
|
position: "absolute",
|
|
top: "0",
|
|
left: "0",
|
|
right: "0",
|
|
minHeight: "50px",
|
|
zIndex: "10",
|
|
background: "linear-gradient(to bottom, #404040, #383838)",
|
|
borderBottom: "1px solid #2a2a2a",
|
|
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
|
|
padding: "8px",
|
|
display: "flex",
|
|
gap: "6px",
|
|
flexWrap: "wrap",
|
|
alignItems: "center"
|
|
},
|
|
|
|
onresize: (entries) => {
|
|
const controlsHeight = entries[0].target.offsetHeight;
|
|
canvasContainer.style.top = (controlsHeight + 10) + "px";
|
|
}
|
|
}, [
|
|
$el("button.painter-button", {
|
|
textContent: "?",
|
|
title: "Show shortcuts",
|
|
style: {
|
|
minWidth: "30px",
|
|
maxWidth: "30px",
|
|
fontWeight: "bold",
|
|
},
|
|
onmouseenter: (e) => {
|
|
const rect = e.target.getBoundingClientRect();
|
|
helpTooltip.style.left = `${rect.left}px`;
|
|
helpTooltip.style.top = `${rect.bottom + 5}px`;
|
|
helpTooltip.style.display = 'block';
|
|
},
|
|
onmouseleave: () => {
|
|
helpTooltip.style.display = 'none';
|
|
}
|
|
}),
|
|
$el("button.painter-button.primary", {
|
|
textContent: "Add Image",
|
|
onclick: () => {
|
|
console.log("Add Image button clicked.");
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/*';
|
|
input.multiple = true;
|
|
input.onchange = async (e) => {
|
|
for (const file of e.target.files) {
|
|
console.log("File selected:", file.name);
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
console.log("FileReader finished loading file as data:URL.");
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
console.log("Image object loaded from data:URL.");
|
|
const scale = Math.min(
|
|
canvas.width / img.width,
|
|
canvas.height / img.height
|
|
);
|
|
|
|
const layer = {
|
|
image: img,
|
|
x: (canvas.width - img.width * scale) / 2,
|
|
y: (canvas.height - img.height * scale) / 2,
|
|
width: img.width * scale,
|
|
height: img.height * scale,
|
|
rotation: 0,
|
|
zIndex: canvas.layers.length
|
|
};
|
|
|
|
canvas.layers.push(layer);
|
|
canvas.updateSelection([layer]);
|
|
canvas.render();
|
|
canvas.saveState();
|
|
console.log("New layer added and state saved.");
|
|
await canvas.saveToServer(widget.value);
|
|
app.graph.runStep();
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
}),
|
|
$el("button.painter-button.primary", {
|
|
textContent: "Import Input",
|
|
onclick: async () => {
|
|
try {
|
|
console.log("Import Input clicked");
|
|
const success = await canvas.importLatestImage();
|
|
if (success) {
|
|
await canvas.saveToServer(widget.value);
|
|
app.graph.runStep();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error during import input process:", error);
|
|
alert(`Failed to import input: ${error.message}`);
|
|
}
|
|
}
|
|
}),
|
|
$el("button.painter-button.primary", {
|
|
textContent: "Paste Image",
|
|
onclick: async () => {
|
|
console.log("Paste Image button clicked.");
|
|
try {
|
|
if (!navigator.clipboard || !navigator.clipboard.read) {
|
|
console.warn("Clipboard API not supported.");
|
|
alert("Your browser does not support pasting from the clipboard.");
|
|
return;
|
|
}
|
|
const clipboardItems = await navigator.clipboard.read();
|
|
let imageFound = false;
|
|
|
|
for (const item of clipboardItems) {
|
|
const imageType = item.types.find(type => type.startsWith('image/'));
|
|
|
|
if (imageType) {
|
|
console.log("Image found in clipboard.");
|
|
const blob = await item.getType(imageType);
|
|
const reader = new FileReader();
|
|
reader.onload = (event) => {
|
|
console.log("FileReader finished loading pasted blob as data:URL.");
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
console.log("Image object loaded from pasted data:URL.");
|
|
const scale = Math.min(
|
|
canvas.width / img.width,
|
|
canvas.height / img.height
|
|
);
|
|
|
|
const layer = {
|
|
image: img,
|
|
x: (canvas.width - img.width * scale) / 2,
|
|
y: (canvas.height - img.height * scale) / 2,
|
|
width: img.width * scale,
|
|
height: img.height * scale,
|
|
rotation: 0,
|
|
zIndex: canvas.layers.length
|
|
};
|
|
|
|
canvas.layers.push(layer);
|
|
canvas.updateSelection([layer]);
|
|
canvas.render();
|
|
canvas.saveState();
|
|
console.log("Pasted layer added and state saved.");
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(blob);
|
|
imageFound = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!imageFound) {
|
|
console.warn("No image found in clipboard.");
|
|
alert("No image found in the clipboard.");
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error("Failed to paste image:", err);
|
|
alert("Could not paste image. Please ensure you have granted clipboard permissions or that there is an image in the clipboard.");
|
|
}
|
|
}
|
|
}),
|
|
|
|
|
|
$el("button.painter-button", {
|
|
textContent: "Canvas Size",
|
|
onclick: () => {
|
|
const dialog = $el("div.painter-dialog", {
|
|
style: {
|
|
position: 'fixed',
|
|
left: '50%',
|
|
top: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
zIndex: '1000'
|
|
}
|
|
}, [
|
|
$el("div", {
|
|
style: {
|
|
color: "white",
|
|
marginBottom: "10px"
|
|
}
|
|
}, [
|
|
$el("label", {
|
|
style: {
|
|
marginRight: "5px"
|
|
}
|
|
}, [
|
|
$el("span", {}, ["Width: "])
|
|
]),
|
|
$el("input", {
|
|
type: "number",
|
|
id: "canvas-width",
|
|
value: canvas.width,
|
|
min: "1",
|
|
max: "4096"
|
|
})
|
|
]),
|
|
$el("div", {
|
|
style: {
|
|
color: "white",
|
|
marginBottom: "10px"
|
|
}
|
|
}, [
|
|
$el("label", {
|
|
style: {
|
|
marginRight: "5px"
|
|
}
|
|
}, [
|
|
$el("span", {}, ["Height: "])
|
|
]),
|
|
$el("input", {
|
|
type: "number",
|
|
id: "canvas-height",
|
|
value: canvas.height,
|
|
min: "1",
|
|
max: "4096"
|
|
})
|
|
]),
|
|
$el("div", {
|
|
style: {
|
|
textAlign: "right"
|
|
}
|
|
}, [
|
|
$el("button", {
|
|
id: "cancel-size",
|
|
textContent: "Cancel"
|
|
}),
|
|
$el("button", {
|
|
id: "confirm-size",
|
|
textContent: "OK"
|
|
})
|
|
])
|
|
]);
|
|
document.body.appendChild(dialog);
|
|
|
|
document.getElementById('confirm-size').onclick = () => {
|
|
const width = parseInt(document.getElementById('canvas-width').value) || canvas.width;
|
|
const height = parseInt(document.getElementById('canvas-height').value) || canvas.height;
|
|
canvas.updateCanvasSize(width, height);
|
|
document.body.removeChild(dialog);
|
|
};
|
|
|
|
document.getElementById('cancel-size').onclick = () => {
|
|
document.body.removeChild(dialog);
|
|
};
|
|
}
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Remove Layer",
|
|
onclick: () => {
|
|
if (canvas.selectedLayers.length > 0) {
|
|
canvas.saveState();
|
|
canvas.layers = canvas.layers.filter(l => !canvas.selectedLayers.includes(l));
|
|
canvas.updateSelection([]);
|
|
canvas.render();
|
|
canvas.saveState();
|
|
}
|
|
}
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Rotate +90°",
|
|
onclick: () => canvas.rotateLayer(90)
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Scale +5%",
|
|
onclick: () => canvas.resizeLayer(1.05)
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Scale -5%",
|
|
onclick: () => canvas.resizeLayer(0.95)
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Layer Up",
|
|
onclick: async () => {
|
|
canvas.moveLayerUp();
|
|
await canvas.saveToServer(widget.value);
|
|
app.graph.runStep();
|
|
}
|
|
}),
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Layer Down",
|
|
onclick: async () => {
|
|
canvas.moveLayerDown();
|
|
await canvas.saveToServer(widget.value);
|
|
app.graph.runStep();
|
|
}
|
|
}),
|
|
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Mirror H",
|
|
onclick: () => {
|
|
canvas.mirrorHorizontal();
|
|
}
|
|
}),
|
|
|
|
$el("button.painter-button.requires-selection", {
|
|
textContent: "Mirror V",
|
|
onclick: () => {
|
|
canvas.mirrorVertical();
|
|
}
|
|
}),
|
|
|
|
$el("button.painter-button", {
|
|
id: `undo-button-${node.id}`,
|
|
textContent: "Undo",
|
|
disabled: true,
|
|
onclick: () => canvas.undo()
|
|
}),
|
|
$el("button.painter-button", {
|
|
id: `redo-button-${node.id}`,
|
|
textContent: "Redo",
|
|
disabled: true,
|
|
onclick: () => canvas.redo()
|
|
}),
|
|
$el("div", {
|
|
style: {
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px"
|
|
}
|
|
}, [
|
|
$el("button.painter-button.requires-selection.matting-button", {
|
|
textContent: "Matting",
|
|
onclick: async (e) => {
|
|
const statusIndicator = MattingStatusIndicator.getInstance(e.target.parentElement);
|
|
|
|
try {
|
|
if (canvas.selectedLayers.length !== 1) {
|
|
throw new Error("Please select exactly one image layer for matting.");
|
|
}
|
|
statusIndicator.setStatus('processing');
|
|
|
|
const selectedLayer = canvas.selectedLayers[0];
|
|
const imageData = await canvas.getLayerImageData(selectedLayer);
|
|
|
|
console.log("Sending image to server for matting...");
|
|
|
|
const response = await fetch("/matting", {
|
|
method: "POST",
|
|
headers: {"Content-Type": "application/json"},
|
|
body: JSON.stringify({image: imageData})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Server error: ${response.status} - ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
console.log("Creating new layer with matting result...");
|
|
|
|
const mattedImage = new Image();
|
|
mattedImage.onload = async () => {
|
|
const newImage = new Image();
|
|
newImage.onload = async () => {
|
|
const newLayer = {
|
|
image: newImage,
|
|
x: selectedLayer.x,
|
|
y: selectedLayer.y,
|
|
width: selectedLayer.width,
|
|
height: selectedLayer.height,
|
|
rotation: selectedLayer.rotation,
|
|
zIndex: canvas.layers.length + 1
|
|
};
|
|
canvas.layers.push(newLayer);
|
|
canvas.updateSelection([newLayer]);
|
|
canvas.render();
|
|
canvas.saveState();
|
|
|
|
await canvas.saveToServer(widget.value);
|
|
app.graph.runStep();
|
|
statusIndicator.setStatus('completed');
|
|
};
|
|
newImage.src = result.matted_image;
|
|
};
|
|
mattedImage.onerror = () => {
|
|
throw new Error("Failed to load the matted image from server response.");
|
|
};
|
|
mattedImage.src = result.matted_image;
|
|
|
|
} catch (error) {
|
|
console.error("Matting error:", error);
|
|
alert(`Error during matting process: ${error.message}`);
|
|
statusIndicator.setStatus('error');
|
|
}
|
|
}
|
|
})
|
|
]),
|
|
$el("button.painter-button", {
|
|
textContent: "Clear Cache",
|
|
style: {
|
|
backgroundColor: "#d44a4a",
|
|
borderColor: "#b42a2a",
|
|
},
|
|
onclick: async () => {
|
|
if (confirm("Are you sure you want to clear all saved canvas states? This action cannot be undone.")) {
|
|
try {
|
|
await clearAllCanvasStates();
|
|
alert("Canvas cache cleared successfully!");
|
|
} catch (e) {
|
|
console.error("Failed to clear canvas cache:", e);
|
|
alert("Error clearing canvas cache. Check the console for details.");
|
|
}
|
|
}
|
|
}
|
|
})
|
|
])
|
|
]);
|
|
|
|
|
|
const updateButtonStates = () => {
|
|
const selectionCount = canvas.selectedLayers.length;
|
|
const hasSelection = selectionCount > 0;
|
|
controlPanel.querySelectorAll('.requires-selection').forEach(btn => {
|
|
btn.disabled = !hasSelection;
|
|
});
|
|
const mattingBtn = controlPanel.querySelector('.matting-button');
|
|
if (mattingBtn) {
|
|
mattingBtn.disabled = selectionCount !== 1;
|
|
}
|
|
};
|
|
|
|
canvas.onSelectionChange = updateButtonStates;
|
|
|
|
const undoButton = controlPanel.querySelector(`#undo-button-${node.id}`);
|
|
const redoButton = controlPanel.querySelector(`#redo-button-${node.id}`);
|
|
|
|
canvas.onHistoryChange = ({ canUndo, canRedo }) => {
|
|
if(undoButton) undoButton.disabled = !canUndo;
|
|
if(redoButton) redoButton.disabled = !canRedo;
|
|
};
|
|
|
|
updateButtonStates();
|
|
canvas.updateHistoryButtons();
|
|
|
|
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
const controlsHeight = entries[0].target.offsetHeight;
|
|
canvasContainer.style.top = (controlsHeight + 10) + "px";
|
|
});
|
|
|
|
resizeObserver.observe(controlPanel.querySelector('.controls'));
|
|
|
|
const triggerWidget = node.widgets.find(w => w.name === "trigger");
|
|
|
|
const updateOutput = async () => {
|
|
|
|
await canvas.saveToServer(widget.value);
|
|
|
|
triggerWidget.value = (triggerWidget.value + 1) % 99999999;
|
|
|
|
app.graph.runStep();
|
|
};
|
|
|
|
const addUpdateToButton = (button) => {
|
|
if (button.textContent === "Undo" || button.textContent === "Redo") {
|
|
return;
|
|
}
|
|
const origClick = button.onclick;
|
|
button.onclick = async (...args) => {
|
|
if (origClick) {
|
|
await origClick(...args);
|
|
}
|
|
await updateOutput();
|
|
};
|
|
};
|
|
|
|
controlPanel.querySelectorAll('button').forEach(addUpdateToButton);
|
|
|
|
const canvasContainer = $el("div.painterCanvasContainer.painter-container", {
|
|
style: {
|
|
position: "absolute",
|
|
top: "60px",
|
|
left: "10px",
|
|
right: "10px",
|
|
bottom: "10px",
|
|
|
|
overflow: "hidden"
|
|
}
|
|
}, [canvas.canvas]);
|
|
|
|
canvas.canvas.addEventListener('focus', () => {
|
|
canvasContainer.classList.add('has-focus');
|
|
});
|
|
|
|
canvas.canvas.addEventListener('blur', () => {
|
|
canvasContainer.classList.remove('has-focus');
|
|
});
|
|
|
|
|
|
node.onResize = function () {
|
|
canvas.render();
|
|
};
|
|
|
|
canvas.canvas.addEventListener('mouseup', updateOutput);
|
|
canvas.canvas.addEventListener('mouseleave', updateOutput);
|
|
|
|
|
|
const mainContainer = $el("div.painterMainContainer", {
|
|
style: {
|
|
position: "relative",
|
|
width: "100%",
|
|
height: "100%"
|
|
}
|
|
}, [controlPanel, canvasContainer]);
|
|
const handleFileLoad = async (file) => {
|
|
console.log("File dropped:", file.name);
|
|
if (!file.type.startsWith('image/')) {
|
|
console.log("Dropped file is not an image.");
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = async (event) => {
|
|
console.log("FileReader finished loading dropped file as data:URL.");
|
|
const img = new Image();
|
|
img.onload = async () => {
|
|
console.log("Image object loaded from dropped data:URL.");
|
|
const scale = Math.min(
|
|
canvas.width / img.width,
|
|
canvas.height / img.height
|
|
);
|
|
|
|
const layer = {
|
|
image: img,
|
|
x: (canvas.width - img.width * scale) / 2,
|
|
y: (canvas.height - img.height * scale) / 2,
|
|
width: img.width * scale,
|
|
height: img.height * scale,
|
|
rotation: 0,
|
|
zIndex: canvas.layers.length,
|
|
blendMode: 'normal',
|
|
opacity: 1
|
|
};
|
|
|
|
canvas.layers.push(layer);
|
|
canvas.updateSelection([layer]);
|
|
canvas.render();
|
|
canvas.saveState();
|
|
console.log("Dropped layer added and state saved.");
|
|
await updateOutput();
|
|
};
|
|
img.src = event.target.result;
|
|
};
|
|
reader.readAsDataURL(file);
|
|
};
|
|
|
|
mainContainer.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
canvasContainer.classList.add('drag-over');
|
|
});
|
|
|
|
mainContainer.addEventListener('dragleave', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
canvasContainer.classList.remove('drag-over');
|
|
});
|
|
|
|
mainContainer.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
canvasContainer.classList.remove('drag-over');
|
|
|
|
if (e.dataTransfer.files) {
|
|
for (const file of e.dataTransfer.files) {
|
|
await handleFileLoad(file);
|
|
}
|
|
}
|
|
});
|
|
|
|
const mainWidget = node.addDOMWidget("mainContainer", "widget", mainContainer);
|
|
|
|
node.size = [500, 500];
|
|
api.addEventListener("execution_start", async () => {
|
|
|
|
await canvas.saveToServer(widget.value);
|
|
|
|
if (node.inputs[0].link) {
|
|
const linkId = node.inputs[0].link;
|
|
const inputData = app.nodeOutputs[linkId];
|
|
if (inputData) {
|
|
ImageCache.set(linkId, inputData);
|
|
}
|
|
}
|
|
});
|
|
|
|
const originalSaveToServer = canvas.saveToServer;
|
|
canvas.saveToServer = async function (fileName) {
|
|
const result = await originalSaveToServer.call(this, fileName);
|
|
return result;
|
|
};
|
|
|
|
node.canvasWidget = canvas;
|
|
|
|
setTimeout(() => {
|
|
canvas.loadInitialState();
|
|
}, 100);
|
|
|
|
return {
|
|
canvas: canvas,
|
|
panel: controlPanel
|
|
};
|
|
}
|
|
|
|
class MattingStatusIndicator {
|
|
static instance = null;
|
|
|
|
static getInstance(container) {
|
|
if (!MattingStatusIndicator.instance) {
|
|
MattingStatusIndicator.instance = new MattingStatusIndicator(container);
|
|
}
|
|
if (container && !container.contains(MattingStatusIndicator.instance.indicator)) {
|
|
container.appendChild(MattingStatusIndicator.instance.indicator);
|
|
}
|
|
return MattingStatusIndicator.instance;
|
|
}
|
|
|
|
constructor(container) {
|
|
this.statuses = ['processing', 'completed', 'error'];
|
|
|
|
this.indicator = document.createElement('div');
|
|
this.indicator.className = 'matting-indicator';
|
|
this.indicator.style.cssText = `
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
margin-left: 10px;
|
|
display: inline-block;
|
|
transition: background-color 0.3s ease;
|
|
`;
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
/* Styl dla domyślnego stanu (szary) */
|
|
.matting-indicator {
|
|
background-color: #808080;
|
|
}
|
|
/* Style dla konkretnych statusów, które nadpiszą domyślny */
|
|
.matting-indicator.processing {
|
|
background-color: #FFC107; /* Żółty */
|
|
animation: blink 1s infinite;
|
|
}
|
|
.matting-indicator.completed {
|
|
background-color: #4CAF50; /* Zielony */
|
|
}
|
|
.matting-indicator.error {
|
|
background-color: #f44336; /* Czerwony */
|
|
}
|
|
@keyframes blink {
|
|
0% { opacity: 1; }
|
|
50% { opacity: 0.4; }
|
|
100% { opacity: 1; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
if (container) {
|
|
container.appendChild(this.indicator);
|
|
}
|
|
}
|
|
|
|
setStatus(status) {
|
|
this.indicator.classList.remove(...this.statuses);
|
|
if (status && this.statuses.includes(status)) {
|
|
this.indicator.classList.add(status);
|
|
}
|
|
|
|
if (status === 'completed' || status === 'error') {
|
|
setTimeout(() => {
|
|
this.indicator.classList.remove(status);
|
|
}, 3000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateImageData(data) {
|
|
|
|
console.log("Validating data structure:", {
|
|
hasData: !!data,
|
|
type: typeof data,
|
|
isArray: Array.isArray(data),
|
|
keys: data ? Object.keys(data) : null,
|
|
shape: data?.shape,
|
|
dataType: data?.data ? data.data.constructor.name : null,
|
|
fullData: data
|
|
});
|
|
|
|
if (!data) {
|
|
console.log("Data is null or undefined");
|
|
return false;
|
|
}
|
|
|
|
if (Array.isArray(data)) {
|
|
console.log("Data is array, getting first element");
|
|
data = data[0];
|
|
}
|
|
|
|
if (!data || typeof data !== 'object') {
|
|
console.log("Invalid data type");
|
|
return false;
|
|
}
|
|
|
|
if (!data.data) {
|
|
console.log("Missing data property");
|
|
return false;
|
|
}
|
|
|
|
if (!(data.data instanceof Float32Array)) {
|
|
|
|
try {
|
|
data.data = new Float32Array(data.data);
|
|
} catch (e) {
|
|
console.log("Failed to convert data to Float32Array:", e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function convertImageData(data) {
|
|
console.log("Converting image data:", data);
|
|
|
|
if (Array.isArray(data)) {
|
|
data = data[0];
|
|
}
|
|
|
|
const shape = data.shape;
|
|
const height = shape[1];
|
|
const width = shape[2];
|
|
const channels = shape[3];
|
|
const floatData = new Float32Array(data.data);
|
|
|
|
console.log("Processing dimensions:", {height, width, channels});
|
|
|
|
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
|
|
|
for (let h = 0; h < height; h++) {
|
|
for (let w = 0; w < width; w++) {
|
|
const pixelIndex = (h * width + w) * 4;
|
|
const tensorIndex = (h * width + w) * channels;
|
|
|
|
for (let c = 0; c < channels; c++) {
|
|
const value = floatData[tensorIndex + c];
|
|
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
|
}
|
|
|
|
rgbaData[pixelIndex + 3] = 255;
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: rgbaData,
|
|
width: width,
|
|
height: height
|
|
};
|
|
}
|
|
|
|
function applyMaskToImageData(imageData, maskData) {
|
|
console.log("Applying mask to image data");
|
|
|
|
const rgbaData = new Uint8ClampedArray(imageData.data);
|
|
const width = imageData.width;
|
|
const height = imageData.height;
|
|
|
|
const maskShape = maskData.shape;
|
|
const maskFloatData = new Float32Array(maskData.data);
|
|
|
|
console.log(`Applying mask of shape: ${maskShape}`);
|
|
|
|
for (let h = 0; h < height; h++) {
|
|
for (let w = 0; w < width; w++) {
|
|
const pixelIndex = (h * width + w) * 4;
|
|
const maskIndex = h * width + w;
|
|
|
|
const alpha = maskFloatData[maskIndex];
|
|
rgbaData[pixelIndex + 3] = Math.max(0, Math.min(255, Math.round(alpha * 255)));
|
|
}
|
|
}
|
|
|
|
console.log("Mask application completed");
|
|
|
|
return {
|
|
data: rgbaData,
|
|
width: width,
|
|
height: height
|
|
};
|
|
}
|
|
|
|
const ImageCache = {
|
|
cache: new Map(),
|
|
|
|
set(key, imageData) {
|
|
console.log("Caching image data for key:", key);
|
|
this.cache.set(key, imageData);
|
|
},
|
|
|
|
get(key) {
|
|
const data = this.cache.get(key);
|
|
console.log("Retrieved cached data for key:", key, !!data);
|
|
return data;
|
|
},
|
|
|
|
has(key) {
|
|
return this.cache.has(key);
|
|
},
|
|
|
|
clear() {
|
|
console.log("Clearing image cache");
|
|
this.cache.clear();
|
|
}
|
|
};
|
|
|
|
function prepareImageForCanvas(inputImage) {
|
|
console.log("Preparing image for canvas:", inputImage);
|
|
|
|
try {
|
|
|
|
if (Array.isArray(inputImage)) {
|
|
inputImage = inputImage[0];
|
|
}
|
|
|
|
if (!inputImage || !inputImage.shape || !inputImage.data) {
|
|
throw new Error("Invalid input image format");
|
|
}
|
|
|
|
const shape = inputImage.shape;
|
|
const height = shape[1];
|
|
const width = shape[2];
|
|
const channels = shape[3];
|
|
const floatData = new Float32Array(inputImage.data);
|
|
|
|
console.log("Image dimensions:", {height, width, channels});
|
|
|
|
const rgbaData = new Uint8ClampedArray(width * height * 4);
|
|
|
|
for (let h = 0; h < height; h++) {
|
|
for (let w = 0; w < width; w++) {
|
|
const pixelIndex = (h * width + w) * 4;
|
|
const tensorIndex = (h * width + w) * channels;
|
|
|
|
for (let c = 0; c < channels; c++) {
|
|
const value = floatData[tensorIndex + c];
|
|
rgbaData[pixelIndex + c] = Math.max(0, Math.min(255, Math.round(value * 255)));
|
|
}
|
|
|
|
rgbaData[pixelIndex + 3] = 255;
|
|
}
|
|
}
|
|
|
|
return {
|
|
data: rgbaData,
|
|
width: width,
|
|
height: height
|
|
};
|
|
} catch (error) {
|
|
console.error("Error preparing image:", error);
|
|
throw new Error(`Failed to prepare image: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Comfy.CanvasNode",
|
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
|
if (nodeType.comfyClass === "CanvasNode") {
|
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = async function () {
|
|
console.log("CanvasNode created, ID:", this.id);
|
|
const r = onNodeCreated?.apply(this, arguments);
|
|
|
|
const widget = this.widgets.find(w => w.name === "canvas_image");
|
|
console.log("Found canvas_image widget:", widget);
|
|
await createCanvasWidget(this, widget, app);
|
|
|
|
return r;
|
|
};
|
|
|
|
const onRemoved = nodeType.prototype.onRemoved;
|
|
nodeType.prototype.onRemoved = function () {
|
|
const tooltip = document.getElementById(`painter-help-tooltip-${this.id}`);
|
|
if (tooltip) {
|
|
tooltip.remove();
|
|
}
|
|
return onRemoved?.apply(this, arguments);
|
|
};
|
|
|
|
|
|
const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
|
|
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
|
originalGetExtraMenuOptions?.apply(this, arguments);
|
|
|
|
const self = this;
|
|
const newOptions = [
|
|
{
|
|
content: "Open Image",
|
|
callback: async () => {
|
|
try {
|
|
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
|
const url = URL.createObjectURL(blob);
|
|
window.open(url, '_blank');
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (e) {
|
|
console.error("Error opening image:", e);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
content: "Copy Image",
|
|
callback: async () => {
|
|
try {
|
|
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
|
const item = new ClipboardItem({'image/png': blob});
|
|
await navigator.clipboard.write([item]);
|
|
console.log("Image copied to clipboard.");
|
|
} catch (e) {
|
|
console.error("Error copying image:", e);
|
|
alert("Failed to copy image to clipboard.");
|
|
}
|
|
},
|
|
},
|
|
{
|
|
content: "Save Image",
|
|
callback: async () => {
|
|
try {
|
|
const blob = await self.canvasWidget.getFlattenedCanvasAsBlob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'canvas_output.png';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (e) {
|
|
console.error("Error saving image:", e);
|
|
}
|
|
},
|
|
},
|
|
];
|
|
if (options.length > 0) {
|
|
options.unshift({content: "___", disabled: true});
|
|
}
|
|
options.unshift(...newOptions);
|
|
};
|
|
}
|
|
}
|
|
});
|
|
|
|
async function handleImportInput(data) {
|
|
if (data && data.image) {
|
|
const imageData = data.image;
|
|
await importImage(imageData);
|
|
}
|
|
}
|