877 lines
31 KiB
JavaScript
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;
|
|
});
|