Files
2026-04-13 00:23:03 +02:00

644 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
app.registerExtension({
name: "iTools.ImageAdjust",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== "iToolsImageAdjust") return;
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
origOnNodeCreated?.apply(this, arguments);
localStorage.removeItem('Comfy.Workflow.Drafts')
// Remove the default string widget auto-created for widget_state
const idx = this.widgets?.findIndex((w) => w.name === "widget_state");
if (idx !== undefined && idx !== -1) this.widgets.splice(idx, 1);
this.size = [Math.max(this.size[0], 256), Math.max(this.size[1], 400)];
const node = this;
function isImageConnected() {
const inp = (node.inputs || []).find((i) => i.name === "image");
return inp && inp.link != null;
}
// ── State ────────────────────────────────────────────────────────────
let imagePath = ""; // server path after upload
let imageData = ""; // raw source image (uploaded blob URL or upstream URL)
let brightness = 0; // -100..+100
let contrast = 100; // 50..200
let saturation = 100; // 0..200
let temperature = 0; // -100..+100
let gamma = 100; // 25..400 (/100 → 0.25..4.0)
let sharpness = 100; // 0..200
let hue = 0; // -180..+180
// ── Root container ───────────────────────────────────────────────────
const container = document.createElement("div");
container.style.cssText =
"display:none;flex-direction:column;gap:4px;padding:0px;" +
"width:100%;height:100%;box-sizing:border-box;align-items:center;";
// ── Upload / preview area ────────────────────────────────────────────
const uploadArea = document.createElement("div");
uploadArea.style.cssText =
"width:100%;height:100%;border:2px dashed #555;border-radius:8px;" +
"margin-bottom:5px;display:flex;align-items:center;justify-content:center;" +
"cursor:pointer;position:relative;overflow:hidden;background:#1a1a1a;" +
"box-sizing:border-box;transition:border-color .15s;";
const uploadLabel = document.createElement("span");
uploadLabel.textContent = "Drop image here or click to upload";
uploadLabel.style.cssText =
"color:#777;font-size:11px;pointer-events:none;text-align:center;padding:4px;";
const previewImg = document.createElement("img");
previewImg.style.cssText =
"position:absolute;top:0;left:0;width:100%;height:100%;" +
"object-fit:contain;display:none;";
const clearBtn = document.createElement("button");
clearBtn.textContent = "✕";
clearBtn.title = "Clear uploaded image";
clearBtn.style.cssText =
"position:absolute;top:4px;right:4px;width:20px;height:20px;" +
"border:none;border-radius:4px;background:rgba(0,0,0,.6);color:#ccc;" +
"font-size:12px;cursor:pointer;display:none;line-height:1;padding:0;";
uploadArea.appendChild(uploadLabel);
uploadArea.appendChild(previewImg);
uploadArea.appendChild(clearBtn);
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/*";
fileInput.style.display = "none";
// ── SVG filter for live CSS preview (temperature + gamma approximation) ──
// This only drives the visual preview; getFilteredDataUrl() does exact
// pixel-level processing for the actual output.
const svgNS = "http://www.w3.org/2000/svg";
const svgEl = document.createElementNS(svgNS, "svg");
svgEl.setAttribute("width", "0");
svgEl.setAttribute("height", "0");
svgEl.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;";
const filterId = "itools-img-adjust-" + Math.random().toString(36).slice(2);
const filterEl = document.createElementNS(svgNS, "filter");
filterEl.setAttribute("id", filterId);
filterEl.setAttribute("color-interpolation-filters", "linearRGB");
const tempMatrix = document.createElementNS(svgNS, "feColorMatrix");
tempMatrix.setAttribute("type", "matrix");
tempMatrix.setAttribute("result", "tempOut");
const gammaTransfer = document.createElementNS(svgNS, "feComponentTransfer");
gammaTransfer.setAttribute("in", "tempOut");
const gammaR = document.createElementNS(svgNS, "feFuncR");
const gammaG = document.createElementNS(svgNS, "feFuncG");
const gammaB = document.createElementNS(svgNS, "feFuncB");
for (const fn of [gammaR, gammaG, gammaB]) {
fn.setAttribute("type", "gamma");
fn.setAttribute("amplitude", "1");
fn.setAttribute("offset", "0");
}
gammaTransfer.appendChild(gammaR);
gammaTransfer.appendChild(gammaG);
gammaTransfer.appendChild(gammaB);
filterEl.appendChild(tempMatrix);
filterEl.appendChild(gammaTransfer);
svgEl.appendChild(filterEl);
document.body.appendChild(svgEl);
function updateSvgFilter() {
const t = (temperature / 100) * 0.3;
tempMatrix.setAttribute("values",
`${1+t} 0 0 0 0 0 1 0 0 0 0 0 ${1-t} 0 0 0 0 0 1 0`);
const exp = 1.0 / Math.max(gamma / 100, 0.01);
for (const fn of [gammaR, gammaG, gammaB]) fn.setAttribute("exponent", exp.toFixed(4));
}
function refreshFilter() {
const b = 1.0 + brightness / 100;
const c = contrast / 100;
const s = saturation / 100;
updateSvgFilter();
previewImg.style.filter =
`brightness(${b}) contrast(${c}) saturate(${s}) hue-rotate(${hue}deg) url(#${filterId})`;
}
// ── Exact pixel rendering — this IS the output ────────────────────────
// Applies all adjustments in pixel space. Order matches PIL pipeline:
// brightness → contrast → saturation → temperature → gamma → sharpness → hue
function getFilteredDataUrl() {
return new Promise((resolve, reject) => {
const src = previewImg.src;
if (!src) { reject(new Error("No image loaded")); return; }
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const w = img.naturalWidth, h = img.naturalHeight;
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
// Step 1-4 via CSS filter + hue-rotate (browser applies in linear light)
const b = 1.0 + brightness / 100;
const c = contrast / 100;
const s = saturation / 100;
ctx.filter = `brightness(${b}) contrast(${c}) saturate(${s}) hue-rotate(${hue}deg)`;
ctx.drawImage(img, 0, 0);
ctx.filter = "none";
// Step 5-6: temperature + gamma on pixel data
const needsTemp = temperature !== 0;
const needsGamma = gamma !== 100;
if (needsTemp || needsGamma) {
const id = ctx.getImageData(0, 0, w, h);
const d = id.data;
const t = (temperature / 100) * 0.3;
const rScale = 1.0 + t, bScale = 1.0 - t;
const gExp = 1.0 / Math.max(gamma / 100, 0.01);
const lut = new Uint8ClampedArray(256);
for (let i = 0; i < 256; i++) lut[i] = Math.round(Math.pow(i / 255, gExp) * 255);
for (let i = 0; i < d.length; i += 4) {
let r = d[i], g = d[i+1], bv = d[i+2];
if (needsTemp) {
r = Math.min(255, Math.max(0, Math.round(r * rScale)));
bv = Math.min(255, Math.max(0, Math.round(bv * bScale)));
}
if (needsGamma) { r = lut[r]; g = lut[g]; bv = lut[bv]; }
d[i]=r; d[i+1]=g; d[i+2]=bv;
}
ctx.putImageData(id, 0, 0);
}
// Step 7: sharpness via convolution kernel
// 100 = neutral, >100 = sharpen, <100 = soften (blur)
if (sharpness !== 100) {
const id = ctx.getImageData(0, 0, w, h);
const src2 = new Uint8ClampedArray(id.data);
const dst = id.data;
const amount = (sharpness - 100) / 100; // -1..+1
if (amount > 0) {
// Unsharp mask: center = 1+4*amount, cross neighbors = -amount
const center = 1 + 4 * amount, nbr = -amount;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
for (let c2 = 0; c2 < 3; c2++) {
let v = src2[i+c2] * center;
if (y > 0) v += src2[((y-1)*w+x)*4+c2] * nbr;
if (y < h-1) v += src2[((y+1)*w+x)*4+c2] * nbr;
if (x > 0) v += src2[(y*w+x-1)*4+c2] * nbr;
if (x < w-1) v += src2[(y*w+x+1)*4+c2] * nbr;
dst[i+c2] = Math.min(255, Math.max(0, Math.round(v)));
}
dst[i+3] = src2[i+3];
}
}
} else {
// Soften: blend original with 3×3 box blur
const blur = -amount; // 0..1
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
for (let c2 = 0; c2 < 3; c2++) {
let sum = 0, cnt = 0;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const ny = y+dy, nx = x+dx;
if (ny >= 0 && ny < h && nx >= 0 && nx < w) {
sum += src2[(ny*w+nx)*4+c2]; cnt++;
}
}
}
dst[i+c2] = Math.round(src2[i+c2] * (1-blur) + (sum/cnt) * blur);
}
dst[i+3] = src2[i+3];
}
}
}
ctx.putImageData(id, 0, 0);
}
resolve(canvas.toDataURL("image/png"));
};
img.onerror = () => reject(new Error("Image failed to load"));
img.src = src;
});
}
// Debounced update
let _updateTimer = null;
function scheduleProcessedUpdate() {
// We no longer rely on processedImageData string conversion, saving workflow bloat
}
function showPreview(dataUrl) {
imageData = dataUrl;
previewImg.src = dataUrl;
previewImg.style.display = "block";
uploadLabel.style.display = "none";
clearBtn.style.display = isImageConnected() ? "none" : "block";
uploadArea.style.border = "none";
refreshFilter();
scheduleProcessedUpdate();
}
function clearPreview() {
imageData = "";
imagePath = "";
previewImg.src = "";
previewImg.style.display = "none";
uploadLabel.style.display = "block";
clearBtn.style.display = "none";
uploadArea.style.border = "2px dashed #555";
fileInput.value = "";
}
async function loadFile(file) {
try {
const body = new FormData();
body.append("image", file);
body.append("type", "input");
const resp = await api.fetchApi("/upload/image", { method: "POST", body });
if (resp.status === 200) {
const data = await resp.json();
imagePath = data.subfolder ? data.subfolder + "/" + data.name : data.name;
const viewUrl = api.apiURL(`/view?filename=${encodeURIComponent(data.name)}&type=input&subfolder=${encodeURIComponent(data.subfolder || "")}&t=${Date.now()}`);
showPreview(viewUrl);
} else {
console.error("Upload failed", resp.status, await resp.text());
}
} catch (e) {
console.error("Upload error", e);
}
}
uploadArea.addEventListener("click", (e) => {
if (isImageConnected()) return;
if (e.target === clearBtn) return;
fileInput.click();
});
clearBtn.addEventListener("click", (e) => { e.stopPropagation(); clearPreview(); });
// ── Right-click context menu ─────────────────────────────────────────
function makeMenuItem(text) {
const item = document.createElement("div");
item.textContent = text;
item.style.cssText =
"padding:6px 14px;margin:0 5px;border-radius:4px;font-size:12px;" +
"color:#ddd;cursor:pointer;white-space:nowrap;";
item.addEventListener("mouseenter", () => { item.style.background = "#3a3a3a"; });
item.addEventListener("mouseleave", () => { item.style.background = ""; });
return item;
}
uploadArea.addEventListener("contextmenu", async (e) => {
e.preventDefault(); e.stopPropagation();
const existing = document.getElementById("itools-ctx-menu");
if (existing) existing.remove();
const menu = document.createElement("div");
menu.id = "itools-ctx-menu";
menu.style.cssText =
"position:fixed;z-index:99999;background:#2a2a2a;border:1px solid #555;" +
"border-radius:6px;padding:4px 0;box-shadow:0 4px 12px rgba(0,0,0,.6);";
// remove context when mouse leaves
menu.addEventListener("mouseleave", () => { menu.remove(); });
const pasteItem = makeMenuItem("Paste image");
if (isImageConnected()) pasteItem.style.color = "#555";
pasteItem.addEventListener("click", async () => {
menu.remove();
if (isImageConnected()) return;
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find((t) => t.startsWith("image/"));
if (imageType) {
const blob = await item.getType(imageType);
const file = new File([blob], "pasted_image.png", { type: imageType });
loadFile(file);
return;
}
}
alert("No image found in clipboard.");
} catch (err) { alert("Could not read clipboard: " + err.message); }
});
const copyItem = makeMenuItem("Copy image");
if (!imageData) copyItem.style.color = "#555";
copyItem.addEventListener("click", async () => {
menu.remove();
if (!imageData) return;
try {
const dataUrl = await getFilteredDataUrl();
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
} catch (err) { alert("Could not copy to clipboard: " + err.message); }
});
const saveItem = makeMenuItem("Save image");
if (!imageData) saveItem.style.color = "#555";
saveItem.addEventListener("click", async () => {
menu.remove();
if (!imageData) return;
try {
const dataUrl = await getFilteredDataUrl();
const a = document.createElement("a");
a.href = dataUrl; a.download = "image_adjust.png"; a.click();
} catch (err) { alert("Could not save image: " + err.message); }
});
const openItem = makeMenuItem("Open image");
if (!imageData) openItem.style.color = "#555";
openItem.addEventListener("click", async () => {
menu.remove();
if (!imageData) return;
try {
const dataUrl = await getFilteredDataUrl();
const res = await fetch(dataUrl);
const blob = await res.blob();
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl, "_blank");
} catch (err) { alert("Could not open image: " + err.message); }
});
menu.appendChild(openItem);
menu.appendChild(copyItem);
menu.appendChild(pasteItem);
menu.appendChild(saveItem);
document.body.appendChild(menu);
const mw = 240, mh = 144;
menu.style.left = Math.min(e.clientX, window.innerWidth - mw - 8) + "px";
menu.style.top = Math.min(e.clientY, window.innerHeight - mh - 8) + "px";
const close = (ev) => {
if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener("mousedown", close); }
};
document.addEventListener("mousedown", close);
});
uploadArea.addEventListener("dragover", (e) => {
if (isImageConnected()) return;
e.preventDefault(); uploadArea.style.borderColor = "#7ab";
});
uploadArea.addEventListener("dragleave", () => { uploadArea.style.borderColor = "#555"; });
uploadArea.addEventListener("drop", (e) => {
if (isImageConnected()) return;
e.preventDefault(); uploadArea.style.borderColor = "#555";
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith("image/")) loadFile(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) loadFile(fileInput.files[0]);
});
// ── Slider helper ────────────────────────────────────────────────────
function makeSlider(label, min, max, initValue, formatValue) {
const row = document.createElement("div");
row.style.cssText = "display:flex;align-items:center;width:90%;box-sizing:border-box;";
const lbl = document.createElement("span");
lbl.textContent = label;
lbl.style.cssText = "color:#aaa;font-size:11px;min-width:72px;flex-shrink:0;";
const slider = document.createElement("input");
slider.type = "range"; slider.min = min; slider.max = max; slider.value = initValue;
slider.style.cssText = "flex:1;cursor:pointer;accent-color:#4a9eff;min-width:0;";
const valDisplay = document.createElement("span");
valDisplay.textContent = formatValue(initValue);
valDisplay.style.cssText =
"color:#aaa;font-size:11px;min-width:36px;text-align:right;" +
"font-family:monospace;flex-shrink:0;";
const resetBtn = document.createElement("button");
resetBtn.textContent = "↺";
resetBtn.title = "Reset to default";
resetBtn.style.cssText =
"margin-left:4px;width:16px;height:16px;padding:0;border:none;" +
"background:none;color:#666;font-size:12px;cursor:pointer;flex-shrink:0;" +
"line-height:1;display:flex;align-items:center;justify-content:center;";
resetBtn.addEventListener("mouseenter", () => { resetBtn.style.color = "#aaa"; });
resetBtn.addEventListener("mouseleave", () => { resetBtn.style.color = "#666"; });
resetBtn.addEventListener("click", () => {
slider.value = initValue;
valDisplay.textContent = formatValue(initValue);
slider.dispatchEvent(new Event("input"));
});
slider.addEventListener("input", () => {
valDisplay.textContent = formatValue(Number(slider.value));
});
row.appendChild(lbl); row.appendChild(slider);
row.appendChild(valDisplay); row.appendChild(resetBtn);
return { row, slider, valDisplay };
}
const fmtSign = (v) => (v >= 0 ? "+" : "") + v;
const fmtPct = (v) => v + "%";
const fmtDeg = (v) => (v >= 0 ? "+" : "") + v + "°";
const fmtGamma = (v) => (v / 100).toFixed(2);
// Slider ranges: designed so the neutral/default sits at a natural midpoint
// Brightness : -100..+100 neutral=0 (±100% of image brightness)
// Contrast : 50..200 neutral=100 (50% = low contrast, 200% = high)
// Saturation : 0..200 neutral=100 (0 = grayscale, 200 = vivid)
// Temperature: -100..+100 neutral=0 (neg = cool, pos = warm)
// Gamma : 25..400 neutral=100 (0.25..4.0 — wide range for correction)
// Sharpness : 0..200 neutral=100 (0 = soft, 200 = very sharp)
// Hue : -180..+180 neutral=0 (full rotation in degrees)
const brightnessCtrl = makeSlider("Brightness", -100, 100, 0, fmtSign);
const contrastCtrl = makeSlider("Contrast", 50, 200, 100, fmtPct);
const saturationCtrl = makeSlider("Saturation", 0, 200, 100, fmtPct);
const temperatureCtrl = makeSlider("Temperature", -100, 100, 0, fmtSign);
const gammaCtrl = makeSlider("Gamma", 25, 400, 100, fmtGamma);
const sharpnessCtrl = makeSlider("Sharpness", 0, 200, 100, fmtPct);
const hueCtrl = makeSlider("Hue", -180, 180, 0, fmtDeg);
function onSliderChange() {
brightness = parseInt(brightnessCtrl.slider.value, 10);
contrast = parseInt(contrastCtrl.slider.value, 10);
saturation = parseInt(saturationCtrl.slider.value, 10);
temperature = parseInt(temperatureCtrl.slider.value, 10);
gamma = parseInt(gammaCtrl.slider.value, 10);
sharpness = parseInt(sharpnessCtrl.slider.value, 10);
hue = parseInt(hueCtrl.slider.value, 10);
refreshFilter();
scheduleProcessedUpdate();
}
for (const ctrl of [brightnessCtrl, contrastCtrl, saturationCtrl,
temperatureCtrl, gammaCtrl, sharpnessCtrl, hueCtrl]) {
ctrl.slider.addEventListener("input", onSliderChange);
}
// ── Assemble ─────────────────────────────────────────────────────────
container.appendChild(uploadArea);
container.appendChild(fileInput);
container.appendChild(brightnessCtrl.row);
container.appendChild(contrastCtrl.row);
container.appendChild(saturationCtrl.row);
container.appendChild(temperatureCtrl.row);
container.appendChild(gammaCtrl.row);
container.appendChild(sharpnessCtrl.row);
container.appendChild(hueCtrl.row);
// ── Auto-detect upstream image changes ───────────────────────────────
let _lastUpstreamSnapshot = "";
function getUpstreamSnapshot() {
const graph = node.graph;
if (!graph) return "";
const inp = (node.inputs || []).find((i) => i.name === "image");
if (!inp || inp.link == null) return "";
const link = graph.links[inp.link];
if (!link) return "";
const src = graph.getNodeById(link.origin_id);
if (!src) return "";
const pieces = [];
if (src.comfyClass === "LoadImage" || src.type === "LoadImage") {
const w = (src.widgets || []).find((w) => w.name === "image");
if (w) pieces.push("file=" + w.value);
}
if (src.imgs?.length) {
const img = src.imgs[link.origin_slot] || src.imgs[0];
const s = typeof img === "string" ? img : img?.src || "";
pieces.push("prev=" + s);
}
return pieces.join("|");
}
function getUpstreamImageUrl() {
const graph = node.graph;
if (!graph) return null;
const inp = (node.inputs || []).find((i) => i.name === "image");
if (!inp || inp.link == null) return null;
const link = graph.links[inp.link];
if (!link) return null;
const src = graph.getNodeById(link.origin_id);
if (!src) return null;
if (src.imgs?.length) {
const img = src.imgs[link.origin_slot] || src.imgs[0];
const s = typeof img === "string" ? img : img?.src || "";
if (s) return s + (s.includes("?") ? "&" : "?") + "t=" + Date.now();
}
if (src.comfyClass === "LoadImage" || src.type === "LoadImage") {
const w = (src.widgets || []).find((w) => w.name === "image");
if (w?.value)
return `/view?filename=${encodeURIComponent(w.value)}&type=input&t=${Date.now()}`;
}
return null;
}
updateSvgFilter();
const origOnRemoved = node.onRemoved;
node.onRemoved = function () {
origOnRemoved?.apply(this, arguments);
svgEl.remove();
};
const origOnDrawForeground = node.onDrawForeground;
node.onDrawForeground = function (_ctx) {
origOnDrawForeground?.apply(this, arguments);
const snap = getUpstreamSnapshot();
if (snap && snap !== _lastUpstreamSnapshot) {
_lastUpstreamSnapshot = snap;
const url = getUpstreamImageUrl();
if (url) {
imagePath = ""; // Driven via graph, not saved
showPreview(url);
}
}
};
const origOnConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function (_type, index, connected, _linkInfo) {
origOnConnectionsChange?.apply(this, arguments);
const inp = (node.inputs || [])[index];
if (inp?.name !== "image") return;
if (!connected) {
_lastUpstreamSnapshot = "";
clearPreview();
} else {
_lastUpstreamSnapshot = "";
clearBtn.style.display = "none";
}
};
// ── Register the DOM widget ──────────────────────────────────────────
this.addDOMWidget("widget_state", "imageAdjust", container, {
getValue() {
return JSON.stringify({
brightness, contrast, saturation, temperature, gamma, sharpness, hue,
imagePath, // saving the server path instead of base64
});
},
setValue(val) {
try {
const state = typeof val === "string" ? JSON.parse(val) : val;
brightness = state.brightness ?? 0;
contrast = state.contrast ?? 100;
saturation = state.saturation ?? 100;
temperature = state.temperature ?? 0;
gamma = state.gamma ?? 100;
sharpness = state.sharpness ?? 100;
hue = state.hue ?? 0;
imagePath = state.imagePath ?? "";
brightnessCtrl.slider.value = brightness;
brightnessCtrl.valDisplay.textContent = fmtSign(brightness);
contrastCtrl.slider.value = contrast;
contrastCtrl.valDisplay.textContent = fmtPct(contrast);
saturationCtrl.slider.value = saturation;
saturationCtrl.valDisplay.textContent = fmtPct(saturation);
temperatureCtrl.slider.value = temperature;
temperatureCtrl.valDisplay.textContent = fmtSign(temperature);
gammaCtrl.slider.value = gamma;
gammaCtrl.valDisplay.textContent = fmtGamma(gamma);
sharpnessCtrl.slider.value = sharpness;
sharpnessCtrl.valDisplay.textContent = fmtPct(sharpness);
hueCtrl.slider.value = hue;
hueCtrl.valDisplay.textContent = fmtDeg(hue);
if (imagePath) {
const parts = imagePath.split("/");
const filename = parts.pop();
const subfolder = parts.join("/");
const viewUrl = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input&subfolder=${encodeURIComponent(subfolder)}&t=${Date.now()}`);
showPreview(viewUrl);
} else if (state.imageData) {
showPreview(state.imageData);
} else {
clearPreview();
}
} catch (_) {}
},
});
// Force canvas update after 100ms
setTimeout(() => {
container.style.display = "flex";
node.setDirtyCanvas(true, true);
}, 200);
};
},
});