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

323 lines
12 KiB
TypeScript

import { APP_CONFIG } from "../../app/config.js";
import { buildViewURL, buildAssetViewURL } from "../../api/endpoints.js";
import { createModel3DMediaElement, MODEL3D_EXTS } from "./model3dRenderer.js";
import { mountUnifiedMediaControls } from "./mediaPlayer.js";
import { mountFloatingViewerSimplePlayer } from "./floatingViewerSimplePlayer.js";
import { readAssetFps, readAssetFrameCount } from "../../utils/mediaFps.js";
const VIDEO_EXTS = new Set([".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"]);
const AUDIO_EXTS = new Set([".mp3", ".wav", ".flac", ".ogg", ".m4a", ".aac", ".opus", ".wma"]);
export function getFloatingViewerMediaExt(filename: any): string {
try {
const name = String(filename || "").trim();
const idx = name.lastIndexOf(".");
return idx >= 0 ? name.slice(idx).toLowerCase() : "";
} catch (_e: any) {
return "";
}
}
export function getFloatingViewerMediaKind(fileData: any): string {
const kind = String(fileData?.kind || "").toLowerCase();
if (kind === "video") return "video";
if (kind === "audio") return "audio";
if (kind === "model3d") return "model3d";
const rawType = String(fileData?.type || "").toLowerCase();
const assetType = String(fileData?.asset_type || fileData?.media_type || rawType).toLowerCase();
if (assetType === "video") return "video";
if (assetType === "audio") return "audio";
if (assetType === "model3d") return "model3d";
const mime = String(fileData?.mime || fileData?.mimetype || fileData?.content_type || "")
.trim()
.toLowerCase();
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("image/gif")) return "gif";
const ext = getFloatingViewerMediaExt(fileData?.filename || "");
if (ext === ".gif") return "gif";
if (VIDEO_EXTS.has(ext)) return "video";
if (AUDIO_EXTS.has(ext)) return "audio";
if (MODEL3D_EXTS.has(ext)) return "model3d";
return "image";
}
export function resolveFloatingViewerMediaUrl(fileData: any): string {
if (!fileData) return "";
if (fileData.url) return String(fileData.url);
if (fileData.filename && fileData.id == null) {
return buildViewURL(fileData.filename, fileData.subfolder || "", fileData.type || "output");
}
if (fileData.filename) return buildAssetViewURL(fileData) || "";
return "";
}
export function createFloatingViewerEmptyState(msg: string = "No media - select assets in the grid"): HTMLElement {
const div = document.createElement("div");
div.className = "mjr-mfv-empty";
div.textContent = msg;
return div;
}
export function createFloatingViewerSlotLabel(text: string, side: string): HTMLElement {
const el = document.createElement("div");
el.className = `mjr-mfv-label label-${side}`;
el.textContent = text;
return el;
}
export function attemptFloatingViewerAutoplay(mediaEl: any): void {
if (!mediaEl || typeof mediaEl.play !== "function") return;
try {
const p = mediaEl.play();
if (p && typeof p.catch === "function") p.catch(() => {});
} catch (e: any) {
console.debug?.(e);
}
}
// Browsers generally block unmuted autoplay. When the caller wants sound on by
// default, try playing unmuted first and fall back to muted playback if the
// browser rejects it, so the media still autoplays either way.
export function attemptFloatingViewerAutoplayWithSound(mediaEl: any): void {
if (!mediaEl || typeof mediaEl.play !== "function") return;
try {
const p = mediaEl.play();
if (p && typeof p.catch === "function") {
p.catch(() => {
try {
mediaEl.muted = true;
attemptFloatingViewerAutoplay(mediaEl);
} catch (e: any) {
console.debug?.(e);
}
});
}
} catch (e: any) {
console.debug?.(e);
}
}
export function findFloatingViewerScrollableAncestor(target: any, boundary: any) {
let node = target && target.nodeType === 1 ? target : target?.parentElement || null;
while (node && node !== boundary) {
try {
const style = window.getComputedStyle?.(node);
const canScrollY = /(auto|scroll|overlay)/.test(String(style?.overflowY || ""));
const canScrollX = /(auto|scroll|overlay)/.test(String(style?.overflowX || ""));
if (canScrollY || canScrollX) {
return node;
}
} catch (e: any) {
console.debug?.(e);
}
node = node.parentElement || null;
}
return null;
}
export function canFloatingViewerWheelScroll(element: any, deltaX: any, deltaY: any) {
if (!element) return false;
if (Math.abs(Number(deltaY) || 0) >= Math.abs(Number(deltaX) || 0)) {
const top = Number(element.scrollTop || 0);
const maxTop = Math.max(
0,
Number(element.scrollHeight || 0) - Number(element.clientHeight || 0),
);
if (deltaY < 0 && top > 0) return true;
if (deltaY > 0 && top < maxTop) return true;
}
const left = Number(element.scrollLeft || 0);
const maxLeft = Math.max(
0,
Number(element.scrollWidth || 0) - Number(element.clientWidth || 0),
);
if (deltaX < 0 && left > 0) return true;
if (deltaX > 0 && left < maxLeft) return true;
return false;
}
export function pauseFloatingViewerMediaIn(rootEl: any) {
if (!rootEl) return;
try {
const mediaEls = rootEl.querySelectorAll?.("video, audio");
if (!mediaEls || !mediaEls.length) return;
for (const el of mediaEls) {
try {
el.pause?.();
} catch (e: any) {
console.debug?.(e);
}
}
} catch (e: any) {
console.debug?.(e);
}
}
export function buildFloatingViewerMediaElement(
fileData: any,
{ fill = false, controls = true, initialMuted = false, initialPlaybackRate = 1 } = {},
) {
const url = resolveFloatingViewerMediaUrl(fileData);
if (!url) return null;
const kind = getFloatingViewerMediaKind(fileData);
const mediaClass = `mjr-mfv-media mjr-mfv-media--fit-height${fill ? " mjr-mfv-media--fill" : ""}`;
const ext = getFloatingViewerMediaExt(fileData?.filename || "");
const isAnimatedWebp =
ext === ".webp" && Number(fileData?.duration ?? fileData?.metadata_raw?.duration ?? 0) > 0;
const buildPlayableMediaHost = (mediaEl: any, mediaKind: any) => {
if (!controls) return mediaEl;
const host = document.createElement("div");
host.className = `mjr-mfv-player-host${fill ? " mjr-mfv-player-host--fill" : ""}`;
if (mediaKind === "audio") {
// Audio has no visual frame — give the host a styled backdrop with
// the filename so it reads like the video player (parity with the
// simple-player audio treatment).
host.classList.add("mjr-mfv-player-host--audio");
const backdrop = document.createElement("div");
backdrop.className = "mjr-mfv-player-audio-backdrop";
backdrop.textContent = String(fileData?.filename || "Audio");
host.appendChild(backdrop);
}
host.appendChild(mediaEl);
// Make the player host focusable so clicking it routes keyboard shortcuts
// (Space / ArrowLeft / ArrowRight) to this player instead of the grid/body.
// Mirrors the simple-player focus behaviour. tabIndex -1 keeps it out of the
// tab order while still allowing programmatic focus.
try {
host.tabIndex = -1;
host.addEventListener("pointerdown", (e: any) => {
try {
if (e?.target?.closest?.("button, input, textarea, select, a, [contenteditable='true']"))
return;
host.focus?.({ preventScroll: true });
} catch (err: any) {
console.debug?.(err);
}
});
} catch (e: any) {
console.debug?.(e);
}
const mounted = mountUnifiedMediaControls(mediaEl, {
variant: "viewer",
hostEl: host,
mediaKind,
initialFps: readAssetFps(fileData) || undefined,
initialFrameCount: readAssetFrameCount(fileData, readAssetFps(fileData)) || undefined,
initialPlaybackRate,
});
try {
if (mounted) (host as any)._mjrMediaControlsHandle = mounted;
} catch (e: any) {
console.debug?.(e);
}
return host;
};
if (kind === "audio") {
const audio = document.createElement("audio");
audio.className = mediaClass;
audio.src = url;
audio.controls = false;
audio.autoplay = true;
audio.preload = "metadata";
audio.loop = true;
audio.muted = initialMuted;
audio.playbackRate = initialPlaybackRate;
const autoplay = initialMuted
? () => attemptFloatingViewerAutoplay(audio)
: () => attemptFloatingViewerAutoplayWithSound(audio);
try {
audio.addEventListener("loadedmetadata", autoplay, { once: true });
} catch (e: any) {
console.debug?.(e);
}
autoplay();
return buildPlayableMediaHost(audio, "audio");
}
if (kind === "video") {
const v = document.createElement("video");
v.className = mediaClass;
v.src = url;
v.controls = false;
v.loop = true;
v.preload = "metadata";
// Generated preview clips (including KJNodes Model Preview Override)
// are visual-only and must autoplay even under strict browser policies.
v.muted = fileData?._isPreview ? true : initialMuted;
v.playbackRate = initialPlaybackRate;
v.autoplay = true;
v.playsInline = true;
const autoplay = v.muted
? () => attemptFloatingViewerAutoplay(v)
: () => attemptFloatingViewerAutoplayWithSound(v);
try {
v.addEventListener("loadedmetadata", autoplay, { once: true });
} catch (e: any) {
console.debug?.(e);
}
autoplay();
return buildPlayableMediaHost(v, "video");
}
if (kind === "model3d") {
return createModel3DMediaElement(fileData, url, {
hostClassName: `mjr-model3d-host mjr-mfv-model3d-host${fill ? " mjr-mfv-model3d-host--fill" : ""}`,
canvasClassName: `mjr-mfv-media mjr-model3d-render-canvas${fill ? " mjr-mfv-media--fill" : ""}`,
hintText: "Rotate: left drag Pan: right drag Zoom: wheel or middle drag",
disableViewerTransform: true,
pauseDuringExecution: !!APP_CONFIG.FLOATING_VIEWER_PAUSE_DURING_EXECUTION,
});
}
const img = document.createElement("img");
img.className = mediaClass;
img.src = url;
img.alt = String(fileData?.filename || "");
img.draggable = false;
if (kind === "gif" || isAnimatedWebp) {
const playerRoot = mountFloatingViewerSimplePlayer(img, fileData, {
kind: kind === "gif" ? "gif" : "animated-image",
});
return playerRoot || img;
}
return img;
}
export function drawFloatingViewerCanvasRoundRect(ctx: any, x: any, y: any, w: any, h: any, r: any) {
ctx.beginPath();
if (typeof ctx.roundRect === "function") {
ctx.roundRect(x, y, w, h, r);
} else {
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
}
export function drawFloatingViewerCanvasLabel(ctx: any, text: any, x: any, y: any) {
ctx.save();
ctx.font = "bold 10px system-ui, sans-serif";
const pad = 5;
const tw = ctx.measureText(text).width;
ctx.fillStyle = "rgba(0,0,0,0.58)";
drawFloatingViewerCanvasRoundRect(ctx, x, y, tw + pad * 2, 18, 4);
ctx.fill();
ctx.fillStyle = "#fff";
ctx.fillText(text, x + pad, y + 13);
ctx.restore();
}