488 lines
17 KiB
TypeScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|