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

350 lines
14 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.
// Layer Blending - In-node live preview (canvas)
// Displays the blended result of two images with selected blend mode and opacity.
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
const EXT_NAME = "Layer_Blending.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;
(function(){
// One-time node resize when the first preview becomes available. Fresh nodes
// get a sensible preview area; 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 drawPreview(node, ctx){
const state = node._layerBlendState;
if(!state) return;
// Nothing rendered yet? Don't draw an empty box.
if(!state.outReady) 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;
// First-frame fallback (before any widget has been drawn): estimate from
// the header height + the taller of the input/output slot columns.
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;
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.outCanvas.width, ih = state.outCanvas.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.outCanvas, 0, 0, iw, ih, ox, oy, rw, rh);
ctx.restore();
}
function ensureBehavior(node){
if(node._layerBlendPreviewAdded) return;
node._layerBlendPreviewAdded = true;
const state = node._layerBlendState = {
bgImg: new Image(),
fgImg: new Image(),
bgReady: false,
fgReady: false,
bgUrl: null,
fgUrl: null,
loadToken: 0,
outCanvas: document.createElement('canvas'),
outCtx: null,
};
state.outCtx = state.outCanvas.getContext('2d');
function revokeUrl(kind){
try{
if(kind==='bg' && state.bgUrl){ URL.revokeObjectURL(state.bgUrl); state.bgUrl = null; }
if(kind==='fg' && state.fgUrl){ URL.revokeObjectURL(state.fgUrl); state.fgUrl = null; }
}catch(_){ }
}
function getWidget(name){
if(!node.widgets) return null;
return node.widgets.find(w => (w && (w.name===name || w.label===name)));
}
function getVal(name){ const w = getWidget(name); return w ? w.value : null; }
function setPreviewId(){
let w = getWidget('preview_id');
if(!w && node.addWidget){ w = node.addWidget('string','preview_id', String(node.id ?? '0'), null, {serialize:true}); }
if(w){
w.computeSize = () => [0,0]; w.draw = () => {};
const setVal = (v)=>{ try{ w.value = String(v ?? ''); }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;
}
};
setVal(node.id); setTimeout(()=>setVal(node.id),0);
}
}
function buildPaths(pid, ts){
const q = `?ts=${ts}`;
const base = [
`/extensions/ComfyUI_Channel_Ops/layer_blend_bg_${pid}.png${q}`,
`/extensions/Channel_Ops/layer_blend_bg_${pid}.png${q}`,
`/extensions/ChannelOps/layer_blend_bg_${pid}.png${q}`,
];
const top = [
`/extensions/ComfyUI_Channel_Ops/layer_blend_fg_${pid}.png${q}`,
`/extensions/Channel_Ops/layer_blend_fg_${pid}.png${q}`,
`/extensions/ChannelOps/layer_blend_fg_${pid}.png${q}`,
];
return {base, top};
}
async function fetchAsImage(urls, kind, token){
try{
for(const u of urls){
const resp = await fetch(u, {cache:'no-store'});
if(resp.ok){
const blob = await resp.blob();
const obj = URL.createObjectURL(blob);
revokeUrl(kind);
if(kind==='bg'){ state.bgUrl = obj; state.bgImg.src = obj; }
else { state.fgUrl = obj; state.fgImg.src = obj; }
return true;
}
}
}catch(_){ }
return false;
}
function loadBoth(tsOverride){
const wid = getWidget('preview_id');
const pid = String((wid && wid.value) || node.id || 'A').replace(/[^a-zA-Z0-9_-]/g, "_");
const ts = (typeof tsOverride==='number' && isFinite(tsOverride)) ? tsOverride : Date.now();
const paths = buildPaths(pid, ts);
const token = ++state.loadToken;
state.bgReady = false; state.fgReady = false;
state.bgImg.onload = ()=>{ if(token!==state.loadToken) return; state.bgReady = true; render(); };
state.fgImg.onload = ()=>{ if(token!==state.loadToken) return; state.fgReady = true; render(); };
state.bgImg.onerror = ()=>{ if(token!==state.loadToken) return; state.bgReady = false; render(); };
state.fgImg.onerror = ()=>{ if(token!==state.loadToken) return; state.fgReady = false; render(); };
// fire and forget
fetchAsImage(paths.base, 'bg', token);
fetchAsImage(paths.top, 'fg', token);
}
function clamp01(x){ return Math.min(1, Math.max(0, x)); }
// Mirrors layer_blending.py's _BLEND_FUNCS: the same modes clamp their
// result to [0,1] here as in the backend, so preview == output.
function blendPixel(a, b, mode){
// a,b in [0,1]
switch(mode){
case 'multiply': return a*b;
case 'screen': return 1 - (1-a)*(1-b);
case 'overlay': return (a<=0.5) ? (2*a*b) : (1 - 2*(1-a)*(1-b));
case 'hard light': return (b<=0.5) ? (2*a*b) : (1 - 2*(1-a)*(1-b));
case 'soft light': return (1 - 2*b)*a*a + 2*b*a;
case 'darken': return Math.min(a,b);
case 'lighten': return Math.max(a,b);
case 'color dodge': return clamp01(a / Math.max(1e-8, 1-b));
case 'color burn': return clamp01(1 - ((1-a) / Math.max(1e-8, b)));
case 'linear burn': return a + b - 1;
case 'vivid light': return (b<0.5) ? clamp01(1 - ((1-a)/Math.max(1e-8, 2*b))) : clamp01(a/Math.max(1e-8, 2*(1-b)));
case 'linear light': return a + 2*b - 1;
case 'pin light': return (b<0.5) ? Math.min(a, clamp01(2*b)) : Math.max(a, clamp01(2*b-1));
case 'difference': return Math.abs(a-b);
case 'exclusion': return a + b - 2*a*b;
case 'divide': return clamp01(a / Math.max(1e-8, b));
case 'hard mix': return (a + b < 1) ? 0 : 1;
case 'linear dodge':
case 'linear dodge (add)':
case 'add': return a + b;
case 'subtract': return a - b;
case 'normal':
default: return b;
}
}
function render(){
// Opacity defaults to 255 (matches the Python node default) when the
// widget value is missing/NaN — not 0, which would blank the blend.
const opRaw = parseFloat(getVal('opacity'));
const opacity = (isFinite(opRaw) ? opRaw : 255) / 255.0;
const mode = String(getVal('mode') || 'Normal').toLowerCase();
const ready = state.bgReady && state.fgReady;
state.outReady = false;
if(!ready){ app.graph.setDirtyCanvas(true,true); return; }
// Match the backend geometry: background keeps its size, foreground is
// resized to the background's H×W (apply_blend does the same). Using the
// background size — not min() of both — avoids distorting the result when
// the two inputs have different dimensions.
const w = Math.max(1, state.bgImg.naturalWidth);
const h = Math.max(1, state.bgImg.naturalHeight);
state.outCanvas.width = w; state.outCanvas.height = h;
const ctx = state.outCtx;
// Draw inputs resampled into temp offscreens (fg stretched to bg size).
const cA = document.createElement('canvas'); cA.width=w; cA.height=h; const xA = cA.getContext('2d'); xA.drawImage(state.bgImg, 0,0,w,h);
const cB = document.createElement('canvas'); cB.width=w; cB.height=h; const xB = cB.getContext('2d'); xB.drawImage(state.fgImg, 0,0,w,h);
const aData = xA.getImageData(0,0,w,h).data;
const bData = xB.getImageData(0,0,w,h).data;
const out = ctx.createImageData(w, h);
const odata = out.data;
for(let i=0;i<odata.length;i+=4){
const ar=aData[i]/255, ag=aData[i+1]/255, ab=aData[i+2]/255;
const br=bData[i]/255, bg=bData[i+1]/255, bb=bData[i+2]/255;
let rr, gg, bb2;
const m = mode;
if(m === 'darker color'){
const suma = ar+ag+ab, sumb = br+bg+bb;
if(sumb < suma){ rr=br; gg=bg; bb2=bb; } else { rr=ar; gg=ag; bb2=ab; }
} else if(m === 'lighter color'){
const suma = ar+ag+ab, sumb = br+bg+bb;
if(sumb > suma){ rr=br; gg=bg; bb2=bb; } else { rr=ar; gg=ag; bb2=ab; }
} else {
rr = blendPixel(ar, br, m); gg = blendPixel(ag, bg, m); bb2 = blendPixel(ab, bb, m);
}
// opacity composite over A
rr = clamp01(ar*(1-opacity) + rr*opacity);
gg = clamp01(ag*(1-opacity) + gg*opacity);
bb2 = clamp01(ab*(1-opacity) + bb2*opacity);
odata[i] = Math.round(rr*255);
odata[i+1] = Math.round(gg*255);
odata[i+2] = Math.round(bb2*255);
odata[i+3] = 255;
}
ctx.putImageData(out, 0, 0);
state.outReady = true;
seedPreviewSize(node);
app.graph.setDirtyCanvas(true,true);
}
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){} render(); };
w.callback = cb; w.onChange = cb;
});
}
function applyTooltips(){
const wMode = getWidget('mode');
const wOpac = getWidget('opacity');
if(wMode){ wMode.tooltip = wMode.description = 'Blend modes: Normal, Multiply, Screen, Overlay, Soft Light, Add, Subtract'; }
if(wOpac){ wOpac.tooltip = wOpac.description = 'Opacity: 0–255 (0 transparent, 255 fully applied)'; }
}
setPreviewId();
bindChanges();
applyTooltips();
loadBoth();
node._layerBlendLoad = loadBoth;
const prevExec = node.onExecuted;
node.onExecuted = function(){ if(prevExec) try{ prevExec.apply(this, arguments); }catch(_e){} try{ loadBoth(); }catch(_e){} };
}
app.registerExtension({
name: EXT_NAME,
async setup(){
try{
api.addEventListener('layer_blend_preview', (ev)=>{
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('LayerBlendingNode'));
for(const n of nodes){
try{
if(!n._layerBlendPreviewAdded) ensureBehavior(n);
let w = n.widgets ? n.widgets.find(w => w && (w.name==='preview_id' || w.label==='preview_id')) : null;
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 && n._layerBlendLoad) n._layerBlendLoad(ts);
}catch(_e){}
}
});
}catch(_e){}
},
async beforeRegisterNodeDef(nodeType, nodeData){
const nm = (nodeData?.name || nodeType?.name || '').toString();
if(nm.includes('LayerBlendingNode')){
const origCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function(){ if(origCreated) try{ origCreated.apply(this, arguments); }catch(_e){} try{ ensureBehavior(this); }catch(_e){} };
const origConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info){ this._loadedFromWorkflow = true; if(origConfigure) try{ origConfigure.apply(this, arguments); }catch(_e){} };
const origDraw = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function(ctx){ if(origDraw) try{ origDraw.apply(this, arguments); }catch(_e){} try{ if(this._layerBlendPreviewAdded) drawPreview(this, ctx); }catch(_e){} };
}
},
});
})();