Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/viewer/nodeStream/imageOpsPreviewBridge.ts
T

149 lines
5.5 KiB
TypeScript

/**
* ImageOps preview bridge — minimal, canvas-only.
*
* majoor-imageops keeps a rendered preview canvas at
* `node.__imageops_state.canvas`. We stream that canvas to the MFV after
* cropping out the letterbox/guide area so the displayed content fills the
* viewer (no square scope, no dashed guides outside the image).
*
* Crop math: ImageOps draws the source `previewSourceWidth x previewSourceHeight`
* into a square `canvasSize x canvasSize` via a centered contain-fit. We
* recompute the same fit and copy that sub-rect into a temp canvas before
* encoding. When the user has panned/zoomed inside ImageOps, the content
* overflows that rect — we fall back to streaming the full canvas in that
* case to avoid clipping the scene.
*
* The encoded data-URL is cached per signature so we re-encode only when
* the canvas (or crop rect) actually changes.
*/
const IMAGEOPS_STATE_KEY = "__imageops_state";
const IMAGEOPS_SOURCE = "imageops-live-preview";
function _isCanvas(value: any) {
return typeof HTMLCanvasElement !== "undefined" && value instanceof HTMLCanvasElement;
}
function _canvasUsable(canvas: any) {
return _isCanvas(canvas) && Number(canvas.width) > 0 && Number(canvas.height) > 0;
}
function _hashText(value: any) {
let hash = 2166136261;
const text = String(value || "");
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16);
}
/**
* Compute the contain-fit sub-rect of the source image inside the square
* preview canvas. Returns null when crop should be skipped (no source dims,
* user pan/zoom active, or the fit equals the full canvas).
*/
function _computeContentRect(st: any, canvas: any) {
const sw = Number(st?.previewSourceWidth) || 0;
const sh = Number(st?.previewSourceHeight) || 0;
const cw = Number(canvas?.width) || 0;
const ch = Number(canvas?.height) || 0;
if (sw <= 0 || sh <= 0 || cw <= 0 || ch <= 0) return null;
// ImageOps applies the user's pan/zoom inside its own canvas. Cropping
// the static fit rect would clip the panned content — keep the full
// canvas in that case.
const zoom = Number(st?.previewZoom);
const panX = Number(st?.previewPanX) || 0;
const panY = Number(st?.previewPanY) || 0;
if ((Number.isFinite(zoom) && Math.abs(zoom - 1) > 1e-3) || panX !== 0 || panY !== 0) {
return null;
}
const scale = Math.min(cw / sw, ch / sh);
const drawW = Math.max(1, Math.round(sw * scale));
const drawH = Math.max(1, Math.round(sh * scale));
const dx = Math.max(0, Math.round((cw - drawW) / 2));
const dy = Math.max(0, Math.round((ch - drawH) / 2));
// No letterbox ? nothing to crop, skip the temp-canvas detour.
if (dx === 0 && dy === 0 && drawW === cw && drawH === ch) return null;
return { dx, dy, w: drawW, h: drawH };
}
function _signatureFor(node: any, st: any, canvas: any, crop: any) {
const cropPart = crop ? `${crop.dx},${crop.dy},${crop.w}x${crop.h}` : "full";
const parts = [
String(node?.id ?? ""),
String(st?.lastKey ?? ""),
String(st?.lastRenderTick ?? ""),
String(st?.nativeDirty ? 1 : 0),
`${Number(canvas?.width) || 0}x${Number(canvas?.height) || 0}`,
cropPart,
];
return _hashText(parts.join("|"));
}
function _encodeCropped(canvas: any, crop: any) {
const tmp = document.createElement("canvas");
tmp.width = crop.w;
tmp.height = crop.h;
const ctx = tmp.getContext("2d");
if (!ctx) return "";
ctx.drawImage(canvas, crop.dx, crop.dy, crop.w, crop.h, 0, 0, crop.w, crop.h);
return tmp.toDataURL("image/png");
}
// WeakMap caches keep ImageOps nodes garbage-collectable. We reuse the last
// encoded URL whenever the signature is unchanged — avoids re-encoding the
// full canvas on every poll tick.
const _lastSigByNode = new WeakMap();
const _lastUrlByNode = new WeakMap();
export function extractImageOpsPreview(node: any): any {
if (!node) return null;
const st = node[IMAGEOPS_STATE_KEY];
const canvas = st?.canvas;
if (!_canvasUsable(canvas)) return null;
const crop = _computeContentRect(st, canvas);
const signature = _signatureFor(node, st, canvas, crop);
let url = _lastSigByNode.get(node) === signature ? _lastUrlByNode.get(node) : "";
if (!url) {
try {
url = crop ? _encodeCropped(canvas, crop) : canvas.toDataURL("image/png");
} catch (error: any) {
// Tainted canvas (cross-origin). Warn so the cause is visible.
console.warn("[NodeStream] ImageOps canvas export failed:", error);
return null;
}
if (!url) return null;
_lastSigByNode.set(node, signature);
_lastUrlByNode.set(node, url);
}
const outW = crop ? crop.w : Number(canvas.width) || undefined;
const outH = crop ? crop.h : Number(canvas.height) || undefined;
const classType = node.comfyClass || node.type || "ImageOps";
return {
filename: `imageops_${node.id ?? "node"}_${signature}.png`,
subfolder: "",
type: "temp",
kind: "image",
url,
width: outW,
height: outH,
_nodeId: String(node.id ?? ""),
_classType: classType,
_source: IMAGEOPS_SOURCE,
_signature: signature,
};
}
export function hasImageOpsPreviewState(node: any): boolean {
return _canvasUsable(node?.[IMAGEOPS_STATE_KEY]?.canvas);
}
export const IMAGEOPS_PREVIEW_SOURCE = IMAGEOPS_SOURCE;