960 lines
33 KiB
JavaScript
960 lines
33 KiB
JavaScript
/**
|
|
* ComfyUI frontend extension for the `resize and dither` node.
|
|
*
|
|
* Responsibilities:
|
|
* - Render before/after previews directly inside the node.
|
|
* - Render a palette swatch grid from the frontend-computed active palette.
|
|
* - Compute local preview with the same nearest + dither logic as backend.
|
|
*
|
|
* Event contract:
|
|
* - Receives initial state from `game_dev.resize_dither.initialise`.
|
|
*/
|
|
import { app } from "/scripts/app.js";
|
|
import { api } from "/scripts/api.js";
|
|
|
|
const NODE_BASE_WIDTH = 280;
|
|
const NODE_HEADER_HEIGHT = 28;
|
|
const NODE_PANEL_HEIGHT = 130;
|
|
const NODE_PADDING = 6;
|
|
const PREVIEW_GAP = 6;
|
|
const MAX_DIMENSION = 8192;
|
|
const NODE_MIN_TOP_RESERVED = 74;
|
|
const PALETTE_GRID_GAP = 1;
|
|
const PALETTE_GRID_TOP_INSET = 20;
|
|
const PALETTE_GRID_BOTTOM_INSET = 4;
|
|
const PALETTE_GRID_MAX_COLUMNS = 32;
|
|
|
|
const TEXT_COLOR_DEFAULT = "#e6e6e6";
|
|
const BG_PANEL_COLOR = "#2d2d2d";
|
|
const BG_CONTROL_COLOR = "#2a2a2a";
|
|
const PREVIEW_PLACEHOLDER_TEXT_COLOR = "#ebebeb";
|
|
const PALETTE_SWATCH_BORDER_COLOR = "rgba(0,0,0,0.35)";
|
|
|
|
const DITHER_MODES = ["positional", "floyd"];
|
|
const PALETTE_MODES = ["vga_256", "optimized_x_colors"];
|
|
const MIN_PALETTE_COLORS = 2;
|
|
const MAX_PALETTE_COLORS = 256;
|
|
const FRONTEND_PREVIEW_INPUT_WIDGETS = new Set(["width", "height", "dither_mode", "palette_mode", "palette_colors"]);
|
|
|
|
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 clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
const toInt = (value, fallback = 0) => {
|
|
const n = Number.parseInt(value, 10);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
};
|
|
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);
|
|
};
|
|
const setNearestNeighborScaling = (ctx) => {
|
|
ctx.imageSmoothingEnabled = false;
|
|
if ("mozImageSmoothingEnabled" in ctx) ctx.mozImageSmoothingEnabled = false;
|
|
if ("webkitImageSmoothingEnabled" in ctx) ctx.webkitImageSmoothingEnabled = false;
|
|
if ("msImageSmoothingEnabled" in ctx) ctx.msImageSmoothingEnabled = false;
|
|
if ("imageSmoothingQuality" in ctx) ctx.imageSmoothingQuality = "low";
|
|
};
|
|
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;
|
|
};
|
|
app.registerExtension({
|
|
name: "resize and dither",
|
|
|
|
nodeCreated(node) {
|
|
if (node.comfyClass !== "resize and dither") {
|
|
return;
|
|
}
|
|
|
|
Object.assign(node, {
|
|
size: [NODE_BASE_WIDTH, 330],
|
|
_configure: node.configure,
|
|
_serialize: node.serialize,
|
|
originalImage: null,
|
|
imagePreview: null,
|
|
sourcePixelData: null,
|
|
sourcePixelWidth: 0,
|
|
sourcePixelHeight: 0,
|
|
localPreviewCanvas: null,
|
|
localPreviewCtx: null,
|
|
hasUserResizedNode: false,
|
|
vgaPalette: [...FALLBACK_PALETTE],
|
|
paletteFloat: null,
|
|
paletteInt: null,
|
|
bootstrapRevision: 0,
|
|
_suspendFrontendInputRefresh: false,
|
|
|
|
findWidget: function (name) {
|
|
return this.widgets?.find((w) => w.name === name);
|
|
},
|
|
|
|
getIntWidgetValue: function (name, fallback) {
|
|
const widget = this.findWidget(name);
|
|
return clamp(toInt(widget?.value, fallback), 0, MAX_DIMENSION);
|
|
},
|
|
|
|
resolveTargetSize: function (requestedWidth, requestedHeight) {
|
|
const srcWidth = this.sourcePixelWidth > 0 ? this.sourcePixelWidth : Math.max(1, requestedWidth || 1);
|
|
const srcHeight = this.sourcePixelHeight > 0 ? this.sourcePixelHeight : Math.max(1, requestedHeight || 1);
|
|
|
|
let width = requestedWidth;
|
|
let height = requestedHeight;
|
|
|
|
if (width <= 0 && height <= 0) {
|
|
width = srcWidth;
|
|
height = srcHeight;
|
|
} else if (width <= 0) {
|
|
width = Math.max(1, Math.round((srcWidth * height) / srcHeight));
|
|
} else if (height <= 0) {
|
|
height = Math.max(1, Math.round((srcHeight * width) / srcWidth));
|
|
}
|
|
|
|
return {
|
|
width: clamp(width, 1, MAX_DIMENSION),
|
|
height: clamp(height, 1, MAX_DIMENSION),
|
|
};
|
|
},
|
|
|
|
getModeValue: function () {
|
|
const widget = this.findWidget("dither_mode");
|
|
const mode = String(widget?.value || "positional");
|
|
return DITHER_MODES.includes(mode) ? mode : "positional";
|
|
},
|
|
|
|
getPaletteModeValue: function () {
|
|
const widget = this.findWidget("palette_mode");
|
|
const mode = String(widget?.value || "vga_256");
|
|
return PALETTE_MODES.includes(mode) ? mode : "vga_256";
|
|
},
|
|
|
|
getPaletteColorsValue: function () {
|
|
const widget = this.findWidget("palette_colors");
|
|
const raw = toInt(widget?.value, 24);
|
|
return clamp(raw, MIN_PALETTE_COLORS, MAX_PALETTE_COLORS);
|
|
},
|
|
|
|
setWidgetValue: function (name, value) {
|
|
const widget = this.findWidget(name);
|
|
if (!widget) return;
|
|
|
|
widget.value = value;
|
|
if (typeof widget.callback === "function") {
|
|
widget.callback(value, app.canvas, this);
|
|
}
|
|
},
|
|
|
|
isImageLoadedForFrontendPreview: function () {
|
|
return (
|
|
!!this.originalImage && !!this.sourcePixelData && this.sourcePixelWidth > 0 && this.sourcePixelHeight > 0
|
|
);
|
|
},
|
|
|
|
refreshFrontendPreviewFromInputChange: function (widgetName) {
|
|
if (!FRONTEND_PREVIEW_INPUT_WIDGETS.has(widgetName)) {
|
|
return;
|
|
}
|
|
if (!this.isImageLoadedForFrontendPreview()) {
|
|
return;
|
|
}
|
|
|
|
this.renderLocalPreview();
|
|
this.setDirtyCanvas(true, true);
|
|
},
|
|
|
|
bindFrontendPreviewInputCallbacks: function () {
|
|
if (!Array.isArray(this.widgets)) {
|
|
return;
|
|
}
|
|
|
|
for (const widget of this.widgets) {
|
|
if (!widget || !FRONTEND_PREVIEW_INPUT_WIDGETS.has(widget.name)) {
|
|
continue;
|
|
}
|
|
if (widget._gdFrontendPreviewBound) {
|
|
continue;
|
|
}
|
|
|
|
const originalCallback = widget.callback;
|
|
widget.callback = (value, canvasRef, nodeRef, ...rest) => {
|
|
if (typeof originalCallback === "function") {
|
|
originalCallback.call(widget, value, canvasRef, nodeRef, ...rest);
|
|
}
|
|
|
|
const targetNode = nodeRef && nodeRef.comfyClass === this.comfyClass ? nodeRef : this;
|
|
if (targetNode?._suspendFrontendInputRefresh) {
|
|
return;
|
|
}
|
|
targetNode?.refreshFrontendPreviewFromInputChange(widget.name);
|
|
};
|
|
|
|
widget._gdFrontendPreviewBound = true;
|
|
}
|
|
},
|
|
|
|
rebuildBinaryPaletteLut: function () {
|
|
this.setActivePalette(this.vgaPalette?.length ? this.vgaPalette : FALLBACK_PALETTE);
|
|
},
|
|
|
|
setActivePalette: function (palette) {
|
|
const normalized = normalizePalette(palette);
|
|
const resolved = normalized && normalized.length > 0 ? normalized : FALLBACK_PALETTE;
|
|
this.paletteInt = resolved.map((c) => [c[0], c[1], c[2]]);
|
|
this.paletteFloat = this.paletteInt.map((c) => [c[0] / 255, c[1] / 255, c[2] / 255]);
|
|
},
|
|
|
|
buildOptimizedPalette: function (rgb, width, height, kColors) {
|
|
const pixelCount = width * height;
|
|
if (pixelCount <= 0) {
|
|
return this.vgaPalette;
|
|
}
|
|
|
|
const k = clamp(toInt(kColors, 24), MIN_PALETTE_COLORS, MAX_PALETTE_COLORS);
|
|
const sampleLimit = 20000;
|
|
const step = Math.max(1, Math.floor(pixelCount / sampleLimit));
|
|
const sample = [];
|
|
|
|
for (let i = 0; i < pixelCount; i += step) {
|
|
const base = i * 3;
|
|
sample.push([rgb[base], rgb[base + 1], rgb[base + 2]]);
|
|
if (sample.length >= sampleLimit) break;
|
|
}
|
|
|
|
if (sample.length === 0) {
|
|
return this.vgaPalette;
|
|
}
|
|
|
|
const originalSampleLength = sample.length;
|
|
while (sample.length < k) {
|
|
sample.push(sample[sample.length % originalSampleLength].slice());
|
|
}
|
|
|
|
const centers = [];
|
|
for (let j = 0; j < k; j++) {
|
|
const index = Math.round((j * (sample.length - 1)) / Math.max(1, k - 1));
|
|
centers.push(sample[index].slice());
|
|
}
|
|
|
|
for (let iter = 0; iter < 10; iter++) {
|
|
const sumR = new Float32Array(k);
|
|
const sumG = new Float32Array(k);
|
|
const sumB = new Float32Array(k);
|
|
const counts = new Uint32Array(k);
|
|
|
|
for (let i = 0; i < sample.length; i++) {
|
|
const point = sample[i];
|
|
let bestIdx = 0;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
|
|
for (let j = 0; j < k; j++) {
|
|
const center = centers[j];
|
|
const dr = point[0] - center[0];
|
|
const dg = point[1] - center[1];
|
|
const db = point[2] - center[2];
|
|
const dist = dr * dr + dg * dg + db * db;
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestIdx = j;
|
|
}
|
|
}
|
|
|
|
sumR[bestIdx] += point[0];
|
|
sumG[bestIdx] += point[1];
|
|
sumB[bestIdx] += point[2];
|
|
counts[bestIdx] += 1;
|
|
}
|
|
|
|
for (let j = 0; j < k; j++) {
|
|
if (counts[j] > 0) {
|
|
centers[j][0] = sumR[j] / counts[j];
|
|
centers[j][1] = sumG[j] / counts[j];
|
|
centers[j][2] = sumB[j] / counts[j];
|
|
}
|
|
}
|
|
}
|
|
|
|
centers.sort(
|
|
(a, b) => 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2] - (0.2126 * b[0] + 0.7152 * b[1] + 0.0722 * b[2]),
|
|
);
|
|
|
|
return centers.map((c) => [
|
|
clamp(Math.round(c[0] * 255), 0, 255),
|
|
clamp(Math.round(c[1] * 255), 0, 255),
|
|
clamp(Math.round(c[2] * 255), 0, 255),
|
|
]);
|
|
},
|
|
|
|
nearestPaletteIndex: function (r, g, b) {
|
|
const palette = this.paletteFloat?.length
|
|
? this.paletteFloat
|
|
: FALLBACK_PALETTE.map((c) => [c[0] / 255, c[1] / 255, c[2] / 255]);
|
|
let bestIndex = 0;
|
|
let bestDist = Number.POSITIVE_INFINITY;
|
|
|
|
for (let i = 0; i < palette.length; i++) {
|
|
const color = palette[i];
|
|
const dr = r - color[0];
|
|
const dg = g - color[1];
|
|
const db = b - color[2];
|
|
const dist = dr * dr + dg * dg + db * db;
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
|
|
return bestIndex;
|
|
},
|
|
|
|
quantizeToPalette: function (rgbFloat, width, height) {
|
|
const paletteInt = this.paletteInt?.length ? this.paletteInt : FALLBACK_PALETTE;
|
|
const out = new Uint8Array(width * height * 3);
|
|
|
|
for (let i = 0; i < width * height; i++) {
|
|
const base = i * 3;
|
|
const idx = this.nearestPaletteIndex(rgbFloat[base], rgbFloat[base + 1], rgbFloat[base + 2]);
|
|
const color = paletteInt[idx] || paletteInt[0] || [255, 255, 255];
|
|
out[base] = color[0];
|
|
out[base + 1] = color[1];
|
|
out[base + 2] = color[2];
|
|
}
|
|
|
|
return out;
|
|
},
|
|
|
|
getTopReservedHeight: function () {
|
|
const slotHeight =
|
|
typeof LiteGraph !== "undefined" && Number.isFinite(LiteGraph.NODE_SLOT_HEIGHT)
|
|
? LiteGraph.NODE_SLOT_HEIGHT
|
|
: 20;
|
|
|
|
const inputRows = Array.isArray(this.inputs) ? this.inputs.length : 0;
|
|
const visibleWidgets = Array.isArray(this.widgets) ? this.widgets.filter((w) => w && w.type !== "hidden") : [];
|
|
|
|
// Prefer measured widget positions because counting widgets can overestimate
|
|
// top space when the runtime injects auxiliary widgets.
|
|
let measuredWidgetBottom = 0;
|
|
for (const widget of visibleWidgets) {
|
|
if (!Number.isFinite(widget?.last_y)) {
|
|
continue;
|
|
}
|
|
const widgetBottom = widget.last_y + slotHeight;
|
|
measuredWidgetBottom = Math.max(measuredWidgetBottom, widgetBottom);
|
|
}
|
|
|
|
if (measuredWidgetBottom > 0) {
|
|
const measuredTopReserved = Math.max(0, measuredWidgetBottom - NODE_HEADER_HEIGHT);
|
|
return Math.max(NODE_MIN_TOP_RESERVED, measuredTopReserved);
|
|
}
|
|
|
|
const knownWidgetRows = Array.from(FRONTEND_PREVIEW_INPUT_WIDGETS).filter((name) => {
|
|
const widget = this.findWidget(name);
|
|
return !!widget && widget.type !== "hidden";
|
|
}).length;
|
|
|
|
const estimatedRows = inputRows + knownWidgetRows;
|
|
const estimatedTopReserved = estimatedRows * slotHeight + NODE_PADDING;
|
|
return Math.max(NODE_MIN_TOP_RESERVED, estimatedTopReserved);
|
|
},
|
|
|
|
getPreviewPanelArea: function () {
|
|
if (!this.size) return null;
|
|
const topReserved = this.getTopReservedHeight();
|
|
const mainAreaHeight = this.size[1] - NODE_PANEL_HEIGHT - topReserved;
|
|
if (mainAreaHeight < 20) return null;
|
|
|
|
const area = {
|
|
x: NODE_PADDING,
|
|
y: NODE_HEADER_HEIGHT + topReserved + NODE_PADDING,
|
|
width: this.size[0] - NODE_PADDING * 2,
|
|
height: mainAreaHeight,
|
|
};
|
|
|
|
if (area.width < 1 || area.height < 1) {
|
|
return null;
|
|
}
|
|
return area;
|
|
},
|
|
|
|
getTopPreviewArea: function () {
|
|
const previewArea = this.getPreviewPanelArea();
|
|
if (!previewArea) return null;
|
|
|
|
const halfHeight = Math.max(1, Math.floor((previewArea.height - PREVIEW_GAP) / 2));
|
|
return {
|
|
x: previewArea.x,
|
|
y: previewArea.y,
|
|
width: previewArea.width,
|
|
height: halfHeight,
|
|
};
|
|
},
|
|
|
|
getBottomPreviewArea: function () {
|
|
const previewArea = this.getPreviewPanelArea();
|
|
const topArea = this.getTopPreviewArea();
|
|
if (!previewArea || !topArea) return null;
|
|
|
|
const bottomY = topArea.y + topArea.height + PREVIEW_GAP;
|
|
return {
|
|
x: previewArea.x,
|
|
y: bottomY,
|
|
width: previewArea.width,
|
|
height: Math.max(1, previewArea.y + previewArea.height - bottomY),
|
|
};
|
|
},
|
|
|
|
getControlsArea: function () {
|
|
const previewArea = this.getPreviewPanelArea();
|
|
if (!previewArea) return null;
|
|
|
|
const y = previewArea.y + previewArea.height + NODE_PADDING;
|
|
const area = {
|
|
x: NODE_PADDING,
|
|
y,
|
|
width: this.size[0] - NODE_PADDING * 2,
|
|
height: this.size[1] - y - NODE_PADDING,
|
|
};
|
|
|
|
if (area.height < 30) return null;
|
|
return area;
|
|
},
|
|
|
|
drawPaletteGrid: function (ctx, controlsArea) {
|
|
const palette = this.paletteInt?.length ? this.paletteInt : FALLBACK_PALETTE;
|
|
const colorCount = palette.length;
|
|
if (colorCount === 0) {
|
|
return;
|
|
}
|
|
|
|
const labelY = controlsArea.y + PALETTE_GRID_TOP_INSET - 8;
|
|
drawStyledText(ctx, `Palette (${colorCount} colors)`, controlsArea.x + controlsArea.width / 2, labelY, {
|
|
font: "12px Arial",
|
|
fillStyle: "rgba(235,235,235,0.95)",
|
|
});
|
|
|
|
const gridX = controlsArea.x;
|
|
const gridY = controlsArea.y + PALETTE_GRID_TOP_INSET;
|
|
const gridWidth = controlsArea.width;
|
|
const gridHeight = controlsArea.height - PALETTE_GRID_TOP_INSET - PALETTE_GRID_BOTTOM_INSET;
|
|
if (gridWidth <= 0 || gridHeight <= 0) {
|
|
return;
|
|
}
|
|
|
|
const minColumns = Math.min(4, colorCount);
|
|
const maxColumns = Math.min(PALETTE_GRID_MAX_COLUMNS, colorCount);
|
|
let bestColumns = minColumns;
|
|
let bestRows = Math.ceil(colorCount / bestColumns);
|
|
let bestScore = -1;
|
|
|
|
for (let columns = minColumns; columns <= maxColumns; columns++) {
|
|
const rows = Math.ceil(colorCount / columns);
|
|
const cellWidth = (gridWidth - PALETTE_GRID_GAP * (columns - 1)) / columns;
|
|
const cellHeight = (gridHeight - PALETTE_GRID_GAP * (rows - 1)) / rows;
|
|
const score = Math.min(cellWidth, cellHeight);
|
|
if (score > bestScore) {
|
|
bestScore = score;
|
|
bestColumns = columns;
|
|
bestRows = rows;
|
|
}
|
|
}
|
|
|
|
const cellWidth = (gridWidth - PALETTE_GRID_GAP * (bestColumns - 1)) / bestColumns;
|
|
const cellHeight = (gridHeight - PALETTE_GRID_GAP * (bestRows - 1)) / bestRows;
|
|
|
|
ctx.save();
|
|
for (let i = 0; i < colorCount; i++) {
|
|
const col = i % bestColumns;
|
|
const row = Math.floor(i / bestColumns);
|
|
const color = palette[i];
|
|
const x = gridX + col * (cellWidth + PALETTE_GRID_GAP);
|
|
const y = gridY + row * (cellHeight + PALETTE_GRID_GAP);
|
|
|
|
ctx.fillStyle = `rgb(${color[0]},${color[1]},${color[2]})`;
|
|
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
ctx.strokeStyle = PALETTE_SWATCH_BORDER_COLOR;
|
|
ctx.strokeRect(x + 0.5, y + 0.5, Math.max(0, cellWidth - 1), Math.max(0, cellHeight - 1));
|
|
}
|
|
ctx.restore();
|
|
},
|
|
|
|
clearSourcePixelCache: function () {
|
|
this.sourcePixelData = null;
|
|
this.sourcePixelWidth = 0;
|
|
this.sourcePixelHeight = 0;
|
|
this.localPreviewCanvas = null;
|
|
this.localPreviewCtx = null;
|
|
},
|
|
|
|
buildSourcePixelCache: function () {
|
|
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;
|
|
},
|
|
|
|
ensurePreviewCanvas: function (width, height) {
|
|
if (
|
|
!this.localPreviewCanvas ||
|
|
this.localPreviewCanvas.width !== width ||
|
|
this.localPreviewCanvas.height !== height
|
|
) {
|
|
this.localPreviewCanvas = document.createElement("canvas");
|
|
this.localPreviewCanvas.width = width;
|
|
this.localPreviewCanvas.height = height;
|
|
this.localPreviewCtx = this.localPreviewCanvas.getContext("2d");
|
|
}
|
|
|
|
return this.localPreviewCtx ? this.localPreviewCanvas : null;
|
|
},
|
|
|
|
applyPositionalDither: function (rgb, width, height) {
|
|
// Match GlitchNodes Pixel8Bit ordered-size=8 behavior.
|
|
const bayer8 = [
|
|
0, 32, 8, 40, 2, 34, 10, 42, 48, 16, 56, 24, 50, 18, 58, 26, 12, 44, 4, 36, 14, 46, 6, 38, 60, 28, 52, 20, 62,
|
|
30, 54, 22, 3, 35, 11, 43, 1, 33, 9, 41, 51, 19, 59, 27, 49, 17, 57, 25, 15, 47, 7, 39, 13, 45, 5, 37, 63, 31,
|
|
55, 23, 61, 29, 53, 21,
|
|
];
|
|
const nudged = new Float32Array(rgb.length);
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
const rowOffset = y * width;
|
|
const bayerRow = (y & 7) * 8;
|
|
for (let x = 0; x < width; x++) {
|
|
const i = rowOffset + x;
|
|
const base = i * 3;
|
|
const threshold = (bayer8[bayerRow + (x & 7)] + 0.5) / 64.0;
|
|
const nudge = (threshold - 0.5) / 64.0;
|
|
nudged[base] = clamp(rgb[base] + nudge, 0, 1);
|
|
nudged[base + 1] = clamp(rgb[base + 1] + nudge, 0, 1);
|
|
nudged[base + 2] = clamp(rgb[base + 2] + nudge, 0, 1);
|
|
}
|
|
}
|
|
|
|
return this.quantizeToPalette(nudged, width, height);
|
|
},
|
|
|
|
applyFloydDither: function (rgb, width, height) {
|
|
const work = new Float32Array(rgb);
|
|
const out = new Uint8Array(rgb.length);
|
|
const rowStride = width * 3;
|
|
const paletteInt = this.paletteInt?.length ? this.paletteInt : FALLBACK_PALETTE;
|
|
const paletteFloat = this.paletteFloat?.length
|
|
? this.paletteFloat
|
|
: FALLBACK_PALETTE.map((c) => [c[0] / 255, c[1] / 255, c[2] / 255]);
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
const rowOffset = y * width;
|
|
for (let x = 0; x < width; x++) {
|
|
const i = (rowOffset + x) * 3;
|
|
const idx = this.nearestPaletteIndex(work[i], work[i + 1], work[i + 2]);
|
|
const colorI = paletteInt[idx] || paletteInt[0] || [255, 255, 255];
|
|
const colorF = paletteFloat[idx] || paletteFloat[0] || [1, 1, 1];
|
|
|
|
out[i] = colorI[0];
|
|
out[i + 1] = colorI[1];
|
|
out[i + 2] = colorI[2];
|
|
|
|
const errR = work[i] - colorF[0];
|
|
const errG = work[i + 1] - colorF[1];
|
|
const errB = work[i + 2] - colorF[2];
|
|
|
|
if (x + 1 < width) {
|
|
work[i + 3] = clamp(work[i + 3] + errR * (7 / 16), 0, 1);
|
|
work[i + 4] = clamp(work[i + 4] + errG * (7 / 16), 0, 1);
|
|
work[i + 5] = clamp(work[i + 5] + errB * (7 / 16), 0, 1);
|
|
}
|
|
if (y + 1 < height) {
|
|
const down = i + rowStride;
|
|
if (x > 0) {
|
|
work[down - 3] = clamp(work[down - 3] + errR * (3 / 16), 0, 1);
|
|
work[down - 2] = clamp(work[down - 2] + errG * (3 / 16), 0, 1);
|
|
work[down - 1] = clamp(work[down - 1] + errB * (3 / 16), 0, 1);
|
|
}
|
|
work[down] = clamp(work[down] + errR * (5 / 16), 0, 1);
|
|
work[down + 1] = clamp(work[down + 1] + errG * (5 / 16), 0, 1);
|
|
work[down + 2] = clamp(work[down + 2] + errB * (5 / 16), 0, 1);
|
|
if (x + 1 < width) {
|
|
work[down + 3] = clamp(work[down + 3] + errR * (1 / 16), 0, 1);
|
|
work[down + 4] = clamp(work[down + 4] + errG * (1 / 16), 0, 1);
|
|
work[down + 5] = clamp(work[down + 5] + errB * (1 / 16), 0, 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return out;
|
|
},
|
|
|
|
renderLocalPreview: function () {
|
|
if (!this.sourcePixelData || this.sourcePixelWidth <= 0 || this.sourcePixelHeight <= 0) {
|
|
return;
|
|
}
|
|
|
|
const requestedWidth = this.getIntWidgetValue("width", this.sourcePixelWidth || 0);
|
|
const requestedHeight = this.getIntWidgetValue("height", this.sourcePixelHeight || 0);
|
|
const target = this.resolveTargetSize(requestedWidth, requestedHeight);
|
|
const width = target.width;
|
|
const height = target.height;
|
|
const mode = this.getModeValue();
|
|
const paletteMode = this.getPaletteModeValue();
|
|
const paletteColors = this.getPaletteColorsValue();
|
|
|
|
const canvas = this.ensurePreviewCanvas(width, height);
|
|
if (!canvas || !this.localPreviewCtx) {
|
|
return;
|
|
}
|
|
|
|
const rgb = new Float32Array(width * height * 3);
|
|
const source = this.sourcePixelData;
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
const srcY = Math.min(this.sourcePixelHeight - 1, Math.floor((y * this.sourcePixelHeight) / height));
|
|
const rowOffset = y * width;
|
|
for (let x = 0; x < width; x++) {
|
|
const srcX = Math.min(this.sourcePixelWidth - 1, Math.floor((x * this.sourcePixelWidth) / width));
|
|
const srcIndex = (srcY * this.sourcePixelWidth + srcX) * 4;
|
|
const outIndex = (rowOffset + x) * 3;
|
|
rgb[outIndex] = source[srcIndex] / 255;
|
|
rgb[outIndex + 1] = source[srcIndex + 1] / 255;
|
|
rgb[outIndex + 2] = source[srcIndex + 2] / 255;
|
|
}
|
|
}
|
|
|
|
if (paletteMode === "optimized_x_colors") {
|
|
const optimizedPalette = this.buildOptimizedPalette(rgb, width, height, paletteColors);
|
|
this.setActivePalette(optimizedPalette);
|
|
} else {
|
|
this.setActivePalette(this.vgaPalette);
|
|
}
|
|
|
|
const dithered =
|
|
mode === "floyd" ? this.applyFloydDither(rgb, width, height) : this.applyPositionalDither(rgb, width, height);
|
|
|
|
const imageData = this.localPreviewCtx.createImageData(width, height);
|
|
const out = imageData.data;
|
|
|
|
const pixelCount = width * height;
|
|
for (let i = 0; i < pixelCount; i++) {
|
|
const src = i * 3;
|
|
const offset = i * 4;
|
|
out[offset] = dithered[src];
|
|
out[offset + 1] = dithered[src + 1];
|
|
out[offset + 2] = dithered[src + 2];
|
|
out[offset + 3] = 255;
|
|
}
|
|
|
|
this.localPreviewCtx.putImageData(imageData, 0, 0);
|
|
this.imagePreview = this.localPreviewCanvas;
|
|
},
|
|
|
|
cacheSourceAssets: function () {
|
|
if (!this.originalImage) return;
|
|
this.buildSourcePixelCache();
|
|
this.renderLocalPreview();
|
|
},
|
|
|
|
updateBackend: function () {
|
|
const width = this.getIntWidgetValue("width", 0);
|
|
const height = this.getIntWidgetValue("height", 0);
|
|
const paletteMode = this.getPaletteModeValue();
|
|
const paletteColors = this.getPaletteColorsValue();
|
|
|
|
this._suspendFrontendInputRefresh = true;
|
|
try {
|
|
this.setWidgetValue("width", width);
|
|
this.setWidgetValue("height", height);
|
|
this.setWidgetValue("palette_mode", paletteMode);
|
|
this.setWidgetValue("palette_colors", paletteColors);
|
|
} finally {
|
|
this._suspendFrontendInputRefresh = false;
|
|
}
|
|
|
|
this.renderLocalPreview();
|
|
this.setDirtyCanvas(true, true);
|
|
},
|
|
|
|
onDrawForeground: function (ctx) {
|
|
if (this.flags?.collapsed) return;
|
|
|
|
const topPreviewArea = this.getTopPreviewArea();
|
|
const bottomPreviewArea = this.getBottomPreviewArea();
|
|
const controlsArea = this.getControlsArea();
|
|
if (!topPreviewArea || !bottomPreviewArea || !controlsArea) return;
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = BG_PANEL_COLOR;
|
|
ctx.fillRect(topPreviewArea.x, topPreviewArea.y, topPreviewArea.width, topPreviewArea.height);
|
|
ctx.fillRect(bottomPreviewArea.x, bottomPreviewArea.y, bottomPreviewArea.width, bottomPreviewArea.height);
|
|
ctx.restore();
|
|
|
|
if (this.originalImage) {
|
|
ctx.save();
|
|
setNearestNeighborScaling(ctx);
|
|
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();
|
|
} else {
|
|
drawStyledText(
|
|
ctx,
|
|
"Run the workflow first to load source preview",
|
|
topPreviewArea.x + topPreviewArea.width / 2,
|
|
topPreviewArea.y + topPreviewArea.height / 2,
|
|
{ fillStyle: PREVIEW_PLACEHOLDER_TEXT_COLOR },
|
|
);
|
|
}
|
|
|
|
if (this.imagePreview) {
|
|
ctx.save();
|
|
setNearestNeighborScaling(ctx);
|
|
ctx.beginPath();
|
|
ctx.rect(bottomPreviewArea.x, bottomPreviewArea.y, bottomPreviewArea.width, bottomPreviewArea.height);
|
|
ctx.clip();
|
|
ctx.drawImage(
|
|
this.imagePreview,
|
|
bottomPreviewArea.x,
|
|
bottomPreviewArea.y,
|
|
bottomPreviewArea.width,
|
|
bottomPreviewArea.height,
|
|
);
|
|
ctx.restore();
|
|
} else {
|
|
drawStyledText(
|
|
ctx,
|
|
"Processed preview appears here",
|
|
bottomPreviewArea.x + bottomPreviewArea.width / 2,
|
|
bottomPreviewArea.y + bottomPreviewArea.height / 2,
|
|
{ fillStyle: PREVIEW_PLACEHOLDER_TEXT_COLOR },
|
|
);
|
|
}
|
|
|
|
drawStyledText(ctx, "Before", topPreviewArea.x + 34, topPreviewArea.y + 12, {
|
|
font: "bold 12px Arial",
|
|
fillStyle: "rgba(255,255,255,0.95)",
|
|
});
|
|
drawStyledText(ctx, "After", bottomPreviewArea.x + 28, bottomPreviewArea.y + 12, {
|
|
font: "bold 12px Arial",
|
|
fillStyle: "rgba(255,255,255,0.95)",
|
|
});
|
|
|
|
ctx.save();
|
|
ctx.fillStyle = BG_CONTROL_COLOR;
|
|
ctx.fillRect(controlsArea.x, controlsArea.y, controlsArea.width, controlsArea.height);
|
|
ctx.restore();
|
|
|
|
const requestedWidth = this.getIntWidgetValue("width", 0);
|
|
const requestedHeight = this.getIntWidgetValue("height", 0);
|
|
const resolved = this.resolveTargetSize(requestedWidth, requestedHeight);
|
|
const widthLabel = requestedWidth === 0 ? "auto" : String(requestedWidth);
|
|
const heightLabel = requestedHeight === 0 ? "auto" : String(requestedHeight);
|
|
drawStyledText(
|
|
ctx,
|
|
`Output: ${widthLabel} x ${heightLabel} -> ${resolved.width} x ${resolved.height}`,
|
|
controlsArea.x + controlsArea.width / 2,
|
|
controlsArea.y - 8,
|
|
{
|
|
font: "12px Arial",
|
|
fillStyle: "rgba(235,235,235,0.95)",
|
|
},
|
|
);
|
|
|
|
this.drawPaletteGrid(ctx, controlsArea);
|
|
},
|
|
|
|
onResize: function () {
|
|
this.hasUserResizedNode = true;
|
|
},
|
|
|
|
configure: function (data) {
|
|
this._configure(data);
|
|
this.bindFrontendPreviewInputCallbacks();
|
|
|
|
if (data.hasUserResizedNode) {
|
|
this.hasUserResizedNode = data.hasUserResizedNode;
|
|
}
|
|
},
|
|
|
|
serialize: function () {
|
|
const data = this._serialize();
|
|
data.hasUserResizedNode = this.hasUserResizedNode;
|
|
return data;
|
|
},
|
|
});
|
|
|
|
node.bindFrontendPreviewInputCallbacks();
|
|
node.rebuildBinaryPaletteLut();
|
|
},
|
|
});
|
|
|
|
api.addEventListener("game_dev.resize_dither.initialise", ({ detail }) => {
|
|
const node = app.graph.getNodeById(detail.node_id);
|
|
if (!node) {
|
|
return;
|
|
}
|
|
|
|
node.bootstrapRevision = (node.bootstrapRevision || 0) + 1;
|
|
const revision = node.bootstrapRevision;
|
|
|
|
if (Number.isFinite(detail.width)) {
|
|
node.setWidgetValue("width", clamp(toInt(detail.width, 0), 0, MAX_DIMENSION));
|
|
}
|
|
if (Number.isFinite(detail.height)) {
|
|
node.setWidgetValue("height", clamp(toInt(detail.height, 0), 0, MAX_DIMENSION));
|
|
}
|
|
if (typeof detail.dither_mode === "string" && DITHER_MODES.includes(detail.dither_mode)) {
|
|
node.setWidgetValue("dither_mode", detail.dither_mode);
|
|
}
|
|
if (typeof detail.palette_mode === "string" && PALETTE_MODES.includes(detail.palette_mode)) {
|
|
node.setWidgetValue("palette_mode", detail.palette_mode);
|
|
}
|
|
if (Number.isFinite(detail.palette_colors)) {
|
|
node.setWidgetValue(
|
|
"palette_colors",
|
|
clamp(toInt(detail.palette_colors, 24), MIN_PALETTE_COLORS, MAX_PALETTE_COLORS),
|
|
);
|
|
}
|
|
|
|
const onBeforeLoad = () => {
|
|
if (revision !== node.bootstrapRevision) {
|
|
return;
|
|
}
|
|
|
|
if (!node.hasUserResizedNode && node.originalImage) {
|
|
const aspectRatio = node.originalImage.naturalWidth / node.originalImage.naturalHeight;
|
|
node.size[0] = NODE_BASE_WIDTH;
|
|
const previewHeight = ((NODE_BASE_WIDTH - NODE_PADDING * 2) / (aspectRatio || 1)) * 2 + PREVIEW_GAP;
|
|
node.size[1] = Math.max(360, NODE_PANEL_HEIGHT + previewHeight + node.getTopReservedHeight() + NODE_PADDING * 2);
|
|
node.hasUserResizedNode = true;
|
|
}
|
|
node.cacheSourceAssets();
|
|
node.updateBackend();
|
|
};
|
|
|
|
if (!detail.preview_before) {
|
|
node.originalImage = null;
|
|
node.imagePreview = null;
|
|
node.clearSourcePixelCache();
|
|
node.setDirtyCanvas(true, true);
|
|
return;
|
|
}
|
|
|
|
if (detail.preview_after) {
|
|
const afterImg = new Image();
|
|
afterImg.src = "data:image/png;base64," + detail.preview_after;
|
|
afterImg.onload = () => {
|
|
if (revision !== node.bootstrapRevision) {
|
|
return;
|
|
}
|
|
|
|
// Only use backend after-preview as a temporary fallback before source pixels are ready.
|
|
if (!node.sourcePixelData || !node.originalImage) {
|
|
node.imagePreview = afterImg;
|
|
node.setDirtyCanvas(true, true);
|
|
}
|
|
};
|
|
}
|
|
|
|
const beforeImg = new Image();
|
|
beforeImg.src = "data:image/png;base64," + detail.preview_before;
|
|
beforeImg.onload = () => {
|
|
if (revision !== node.bootstrapRevision) {
|
|
return;
|
|
}
|
|
|
|
node.originalImage = beforeImg;
|
|
onBeforeLoad();
|
|
};
|
|
node.originalImage = beforeImg;
|
|
});
|