919 lines
27 KiB
JavaScript
919 lines
27 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
|
|
const EXTENSION_NAME = "LLS.MaskDraw";
|
|
const TARGET_NODE_CLASS = "LLSSimpleMaskDraw";
|
|
const TARGET_NODE_DISPLAY_NAME = "LLS Simple Mask Draw";
|
|
const HISTORY_LIMIT = 20;
|
|
const MAX_PREVIEW_EDGE = 420;
|
|
const MIN_PREVIEW_EDGE = 220;
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
function asNumber(value, fallback) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
}
|
|
|
|
function asBoolean(value, fallback = false) {
|
|
return typeof value === "boolean" ? value : fallback;
|
|
}
|
|
|
|
function findWidget(node, name) {
|
|
return node.widgets?.find((widget) => widget.name === name) ?? null;
|
|
}
|
|
|
|
function getNodeClassName(node) {
|
|
return String(node?.comfyClass || node?.constructor?.comfyClass || node?.type || node?.title || "");
|
|
}
|
|
|
|
function hideWidget(widget) {
|
|
if (!widget) {
|
|
return;
|
|
}
|
|
widget.type = "hidden";
|
|
widget.computeSize = () => [0, -4];
|
|
}
|
|
|
|
function chainWidgetCallback(widget, handler) {
|
|
if (!widget || widget.__llsMaskDrawChained) {
|
|
return;
|
|
}
|
|
const previous = widget.callback;
|
|
widget.callback = function chainedMaskDrawCallback() {
|
|
previous?.apply(this, arguments);
|
|
handler?.apply(this, arguments);
|
|
};
|
|
widget.__llsMaskDrawChained = true;
|
|
}
|
|
|
|
function parseState(rawValue) {
|
|
try {
|
|
const parsed = JSON.parse(rawValue || "{}");
|
|
if (!parsed || typeof parsed !== "object") {
|
|
throw new Error("Invalid state payload");
|
|
}
|
|
return {
|
|
version: asNumber(parsed.version, 1),
|
|
mask_png_base64: String(parsed.mask_png_base64 || ""),
|
|
touched: asBoolean(parsed.touched, false),
|
|
editor: parsed.editor && typeof parsed.editor === "object" ? parsed.editor : {},
|
|
};
|
|
} catch {
|
|
return {
|
|
version: 1,
|
|
mask_png_base64: "",
|
|
touched: false,
|
|
editor: {},
|
|
};
|
|
}
|
|
}
|
|
|
|
function dataUrlToBase64(dataUrl) {
|
|
const commaIndex = String(dataUrl || "").indexOf(",");
|
|
return commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : "";
|
|
}
|
|
|
|
function getInputLink(node, inputName) {
|
|
const input = node.inputs?.find((item) => item.name === inputName);
|
|
if (!input || input.link == null) {
|
|
return null;
|
|
}
|
|
return node.graph?.links?.[input.link] ?? null;
|
|
}
|
|
|
|
function getUpstreamNode(node, inputName) {
|
|
const link = getInputLink(node, inputName);
|
|
if (!link) {
|
|
return null;
|
|
}
|
|
return node.graph?.getNodeById?.(link.origin_id) ?? null;
|
|
}
|
|
|
|
function normalizePath(rawValue) {
|
|
return String(rawValue || "").trim().replace(/\\/g, "/");
|
|
}
|
|
|
|
function splitAnnotatedPath(rawValue) {
|
|
const normalized = normalizePath(rawValue);
|
|
const match = normalized.match(/^(.*?)(?:\s+\[([^\]]+)\])?$/);
|
|
const path = match?.[1] ?? normalized;
|
|
const annotatedType = match?.[2] ?? "";
|
|
const slashIndex = path.lastIndexOf("/");
|
|
return {
|
|
path,
|
|
annotatedType,
|
|
subfolder: slashIndex >= 0 ? path.slice(0, slashIndex) : "",
|
|
filename: slashIndex >= 0 ? path.slice(slashIndex + 1) : path,
|
|
};
|
|
}
|
|
|
|
function buildViewUrl(rawValue, fallbackType, extraParams = {}) {
|
|
const normalized = normalizePath(rawValue);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (normalized.startsWith("blake3:")) {
|
|
params.set("filename", normalized);
|
|
} else {
|
|
const parsed = splitAnnotatedPath(normalized);
|
|
if (!parsed.filename) {
|
|
return null;
|
|
}
|
|
params.set("filename", parsed.filename);
|
|
if (parsed.subfolder) {
|
|
params.set("subfolder", parsed.subfolder);
|
|
}
|
|
params.set("type", parsed.annotatedType || fallbackType || "input");
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(extraParams)) {
|
|
if (value != null && value !== "") {
|
|
params.set(key, String(value));
|
|
}
|
|
}
|
|
|
|
const relativePath = `/view?${params.toString()}`;
|
|
return typeof api?.apiURL === "function" ? api.apiURL(relativePath) : relativePath;
|
|
}
|
|
|
|
function resolveImageSource(node) {
|
|
const upstream = getUpstreamNode(node, "image");
|
|
const className = getNodeClassName(upstream);
|
|
const imageWidget = findWidget(upstream, "image");
|
|
if (!imageWidget?.value) {
|
|
return null;
|
|
}
|
|
|
|
if (className === "LoadImage") {
|
|
return {
|
|
url: buildViewUrl(imageWidget.value, "input"),
|
|
label: "LoadImage",
|
|
};
|
|
}
|
|
|
|
if (className === "LoadImageOutput") {
|
|
return {
|
|
url: buildViewUrl(imageWidget.value, "output"),
|
|
label: "LoadImageOutput",
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function resolveMaskSource(node) {
|
|
const upstream = getUpstreamNode(node, "input_mask");
|
|
const className = getNodeClassName(upstream);
|
|
const imageWidget = findWidget(upstream, "image");
|
|
if (!imageWidget?.value) {
|
|
return null;
|
|
}
|
|
|
|
if (className === "LoadImageMask") {
|
|
const channelName = String(findWidget(upstream, "channel")?.value || "alpha").toLowerCase();
|
|
if (channelName === "alpha") {
|
|
return {
|
|
url: buildViewUrl(imageWidget.value, "input", { channel: "a" }),
|
|
channel: "a",
|
|
invert: true,
|
|
label: "LoadImageMask.alpha",
|
|
};
|
|
}
|
|
|
|
const channelMap = {
|
|
red: "r",
|
|
green: "g",
|
|
blue: "b",
|
|
};
|
|
const mappedChannel = channelMap[channelName];
|
|
if (!mappedChannel) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
url: buildViewUrl(imageWidget.value, "input"),
|
|
channel: mappedChannel,
|
|
invert: false,
|
|
label: `LoadImageMask.${channelName}`,
|
|
};
|
|
}
|
|
|
|
if (className === "LoadImage") {
|
|
return {
|
|
url: buildViewUrl(imageWidget.value, "input", { channel: "a" }),
|
|
channel: "a",
|
|
invert: true,
|
|
label: "LoadImage.alpha",
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function loadImageElement(url) {
|
|
return new Promise((resolve, reject) => {
|
|
if (!url) {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
const image = new Image();
|
|
image.onload = () => resolve(image);
|
|
image.onerror = () => reject(new Error(`Failed to load image: ${url}`));
|
|
image.src = url;
|
|
});
|
|
}
|
|
|
|
function computeDisplaySize(width, height) {
|
|
const safeWidth = Math.max(1, Math.round(width));
|
|
const safeHeight = Math.max(1, Math.round(height));
|
|
const maxEdge = Math.max(safeWidth, safeHeight);
|
|
let scale = 1;
|
|
|
|
if (maxEdge > MAX_PREVIEW_EDGE) {
|
|
scale = MAX_PREVIEW_EDGE / maxEdge;
|
|
} else if (maxEdge < MIN_PREVIEW_EDGE) {
|
|
scale = MIN_PREVIEW_EDGE / maxEdge;
|
|
}
|
|
|
|
return {
|
|
width: Math.max(1, Math.round(safeWidth * scale)),
|
|
height: Math.max(1, Math.round(safeHeight * scale)),
|
|
};
|
|
}
|
|
|
|
function drawHintText(context, lines) {
|
|
context.fillStyle = "#171717";
|
|
context.fillRect(0, 0, context.canvas.width, context.canvas.height);
|
|
context.fillStyle = "#9a9a9a";
|
|
context.font = "12px sans-serif";
|
|
lines.forEach((line, index) => {
|
|
context.fillText(line, 14, 24 + index * 18);
|
|
});
|
|
}
|
|
|
|
function drawMaskOverlay(stageContext, overlayContext, overlayCanvas, maskCanvas, overlayAlpha, invertMask) {
|
|
if (!maskCanvas.width || !maskCanvas.height) {
|
|
return;
|
|
}
|
|
|
|
overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
overlayContext.drawImage(maskCanvas, 0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
const imageData = overlayContext.getImageData(0, 0, overlayCanvas.width, overlayCanvas.height);
|
|
const pixels = imageData.data;
|
|
|
|
for (let index = 0; index < pixels.length; index += 4) {
|
|
let value = pixels[index];
|
|
if (invertMask) {
|
|
value = 255 - value;
|
|
}
|
|
pixels[index] = 255;
|
|
pixels[index + 1] = 0;
|
|
pixels[index + 2] = 0;
|
|
pixels[index + 3] = Math.round((value / 255) * overlayAlpha * 255);
|
|
}
|
|
|
|
overlayContext.putImageData(imageData, 0, 0);
|
|
stageContext.drawImage(overlayCanvas, 0, 0);
|
|
}
|
|
|
|
function attachMaskEditor(node) {
|
|
if (node.__llsMaskDrawEditor || typeof node.addDOMWidget !== "function") {
|
|
return node.__llsMaskDrawEditor ?? null;
|
|
}
|
|
|
|
const stateWidget = findWidget(node, "mask_state_json");
|
|
const drawModeWidget = findWidget(node, "draw_mode");
|
|
const brushSizeWidget = findWidget(node, "brush_size");
|
|
const brushSoftnessWidget = findWidget(node, "brush_softness");
|
|
const overlayAlphaWidget = findWidget(node, "overlay_alpha");
|
|
const invertWidget = findWidget(node, "invert_mask");
|
|
|
|
hideWidget(stateWidget);
|
|
|
|
const savedState = parseState(stateWidget?.value);
|
|
|
|
const container = document.createElement("div");
|
|
container.className = "lls-mask-draw-editor";
|
|
container.style.display = "flex";
|
|
container.style.flexDirection = "column";
|
|
container.style.alignItems = "flex-start";
|
|
container.style.gap = "8px";
|
|
container.style.width = "100%";
|
|
container.style.boxSizing = "border-box";
|
|
|
|
const toolbar = document.createElement("div");
|
|
toolbar.style.display = "flex";
|
|
toolbar.style.flexWrap = "wrap";
|
|
toolbar.style.gap = "6px";
|
|
toolbar.style.width = "100%";
|
|
|
|
const frame = document.createElement("div");
|
|
frame.style.display = "inline-flex";
|
|
frame.style.border = "1px solid #4e4e4e";
|
|
frame.style.borderRadius = "8px";
|
|
frame.style.overflow = "hidden";
|
|
frame.style.background = "#111";
|
|
|
|
const stage = document.createElement("canvas");
|
|
stage.style.display = "block";
|
|
stage.style.cursor = "crosshair";
|
|
stage.style.touchAction = "none";
|
|
stage.style.userSelect = "none";
|
|
frame.appendChild(stage);
|
|
|
|
const status = document.createElement("div");
|
|
status.style.fontSize = "12px";
|
|
status.style.lineHeight = "1.45";
|
|
status.style.color = "#b8b8b8";
|
|
status.style.maxWidth = "420px";
|
|
status.style.whiteSpace = "normal";
|
|
|
|
container.append(toolbar, frame, status);
|
|
|
|
const domWidget = node.addDOMWidget("lls_mask_editor", "lls_mask_editor", container);
|
|
domWidget.serialize = false;
|
|
if (domWidget.options) {
|
|
domWidget.options.hideOnZoom = false;
|
|
}
|
|
|
|
const stageContext = stage.getContext("2d");
|
|
const overlayCanvas = document.createElement("canvas");
|
|
const overlayContext = overlayCanvas.getContext("2d", { willReadFrequently: true });
|
|
const maskCanvas = document.createElement("canvas");
|
|
let maskContext = maskCanvas.getContext("2d", { willReadFrequently: true });
|
|
|
|
const history = {
|
|
undo: [],
|
|
redo: [],
|
|
};
|
|
|
|
const state = {
|
|
baseImage: null,
|
|
localMaskActive: Boolean(savedState.touched && savedState.mask_png_base64),
|
|
drawMode: String(savedState.editor.draw_mode || "brush"),
|
|
brushSize: clamp(asNumber(savedState.editor.brush_size, 32), 1, 512),
|
|
brushSoftness: clamp(asNumber(savedState.editor.brush_softness, 0.5), 0, 1),
|
|
overlayAlpha: clamp(asNumber(savedState.editor.overlay_alpha, 0.4), 0, 1),
|
|
drawing: false,
|
|
dirtyStroke: false,
|
|
lastPoint: null,
|
|
loadToken: 0,
|
|
};
|
|
|
|
let canPersist = false;
|
|
|
|
function updateStatus(message) {
|
|
status.textContent = message;
|
|
}
|
|
|
|
function resetHistory() {
|
|
history.undo.length = 0;
|
|
history.redo.length = 0;
|
|
}
|
|
|
|
function fillMaskBlack() {
|
|
if (!maskCanvas.width || !maskCanvas.height || !maskContext) {
|
|
return;
|
|
}
|
|
maskContext.save();
|
|
maskContext.globalCompositeOperation = "source-over";
|
|
maskContext.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
maskContext.fillStyle = "rgb(0, 0, 0)";
|
|
maskContext.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
|
|
maskContext.restore();
|
|
}
|
|
|
|
function ensureMaskCanvasSize(width, height, options = {}) {
|
|
const preserve = options.preserve !== false;
|
|
const nextWidth = Math.max(1, Math.round(width));
|
|
const nextHeight = Math.max(1, Math.round(height));
|
|
if (maskCanvas.width === nextWidth && maskCanvas.height === nextHeight) {
|
|
return;
|
|
}
|
|
|
|
let previousCanvas = null;
|
|
if (preserve && maskCanvas.width > 0 && maskCanvas.height > 0) {
|
|
previousCanvas = document.createElement("canvas");
|
|
previousCanvas.width = maskCanvas.width;
|
|
previousCanvas.height = maskCanvas.height;
|
|
previousCanvas.getContext("2d").drawImage(maskCanvas, 0, 0);
|
|
}
|
|
|
|
maskCanvas.width = nextWidth;
|
|
maskCanvas.height = nextHeight;
|
|
maskContext = maskCanvas.getContext("2d", { willReadFrequently: true });
|
|
fillMaskBlack();
|
|
|
|
if (previousCanvas) {
|
|
maskContext.drawImage(previousCanvas, 0, 0, nextWidth, nextHeight);
|
|
}
|
|
}
|
|
|
|
function snapshotMask() {
|
|
if (!maskCanvas.width || !maskCanvas.height) {
|
|
return "";
|
|
}
|
|
return maskCanvas.toDataURL("image/png");
|
|
}
|
|
|
|
function persistState() {
|
|
if (!canPersist || !stateWidget) {
|
|
return;
|
|
}
|
|
|
|
stateWidget.value = JSON.stringify({
|
|
version: 1,
|
|
mask_png_base64: state.localMaskActive ? dataUrlToBase64(snapshotMask()) : "",
|
|
touched: state.localMaskActive,
|
|
editor: {
|
|
draw_mode: state.drawMode,
|
|
brush_size: state.brushSize,
|
|
brush_softness: state.brushSoftness,
|
|
overlay_alpha: state.overlayAlpha,
|
|
},
|
|
});
|
|
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
function readWidgetValues(options = {}) {
|
|
const persist = options.persist !== false;
|
|
state.drawMode = String(drawModeWidget?.value ?? state.drawMode || "brush");
|
|
state.brushSize = clamp(asNumber(brushSizeWidget?.value, state.brushSize || 32), 1, 512);
|
|
state.brushSoftness = clamp(asNumber(brushSoftnessWidget?.value, state.brushSoftness ?? 0.5), 0, 1);
|
|
state.overlayAlpha = clamp(asNumber(overlayAlphaWidget?.value, state.overlayAlpha ?? 0.4), 0, 1);
|
|
if (persist) {
|
|
persistState();
|
|
}
|
|
}
|
|
|
|
function updateNodeSize() {
|
|
const sourceWidth = state.baseImage?.naturalWidth || maskCanvas.width || 320;
|
|
const sourceHeight = state.baseImage?.naturalHeight || maskCanvas.height || 240;
|
|
const displaySize = computeDisplaySize(sourceWidth, sourceHeight);
|
|
|
|
stage.width = displaySize.width;
|
|
stage.height = displaySize.height;
|
|
stage.style.width = `${displaySize.width}px`;
|
|
stage.style.height = `${displaySize.height}px`;
|
|
overlayCanvas.width = displaySize.width;
|
|
overlayCanvas.height = displaySize.height;
|
|
frame.style.width = `${displaySize.width}px`;
|
|
container.style.width = `${displaySize.width}px`;
|
|
|
|
if (typeof node.setSize === "function") {
|
|
requestAnimationFrame(() => {
|
|
node.setSize([
|
|
Math.max(360, displaySize.width + 28),
|
|
Math.max(250, displaySize.height + 150),
|
|
]);
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderStage() {
|
|
updateNodeSize();
|
|
stageContext.clearRect(0, 0, stage.width, stage.height);
|
|
|
|
if (state.baseImage) {
|
|
stageContext.drawImage(state.baseImage, 0, 0, stage.width, stage.height);
|
|
} else {
|
|
drawHintText(stageContext, state.localMaskActive
|
|
? [
|
|
"Source preview unavailable.",
|
|
"Connect Load Image / Load Image Output",
|
|
"to show the edited mask over the image.",
|
|
]
|
|
: [
|
|
"Connect Load Image / Load Image Output",
|
|
"to start drawing before execution.",
|
|
]);
|
|
}
|
|
|
|
drawMaskOverlay(
|
|
stageContext,
|
|
overlayContext,
|
|
overlayCanvas,
|
|
maskCanvas,
|
|
state.overlayAlpha,
|
|
asBoolean(invertWidget?.value, false),
|
|
);
|
|
|
|
app.graph?.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
function renderSourceMask(image, channel, invert) {
|
|
if (!maskCanvas.width || !maskCanvas.height) {
|
|
return;
|
|
}
|
|
|
|
const scratchCanvas = document.createElement("canvas");
|
|
scratchCanvas.width = maskCanvas.width;
|
|
scratchCanvas.height = maskCanvas.height;
|
|
const scratchContext = scratchCanvas.getContext("2d", { willReadFrequently: true });
|
|
scratchContext.drawImage(image, 0, 0, scratchCanvas.width, scratchCanvas.height);
|
|
|
|
const imageData = scratchContext.getImageData(0, 0, scratchCanvas.width, scratchCanvas.height);
|
|
const pixels = imageData.data;
|
|
|
|
for (let index = 0; index < pixels.length; index += 4) {
|
|
let value = pixels[index];
|
|
if (channel === "g") {
|
|
value = pixels[index + 1];
|
|
} else if (channel === "b") {
|
|
value = pixels[index + 2];
|
|
} else if (channel === "a") {
|
|
value = pixels[index + 3];
|
|
}
|
|
if (invert) {
|
|
value = 255 - value;
|
|
}
|
|
pixels[index] = value;
|
|
pixels[index + 1] = value;
|
|
pixels[index + 2] = value;
|
|
pixels[index + 3] = 255;
|
|
}
|
|
|
|
scratchContext.putImageData(imageData, 0, 0);
|
|
fillMaskBlack();
|
|
maskContext.drawImage(scratchCanvas, 0, 0);
|
|
}
|
|
|
|
async function restoreMaskFromDataUrl(dataUrl) {
|
|
const image = await loadImageElement(dataUrl);
|
|
if (!image) {
|
|
return false;
|
|
}
|
|
|
|
ensureMaskCanvasSize(image.naturalWidth || image.width, image.naturalHeight || image.height, { preserve: false });
|
|
fillMaskBlack();
|
|
maskContext.drawImage(image, 0, 0, maskCanvas.width, maskCanvas.height);
|
|
return true;
|
|
}
|
|
|
|
async function restoreSerializedLocalMask(maskBase64) {
|
|
if (!maskBase64) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const restored = await restoreMaskFromDataUrl(`data:image/png;base64,${maskBase64}`);
|
|
if (restored) {
|
|
resetHistory();
|
|
}
|
|
return restored;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function pushUndoSnapshot() {
|
|
const snapshot = snapshotMask();
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
history.undo.push(snapshot);
|
|
if (history.undo.length > HISTORY_LIMIT) {
|
|
history.undo.shift();
|
|
}
|
|
history.redo.length = 0;
|
|
}
|
|
|
|
async function restoreHistorySnapshot(snapshot) {
|
|
if (!snapshot) {
|
|
return false;
|
|
}
|
|
const restored = await restoreMaskFromDataUrl(snapshot);
|
|
renderStage();
|
|
return restored;
|
|
}
|
|
|
|
function getMaskPoint(event) {
|
|
if (!maskCanvas.width || !maskCanvas.height) {
|
|
return null;
|
|
}
|
|
|
|
const bounds = stage.getBoundingClientRect();
|
|
if (!bounds.width || !bounds.height) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: clamp((event.clientX - bounds.left) * (maskCanvas.width / bounds.width), 0, maskCanvas.width),
|
|
y: clamp((event.clientY - bounds.top) * (maskCanvas.height / bounds.height), 0, maskCanvas.height),
|
|
};
|
|
}
|
|
|
|
function applyBrushStamp(x, y) {
|
|
const radius = Math.max(1, state.brushSize) / 2;
|
|
const edge = clamp(1 - state.brushSoftness, 0, 1);
|
|
const channelValue = state.drawMode === "erase" ? 0 : 255;
|
|
const gradient = maskContext.createRadialGradient(x, y, 0, x, y, radius);
|
|
gradient.addColorStop(0, `rgba(${channelValue}, ${channelValue}, ${channelValue}, 1)`);
|
|
gradient.addColorStop(edge, `rgba(${channelValue}, ${channelValue}, ${channelValue}, 1)`);
|
|
gradient.addColorStop(1, `rgba(${channelValue}, ${channelValue}, ${channelValue}, 0)`);
|
|
|
|
maskContext.save();
|
|
maskContext.globalCompositeOperation = "source-over";
|
|
maskContext.fillStyle = gradient;
|
|
maskContext.beginPath();
|
|
maskContext.arc(x, y, radius, 0, Math.PI * 2);
|
|
maskContext.fill();
|
|
maskContext.restore();
|
|
}
|
|
|
|
function applyStroke(fromPoint, toPoint) {
|
|
if (!toPoint) {
|
|
return;
|
|
}
|
|
|
|
if (!fromPoint) {
|
|
applyBrushStamp(toPoint.x, toPoint.y);
|
|
return;
|
|
}
|
|
|
|
const deltaX = toPoint.x - fromPoint.x;
|
|
const deltaY = toPoint.y - fromPoint.y;
|
|
const distance = Math.hypot(deltaX, deltaY);
|
|
const spacing = Math.max(1, state.brushSize * 0.2);
|
|
const steps = Math.max(1, Math.ceil(distance / spacing));
|
|
|
|
for (let step = 1; step <= steps; step += 1) {
|
|
const progress = step / steps;
|
|
applyBrushStamp(
|
|
fromPoint.x + deltaX * progress,
|
|
fromPoint.y + deltaY * progress,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function reloadFromInputs() {
|
|
const requestToken = ++state.loadToken;
|
|
const imageSource = resolveImageSource(node);
|
|
const maskSource = state.localMaskActive ? null : resolveMaskSource(node);
|
|
|
|
const baseImagePromise = imageSource?.url
|
|
? loadImageElement(imageSource.url).catch(() => null)
|
|
: Promise.resolve(null);
|
|
const maskImagePromise = maskSource?.url
|
|
? loadImageElement(maskSource.url).catch(() => null)
|
|
: Promise.resolve(null);
|
|
|
|
const [baseImage, maskImage] = await Promise.all([baseImagePromise, maskImagePromise]);
|
|
if (requestToken !== state.loadToken) {
|
|
return;
|
|
}
|
|
|
|
state.baseImage = baseImage;
|
|
|
|
const targetWidth = baseImage?.naturalWidth || maskCanvas.width || maskImage?.naturalWidth || maskImage?.width || 0;
|
|
const targetHeight = baseImage?.naturalHeight || maskCanvas.height || maskImage?.naturalHeight || maskImage?.height || 0;
|
|
|
|
if (targetWidth && targetHeight) {
|
|
if (state.localMaskActive) {
|
|
ensureMaskCanvasSize(targetWidth, targetHeight, { preserve: true });
|
|
} else {
|
|
resetHistory();
|
|
ensureMaskCanvasSize(targetWidth, targetHeight, { preserve: false });
|
|
if (maskImage) {
|
|
renderSourceMask(maskImage, maskSource.channel, maskSource.invert);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!imageSource) {
|
|
updateStatus(state.localMaskActive
|
|
? "Local mask restored. Connect Load Image or Load Image Output for source preview."
|
|
: "Connect Load Image or Load Image Output to paint before execution.");
|
|
} else if (!baseImage) {
|
|
updateStatus("Source preview could not be loaded from the upstream file-backed node.");
|
|
} else if (state.localMaskActive) {
|
|
updateStatus("Editing the local mask raster. input_mask is ignored until this local mask is replaced.");
|
|
} else if (maskSource && maskImage) {
|
|
updateStatus("input_mask loaded as the editable base layer.");
|
|
} else if (maskSource && !maskImage) {
|
|
updateStatus("Source image loaded. input_mask preview is unavailable for this upstream node.");
|
|
} else {
|
|
updateStatus("Source image loaded. Drawing starts from an empty mask.");
|
|
}
|
|
|
|
renderStage();
|
|
}
|
|
|
|
function createButton(label, handler) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.textContent = label;
|
|
button.style.border = "1px solid #5d5d5d";
|
|
button.style.borderRadius = "6px";
|
|
button.style.background = "#202020";
|
|
button.style.color = "#f0f0f0";
|
|
button.style.padding = "4px 10px";
|
|
button.style.cursor = "pointer";
|
|
button.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handler(event);
|
|
});
|
|
return button;
|
|
}
|
|
|
|
toolbar.append(
|
|
createButton("Clear", () => {
|
|
if (!maskCanvas.width || !maskCanvas.height) {
|
|
return;
|
|
}
|
|
pushUndoSnapshot();
|
|
fillMaskBlack();
|
|
state.localMaskActive = true;
|
|
persistState();
|
|
updateStatus("Local mask cleared to black.");
|
|
renderStage();
|
|
}),
|
|
createButton("Undo", async () => {
|
|
if (!history.undo.length) {
|
|
return;
|
|
}
|
|
const previous = history.undo.pop();
|
|
const current = snapshotMask();
|
|
if (current) {
|
|
history.redo.push(current);
|
|
}
|
|
if (await restoreHistorySnapshot(previous)) {
|
|
state.localMaskActive = true;
|
|
persistState();
|
|
updateStatus("Restored the previous local mask state.");
|
|
}
|
|
}),
|
|
createButton("Redo", async () => {
|
|
if (!history.redo.length) {
|
|
return;
|
|
}
|
|
const next = history.redo.pop();
|
|
const current = snapshotMask();
|
|
if (current) {
|
|
history.undo.push(current);
|
|
}
|
|
if (await restoreHistorySnapshot(next)) {
|
|
state.localMaskActive = true;
|
|
persistState();
|
|
updateStatus("Reapplied the next local mask state.");
|
|
}
|
|
}),
|
|
createButton("Invert", () => {
|
|
if (!invertWidget) {
|
|
return;
|
|
}
|
|
invertWidget.value = !Boolean(invertWidget.value);
|
|
invertWidget.callback?.(invertWidget.value);
|
|
renderStage();
|
|
}),
|
|
);
|
|
|
|
stage.addEventListener("pointerdown", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const point = getMaskPoint(event);
|
|
if (!point) {
|
|
updateStatus("Source image is not ready. Connect Load Image or Load Image Output first.");
|
|
renderStage();
|
|
return;
|
|
}
|
|
|
|
stage.setPointerCapture?.(event.pointerId);
|
|
pushUndoSnapshot();
|
|
state.drawing = true;
|
|
state.dirtyStroke = true;
|
|
state.localMaskActive = true;
|
|
applyStroke(null, point);
|
|
state.lastPoint = point;
|
|
renderStage();
|
|
});
|
|
|
|
stage.addEventListener("pointermove", (event) => {
|
|
if (!state.drawing) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const point = getMaskPoint(event);
|
|
if (!point) {
|
|
return;
|
|
}
|
|
|
|
applyStroke(state.lastPoint, point);
|
|
state.lastPoint = point;
|
|
renderStage();
|
|
});
|
|
|
|
function endStroke(event) {
|
|
if (!state.drawing) {
|
|
return;
|
|
}
|
|
event?.preventDefault?.();
|
|
event?.stopPropagation?.();
|
|
|
|
state.drawing = false;
|
|
state.lastPoint = null;
|
|
if (state.dirtyStroke) {
|
|
persistState();
|
|
state.dirtyStroke = false;
|
|
updateStatus("Local mask updated.");
|
|
}
|
|
renderStage();
|
|
}
|
|
|
|
stage.addEventListener("pointerup", endStroke);
|
|
stage.addEventListener("pointercancel", endStroke);
|
|
stage.addEventListener("lostpointercapture", endStroke);
|
|
|
|
chainWidgetCallback(drawModeWidget, () => {
|
|
readWidgetValues({ persist: true });
|
|
renderStage();
|
|
});
|
|
chainWidgetCallback(brushSizeWidget, () => {
|
|
readWidgetValues({ persist: true });
|
|
renderStage();
|
|
});
|
|
chainWidgetCallback(brushSoftnessWidget, () => {
|
|
readWidgetValues({ persist: true });
|
|
renderStage();
|
|
});
|
|
chainWidgetCallback(overlayAlphaWidget, () => {
|
|
readWidgetValues({ persist: true });
|
|
renderStage();
|
|
});
|
|
chainWidgetCallback(invertWidget, () => {
|
|
renderStage();
|
|
});
|
|
|
|
async function initializeFromState(syncSerializedState) {
|
|
const parsedState = syncSerializedState ? parseState(stateWidget?.value) : savedState;
|
|
readWidgetValues({ persist: false });
|
|
|
|
if (parsedState.touched && parsedState.mask_png_base64) {
|
|
state.localMaskActive = await restoreSerializedLocalMask(parsedState.mask_png_base64);
|
|
} else if (!state.localMaskActive) {
|
|
state.localMaskActive = false;
|
|
}
|
|
|
|
await reloadFromInputs();
|
|
canPersist = true;
|
|
renderStage();
|
|
}
|
|
|
|
const editorApi = {
|
|
handleGraphConfigured() {
|
|
void initializeFromState(true);
|
|
},
|
|
handleConnectionsChange() {
|
|
void reloadFromInputs();
|
|
},
|
|
};
|
|
|
|
node.__llsMaskDrawEditor = editorApi;
|
|
updateStatus("Connect Load Image or Load Image Output to start drawing before execution.");
|
|
renderStage();
|
|
void initializeFromState(false);
|
|
return editorApi;
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: EXTENSION_NAME,
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== TARGET_NODE_CLASS && nodeData.display_name !== TARGET_NODE_DISPLAY_NAME) {
|
|
return;
|
|
}
|
|
|
|
const previousOnNodeCreated = nodeType.prototype.onNodeCreated;
|
|
nodeType.prototype.onNodeCreated = function onMaskDrawNodeCreated() {
|
|
const result = previousOnNodeCreated?.apply(this, arguments);
|
|
attachMaskEditor(this);
|
|
return result;
|
|
};
|
|
|
|
const previousOnGraphConfigured = nodeType.prototype.onGraphConfigured;
|
|
nodeType.prototype.onGraphConfigured = function onMaskDrawGraphConfigured() {
|
|
const result = previousOnGraphConfigured?.apply(this, arguments);
|
|
attachMaskEditor(this)?.handleGraphConfigured?.();
|
|
return result;
|
|
};
|
|
|
|
const previousOnConnectionsChange = nodeType.prototype.onConnectionsChange;
|
|
nodeType.prototype.onConnectionsChange = function onMaskDrawConnectionsChange() {
|
|
const result = previousOnConnectionsChange?.apply(this, arguments);
|
|
attachMaskEditor(this)?.handleConnectionsChange?.();
|
|
return result;
|
|
};
|
|
},
|
|
});
|