557 lines
22 KiB
TypeScript
557 lines
22 KiB
TypeScript
export function createViewerGrid({
|
|
gridCanvas,
|
|
content,
|
|
state,
|
|
VIEWER_MODES,
|
|
getPrimaryMedia,
|
|
getViewportRect,
|
|
clearCanvas,
|
|
}: Record<string, any> = {}): Record<string, any> {
|
|
const _getModeRoot = () => {
|
|
try {
|
|
const mode = state?.mode;
|
|
if (mode === VIEWER_MODES?.AB_COMPARE)
|
|
return content?.querySelector?.(".mjr-viewer-ab") || content || null;
|
|
if (mode === VIEWER_MODES?.SIDE_BY_SIDE)
|
|
return content?.querySelector?.(".mjr-viewer-sidebyside") || content || null;
|
|
return content?.querySelector?.(".mjr-viewer-single") || content || null;
|
|
} catch {
|
|
return content || null;
|
|
}
|
|
};
|
|
|
|
const _getBoxElForMedia = (mediaEl: any, root: any) => {
|
|
try {
|
|
if (!mediaEl) return root || null;
|
|
const mode = state?.mode;
|
|
if (mode === VIEWER_MODES?.SIDE_BY_SIDE || mode === VIEWER_MODES?.AB_COMPARE) {
|
|
// Prefer the direct child panel/layer of the mode root (stable, not transformed by pan/zoom).
|
|
let el = mediaEl;
|
|
while (el && el !== root && el.parentElement) {
|
|
if (el.parentElement === root) return el;
|
|
el = el.parentElement;
|
|
}
|
|
return root || null;
|
|
}
|
|
return root || content || null;
|
|
} catch {
|
|
return root || content || null;
|
|
}
|
|
};
|
|
|
|
const _getAssetForMedia = (mediaEl: any) => {
|
|
try {
|
|
const id = mediaEl?.dataset?.mjrAssetId;
|
|
if (id == null || id === "") return state?.assets?.[state?.currentIndex] || null;
|
|
const list = Array.isArray(state?.assets) ? state.assets : [];
|
|
for (const a of list) {
|
|
try {
|
|
if (a?.id != null && String(a.id) === String(id)) return a;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
return state?.assets?.[state?.currentIndex] || null;
|
|
} catch {
|
|
return state?.assets?.[state?.currentIndex] || null;
|
|
}
|
|
};
|
|
|
|
const _getNaturalSize = (el: any, assetHint: any = null) => {
|
|
try {
|
|
if (!el) return { w: 0, h: 0 };
|
|
if (el instanceof HTMLCanvasElement) {
|
|
const w = Number((el as any)._mjrNaturalW) || Number(el.width) || 0;
|
|
const h = Number((el as any)._mjrNaturalH) || Number(el.height) || 0;
|
|
if (w > 0 && h > 0) return { w, h };
|
|
}
|
|
const w = Number(el.videoWidth) || Number(el.naturalWidth) || 0;
|
|
const h = Number(el.videoHeight) || Number(el.naturalHeight) || 0;
|
|
if (w > 0 && h > 0) return { w, h };
|
|
|
|
// Prefer per-media asset dimensions (works in side-by-side / A-B while processors are still loading).
|
|
try {
|
|
const aw = Number(assetHint?.width) || 0;
|
|
const ah = Number(assetHint?.height) || 0;
|
|
if (aw > 0 && ah > 0) return { w: aw, h: ah };
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Fallbacks (before processors report natural size).
|
|
try {
|
|
const sw = Number(state?._mediaW) || 0;
|
|
const sh = Number(state?._mediaH) || 0;
|
|
if (sw > 0 && sh > 0) return { w: sw, h: sh };
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
const cur = state?.assets?.[state?.currentIndex] || null;
|
|
const aw = Number(cur?.width) || 0;
|
|
const ah = Number(cur?.height) || 0;
|
|
if (aw > 0 && ah > 0) return { w: aw, h: ah };
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return { w: 0, h: 0 };
|
|
} catch {
|
|
return { w: 0, h: 0 };
|
|
}
|
|
};
|
|
|
|
const _fitHeightRect = (boxW: any, boxH: any, naturalW: any, naturalH: any) => {
|
|
try {
|
|
const bw = Number(boxW) || 0;
|
|
const bh = Number(boxH) || 0;
|
|
const nw = Number(naturalW) || 0;
|
|
const nh = Number(naturalH) || 0;
|
|
if (!(bw > 0 && bh > 0 && nw > 0 && nh > 0)) return { x: 0, y: 0, w: bw, h: bh };
|
|
const aspect = nw / nh;
|
|
if (!Number.isFinite(aspect) || aspect <= 0) return { x: 0, y: 0, w: bw, h: bh };
|
|
const h = bh;
|
|
const w = bh * aspect;
|
|
const x = (bw - w) / 2;
|
|
const y = 0;
|
|
return { x, y, w, h };
|
|
} catch {
|
|
return { x: 0, y: 0, w: Number(boxW) || 0, h: Number(boxH) || 0 };
|
|
}
|
|
};
|
|
|
|
const _transformRectAround = (rectCss: any, centerCss: any, zoom: any, panX: any, panY: any) => {
|
|
try {
|
|
const z = Math.max(0.1, Math.min(16, Number(zoom) || 1));
|
|
const cx = Number(centerCss?.x) || 0;
|
|
const cy = Number(centerCss?.y) || 0;
|
|
const x = Number(rectCss?.x) || 0;
|
|
const y = Number(rectCss?.y) || 0;
|
|
const w = Number(rectCss?.w) || 0;
|
|
const h = Number(rectCss?.h) || 0;
|
|
const px = Number(panX) || 0;
|
|
const py = Number(panY) || 0;
|
|
return {
|
|
x: cx + (x - cx) * z + px,
|
|
y: cy + (y - cy) * z + py,
|
|
w: w * z,
|
|
h: h * z,
|
|
};
|
|
} catch {
|
|
return { x: 0, y: 0, w: 0, h: 0 };
|
|
}
|
|
};
|
|
|
|
const _drawHudSizeLabel = (ctx: any, rect: any, label: any, dpr: any) => {
|
|
try {
|
|
const r = rect || {};
|
|
const x = Number(r.x) || 0;
|
|
const y = Number(r.y) || 0;
|
|
const w = Number(r.w) || 0;
|
|
const h = Number(r.h) || 0;
|
|
if (!(w > 8 && h > 8)) return;
|
|
const text = String(label || "");
|
|
if (!text) return;
|
|
|
|
const padX = Math.max(6, Math.round(6 * dpr));
|
|
const padY = Math.max(3, Math.round(3 * dpr));
|
|
const fontPx = Math.max(11, Math.round(11 * dpr));
|
|
|
|
ctx.save();
|
|
ctx.font = `${fontPx}px var(--comfy-font, ui-sans-serif, system-ui)`;
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "top";
|
|
|
|
const tw = Math.ceil(ctx.measureText(text).width);
|
|
const bw = tw + padX * 2;
|
|
const bh = fontPx + padY * 2;
|
|
|
|
const bx = x + Math.max(2, Math.round(8 * dpr));
|
|
const by = y + Math.max(2, Math.round(8 * dpr));
|
|
|
|
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
|
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
|
ctx.lineWidth = Math.max(1, Math.round(1 * dpr));
|
|
ctx.beginPath();
|
|
const rr = Math.max(6, Math.round(8 * dpr));
|
|
ctx.moveTo(bx + rr, by);
|
|
ctx.arcTo(bx + bw, by, bx + bw, by + bh, rr);
|
|
ctx.arcTo(bx + bw, by + bh, bx, by + bh, rr);
|
|
ctx.arcTo(bx, by + bh, bx, by, rr);
|
|
ctx.arcTo(bx, by, bx + bw, by, rr);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
|
ctx.fillText(text, bx + padX, by + padY);
|
|
ctx.restore();
|
|
} catch {
|
|
try {
|
|
ctx.restore();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
const ensureCanvasSize = () => {
|
|
try {
|
|
const rect = getViewportRect?.();
|
|
if (!rect) return { w: 0, h: 0, dpr: 1 };
|
|
const dpr = Math.max(1, Math.min(3, Number(window.devicePixelRatio) || 1));
|
|
const w = Math.max(1, Math.floor(rect.width * dpr));
|
|
const h = Math.max(1, Math.floor(rect.height * dpr));
|
|
try {
|
|
if (gridCanvas?.width !== w) gridCanvas.width = w;
|
|
if (gridCanvas?.height !== h) gridCanvas.height = h;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return { w, h, dpr };
|
|
} catch {
|
|
return { w: 0, h: 0, dpr: 1 };
|
|
}
|
|
};
|
|
|
|
const _aspectFromFormat = (format: any, mediaEl: any, mediaRect: any) => {
|
|
try {
|
|
const f = String(format || "image");
|
|
if (f === "image") {
|
|
const nat = _getNaturalSize(mediaEl);
|
|
const a = (Number(nat.w) || 0) / (Number(nat.h) || 1);
|
|
if (Number.isFinite(a) && a > 0) return a;
|
|
const b = (Number(mediaRect?.width) || 0) / (Number(mediaRect?.height) || 1);
|
|
return Number.isFinite(b) && b > 0 ? b : 1;
|
|
}
|
|
if (f === "16:9") return 16 / 9;
|
|
if (f === "9:16") return 9 / 16;
|
|
if (f === "1:1") return 1;
|
|
if (f === "4:3") return 4 / 3;
|
|
if (f === "2.39") return 2.39;
|
|
return 1;
|
|
} catch {
|
|
return 1;
|
|
}
|
|
};
|
|
|
|
const _fitAspectInBox = (boxW: any, boxH: any, aspect: any) => {
|
|
try {
|
|
const bw = Number(boxW) || 0;
|
|
const bh = Number(boxH) || 0;
|
|
const a = Number(aspect) || 1;
|
|
if (!(bw > 0 && bh > 0 && a > 0)) return { x: 0, y: 0, w: bw, h: bh };
|
|
const boxA = bw / bh;
|
|
let w = bw;
|
|
let h = bh;
|
|
if (a >= boxA) {
|
|
w = bw;
|
|
h = bw / a;
|
|
} else {
|
|
h = bh;
|
|
w = bh * a;
|
|
}
|
|
const x = (bw - w) / 2;
|
|
const y = (bh - h) / 2;
|
|
return { x, y, w, h };
|
|
} catch {
|
|
return { x: 0, y: 0, w: Number(boxW) || 0, h: Number(boxH) || 0 };
|
|
}
|
|
};
|
|
|
|
const _drawMaskOutside = (ctx: any, rect: any, alpha: any) => {
|
|
try {
|
|
const a = Math.max(0, Math.min(0.92, Number(alpha)));
|
|
if (!(a > 0)) return;
|
|
ctx.save();
|
|
ctx.globalCompositeOperation = "source-over";
|
|
ctx.fillStyle = `rgba(0,0,0,${a})`;
|
|
ctx.fillRect(0, 0, gridCanvas.width, gridCanvas.height);
|
|
ctx.globalCompositeOperation = "destination-out";
|
|
ctx.fillStyle = "rgba(0,0,0,1)";
|
|
const rects = Array.isArray(rect) ? rect : [rect];
|
|
for (const r of rects) {
|
|
if (!r) continue;
|
|
const x = Number(r.x) || 0;
|
|
const y = Number(r.y) || 0;
|
|
const w = Number(r.w) || 0;
|
|
const h = Number(r.h) || 0;
|
|
if (!(w > 1 && h > 1)) continue;
|
|
ctx.fillRect(x, y, w, h);
|
|
}
|
|
ctx.restore();
|
|
} catch {
|
|
try {
|
|
ctx.restore();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
const _drawFormatOutline = (ctx: any, rect: any, dpr: any) => {
|
|
try {
|
|
ctx.save();
|
|
try {
|
|
ctx.setLineDash?.([Math.max(2, 4 * dpr), Math.max(2, 3 * dpr)]);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
|
ctx.lineWidth = Math.max(1, Math.floor(1 * dpr));
|
|
ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1);
|
|
ctx.restore();
|
|
} catch {
|
|
try {
|
|
ctx.restore();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
const redrawGrid = ({ w, h, dpr }: Record<string, any> = {}) => {
|
|
try {
|
|
const ctx = gridCanvas?.getContext?.("2d");
|
|
if (!ctx) return;
|
|
try {
|
|
clearCanvas?.(ctx, w, h);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const vp = getViewportRect?.();
|
|
if (!vp) return;
|
|
const vpW = Number(vp.width) || 0;
|
|
const vpH = Number(vp.height) || 0;
|
|
if (!(vpW > 1 && vpH > 1)) return;
|
|
|
|
const root = _getModeRoot();
|
|
const mode = state?.mode;
|
|
const mediaEls = (() => {
|
|
try {
|
|
if (mode === VIEWER_MODES?.SINGLE) return [getPrimaryMedia?.()].filter(Boolean);
|
|
// Multi-media modes: collect all .mjr-viewer-media elements in the active view root.
|
|
const els = root?.querySelectorAll?.(".mjr-viewer-media") || [];
|
|
return Array.from(els || []).filter(Boolean);
|
|
} catch {
|
|
return [getPrimaryMedia?.()].filter(Boolean);
|
|
}
|
|
})();
|
|
if (!mediaEls.length) return;
|
|
|
|
// IMPORTANT: overlays must not move with pan/zoom.
|
|
// Compute the format rect purely from the viewport and the image aspect, NOT from the transformed media element rect.
|
|
const rects = [];
|
|
const baseRects = [];
|
|
for (const mediaEl of mediaEls) {
|
|
try {
|
|
const boxEl = _getBoxElForMedia(mediaEl, root);
|
|
const boxRect = boxEl?.getBoundingClientRect?.() || null;
|
|
if (!boxRect) continue;
|
|
|
|
const boxW = Number(boxRect.width) || 0;
|
|
const boxH = Number(boxRect.height) || 0;
|
|
if (!(boxW > 1 && boxH > 1)) continue;
|
|
|
|
const assetHint = _getAssetForMedia(mediaEl);
|
|
const nat = _getNaturalSize(mediaEl, assetHint);
|
|
|
|
const base = _fitHeightRect(boxW, boxH, nat.w, nat.h);
|
|
const aspect = _aspectFromFormat(state?.overlayFormat, mediaEl, {
|
|
width: base.w,
|
|
height: base.h,
|
|
});
|
|
const fmt = _fitAspectInBox(base.w, base.h, aspect);
|
|
|
|
const ox = (Number(boxRect.left) || 0) - (Number(vp.left) || 0);
|
|
const oy = (Number(boxRect.top) || 0) - (Number(vp.top) || 0);
|
|
|
|
const baseRectCss0 = {
|
|
x: ox + (base.x || 0),
|
|
y: oy + (base.y || 0),
|
|
w: base.w || boxW,
|
|
h: base.h || boxH,
|
|
};
|
|
const fmtRectCss0 = {
|
|
x: baseRectCss0.x + (fmt.x || 0),
|
|
y: baseRectCss0.y + (fmt.y || 0),
|
|
w: fmt.w || baseRectCss0.w,
|
|
h: fmt.h || baseRectCss0.h,
|
|
};
|
|
|
|
const centerCss = { x: ox + boxW / 2, y: oy + boxH / 2 };
|
|
const z = Number(state?.zoom) || 1;
|
|
// Pan state is stored as "screen_pixels * zoom" (inflated) in panzoom.js to maintain stability.
|
|
// We must deflate it here to get back to screen pixels for the grid transform.
|
|
const panX = (Number(state?.panX) || 0) / z;
|
|
const panY = (Number(state?.panY) || 0) / z;
|
|
|
|
const baseRectCss = _transformRectAround(
|
|
baseRectCss0,
|
|
centerCss,
|
|
z,
|
|
panX,
|
|
panY,
|
|
);
|
|
const fmtRectCss = _transformRectAround(fmtRectCss0, centerCss, z, panX, panY);
|
|
|
|
const rb = {
|
|
x: baseRectCss.x * dpr,
|
|
y: baseRectCss.y * dpr,
|
|
w: baseRectCss.w * dpr,
|
|
h: baseRectCss.h * dpr,
|
|
_sizeLabel: (() => {
|
|
try {
|
|
const hw = Number(assetHint?.width) || Number(nat.w) || 0;
|
|
const hh = Number(assetHint?.height) || Number(nat.h) || 0;
|
|
if (hw > 0 && hh > 0) return `${hw}x${hh}`;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return "";
|
|
})(),
|
|
};
|
|
const rf = {
|
|
x: fmtRectCss.x * dpr,
|
|
y: fmtRectCss.y * dpr,
|
|
w: fmtRectCss.w * dpr,
|
|
h: fmtRectCss.h * dpr,
|
|
};
|
|
if (rb.w > 1 && rb.h > 1) baseRects.push(rb);
|
|
if (rf.w > 1 && rf.h > 1) rects.push(rf);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
if (!rects.length && !baseRects.length) return;
|
|
const formatRects = rects.length ? rects : baseRects;
|
|
const rect = formatRects[0] || null;
|
|
|
|
// Optional format mask (dim outside chosen rect).
|
|
try {
|
|
if (state?.overlayMaskEnabled) {
|
|
_drawMaskOutside(ctx, formatRects, state?.overlayMaskOpacity ?? 0.65);
|
|
// The viewer background is often already dark; draw outlines so the format is visible.
|
|
for (const r of formatRects) {
|
|
try {
|
|
_drawFormatOutline(ctx, r, dpr);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Nuke-like format box per image (single mode only).
|
|
try {
|
|
if (state?.hudEnabled && state?.mode === VIEWER_MODES?.SINGLE) {
|
|
for (const r of baseRects) {
|
|
try {
|
|
ctx.save();
|
|
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
|
ctx.lineWidth = Math.max(1, Math.floor(1 * dpr));
|
|
ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);
|
|
ctx.restore();
|
|
} catch {
|
|
try {
|
|
ctx.restore();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
try {
|
|
_drawHudSizeLabel(ctx, r, r._sizeLabel || "", dpr);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Only draw guide lines in single mode (mask can apply in any mode).
|
|
if (state?.mode !== VIEWER_MODES?.SINGLE) return;
|
|
if ((state?.gridMode || 0) === 0) return;
|
|
if (!rect) return;
|
|
|
|
try {
|
|
ctx.save();
|
|
ctx.translate(rect.x, rect.y);
|
|
|
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.22)";
|
|
// Slightly thicker grid lines for readability.
|
|
ctx.lineWidth = Math.max(2, Math.round(1.25 * dpr));
|
|
|
|
const drawLine = (x1: any, y1: any, x2: any, y2: any) => {
|
|
try {
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
|
|
ctx.lineTo(Math.round(x2) + 0.5, Math.round(y2) + 0.5);
|
|
ctx.stroke();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
if (state.gridMode === 1) {
|
|
// Thirds
|
|
drawLine(rect.w / 3, 0, rect.w / 3, rect.h);
|
|
drawLine((2 * rect.w) / 3, 0, (2 * rect.w) / 3, rect.h);
|
|
drawLine(0, rect.h / 3, rect.w, rect.h / 3);
|
|
drawLine(0, (2 * rect.h) / 3, rect.w, (2 * rect.h) / 3);
|
|
} else if (state.gridMode === 2) {
|
|
// Center crosshair
|
|
drawLine(rect.w / 2, 0, rect.w / 2, rect.h);
|
|
drawLine(0, rect.h / 2, rect.w, rect.h / 2);
|
|
} else if (state.gridMode === 3) {
|
|
// Safe zones (title/action safe)
|
|
const drawRect = (inset: any, alpha: any) => {
|
|
try {
|
|
ctx.save();
|
|
ctx.strokeStyle = `rgba(255,255,255,${alpha})`;
|
|
const ix = Math.round(rect.w * inset);
|
|
const iy = Math.round(rect.h * inset);
|
|
const rw = Math.round(rect.w * (1 - inset * 2));
|
|
const rh = Math.round(rect.h * (1 - inset * 2));
|
|
ctx.strokeRect(ix + 0.5, iy + 0.5, rw, rh);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
} finally {
|
|
try {
|
|
ctx.restore();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
// Approx Nuke-like: action safe 90% (5%), title safe 80% (10%)
|
|
drawRect(0.05, 0.24);
|
|
drawRect(0.1, 0.18);
|
|
} else if (state.gridMode === 4) {
|
|
// Golden ratio lines (0.382 / 0.618)
|
|
const g0 = 0.382;
|
|
const g1 = 1 - g0;
|
|
drawLine(rect.w * g0, 0, rect.w * g0, rect.h);
|
|
drawLine(rect.w * g1, 0, rect.w * g1, rect.h);
|
|
drawLine(0, rect.h * g0, rect.w, rect.h * g0);
|
|
drawLine(0, rect.h * g1, rect.w, rect.h * g1);
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
} finally {
|
|
try {
|
|
ctx.restore();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
return { ensureCanvasSize, redrawGrid };
|
|
}
|