Files
l33chKing f3344769d2 Add Color Matching Ops node, new sharpen filters, and preview fixes
New ColorMatchingNode (Color Matching Ops): image + reference inputs, one
output. Methods LAB/RGB/Histogram (LAB default) and a target selector
(All/Lightness/Color) that keeps only the matched Oklab lightness or color.
Statistics-only, so inputs may be any size. Live in-node preview.

Filter Ops: add Laplacian Sharpen (4-/8-neighbor kernel) and Unsharp Masking
(Gaussian amount/radius/threshold), with matching JS live previews, widget
visibility, and tooltips.

Layer Blending: rename to Layer Blending Ops, drop the unused preview_ref
output, and make the live preview match the backend exactly (resize fg to bg
size, default opacity 255, per-mode clamp parity) - fixes big mismatches e.g.
Linear Dodge (Add) at high opacity.

Rename Color Matching node display to Color Matching Ops. Raise color-match
preview resolution to 1024 to tighten preview-vs-output stats.

Previews: use widget.last_y to stop the preview box overlapping the last
widget (all nodes, incl. Channel Ops); hide the preview box until a preview
exists; auto-size the node to a sensible preview area on first preview; keep
fresh Filter Ops compact instead of huge with empty space.

README: add per-node preview GIFs (channel_ops/Layer_blend/filter_ops/
color_match) and document the new node/effects; drop the old preview.gif
reference.
2026-07-03 13:34:58 +03:00

945 lines
38 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.
// ChannelOps - In-node live preview (canvas)
// This script runs in the ComfyUI frontend and augments the ChannelOpsNode UI.
// It displays a canvas below the node's sliders and updates in real-time as you tweak values.
// It uses a per-node preview image saved by the backend to web/channel_ops_preview_<preview_id>.png on each run.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const EXT_NAME = "Channel_Ops.Preview";
// Height (px) of the preview area added below the widgets the first time a
// preview appears. Shared by all preview nodes for a consistent look.
const PREVIEW_H = 256;
// Draw helper using node-scoped state (called from prototype wrapper)
function drawPreview(node, ctx){
const state = node._channelOpsState;
if(!state) return;
// Nothing rendered yet? Don't draw an empty box — keep the node clean until
// there's an actual preview to show.
const ready = state.baseLoaded && state.offscreen.width > 0 && state.offscreen.height > 0;
if(!ready) return;
const w = node.size[0];
const h = node.size[1];
const pad = 11;
function widgetHeight(wg){
let wh = 0;
try{
if(typeof wg.computeSize === 'function'){
const sz = wg.computeSize(w);
if(Array.isArray(sz)) wh = sz[1] || 0; else if(typeof sz === 'number') wh = sz;
} else if(typeof wg.height === 'number'){ wh = wg.height; }
else { wh = 20; }
}catch(_){ wh = 20; }
return wh > 0 ? wh : 0;
}
function widgetsBottomY(n){
// Prefer the real drawn positions: LiteGraph sets widget.last_y (node-local)
// during widget drawing, which runs immediately before onDrawForeground —
// far more reliable than summing heights from a possibly-unset start offset
// (which ignored the title bar + input/output slot rows and let the preview
// overlap the last widget).
let maxBottom = 0;
if(Array.isArray(n.widgets)){
for(const wg of n.widgets){
if(!wg || wg.hidden) continue;
if(typeof wg.last_y !== 'number' || wg.last_y <= 0) continue;
const b = wg.last_y + widgetHeight(wg);
if(b > maxBottom) maxBottom = b;
}
}
if(maxBottom > 0) return maxBottom;
const LG = (typeof window !== 'undefined') ? window.LiteGraph : null;
const titleH = (LG && LG.NODE_TITLE_HEIGHT) || 30;
const slotH = (LG && LG.NODE_SLOT_HEIGHT) || 20;
const nIn = (n.inputs && n.inputs.length) || 0;
const nOut = (n.outputs && n.outputs.length) || 0;
let y = titleH + Math.max(nIn, nOut) * slotH + 4;
if(Array.isArray(n.widgets)){
for(const wg of n.widgets){
if(!wg || wg.hidden) continue;
y += widgetHeight(wg) + 4;
}
}
return y;
}
const safeTop = widgetsBottomY(node) + 18; // extra padding below widgets
const availableH = Math.max(0, h - safeTop - pad);
const dh = Math.max(24, availableH);
const dw = Math.max(0, w - pad*2);
const x = pad;
const y = Math.max(safeTop, h - dh - pad);
ctx.save();
ctx.fillStyle = "#111";
ctx.fillRect(x, y, dw, dh);
ctx.strokeStyle = "rgba(255,255,255,0.1)";
ctx.strokeRect(x, y, dw, dh);
const iw = state.offscreen.width;
const ih = state.offscreen.height;
const scale = Math.min(dw/iw, dh/ih);
const rw = Math.max(1, Math.floor(iw*scale));
const rh = Math.max(1, Math.floor(ih*scale));
const ox = x + Math.floor((dw - rw)/2);
const oy = y + Math.floor((dh - rh)/2);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(state.offscreen, 0, 0, iw, ih, ox, oy, rw, rh);
ctx.restore();
}
// One-time node resize when the first preview becomes available. Fresh nodes
// get a sensible preview area (fixes too-small / too-large starting sizes);
// workflow-restored nodes keep their saved size.
function seedPreviewSize(node){
if(node._previewAutoSized) return;
node._previewAutoSized = true;
if(node._loadedFromWorkflow) return;
try{
const min = node.computeSize();
const w = Math.max(node.size[0], min[0] || 0);
node.setSize([w, (min[1] || 0) + PREVIEW_H]);
app.graph.setDirtyCanvas(true, true);
}catch(_){ }
}
function ensureBehavior(node){
if(node._channelOpsPreviewAdded) return;
node._channelOpsPreviewAdded = true;
// Internal state for offscreen rendering
const state = node._channelOpsState = {
baseImg: new Image(),
baseLoaded: false,
offscreen: document.createElement('canvas'),
offctx: null,
previewHeight: 256,
loadToken: 0,
pendingRetry: null,
objectUrl: null,
// Secondary source image for overwrite-from-image
srcImg: new Image(),
srcLoaded: false,
srcOffscreen: document.createElement('canvas'),
srcOffctx: null,
srcPendingRetry: null,
srcLoadToken: 0,
srcObjectUrl: null,
};
state.offctx = state.offscreen.getContext('2d');
state.srcOffctx = state.srcOffscreen.getContext('2d');
// Do not alter node height; draw within current area to avoid layout issues
function revokeObjectUrl(){
if(state.objectUrl){
try{ URL.revokeObjectURL(state.objectUrl); }catch(_){ }
state.objectUrl = null;
}
}
function revokeSrcObjectUrl(){
if(state.srcObjectUrl){
try{ URL.revokeObjectURL(state.srcObjectUrl); }catch(_){ }
state.srcObjectUrl = null;
}
}
function resetPreview(){
// Clear current preview and show placeholder until a new image loads
state.baseLoaded = false;
revokeObjectUrl();
try { state.baseImg.src = ""; } catch(_) {}
try {
state.offscreen.width = 0; state.offscreen.height = 0;
} catch(_) {}
// Clear secondary source
state.srcLoaded = false;
revokeSrcObjectUrl();
try { state.srcImg.src = ""; } catch(_) {}
try { state.srcOffscreen.width = 0; state.srcOffscreen.height = 0; } catch(_) {}
app.graph.setDirtyCanvas(true,true);
}
async function loadBase(tsOverride){
// Single-attempt load (used by retry scheduler)
state.baseLoaded = false;
const wid = getWidget('preview_id');
const raw = wid ? (wid.value ?? '') : '';
const nodeIdOk = (typeof node.id === 'number' && node.id >= 0) ? String(node.id) : '';
const userPid = String(raw || '').replace(/[^a-zA-Z0-9_-]/g, "_");
// Strictly use per-node identifiers to avoid cross-node preview bleed; no global 'A' fallback
const candidates = Array.from(new Set([
userPid || undefined,
nodeIdOk || undefined
].filter(Boolean)));
const ts = (typeof tsOverride === 'number' && isFinite(tsOverride)) ? tsOverride : Date.now();
let ci = 0;
const token = ++state.loadToken;
// Reset src to avoid cached decode pipelines on same Image object
try { state.baseImg.src = ""; } catch(_) {}
state.baseImg.crossOrigin = "anonymous";
state.baseImg.onload = () => {
if(token !== state.loadToken) return; // stale
state.baseLoaded = true;
renderEffect();
app.graph.setDirtyCanvas(true,true);
};
state.baseImg.onerror = () => {
if(token !== state.loadToken) return; // stale
// This error handler is a last resort; normally we set the src from a fetched blob
state.baseLoaded = false;
app.graph.setDirtyCanvas(true,true);
};
// Try candidates by fetching as blob with no-store, then set object URL
while(ci < candidates.length){
if(token !== state.loadToken) return;
// Prefer current project path ComfyUI_Channel_Ops, then Channel_Ops, then legacy ChannelOps (backward compatibility)
const tryPaths = [
`/extensions/ComfyUI_Channel_Ops/channel_ops_preview_${candidates[ci]}.png?ts=${ts}`,
`/extensions/Channel_Ops/channel_ops_preview_${candidates[ci]}.png?ts=${ts}`,
`/extensions/ChannelOps/channel_ops_preview_${candidates[ci]}.png?ts=${ts}`
];
try{
let blob = null;
for(const p of tryPaths){
const resp = await fetch(p, { cache: 'no-store' });
if(resp.ok){ blob = await resp.blob(); break; }
}
if(!blob){ ci++; continue; }
if(token !== state.loadToken) return;
revokeObjectUrl();
const obj = URL.createObjectURL(blob);
state.objectUrl = obj;
state.baseImg.src = obj;
return;
}catch(_e){
ci++;
}
}
// All candidates failed
state.baseLoaded = false;
app.graph.setDirtyCanvas(true,true);
}
// Retry loader to cope with FS write latency after execution
function loadBaseWithRetry(tsHint){
// Cancel any pending retry chain
if(state.pendingRetry){
state.pendingRetry.cancelled = true;
state.pendingRetry = null;
}
// Immediately clear any stale preview so we don't show old images
resetPreview();
const schedule = [100, 250, 500, 1000, 1500]; // ms
const token = ++state.loadToken;
let idx = -1;
const task = { cancelled: false };
state.pendingRetry = task;
const attempt = () => {
if(task.cancelled) return;
// Attempt a load; on error we'll schedule next delay
let settled = false;
const prevOnLoad = state.baseImg.onload;
const prevOnError = state.baseImg.onerror;
state.baseImg.onload = () => {
if(task.cancelled || token !== state.loadToken) return;
settled = true;
// restore handlers to default flow
state.baseImg.onload = prevOnLoad;
state.baseImg.onerror = prevOnError;
state.baseLoaded = true;
renderEffect();
app.graph.setDirtyCanvas(true,true);
state.pendingRetry = null;
};
state.baseImg.onerror = () => {
if(task.cancelled || token !== state.loadToken) return;
// restore handlers and schedule next try
state.baseImg.onload = prevOnLoad;
state.baseImg.onerror = prevOnError;
idx += 1;
if(idx < schedule.length){
setTimeout(() => {
if(task.cancelled || token !== state.loadToken) return;
loadBase(tsHint); // reattempt with provided ts if any
attempt();
}, schedule[idx]);
} else {
// Give up; keep last state
state.pendingRetry = null;
app.graph.setDirtyCanvas(true,true);
}
};
// Kick the first attempt
loadBase(tsHint);
};
attempt();
}
async function loadSrc(tsOverride){
// Attempt to load per-node secondary source preview used in Overwrite from Image
state.srcLoaded = false;
const wid = getWidget('preview_id');
const raw = wid ? (wid.value ?? '') : '';
const nodeIdOk = (typeof node.id === 'number' && node.id >= 0) ? String(node.id) : '';
const userPid = String(raw || '').replace(/[^a-zA-Z0-9_-]/g, "_");
const candidates = Array.from(new Set([
userPid || undefined,
nodeIdOk || undefined
].filter(Boolean)));
const ts = (typeof tsOverride === 'number' && isFinite(tsOverride)) ? tsOverride : Date.now();
let ci = 0;
const token = ++state.srcLoadToken;
try { state.srcImg.src = ""; } catch(_) {}
state.srcImg.crossOrigin = "anonymous";
state.srcImg.onload = () => {
if(token !== state.srcLoadToken) return; // stale
state.srcLoaded = true;
renderEffect();
app.graph.setDirtyCanvas(true,true);
};
state.srcImg.onerror = () => {
if(token !== state.srcLoadToken) return; // stale
state.srcLoaded = false;
app.graph.setDirtyCanvas(true,true);
};
while(ci < candidates.length){
if(token !== state.srcLoadToken) return;
const tryPaths = [
`/extensions/ComfyUI_Channel_Ops/channel_ops_preview_src_${candidates[ci]}.png?ts=${ts}`,
`/extensions/Channel_Ops/channel_ops_preview_src_${candidates[ci]}.png?ts=${ts}`,
`/extensions/ChannelOps/channel_ops_preview_src_${candidates[ci]}.png?ts=${ts}`
];
try{
let blob = null;
for(const p of tryPaths){
const resp = await fetch(p, { cache: 'no-store' });
if(resp.ok){ blob = await resp.blob(); break; }
}
if(!blob){ ci++; continue; }
if(token !== state.srcLoadToken) return;
revokeSrcObjectUrl();
const obj = URL.createObjectURL(blob);
state.srcObjectUrl = obj;
state.srcImg.src = obj;
return;
}catch(_e){ ci++; }
}
state.srcLoaded = false;
app.graph.setDirtyCanvas(true,true);
}
function loadSrcWithRetry(tsHint){
if(state.srcPendingRetry){ state.srcPendingRetry.cancelled = true; state.srcPendingRetry = null; }
const schedule = [100, 250, 500, 1000, 1500];
const token = ++state.srcLoadToken;
let idx = -1;
const task = { cancelled: false };
state.srcPendingRetry = task;
const attempt = () => {
if(task.cancelled) return;
const prevOnLoad = state.srcImg.onload;
const prevOnError = state.srcImg.onerror;
state.srcImg.onload = () => {
if(task.cancelled || token !== state.srcLoadToken) return;
state.srcImg.onload = prevOnLoad; state.srcImg.onerror = prevOnError;
state.srcLoaded = true;
renderEffect();
app.graph.setDirtyCanvas(true,true);
state.srcPendingRetry = null;
};
state.srcImg.onerror = () => {
if(task.cancelled || token !== state.srcLoadToken) return;
state.srcImg.onload = prevOnLoad; state.srcImg.onerror = prevOnError;
idx += 1;
if(idx < schedule.length){
setTimeout(() => { if(task.cancelled || token !== state.srcLoadToken) return; loadSrc(tsHint); attempt(); }, schedule[idx]);
} else { state.srcPendingRetry = null; app.graph.setDirtyCanvas(true,true); }
};
loadSrc(tsHint);
};
attempt();
}
function getWidget(name){
if(!node.widgets) return null;
return node.widgets.find(w => (w && w.name === name) || (w && w.label === name));
}
function getValue(name){
const w = getWidget(name);
if(!w) return null;
return w.value;
}
// --- UI helpers: attach tooltips ---
function setTooltip(widget, text){
if(!widget) return;
widget.tooltip = text;
widget.description = text; // some builds use description for hover
}
function applyTooltips(){
const wOp = getWidget('operation');
const wSrc = getWidget('Source');
const wDst = getWidget('Destination');
const wAmt = getWidget('amount');
setTooltip(wOp,
'Operations:\n'+
'• Invert: Flip selected channel(s); Hue wraps.\n'+
'• Overwrite: Copy Source into Destination (cross-space allowed).\n'+
'• Overwrite from Image: Same as Overwrite but from second image input.\n'+
'• Set/Add/Sub/Mult/Div: Arithmetic on Source channel(s). Mult/Div use raw factor.\n'+
'• Clamp Min/Max: Clamp Source channel(s).\n'+
'• Truncate: Snap to step size (1-amount).\n'+
'• Contrast: Piecewise contrast around 0.5 (a/b normalized in Oklab).'
);
setTooltip(wSrc,
'Sources:\n'+
'• RGB: all three RGB channels.\n'+
'• Red/Green/Blue: single RGB channel.\n'+
'• HSV / Hue/Saturation/Value: operate in HSV; Hue wraps on arithmetic.\n'+
'• Oklab: L,a,b in perceptual space; a/b normalized to ±0.4 for some ops.'
);
setTooltip(wDst,
'Destinations (used by Overwrite):\n'+
'• R/G/B or H/S/V: write scalar to that component.\n'+
'• RGB/HSV/Oklab: if Source matches, copy full vector; otherwise map scalar.'
);
setTooltip(wAmt,
'Amount: 0–255.\n'+
'• For Set/Add/Sub: normalized (x/255).\n'+
'• Multiply/Divide: raw factor (2 = 2×, 255 = 255×).\n'+
'• Truncate/Contrast: interpreted as 1 - (x/255).'
);
}
function renderEffect(){
if(!state.baseLoaded) return;
const op = (getValue('operation') || '').toString().toLowerCase().replace(/\s+/g,'_');
const src = (getValue('Source') || '').toString();
const dst = (getValue('Destination') || '').toString();
const amount255 = parseFloat(getValue('amount') || 0) || 0;
// If this operation depends on a secondary image, ensure it's loading
if(op === 'overwrite_from_image' && !state.srcLoaded){
try{
if(node._channelOpsLoadSrcWithRetry) node._channelOpsLoadSrcWithRetry();
else if(node._channelOpsLoadSrc) node._channelOpsLoadSrc();
}catch(_e){}
}
// Prepare offscreen canvas based on base image
const w = state.baseImg.naturalWidth;
const h = state.baseImg.naturalHeight;
state.offscreen.width = w;
state.offscreen.height = h;
const ctx = state.offctx;
ctx.drawImage(state.baseImg, 0, 0);
const imgData = ctx.getImageData(0, 0, w, h);
const data = imgData.data; // RGBA 0..255
// Prepare src buffer if needed for overwrite_from_image
let srcData = null;
if(op === 'overwrite_from_image' && state.srcLoaded){
const sctx = state.srcOffctx;
state.srcOffscreen.width = w;
state.srcOffscreen.height = h;
sctx.drawImage(state.srcImg, 0, 0, w, h);
srcData = sctx.getImageData(0, 0, w, h).data;
}
// Minimal implementations for common ops in RGB/HSV/OKLAB.
// For parity with backend: multiply/divide as raw factor; truncate step uses inverted normalized weight; contrast piecewise.
function clamp01(x){ return Math.min(1, Math.max(0, x)); }
function wrap01(x){ x = x % 1; return x < 0 ? x + 1 : x; }
// Combined-RGB-source helper. Maps "red+green" etc. to channel indices.
const RGB_COMBO_IDX = {
'red+green': [0, 1],
'red+blue': [0, 2],
'green+blue':[1, 2],
};
function rgb2hsv(r,g,b){
const max = Math.max(r,g,b), min = Math.min(r,g,b);
const v = max;
const d = max - min;
const s = max ? d / max : 0;
let h = 0;
if(d !== 0){
if(max === r){ h = ((g - b) / d) % 6; }
else if(max === g){ h = (b - r) / d + 2; }
else { h = (r - g) / d + 4; }
h /= 6; if(h < 0) h += 1;
}
return [h,s,v];
}
function hsv2rgb(h,s,v){
const i = Math.floor(h*6);
const f = h*6 - i;
const p = v*(1-s);
const q = v*(1-f*s);
const t = v*(1-(1-f)*s);
switch(i % 6){
case 0: return [v,t,p];
case 1: return [q,v,p];
case 2: return [p,v,t];
case 3: return [p,q,v];
case 4: return [t,p,v];
case 5: return [v,p,q];
}
}
// --- Oklab conversions ---
function srgbToLinear(c){
return c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4);
}
function linearToSrgb(c){
return c <= 0.0031308 ? 12.92*c : 1.055*Math.pow(Math.max(0,c), 1/2.4) - 0.055;
}
function rgb2oklab(r,g,b){
// sRGB->linear
const rl=srgbToLinear(r), gl=srgbToLinear(g), bl=srgbToLinear(b);
const l = 0.4122214708*rl + 0.5363325363*gl + 0.0514459929*bl;
const m = 0.2119034982*rl + 0.6806995451*gl + 0.1073969566*bl;
const s = 0.0883024619*rl + 0.2817188376*gl + 0.6299787005*bl;
const l3 = Math.cbrt(l), m3 = Math.cbrt(m), s3 = Math.cbrt(s);
const L = 0.2104542553*l3 + 0.7936177850*m3 - 0.0040720468*s3;
const A = 1.9779984951*l3 - 2.4285922050*m3 + 0.4505937099*s3;
const B = 0.0259040371*l3 + 0.7827717662*m3 - 0.8086757660*s3;
return [L,A,B];
}
function oklab2rgb(L,A,B){
const l3 = L + 0.3963377774*A + 0.2158037573*B;
const m3 = L - 0.1055613458*A - 0.0638541728*B;
const s3 = L - 0.0894841775*A - 1.2914855480*B;
const l = l3*l3*l3, m = m3*m3*m3, s = s3*s3*s3;
const rl = 4.0767416621*l - 3.3077115913*m + 0.2309699292*s;
const gl = -1.2684380046*l + 2.6097574011*m - 0.3413193965*s;
const bl = -0.0041960863*l - 0.7034186147*m + 1.7076147010*s;
const r = linearToSrgb(rl), g = linearToSrgb(gl), b = linearToSrgb(bl);
return [clamp01(r), clamp01(g), clamp01(b)];
}
const amtNorm = amount255 / 255.0;
function applyPixel(i){
const r = data[i] / 255, g = data[i+1] / 255, b = data[i+2] / 255;
if(op === 'invert'){
const S = src.toLowerCase();
if(S === 'rgb'){
return [1-r, 1-g, 1-b];
}else if(S === 'red' || S==='green' || S==='blue'){
return [S==='red'?1-r:r, S==='green'?1-g:g, S==='blue'?1-b:b];
}else if(RGB_COMBO_IDX[S]){
const idxs = RGB_COMBO_IDX[S];
const ch = [r, g, b];
for(const i of idxs) ch[i] = 1 - ch[i];
return ch;
}else if(S === 'hue' || S==='saturation' || S==='value' || S==='hsv'){
const hsv = rgb2hsv(r,g,b);
if(S==='hsv'){
hsv[0] = 1-hsv[0]; hsv[1] = 1-hsv[1]; hsv[2] = 1-hsv[2];
}else{
const idx = {hue:0,saturation:1,value:2}[S];
hsv[idx] = 1-hsv[idx];
}
return hsv2rgb(hsv[0],hsv[1],hsv[2]);
}else if(S === 'oklab'){
const lab = rgb2oklab(r,g,b);
const L = 1 - clamp01(lab[0]);
const a_n = clamp01((lab[1] + 0.4) / 0.8);
const b_n = clamp01((lab[2] + 0.4) / 0.8);
const a_i = (1 - a_n) * 0.8 - 0.4;
const b_i = (1 - b_n) * 0.8 - 0.4;
return oklab2rgb(L, a_i, b_i);
}
}
// Overwrite operation: copy source channel/value into destination channel (cross-space allowed)
if(op === 'overwrite' || op === 'overwrite_from_image'){
const S = src.toLowerCase();
const D = dst.toLowerCase();
let rr=r, gg=g, bb=b;
// Compute scalar source value in [0,1]
let sr = r, sg = g, sb = b;
if(op === 'overwrite_from_image' && srcData){
sr = srcData[i] / 255; sg = srcData[i+1] / 255; sb = srcData[i+2] / 255;
}
// Fast path: group-to-group overwrite should copy full vector (parity with backend)
if((S==='rgb' && D==='rgb')){
return [sr, sg, sb];
} else if((S==='hsv' && D==='hsv')){
const [h,s,v] = rgb2hsv(sr,sg,sb);
const rgb = hsv2rgb(h,s,v);
return [rgb[0], rgb[1], rgb[2]];
} else if((S==='oklab' && D==='oklab')){
const [L,A,B_] = rgb2oklab(sr,sg,sb);
const rgb = oklab2rgb(L,A,B_);
return [rgb[0], rgb[1], rgb[2]];
}
let sval = 0;
if(S==='red' || S==='green' || S==='blue'){
sval = (S==='red') ? sr : (S==='green' ? sg : sb);
} else if(RGB_COMBO_IDX[S]){
// Mean of the two selected RGB channels (matches Python).
const idxs = RGB_COMBO_IDX[S];
const ch = [sr, sg, sb];
sval = (ch[idxs[0]] + ch[idxs[1]]) * 0.5;
} else if(S==='hue' || S==='saturation' || S==='value' || S==='hsv'){
const [h,s,v] = rgb2hsv(sr,sg,sb);
sval = (S==='hsv') ? h : (S==='hue' ? h : (S==='saturation' ? s : v));
} else if(S==='oklab'){
const lab = rgb2oklab(sr,sg,sb);
sval = clamp01(lab[0]); // use L channel
} else {
// Default fallback mirrors backend: use red channel when source not a single channel
sval = sr;
}
sval = clamp01(sval);
// Apply to destination
if(D==='red' || D==='green' || D==='blue'){
if(D==='red') rr = sval;
else if(D==='green') gg = sval;
else bb = sval;
return [rr,gg,bb];
} else if(RGB_COMBO_IDX[D]){
// Write scalar source value to each selected RGB channel.
const idxs = RGB_COMBO_IDX[D];
const ch = [rr, gg, bb];
for(const i of idxs) ch[i] = sval;
return ch;
} else if(D==='hue' || D==='saturation' || D==='value'){
let [h,s,v] = rgb2hsv(rr,gg,bb);
if(D==='hue') h = sval; // backend clamps rather than wrapping
else if(D==='saturation') s = sval;
else v = sval;
const rgb = hsv2rgb(h,s,v);
return [rgb[0], rgb[1], rgb[2]];
} else if(D==='rgb'){
// Grayscale overwrite by scalar
return [sval, sval, sval];
} else if(D==='hsv'){
// Set all HSV components from scalar; hue wraps [0,1), s and v clamp
let h = sval - Math.floor(sval); // wrap
let s2 = Math.max(0, Math.min(1, sval));
let v2 = Math.max(0, Math.min(1, sval));
const rgb = hsv2rgb(h, s2, v2);
return [rgb[0], rgb[1], rgb[2]];
} else if(D==='oklab'){
// Set Oklab L directly; a and b via normalization [-0.4,0.4]
const L = Math.max(0, Math.min(1, sval));
const an = Math.max(0, Math.min(1, sval));
const bn = Math.max(0, Math.min(1, sval));
const a2 = an * 0.8 - 0.4;
const b2 = bn * 0.8 - 0.4;
const rgb = oklab2rgb(L, a2, b2);
return [rgb[0], rgb[1], rgb[2]];
}
// If destination unsupported, return unchanged
return [rr,gg,bb];
}
// Scalar ops subset: set/add/subtract/multiply/divide/clamp_min/clamp_max/truncate/contrast applied to source channel(s)
const S = src.toLowerCase();
let rr=r, gg=g, bb=b;
function applyScalarRaw(x){
if(op==='set') return amtNorm;
if(op==='add') return x + amtNorm;
if(op==='subtract') return x - amtNorm;
if(op==='multiply') return x * Math.max(0, amount255);
if(op==='divide') return x / Math.max(1e-8, amount255);
if(op==='clamp_min') return Math.max(amtNorm, x);
if(op==='clamp_max') return Math.min(amtNorm, x);
if(op==='truncate'){
const step = 1 - amtNorm; // inverted
if(step <= 0) return x;
return Math.round(x / step) * step;
}
if(op==='contrast'){
const a = 1 - amtNorm; // inverted
const k = 1 / Math.max(1e-6, 1 - a);
let y = x;
if(x > 0.5) y = x * k;
else if(x < 0.5) y = x * (2 - k);
// Backend clamps inside _apply_op for contrast
return clamp01(y);
}
return x;
}
if(S==='rgb'){
rr = clamp01(applyScalarRaw(rr)); gg = clamp01(applyScalarRaw(gg)); bb = clamp01(applyScalarRaw(bb));
} else if(S==='red' || S==='green' || S==='blue'){
if(S==='red') rr = clamp01(applyScalarRaw(rr));
if(S==='green') gg = clamp01(applyScalarRaw(gg));
if(S==='blue') bb = clamp01(applyScalarRaw(bb));
} else if(RGB_COMBO_IDX[S]){
const idxs = RGB_COMBO_IDX[S];
const ch = [rr, gg, bb];
for(const i of idxs) ch[i] = clamp01(applyScalarRaw(ch[i]));
rr = ch[0]; gg = ch[1]; bb = ch[2];
} else if(S==='hue' || S==='saturation' || S==='value' || S==='hsv'){
let [h,s,v] = rgb2hsv(rr,gg,bb);
if(S==='hsv'){
h = wrap01(applyScalarRaw(h)); s = clamp01(applyScalarRaw(s)); v = clamp01(applyScalarRaw(v));
} else if(S==='hue'){
h = wrap01(applyScalarRaw(h));
} else if(S==='saturation'){
s = clamp01(applyScalarRaw(s));
} else if(S==='value'){
v = clamp01(applyScalarRaw(v));
}
const rgb = hsv2rgb(h,s,v); rr=rgb[0]; gg=rgb[1]; bb=rgb[2];
} else if(S==='oklab'){
// Apply scalar op to all Oklab channels with a/b normalization for non-linear ops
let [L,a,b_] = rgb2oklab(rr,gg,bb);
function applyABNonLinear(val){
const vn = clamp01((val + 0.4) / 0.8);
const vn2 = clamp01(applyScalarRaw(vn));
return vn2 * 0.8 - 0.4;
}
if(op==='set' || op==='contrast' || op==='truncate'){
L = clamp01(applyScalarRaw(L));
a = applyABNonLinear(a);
b_ = applyABNonLinear(b_);
} else {
L = clamp01(applyScalarRaw(L));
a = applyScalarRaw(a);
b_ = applyScalarRaw(b_);
}
const rgb2 = oklab2rgb(L,a,b_);
rr=rgb2[0]; gg=rgb2[1]; bb=rgb2[2];
}
return [rr,gg,bb];
}
for(let i=0;i<data.length;i+=4){
const out = applyPixel(i);
data[i] = Math.round(clamp01(out[0]) * 255);
data[i+1] = Math.round(clamp01(out[1]) * 255);
data[i+2] = Math.round(clamp01(out[2]) * 255);
// alpha unchanged
}
ctx.putImageData(imgData, 0, 0);
seedPreviewSize(node);
app.graph.setDirtyCanvas(true,true);
}
// Wire up events
function bindChanges(){
if(!node.widgets) return;
node.widgets.forEach(w => {
const orig = w.callback || w.onChange;
const cb = function(){
if(orig) try{ orig.apply(this, arguments); }catch(e){}
renderEffect();
app.graph.setDirtyCanvas(true,true);
};
w.callback = cb;
w.onChange = cb;
});
}
// Ensure a hidden 'preview_id' widget exists and is synced to node.id
try {
let w = getWidget('preview_id');
if(!w){
w = node.addWidget && node.addWidget("string", "preview_id", String(node.id ?? '0'), null, {serialize:true});
}
if(w){
// hide widget completely
w.computeSize = () => [0,0];
w.draw = () => {};
const setVal = (val) => {
try{ w.value = String(val ?? ''); }catch(_){}
if(Array.isArray(node.widgets_values)){
const idx = node.widgets ? node.widgets.indexOf(w) : -1;
if(idx >= 0) node.widgets_values[idx] = w.value;
}
};
// Set immediately and re-sync shortly after creation to catch late id assignment
setVal(node.id);
setTimeout(() => setVal(node.id), 0);
}
} catch(e) { console.warn(`[${EXT_NAME}] preview_id widget setup failed`, e); }
// Expose load functions so prototype wrappers can trigger loads
node._channelOpsLoadBase = loadBase;
node._channelOpsLoadBaseWithRetry = loadBaseWithRetry;
node._channelOpsLoadBaseReset = resetPreview;
node._channelOpsLoadSrc = loadSrc;
node._channelOpsLoadSrcWithRetry = loadSrcWithRetry;
// Replace underscores with spaces in the visible widget label.
try{
if(node.widgets){
for(const w of node.widgets){
if(!w || !w.name) continue;
if(w.name === 'preview_id') continue;
if(w.label && w.label !== w.name) continue;
if(w.name.indexOf('_') < 0) continue;
w.label = w.name.replace(/_/g, ' ');
}
}
}catch(_e){}
bindChanges();
applyTooltips();
loadBase();
// Opportunistically try to load a secondary source preview if present
loadSrc();
}
// removed: duplicate global loadSrc/loadSrcWithRetry (they now live inside ensureBehavior where 'state' exists)
app.registerExtension({
name: EXT_NAME,
async beforeQueuePrompt(data){
// Ensure preview_id is correctly set to the node id in the prompt
try {
if(data && data.output){
for(const [nid, entry] of Object.entries(data.output)){
const ct = (entry && entry.class_type) ? String(entry.class_type) : '';
if(ct.includes('ChannelOpsNode')){
entry.inputs = entry.inputs || {};
entry.inputs.preview_id = String(nid);
}
}
}
} catch(e) { console.warn(`[${EXT_NAME}] beforeQueuePrompt`, e); }
return data;
},
async setup(){
// Listen for backend push to refresh preview immediately after save
try{
api.addEventListener("channel_ops_preview", (ev) => {
try{
const detail = ev?.detail ?? ev;
const pid = String(detail?.preview_id ?? "");
const ts = (typeof detail?.ts === 'number') ? Math.floor(detail.ts * 1000) : Date.now();
if(!pid) return;
const nodes = (app?.graph?._nodes || []).filter(n => (n.comfyClass || n.type || '').toString().includes('ChannelOpsNode'));
for(const n of nodes){
try{
// Ensure behavior attached
if(!n._channelOpsPreviewAdded){ ensureBehavior(n); }
// Match by preview_id widget value OR by node.id to avoid cross-node updates
let w = null;
if(n.widgets){ w = n.widgets.find(w => (w && (w.name === 'preview_id' || w.label === 'preview_id'))); }
const widVal = w ? String(w.value ?? '') : '';
const nodeIdOk = (typeof n.id === 'number' && n.id >= 0) ? String(n.id) : '';
const matches = (widVal && widVal === pid) || (nodeIdOk && nodeIdOk === pid);
if(matches){
if(n._channelOpsLoadBaseReset) n._channelOpsLoadBaseReset();
if(n._channelOpsLoadBaseWithRetry) n._channelOpsLoadBaseWithRetry(ts);
else if(n._channelOpsLoadBase) n._channelOpsLoadBase(ts);
// Also attempt to refresh potential secondary source preview
if(n._channelOpsLoadSrcWithRetry) n._channelOpsLoadSrcWithRetry(ts);
else if(n._channelOpsLoadSrc) n._channelOpsLoadSrc(ts);
}
}catch(e){}
}
}catch(e){ console.warn(`[${EXT_NAME}] event handler`, e); }
});
}catch(e){ console.warn(`[${EXT_NAME}] api event wiring`, e); }
// Attach to any nodes already created (when loading a workflow)
if(app?.graph?._nodes){
for(const n of app.graph._nodes){
if((n.comfyClass || n.type || '').toString().includes('ChannelOpsNode')){
try{
ensureBehavior(n);
// Per-instance onExecuted wrapper to ensure retry even if prototype hijack misses
const prev = n.onExecuted;
n.onExecuted = function(){
if(prev) try{ prev.apply(this, arguments); }catch(e){}
try{
if(this._channelOpsPreviewAdded){
if(this._channelOpsLoadBaseReset) this._channelOpsLoadBaseReset();
if(this._channelOpsLoadBaseWithRetry) this._channelOpsLoadBaseWithRetry();
else if(this._channelOpsLoadBase) this._channelOpsLoadBase();
// Also refresh potential secondary source preview
if(this._channelOpsLoadSrcWithRetry) this._channelOpsLoadSrcWithRetry();
else if(this._channelOpsLoadSrc) this._channelOpsLoadSrc();
}
}catch(e){}
};
}catch(e){ console.error(`[${EXT_NAME}] setup`, e); }
}
}
}
},
async nodeCreated(node){
if((node.comfyClass || node.type || '').toString().includes('ChannelOpsNode')){
try{
ensureBehavior(node);
// Per-instance onExecuted wrapper
const prev = node.onExecuted;
node.onExecuted = function(){
if(prev) try{ prev.apply(this, arguments); }catch(e){}
try{
if(this._channelOpsPreviewAdded){
if(this._channelOpsLoadBaseReset) this._channelOpsLoadBaseReset();
if(this._channelOpsLoadBaseWithRetry) this._channelOpsLoadBaseWithRetry();
else if(this._channelOpsLoadBase) this._channelOpsLoadBase();
// Also refresh potential secondary source preview
if(this._channelOpsLoadSrcWithRetry) this._channelOpsLoadSrcWithRetry();
else if(this._channelOpsLoadSrc) this._channelOpsLoadSrc();
}
}catch(e){}
};
}catch(e){ console.error(`[${EXT_NAME}] nodeCreated`, e); }
}
},
async beforeRegisterNodeDef(nodeType, nodeData){
// Be flexible about matching the node; some builds differ in name vs displayName
const nm = (nodeData?.name || nodeType?.name || '').toString();
if(nm.includes('ChannelOpsNode')){
// Hijack lifecycle to guarantee our behavior on all instances
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function(){
if(origCreated) try{ origCreated.apply(this, arguments); }catch(e){}
try{ ensureBehavior(this); }catch(e){ console.error(`[${EXT_NAME}] onNodeCreated`, e); }
};
const origExec = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function(){
if(origExec) try{ origExec.apply(this, arguments); }catch(e){}
try{
if(this._channelOpsPreviewAdded){
if(this._channelOpsLoadBaseReset) this._channelOpsLoadBaseReset();
if(this._channelOpsLoadBaseWithRetry){ this._channelOpsLoadBaseWithRetry(); }
else if(this._channelOpsLoadBase){ this._channelOpsLoadBase(); }
// Also refresh potential secondary source preview
if(this._channelOpsLoadSrcWithRetry){ this._channelOpsLoadSrcWithRetry(); }
else if(this._channelOpsLoadSrc){ this._channelOpsLoadSrc(); }
}
}catch(e){}
};
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function(ctx){
if(origDraw) try{ origDraw.apply(this, arguments); }catch(e){}
try{ if(this._channelOpsPreviewAdded){ drawPreview(this, ctx); } }catch(e){}
};
const origResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function(size){
if(origResize) try{ origResize.apply(this, arguments); }catch(e){}
try{ app.graph.setDirtyCanvas(true,true); }catch(e){}
};
// Mark workflow-restored nodes so first-preview auto-sizing respects the
// saved node size instead of overriding it.
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info){
this._loadedFromWorkflow = true;
if(origConfigure) try{ origConfigure.apply(this, arguments); }catch(e){}
};
}
}
});