Files

877 lines
31 KiB
JavaScript

/**
* ComfyUI frontend extension for the `depth to layers` node.
*
* Responsibilities:
* - Render source/preview panels and histogram overlays inside the node.
* - Provide interactive interval editing (add, drag, remove, reset).
* - Keep interval widget state synchronized for backend execution.
* - Render preview updates locally from cached source pixels.
*
* Event contract:
* - Receives initial state from `game_dev.depth_to_layers.initialise`.
*/
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
const NODE_BASE_WIDTH = 256;
const NODE_HEADER_HEIGHT = 28;
const NODE_PANEL_HEIGHT = 167;
const NODE_HISTOGRAM_HEIGHT = 90;
const NODE_RESET_BUTTON_HEIGHT = 24;
const NODE_PADDING = 5;
const MIN_HANDLE_GAP = 0.001;
const INTERVAL_LEVEL = 0.5;
const HANDLE_HEIGHT = 26;
const HANDLE_WIDTH = 10;
const HANDLE_HIT_PAD = 3;
const INTERVAL_OVERLAY_ALPHA = 0.38;
const TEXT_COLOR_DEFAULT = "#e6e6e6";
const PREVIEW_PLACEHOLDER_TEXT_COLOR = "#ebebeb";
const HISTOGRAM_BG_COLOR = "#3a3a3a";
const HISTOGRAM_BAR_COLOR = "rgba(255,255,255,0.95)";
const RESET_BUTTON_BG_COLOR = "#2a2a2a";
const RESET_BUTTON_BORDER_COLOR = "#8a8a8a";
const HOVER_MARKER_SHADOW_COLOR = "rgba(0, 0, 0, 0.88)";
const INTERVAL_LABEL_BADGE_BG_COLOR = "rgba(245, 218, 154, 0.95)";
const INTERVAL_LABEL_BADGE_BORDER_COLOR = "rgba(255,255,255,1)";
const INTERVAL_HANDLE_SELECTED_COLOR = "rgb(233, 62, 15)";
const buildVga256Palette = () => {
const palette = [];
const levels = [0, 51, 102, 153, 204, 255];
for (const r of levels) {
for (const g of levels) {
for (const b of levels) {
palette.push([r, g, b]);
}
}
}
for (let i = 0; i < 40; i++) {
const v = Math.round((255 * i) / 39);
palette.push([v, v, v]);
}
const deduped = [];
const seen = new Set();
for (const color of palette) {
const key = `${color[0]},${color[1]},${color[2]}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(color);
}
return deduped;
};
const FALLBACK_PALETTE = buildVga256Palette();
const FALLBACK_DEFAULT_INTERVALS = [{ start: 0.0, end: 0.3 }];
const cloneIntervals = (intervals) =>
(Array.isArray(intervals) && intervals.length > 0 ? intervals : FALLBACK_DEFAULT_INTERVALS).map((p) => ({
start: p.start,
end: p.end,
}));
const colorToCss = (color) =>
Array.isArray(color) && color.length === 3 ? `rgb(${color[0]},${color[1]},${color[2]})` : String(color);
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const getNextStart = (intervals, index) => (index < intervals.length - 1 ? intervals[index + 1].start : 1);
const normalizePalette = (rawPalette) => {
const toColor = (entry) => {
if (Array.isArray(entry) && entry.length >= 3) {
const r = Number(entry[0]);
const g = Number(entry[1]);
const b = Number(entry[2]);
if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
return [clamp(Math.round(r), 0, 255), clamp(Math.round(g), 0, 255), clamp(Math.round(b), 0, 255)];
}
}
if (entry && typeof entry === "object") {
const r = Number(entry.r ?? entry.red);
const g = Number(entry.g ?? entry.green);
const b = Number(entry.b ?? entry.blue);
if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
return [clamp(Math.round(r), 0, 255), clamp(Math.round(g), 0, 255), clamp(Math.round(b), 0, 255)];
}
}
return null;
};
if (Array.isArray(rawPalette)) {
const normalized = rawPalette.map(toColor).filter(Boolean);
return normalized.length > 0 ? normalized : null;
}
if (rawPalette && typeof rawPalette === "object") {
const keys = Object.keys(rawPalette).sort((a, b) => Number(a) - Number(b));
const normalized = keys.map((k) => toColor(rawPalette[k])).filter(Boolean);
return normalized.length > 0 ? normalized : null;
}
return null;
};
const getHandleY = (area) => area.y + (1.0 - INTERVAL_LEVEL) * area.height;
const isPointInRect = (x, y, rect) =>
!!rect && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
const drawStyledText = (ctx, text, x, y, style) => {
Object.assign(ctx, {
fillStyle: TEXT_COLOR_DEFAULT,
font: "13px Arial",
textAlign: "center",
textBaseline: "middle",
...style,
});
ctx.fillText(text, x, y);
};
app.registerExtension({
name: "depth to layers",
nodeCreated(node) {
if (node.comfyClass !== "depth to layers") {
return;
}
const defaultIntervals = cloneIntervals(FALLBACK_DEFAULT_INTERVALS);
Object.assign(node, {
size: [NODE_BASE_WIDTH, NODE_PANEL_HEIGHT],
_configure: node.configure,
_serialize: node.serialize,
imagePreview: null,
originalImage: null,
hasUserResizedNode: false,
selectedHandleIndex: -1,
selectedHandleType: null,
isDragging: false,
histogram: null,
vgaColors: [...FALLBACK_PALETTE],
defaultIntervals,
allIntervals: cloneIntervals(defaultIntervals),
sourcePixelData: null,
sourcePixelWidth: 0,
sourcePixelHeight: 0,
localDragPreviewCanvas: null,
localDragPreviewCtx: null,
hoverHistogramValue: null,
getPreviewPanelArea: function () {
if (!this.size) return null;
const mainAreaHeight = this.size[1] - NODE_PANEL_HEIGHT;
if (mainAreaHeight < 10) return null;
const area = {
x: NODE_PADDING,
y: NODE_HEADER_HEIGHT + NODE_PADDING,
width: this.size[0] - NODE_PADDING * 2,
height: mainAreaHeight,
};
return area.width < 1 || area.height < 1 ? null : area;
},
getHistogramPanelArea: function () {
const imageArea = this.getPreviewPanelArea();
if (!imageArea) return null;
return {
x: NODE_PADDING,
y: imageArea.y + imageArea.height + NODE_PADDING,
width: this.size[0] - NODE_PADDING * 2,
height: NODE_HISTOGRAM_HEIGHT,
};
},
getTopPreviewArea: function () {
const imageArea = this.getPreviewPanelArea();
if (!imageArea) return null;
const halfHeight = Math.max(1, Math.floor((imageArea.height - NODE_PADDING) / 2));
return {
x: imageArea.x,
y: imageArea.y,
width: imageArea.width,
height: halfHeight,
};
},
getBottomPreviewArea: function () {
const imageArea = this.getPreviewPanelArea();
const topArea = this.getTopPreviewArea();
if (!imageArea || !topArea) return null;
const bottomY = topArea.y + topArea.height + NODE_PADDING;
return {
x: imageArea.x,
y: bottomY,
width: imageArea.width,
height: Math.max(1, imageArea.y + imageArea.height - bottomY),
};
},
getResetButtonArea: function () {
const histogramArea = this.getHistogramPanelArea();
if (!histogramArea) return null;
return {
x: NODE_PADDING,
y: histogramArea.y + histogramArea.height + NODE_PADDING,
width: this.size[0] - NODE_PADDING * 2,
height: NODE_RESET_BUTTON_HEIGHT,
};
},
getVgaColorCount: function () {
return Math.max(2, this.vgaColors.length);
},
getVgaColor: function (index) {
const fallback = [255, 255, 255];
const color = this.vgaColors[index] || this.vgaColors[this.vgaColors.length - 1] || fallback;
return colorToCss(color);
},
updateBackend: function () {
// Persist current interval state and refresh preview from local source cache.
const intervalsWidget = this.widgets.find((w) => w.name === "intervals");
if (!intervalsWidget) return;
if (!this.originalImage) {
this.imagePreview = null;
this.setDirtyCanvas(true, true);
return;
}
const serializableIntervals = [];
for (let i = 0; i < this.allIntervals.length; i++) {
const p = this.allIntervals[i];
const nextStart = getNextStart(this.allIntervals, i);
const start = clamp(p.start, 0, 1);
const end = clamp(p.end, start, nextStart);
serializableIntervals.push({ start, end });
}
intervalsWidget.value = JSON.stringify(serializableIntervals);
this.renderLocalDragPreview();
this.imagePreview = this.localDragPreviewCanvas;
this.setDirtyCanvas(true, true);
},
cacheSourceAssets: function (histogramData) {
// Cache source assets used by both UI rendering and hover sampling.
if (!this.originalImage) return;
this.histogram = Array.isArray(histogramData) ? histogramData : null;
this.buildSourcePixelCache();
this.setDirtyCanvas(true, true);
},
clearSourcePixelCache: function () {
this.sourcePixelData = null;
this.sourcePixelWidth = 0;
this.sourcePixelHeight = 0;
this.localDragPreviewCanvas = null;
this.localDragPreviewCtx = null;
},
resetHoverHistogramMarker: function () {
this.hoverHistogramValue = null;
},
buildSourcePixelCache: function () {
// Create a grayscale sampling buffer for hover-to-histogram mapping.
if (!this.originalImage) {
this.clearSourcePixelCache();
return;
}
const width = this.originalImage.naturalWidth || this.originalImage.width || 0;
const height = this.originalImage.naturalHeight || this.originalImage.height || 0;
if (width <= 0 || height <= 0) {
this.clearSourcePixelCache();
return;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
this.clearSourcePixelCache();
return;
}
ctx.drawImage(this.originalImage, 0, 0, width, height);
this.sourcePixelData = ctx.getImageData(0, 0, width, height).data;
this.sourcePixelWidth = width;
this.sourcePixelHeight = height;
},
ensureLocalDragPreviewCanvas: function () {
if (this.sourcePixelWidth <= 0 || this.sourcePixelHeight <= 0) return null;
if (
!this.localDragPreviewCanvas ||
this.localDragPreviewCanvas.width !== this.sourcePixelWidth ||
this.localDragPreviewCanvas.height !== this.sourcePixelHeight
) {
this.localDragPreviewCanvas = document.createElement("canvas");
this.localDragPreviewCanvas.width = this.sourcePixelWidth;
this.localDragPreviewCanvas.height = this.sourcePixelHeight;
this.localDragPreviewCtx = this.localDragPreviewCanvas.getContext("2d");
}
return this.localDragPreviewCtx ? this.localDragPreviewCanvas : null;
},
renderLocalDragPreview: function () {
// UI-only preview while dragging: mirrors backend interval coloring using cached grayscale pixels.
if (!this.sourcePixelData || this.sourcePixelWidth <= 0 || this.sourcePixelHeight <= 0) {
return;
}
const canvas = this.ensureLocalDragPreviewCanvas();
if (!canvas || !this.localDragPreviewCtx) {
return;
}
const activeHandles = this.allIntervals;
const colorCount = this.getVgaColorCount();
const lutR = new Uint8Array(256);
const lutG = new Uint8Array(256);
const lutB = new Uint8Array(256);
for (let i = 0; i < activeHandles.length; i++) {
const current = activeHandles[i];
const nextStart = getNextStart(activeHandles, i);
const startNorm = clamp(current.start, 0, 1);
const endNorm = clamp(current.end, startNorm, nextStart);
const start = clamp(Math.ceil(startNorm * 255), 0, 255);
const end = clamp(Math.floor(endNorm * 255), start, 255);
const colorIndex = (i % (colorCount - 1)) + 1;
const color = this.vgaColors[colorIndex] || this.vgaColors[this.vgaColors.length - 1] || [255, 255, 255];
const r = color[0] ?? 255;
const g = color[1] ?? 255;
const b = color[2] ?? 255;
for (let value = start; value <= end; value++) {
lutR[value] = r;
lutG[value] = g;
lutB[value] = b;
}
}
const imageData = this.localDragPreviewCtx.createImageData(this.sourcePixelWidth, this.sourcePixelHeight);
const output = imageData.data;
const source = this.sourcePixelData;
for (let i = 0; i < source.length; i += 4) {
const intensity = source[i];
output[i] = lutR[intensity];
output[i + 1] = lutG[intensity];
output[i + 2] = lutB[intensity];
output[i + 3] = 255;
}
this.localDragPreviewCtx.putImageData(imageData, 0, 0);
},
clearHoverHistogramMarker: function () {
if (this.hoverHistogramValue !== null) {
this.resetHoverHistogramMarker();
this.setDirtyCanvas(true, true);
}
},
updateHoverHistogramMarkerFromMouse: function (mouseX, mouseY) {
// Convert mouse position in the top preview to source pixel intensity.
const topPreviewArea = this.getTopPreviewArea();
if (!topPreviewArea || !this.sourcePixelData || this.sourcePixelWidth <= 0 || this.sourcePixelHeight <= 0) {
this.clearHoverHistogramMarker();
return;
}
if (!isPointInRect(mouseX, mouseY, topPreviewArea)) {
this.clearHoverHistogramMarker();
return;
}
const u = clamp((mouseX - topPreviewArea.x) / Math.max(1, topPreviewArea.width), 0, 1);
const v = clamp((mouseY - topPreviewArea.y) / Math.max(1, topPreviewArea.height), 0, 1);
const pixelX = Math.min(this.sourcePixelWidth - 1, Math.floor(u * this.sourcePixelWidth));
const pixelY = Math.min(this.sourcePixelHeight - 1, Math.floor(v * this.sourcePixelHeight));
const index = (pixelY * this.sourcePixelWidth + pixelX) * 4;
const data = this.sourcePixelData;
const intensity = data[index]; // Assuming grayscale, so R=G=B. If not, this will still give a valid intensity based on the red channel.
if (this.hoverHistogramValue !== intensity) {
this.hoverHistogramValue = intensity;
this.setDirtyCanvas(true, true);
}
},
drawHoverHistogramMarker: function (ctx, histogramArea) {
if (this.hoverHistogramValue === null) return;
const x = histogramArea.x + (this.hoverHistogramValue / 255) * histogramArea.width;
ctx.save();
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x, histogramArea.y);
ctx.lineTo(x, histogramArea.y + histogramArea.height);
ctx.strokeStyle = HOVER_MARKER_SHADOW_COLOR;
ctx.lineWidth = 4;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x, histogramArea.y);
ctx.lineTo(x, histogramArea.y + histogramArea.height);
const gray = this.hoverHistogramValue;
ctx.strokeStyle = `rgb(${gray},${gray},${gray})`;
ctx.lineWidth = 2;
ctx.stroke();
ctx.restore();
},
drawHistogram: function (ctx, area, data) {
if (!data || data.length === 0) return;
ctx.fillStyle = HISTOGRAM_BAR_COLOR;
const barWidth = area.width / NODE_BASE_WIDTH;
for (let i = 0; i < NODE_BASE_WIDTH; i++) {
const value = data[i] ?? 0;
const barHeight = value * area.height;
const x = area.x + i * barWidth;
ctx.fillRect(x, area.y + area.height - barHeight, barWidth, barHeight);
}
},
drawIntervalHandle: function (ctx, x, y, isStartHandle, fillColor, isSelected) {
const top = y - HANDLE_HEIGHT / 2;
const bottom = y + HANDLE_HEIGHT / 2;
const left = isStartHandle ? x : x - HANDLE_WIDTH;
const right = isStartHandle ? x + HANDLE_WIDTH : x;
ctx.beginPath();
if (isStartHandle) {
// D shape: flat side aligned at x, rounded side to the right.
ctx.moveTo(left, top);
ctx.lineTo(right - 2, top);
ctx.quadraticCurveTo(right + 3, y, right - 2, bottom);
ctx.lineTo(left, bottom);
} else {
// Inverted D shape: rounded side to the left, flat side aligned at x.
ctx.moveTo(right, top);
ctx.lineTo(left + 2, top);
ctx.quadraticCurveTo(left - 3, y, left + 2, bottom);
ctx.lineTo(right, bottom);
}
ctx.closePath();
ctx.fillStyle = isSelected ? INTERVAL_HANDLE_SELECTED_COLOR : fillColor;
ctx.fill();
},
onDrawForeground: function (ctx) {
// Draw order: histogram -> source/preview panels -> interval overlays -> controls.
if (this.flags?.collapsed) return;
const topPreviewArea = this.getTopPreviewArea();
const bottomPreviewArea = this.getBottomPreviewArea();
const histogramArea = this.getHistogramPanelArea();
const resetArea = this.getResetButtonArea();
if (!topPreviewArea || !bottomPreviewArea) return;
if (histogramArea) {
ctx.fillStyle = HISTOGRAM_BG_COLOR;
ctx.fillRect(histogramArea.x, histogramArea.y, histogramArea.width, histogramArea.height);
if (this.histogram) {
this.drawHistogram(ctx, histogramArea, this.histogram);
}
}
if (this.originalImage) {
ctx.save();
ctx.beginPath();
ctx.rect(topPreviewArea.x, topPreviewArea.y, topPreviewArea.width, topPreviewArea.height);
ctx.clip();
ctx.drawImage(
this.originalImage,
topPreviewArea.x,
topPreviewArea.y,
topPreviewArea.width,
topPreviewArea.height,
);
ctx.restore();
}
const useLocalPreview = this.isDragging && this.localDragPreviewCanvas;
const previewImage = useLocalPreview ? this.localDragPreviewCanvas : this.imagePreview;
if (previewImage) {
ctx.save();
ctx.beginPath();
ctx.rect(bottomPreviewArea.x, bottomPreviewArea.y, bottomPreviewArea.width, bottomPreviewArea.height);
ctx.clip();
ctx.drawImage(
previewImage,
bottomPreviewArea.x,
bottomPreviewArea.y,
bottomPreviewArea.width,
bottomPreviewArea.height,
);
ctx.restore();
} else {
ctx.save();
drawStyledText(
ctx,
"Run the workflow first to generate the preview",
bottomPreviewArea.x + bottomPreviewArea.width / 2,
bottomPreviewArea.y + bottomPreviewArea.height / 2,
{
fillStyle: PREVIEW_PLACEHOLDER_TEXT_COLOR,
},
);
ctx.restore();
}
if (histogramArea) {
const activeHandles = this.allIntervals;
const colorCount = this.getVgaColorCount();
// Color 0 (black): regions that are not part of any active interval.
ctx.save();
ctx.globalAlpha = 0.1;
ctx.fillStyle = this.getVgaColor(0);
ctx.fillRect(histogramArea.x, histogramArea.y, histogramArea.width, histogramArea.height);
ctx.restore();
ctx.save();
ctx.globalAlpha = INTERVAL_OVERLAY_ALPHA;
for (let i = 0; i < activeHandles.length; i++) {
const current = activeHandles[i];
const nextStart = getNextStart(activeHandles, i);
const startXNorm = clamp(current.start, 0, 1);
const endXNorm = clamp(current.end, startXNorm, nextStart);
const startX = histogramArea.x + startXNorm * histogramArea.width;
const endX = histogramArea.x + endXNorm * histogramArea.width;
const colorIndex = (i % (colorCount - 1)) + 1;
ctx.fillStyle = this.getVgaColor(colorIndex);
ctx.fillRect(startX, histogramArea.y, Math.max(0, endX - startX), histogramArea.height);
const intervalWidth = endX - startX;
if (intervalWidth > 18) {
ctx.globalAlpha = 1;
const labelX = startX + intervalWidth / 2;
const labelY = histogramArea.y + 10;
ctx.beginPath();
ctx.arc(labelX, labelY, 8, 0, Math.PI * 2);
ctx.fillStyle = INTERVAL_LABEL_BADGE_BG_COLOR;
ctx.fill();
ctx.strokeStyle = INTERVAL_LABEL_BADGE_BORDER_COLOR;
ctx.lineWidth = 1.1;
ctx.stroke();
drawStyledText(ctx, String(i + 1), labelX, labelY, {
fillStyle: "#000000",
font: "bold 10px Arial",
});
ctx.globalAlpha = INTERVAL_OVERLAY_ALPHA;
}
}
ctx.restore();
const handleY = getHandleY(histogramArea);
for (let i = 0; i < activeHandles.length; i++) {
const current = activeHandles[i];
const nextStart = getNextStart(activeHandles, i);
const startXNorm = clamp(current.start, 0, 1);
const endXNorm = clamp(current.end, startXNorm, nextStart);
const colorIndex = (i % (colorCount - 1)) + 1;
const intervalColor = this.getVgaColor(colorIndex);
const startX = histogramArea.x + startXNorm * histogramArea.width;
const endX = histogramArea.x + endXNorm * histogramArea.width;
this.drawIntervalHandle(
ctx,
startX,
handleY,
true,
intervalColor,
this.selectedHandleIndex === i && this.selectedHandleType === "start",
);
this.drawIntervalHandle(
ctx,
endX,
handleY,
false,
intervalColor,
this.selectedHandleIndex === i && this.selectedHandleType === "end",
);
}
this.drawHoverHistogramMarker(ctx, histogramArea);
}
if (resetArea) {
ctx.save();
ctx.fillStyle = RESET_BUTTON_BG_COLOR;
ctx.fillRect(resetArea.x, resetArea.y, resetArea.width, resetArea.height);
ctx.strokeStyle = RESET_BUTTON_BORDER_COLOR;
ctx.lineWidth = 1;
ctx.strokeRect(resetArea.x + 0.5, resetArea.y + 0.5, resetArea.width - 1, resetArea.height - 1);
drawStyledText(ctx, "Reset", resetArea.x + resetArea.width / 2, resetArea.y + resetArea.height / 2);
ctx.restore();
}
},
onMouseDown: function (event) {
// Primary interactions: reset, handle selection/removal, or interval insertion.
const intervalArea = this.getHistogramPanelArea();
const resetArea = this.getResetButtonArea();
if (event.button !== 0) return false;
const localPos = app.canvas.convertEventToCanvasOffset(event);
const mouseX = localPos[0] - this.pos[0];
const mouseY = localPos[1] - this.pos[1];
if (isPointInRect(mouseX, mouseY, resetArea)) {
this.allIntervals = cloneIntervals(this.defaultIntervals);
this.updateBackend();
return true;
}
if (!this.originalImage || !intervalArea) return false;
if (!isPointInRect(mouseX, mouseY, intervalArea)) return false;
const activeHandles = this.allIntervals;
const handleY = getHandleY(intervalArea);
const removeInterval = (index) => {
activeHandles.splice(index, 1);
this.selectedHandleIndex = -1;
this.selectedHandleType = null;
this.updateBackend();
return true;
};
for (let i = 0; i < activeHandles.length; i++) {
const handle = activeHandles[i];
const startX = intervalArea.x + handle.start * intervalArea.width;
const nextStart = getNextStart(activeHandles, i);
const endXNorm = clamp(handle.end, handle.start, nextStart);
const endX = intervalArea.x + endXNorm * intervalArea.width;
const handleTop = handleY - HANDLE_HEIGHT / 2 - HANDLE_HIT_PAD;
const handleBottom = handleY + HANDLE_HEIGHT / 2 + HANDLE_HIT_PAD;
const inY = mouseY >= handleTop && mouseY <= handleBottom;
const startHandleHit =
inY && mouseX >= startX - HANDLE_HIT_PAD && mouseX <= startX + HANDLE_WIDTH + HANDLE_HIT_PAD;
const endHandleHit = inY && mouseX >= endX - HANDLE_WIDTH - HANDLE_HIT_PAD && mouseX <= endX + HANDLE_HIT_PAD;
if (event.shiftKey && mouseX >= startX && mouseX <= endX) {
return removeInterval(i);
}
// Prioritize start handle if both are hit around adjacent boundaries.
if (startHandleHit || endHandleHit) {
if (event.shiftKey) {
return removeInterval(i);
} else {
this.selectedHandleIndex = i;
this.selectedHandleType = startHandleHit ? "start" : "end";
this.setDirtyCanvas(true, true);
return true;
}
}
}
if (event.shiftKey) return false;
const newX = clamp((mouseX - intervalArea.x) / intervalArea.width, 0, 1);
const insertIndex = activeHandles.findIndex((p) => p.start > newX);
const targetIndex = insertIndex === -1 ? activeHandles.length : insertIndex;
const nextStart = getNextStart(activeHandles, targetIndex - 1);
activeHandles.splice(targetIndex, 0, {
start: newX,
end: clamp(newX + 0.1, newX + MIN_HANDLE_GAP, nextStart - MIN_HANDLE_GAP),
});
this.selectedHandleIndex = targetIndex;
this.selectedHandleType = "start";
this.isDragging = true;
this.updateBackend();
return true;
},
onMouseMove: function (event) {
// Hover updates histogram marker; drag updates selected interval boundary.
const localPos = app.canvas.convertEventToCanvasOffset(event);
const mouseX = localPos[0] - this.pos[0];
const mouseY = localPos[1] - this.pos[1];
this.updateHoverHistogramMarkerFromMouse(mouseX, mouseY);
if (event.buttons !== 1) {
if (this.isDragging || this.selectedHandleIndex !== -1) {
if (this.isDragging) {
this.updateBackend();
}
this.isDragging = false;
this.selectedHandleIndex = -1;
this.selectedHandleType = null;
this.setDirtyCanvas(true, true);
}
return;
}
if (this.selectedHandleIndex === -1) return;
this.isDragging = true;
const intervalArea = this.getHistogramPanelArea();
if (!intervalArea) return;
const activeHandles = this.allIntervals;
const handle = activeHandles[this.selectedHandleIndex];
if (this.selectedHandleType === "start") {
const newX = (mouseX - intervalArea.x) / intervalArea.width;
const minX =
this.selectedHandleIndex > 0 ? activeHandles[this.selectedHandleIndex - 1].end + MIN_HANDLE_GAP : 0;
const maxX = handle.end - MIN_HANDLE_GAP;
handle.start = clamp(newX, minX, maxX);
} else if (this.selectedHandleType === "end") {
const newEndX = (mouseX - intervalArea.x) / intervalArea.width;
const minEndX = handle.start + MIN_HANDLE_GAP;
const maxEndX =
this.selectedHandleIndex < activeHandles.length - 1
? activeHandles[this.selectedHandleIndex + 1].start - MIN_HANDLE_GAP
: 1;
handle.end = clamp(newEndX, minEndX, maxEndX);
}
this.updateBackend();
},
onMouseUp: function () {
if (this.isDragging) {
this.isDragging = false;
if (this.selectedHandleIndex !== -1) {
this.updateBackend();
}
}
},
onMouseLeave: function () {
this.clearHoverHistogramMarker();
},
onResize: function () {
this.hasUserResizedNode = true;
},
hideIntervalTextField: function () {
const intervalsWidget = this.widgets.find((w) => w.name === "intervals");
if (!intervalsWidget) return;
intervalsWidget.type = "hidden";
},
configure: function (data) {
this._configure(data);
this.hideIntervalTextField();
if (data.hasUserResizedNode) {
this.hasUserResizedNode = data.hasUserResizedNode;
}
const savedIntervals = Array.isArray(data.storedIntervals) ? data.storedIntervals : null;
if (Array.isArray(savedIntervals)) {
const mappedIntervals = savedIntervals
.filter((p) => typeof p?.start === "number" && typeof p?.end === "number")
.map((p, idx, arr) => {
const nextStart = getNextStart(arr, idx);
const start = clamp(p.start, 0, 1);
const end = clamp(p.end, start + MIN_HANDLE_GAP, nextStart - MIN_HANDLE_GAP);
return { start, end };
})
.filter((p) => p.start < 1 - MIN_HANDLE_GAP && p.end > p.start);
this.allIntervals = mappedIntervals;
if (this.allIntervals.length === 0) {
this.allIntervals = cloneIntervals(this.defaultIntervals);
}
const intervalsWidget = this.widgets.find((w) => w.name === "intervals");
if (intervalsWidget) {
const serialized = [];
for (let i = 0; i < this.allIntervals.length; i++) {
const p = this.allIntervals[i];
const nextStart = getNextStart(this.allIntervals, i);
const end = clamp(p.end, p.start, nextStart);
serialized.push({ start: p.start, end });
}
intervalsWidget.value = JSON.stringify(serialized);
}
}
},
serialize: function () {
const data = this._serialize();
data.storedIntervals = this.allIntervals;
data.hasUserResizedNode = this.hasUserResizedNode;
return data;
},
});
node.hideIntervalTextField();
},
});
api.addEventListener("game_dev.depth_to_layers.initialise", ({ detail }) => {
// Bootstrap node state and assets after backend execution.
const node = app.graph.getNodeById(detail.node_id);
if (!node) {
return;
}
if (Array.isArray(detail.default_intervals) && detail.default_intervals.length > 0) {
node.defaultIntervals = cloneIntervals(detail.default_intervals);
if (!Array.isArray(node.allIntervals) || node.allIntervals.length === 0) {
node.allIntervals = cloneIntervals(node.defaultIntervals);
}
}
const incomingPalette =
normalizePalette(detail.vga_palette) || normalizePalette(detail.vgaPalette) || normalizePalette(detail.palette);
if (incomingPalette) {
node.vgaColors = incomingPalette;
}
const onAssetLoad = () => {
if (!node.hasUserResizedNode && node.originalImage) {
const aspectRatio = node.originalImage.naturalWidth / node.originalImage.naturalHeight;
node.size[0] = NODE_BASE_WIDTH;
node.size[1] = NODE_PANEL_HEIGHT + (NODE_BASE_WIDTH - NODE_PADDING * 2) / (aspectRatio || 1);
node.hasUserResizedNode = true;
}
node.updateBackend();
};
if (!detail.preview_image) {
node.originalImage = null;
node.histogram = null;
node.clearSourcePixelCache();
node.resetHoverHistogramMarker();
onAssetLoad();
return;
}
const img = new Image();
img.src = "data:image/png;base64," + detail.preview_image;
img.onload = () => {
node.originalImage = img;
node.cacheSourceAssets(detail.histogram_data);
onAssetLoad();
};
node.originalImage = img;
});