149 lines
5.5 KiB
TypeScript
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;
|