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

488 lines
17 KiB
TypeScript

import {
normalizeGenerationMetadata,
sanitizePromptForDisplay,
} from "../../components/sidebar/parsers/geninfoParser.js";
import { isModel3DAsset } from "./model3dRenderer.js";
// -- Compact grid gen-info helpers ---------------------------------------------
function _escHtml(s: any) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function _extractGridGenInfo(asset: any) {
if (!asset) return null;
try {
const candidate = asset.geninfo
? { geninfo: asset.geninfo }
: asset.metadata || asset.metadata_raw || asset;
const norm = normalizeGenerationMetadata(candidate) || null;
if (norm && typeof norm === "object") {
const out = {
prompt:
sanitizePromptForDisplay(norm.prompt) ||
(candidate?.prompt ? sanitizePromptForDisplay(String(candidate.prompt)) : "") ||
"",
seed: norm.seed != null ? String(norm.seed) : "",
sampler: norm.sampler ? String(norm.sampler) : "",
scheduler: norm.scheduler ? String(norm.scheduler) : "",
cfg: norm.cfg != null ? String(norm.cfg) : "",
step: norm.steps != null ? String(norm.steps) : "",
genTime: "",
};
const ms = asset.generation_time_ms ?? candidate?.generation_time_ms ?? 0;
if (ms && Number.isFinite(Number(ms)) && ms > 0 && ms < 86400000) {
out.genTime = (Number(ms) / 1000).toFixed(1) + "s";
}
return out;
}
} catch {
/* fall through */
}
// Fallback: read common flat fields
const meta = asset.meta || asset.metadata || asset.parsed_meta || asset;
const ms = asset.generation_time_ms ?? meta?.generation_time_ms ?? 0;
return {
prompt: sanitizePromptForDisplay(meta?.prompt || meta?.text || ""),
seed:
meta?.seed != null
? String(meta.seed)
: meta?.noise_seed != null
? String(meta.noise_seed)
: "",
sampler: meta?.sampler || meta?.sampler_name || "",
scheduler: meta?.scheduler || "",
cfg:
meta?.cfg != null
? String(meta.cfg)
: meta?.cfg_scale != null
? String(meta.cfg_scale)
: "",
step: meta?.steps != null ? String(meta.steps) : "",
genTime:
ms && Number.isFinite(Number(ms)) && ms > 0 && ms < 86400000
? (Number(ms) / 1000).toFixed(1) + "s"
: "",
};
}
/**
* Build a compact gen-info overlay for one cell of the 2x2 / 2x1 grid.
* The overlay is absolutely positioned at the bottom of the cell and
* is non-interactive (pointer-events:none) so clicks pass through to the media.
*/
function _buildGridCompactOverlay(asset: any, label: any) {
const info = _extractGridGenInfo(asset);
if (!info) return null;
const { prompt, seed, sampler, scheduler, cfg, step, genTime } = info;
// Build compact second line: seed - cfg - sampler - scheduler - steps - gentime
const detail = [
seed ? `Seed: ${seed}` : "",
cfg ? `CFG: ${cfg}` : "",
sampler ? `Sampler: ${sampler}` : "",
scheduler ? `Sched: ${scheduler}` : "",
step ? `Steps: ${step}` : "",
genTime ? `Gen: ${genTime}` : "",
]
.filter(Boolean)
.join(" - ");
if (!prompt && !detail) return null;
const overlay = document.createElement("div");
overlay.style.cssText = [
"position:absolute",
"left:6px",
"right:6px",
"bottom:6px",
"background:rgba(0,0,0,0.68)",
"color:#fff",
"padding:5px 8px",
"border-radius:6px",
"font-size:10px",
"line-height:1.4",
"max-height:38%",
"overflow:hidden",
"word-break:break-word",
"backdrop-filter:blur(4px)",
"-webkit-backdrop-filter:blur(4px)",
"border:1px solid rgba(255,255,255,0.12)",
"box-shadow:0 2px 6px rgba(0,0,0,0.45)",
"pointer-events:none",
"z-index:2",
"box-sizing:border-box",
].join(";");
const shortPrompt = prompt.length > 160 ? prompt.slice(0, 160) + "..." : prompt;
// Build DOM nodes directly to eliminate XSS risk from metadata content.
const row = document.createElement("div");
row.style.cssText = `display:flex;align-items:baseline;gap:5px;flex-wrap:wrap;margin-bottom:${detail ? "3" : "0"}px;`;
const labelSpan = document.createElement("span");
labelSpan.style.cssText =
"background:rgba(255,255,255,0.2);border-radius:3px;padding:0 4px;font-weight:700;font-size:9px;letter-spacing:0.06em;flex-shrink:0;";
labelSpan.textContent = label;
row.appendChild(labelSpan);
if (prompt) {
const promptWrapper = document.createElement("span");
const promptLabel = document.createElement("span");
promptLabel.style.cssText = "color:#7ec8ff;font-weight:600;";
promptLabel.textContent = "Prompt:";
promptWrapper.appendChild(promptLabel);
promptWrapper.appendChild(document.createTextNode(" " + shortPrompt));
row.appendChild(promptWrapper);
}
overlay.appendChild(row);
if (detail) {
const detailDiv = document.createElement("div");
detailDiv.style.cssText =
"color:rgba(255,255,255,0.65);font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
detailDiv.textContent = detail;
overlay.appendChild(detailDiv);
}
return overlay;
}
// -- 3D compare controls sync ----------------------------------------------------
/**
* Poll until both panels have their _mjr3D exposed (async init), then wire
* a bidirectional OrbitControls sync so rotate/pan/zoom on one side mirrors
* the other.
*/
function _setupModel3DSync(panelA: any, panelB: any) {
let attempts = 0;
const MAX = 60; // ~3 seconds at 50ms intervals
const tryConnect = () => {
attempts++;
const hostA = panelA?.querySelector?.(".mjr-model3d-host");
const hostB = panelB?.querySelector?.(".mjr-model3d-host");
const ctlA = hostA?._mjr3D?.controls;
const ctlB = hostB?._mjr3D?.controls;
if (!ctlA || !ctlB) {
if (attempts < MAX) setTimeout(tryConnect, 50);
return;
}
let syncing = false;
const syncAtoB = () => {
if (syncing) return;
syncing = true;
try {
const camA = hostA._mjr3D.camera;
const camB = hostB._mjr3D.camera;
if (camA && camB) {
camB.position.copy(camA.position);
camB.quaternion.copy(camA.quaternion);
if (camA.zoom !== undefined) camB.zoom = camA.zoom;
camB.updateProjectionMatrix();
}
ctlB.target.copy(ctlA.target);
ctlB.update();
} catch (e: any) {
console.debug?.(e);
}
syncing = false;
};
const syncBtoA = () => {
if (syncing) return;
syncing = true;
try {
const camA = hostA._mjr3D.camera;
const camB = hostB._mjr3D.camera;
if (camA && camB) {
camA.position.copy(camB.position);
camA.quaternion.copy(camB.quaternion);
if (camB.zoom !== undefined) camA.zoom = camB.zoom;
camA.updateProjectionMatrix();
}
ctlA.target.copy(ctlB.target);
ctlA.update();
} catch (e: any) {
console.debug?.(e);
}
syncing = false;
};
ctlA.addEventListener("change", syncAtoB);
ctlB.addEventListener("change", syncBtoA);
// Store cleanup on sideView for teardown
try {
const sideView = panelA.parentElement;
if (sideView) {
sideView._mjr3DSyncCleanup = () => {
try {
ctlA.removeEventListener("change", syncAtoB);
} catch (_: any) {}
try {
ctlB.removeEventListener("change", syncBtoA);
} catch (_: any) {}
};
}
} catch (e: any) {
console.debug?.(e);
}
};
setTimeout(tryConnect, 50);
}
/**
* Sync OrbitControls across all 3D model cells in a 2x2 grid layout.
*/
function _setupModel3DGridSync(sideView: any) {
let attempts = 0;
const MAX = 60;
const tryConnect = () => {
attempts++;
const hosts = Array.from(sideView?.querySelectorAll?.(".mjr-model3d-host") || []) as any[];
const ready = hosts.filter((h: any) => h._mjr3D?.controls);
if (ready.length < 2) {
if (attempts < MAX) setTimeout(tryConnect, 50);
return;
}
let syncing = false;
const cleanups: any[] = [];
for (let i = 0; i < ready.length; i++) {
const src = ready[i];
const handler = () => {
if (syncing) return;
syncing = true;
try {
const cam = src._mjr3D.camera;
const ctl = src._mjr3D.controls;
for (let j = 0; j < ready.length; j++) {
if (j === i) continue;
const dst = ready[j];
const dCam = dst._mjr3D.camera;
const dCtl = dst._mjr3D.controls;
if (!dCam || !dCtl) continue;
dCam.position.copy(cam.position);
dCam.quaternion.copy(cam.quaternion);
if (cam.zoom !== undefined) dCam.zoom = cam.zoom;
dCam.updateProjectionMatrix();
dCtl.target.copy(ctl.target);
dCtl.update();
}
} catch (e: any) {
console.debug?.(e);
}
syncing = false;
};
src._mjr3D.controls.addEventListener("change", handler);
cleanups.push(() => {
try {
src._mjr3D?.controls?.removeEventListener?.("change", handler);
} catch (_: any) {}
});
}
sideView._mjr3DSyncCleanup = () => {
for (const c of cleanups) c();
};
};
setTimeout(tryConnect, 50);
}
// -- Main render function -------------------------------------------------------
export function renderSideBySideView({
sideView,
state,
currentAsset,
viewUrl,
buildAssetViewURL,
createMediaElement,
destroyMediaProcessorsIn,
}: Record<string, any> = {}): Record<string, any> | undefined {
try {
sideView?._mjr3DSyncCleanup?.();
} catch (e: any) {
console.debug?.(e);
}
try {
if (sideView) sideView._mjr3DSyncCleanup = null;
} catch (e: any) {
console.debug?.(e);
}
try {
destroyMediaProcessorsIn?.(sideView);
} catch (e: any) {
console.debug?.(e);
}
try {
if (sideView) sideView.innerHTML = "";
} catch (e: any) {
console.debug?.(e);
}
if (!sideView || !state || !currentAsset) return;
const assignPlayableRole = (mediaEl: any, role: any) => {
try {
const video =
mediaEl?.querySelector?.(".mjr-viewer-video-src") ||
mediaEl?.querySelector?.("video");
const audio =
mediaEl?.querySelector?.(".mjr-viewer-audio-src") ||
mediaEl?.querySelector?.("audio");
if (video?.dataset) video.dataset.mjrCompareRole = role;
if (audio?.dataset) audio.dataset.mjrCompareRole = role;
if (audio) {
const isPrimary = String(role || "").toUpperCase() === "A";
audio.muted = !isPrimary;
if (!isPrimary) {
try {
audio.pause?.();
} catch (e: any) {
console.debug?.(e);
}
}
}
} catch (e: any) {
console.debug?.(e);
}
};
const items = Array.isArray(state.assets) ? state.assets.slice(0, 4) : [];
const count = items.length;
const hasFilmstripCompare = !!state.compareAsset;
if (count > 2 && !hasFilmstripCompare) {
// 2x2 grid (3 or 4 items). Do not wrap in another container: theme CSS targets direct children.
try {
sideView.style.display = "grid";
sideView.style.gridTemplateColumns = "1fr 1fr";
sideView.style.gridTemplateRows = "1fr 1fr";
sideView.style.gap = "2px";
sideView.style.padding = "2px";
} catch (e: any) {
console.debug?.(e);
}
const LABELS = ["A", "B", "C", "D"];
for (let i = 0; i < 4; i++) {
const cell = document.createElement("div");
cell.style.cssText = `
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.05);
overflow: hidden;
`;
const a = items[i];
if (a) {
let u = "";
try {
u = buildAssetViewURL?.(a) || "";
} catch (e: any) {
console.debug?.(e);
}
try {
const media = createMediaElement?.(a, u);
if (media) cell.appendChild(media);
assignPlayableRole(media, LABELS[i]);
} catch (e: any) {
console.debug?.(e);
}
try {
const overlay = _buildGridCompactOverlay(a, LABELS[i]);
if (overlay) cell.appendChild(overlay);
} catch (e: any) {
console.debug?.(e);
}
} else {
// Empty slot - show label badge so slot position is clear
const badge = document.createElement("div");
badge.style.cssText =
"position:absolute;top:6px;left:6px;background:rgba(255,255,255,0.12);border-radius:3px;padding:1px 6px;font-size:9px;font-weight:700;color:rgba(255,255,255,0.4);letter-spacing:0.06em;";
badge.textContent = LABELS[i];
cell.appendChild(badge);
}
try {
sideView.appendChild(cell);
} catch (e: any) {
console.debug?.(e);
}
}
// Sync 3D controls across all grid cells
if (items.some((a: any) => a && isModel3DAsset(a))) {
_setupModel3DGridSync(sideView);
}
return;
}
const other =
state.compareAsset ||
(Array.isArray(state.assets) && state.assets.length === 2
? state.assets[1 - (state.currentIndex || 0)]
: null) ||
currentAsset;
const compareUrl = (() => {
try {
return buildAssetViewURL?.(other);
} catch {
return "";
}
})();
const leftPanel = document.createElement("div");
leftPanel.style.cssText = `
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.05);
overflow: hidden;
`;
const leftMedia = createMediaElement?.(currentAsset, viewUrl);
if (leftMedia) leftPanel.appendChild(leftMedia);
const rightPanel = document.createElement("div");
rightPanel.style.cssText = `
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.05);
overflow: hidden;
`;
const rightMedia = createMediaElement?.(other, compareUrl);
if (rightMedia) rightPanel.appendChild(rightMedia);
try {
sideView.style.display = "flex";
sideView.style.flexDirection = "row";
sideView.style.gap = "2px";
sideView.style.padding = "0";
} catch (e: any) {
console.debug?.(e);
}
try {
sideView.appendChild(leftPanel);
sideView.appendChild(rightPanel);
} catch (e: any) {
console.debug?.(e);
}
// Tag roles for the global viewer bar (so it controls the "A" side by default).
assignPlayableRole(leftMedia, "A");
assignPlayableRole(rightMedia, "B");
// Video sync is handled centrally by the viewer bar (Viewer.js) so we avoid double-sync here.
// -- 3D OrbitControls sync -------------------------------------------------
// When both panels contain 3D models, synchronize orbit/pan/zoom controls
// so rotating one side mirrors the same camera state on the other.
if (isModel3DAsset(currentAsset) && isModel3DAsset(other)) {
_setupModel3DSync(leftPanel, rightPanel);
}
}