Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/grid/MediaBlobCache.ts
T

289 lines
8.7 KiB
TypeScript

/**
* MediaBlobCache - Error-tracking and blob URL cache for media assets.
*
* Primary value:
* - hasError() prevents retry storms when a thumbnail fails to load.
* - markError() called from onerror handlers to register a broken URL.
* - acquireUrl() ref-counted blob URL cache (useful with auth-gated endpoints).
* - releaseUrl() decrement refcount; cleanup deferred until TTL expires.
*
* Singleton: stored on `window[CACHE_KEY]` so multiple module imports share state.
*/
const CACHE_KEY = "__MJR_MEDIA_BLOB_CACHE__";
// Keep the cache small and short-lived. Browser-decoded thumbnails and video
// frames can occupy GPU memory even when the JS blob is small, so off-screen
// assets must age out quickly during scroll/filter churn.
const MAX_ENTRIES = 384;
const ACTIVE_TTL_MS = 5 * 60 * 1000;
const RELEASED_TTL_MS = 30 * 1000;
const CLEANUP_INTERVAL_MS = 15 * 1000;
const MAX_CONCURRENT_FETCHES = 6;
const CONSTRAINED_MAX_ENTRIES = 192;
const CONSTRAINED_MAX_CONCURRENT_FETCHES = 3;
/**
* @typedef {{ blobUrl: string|null, refcount: number, expiresAt: number, hasError: boolean }} CacheEntry
*/
function _createCache() {
/** @type {Map<string, CacheEntry>} */
const _entries = new Map();
let _cleanupTimer: any = null;
let _activeFetches = 0;
const _fetchQueue: any[] = [];
function _isConstrainedRuntime() {
try {
const nav: any = typeof navigator !== "undefined" ? navigator : null;
const deviceMemory = Number(nav?.deviceMemory || 0) || 0;
if (deviceMemory > 0 && deviceMemory <= 4) return true;
if (nav?.connection?.saveData === true) return true;
} catch (e: any) {
console.debug?.(e);
}
return false;
}
function _maxEntries() {
return _isConstrainedRuntime() ? CONSTRAINED_MAX_ENTRIES : MAX_ENTRIES;
}
function _maxConcurrentFetches() {
return _isConstrainedRuntime() ? CONSTRAINED_MAX_CONCURRENT_FETCHES : MAX_CONCURRENT_FETCHES;
}
async function _withFetchSlot(task: any) {
if (_activeFetches >= _maxConcurrentFetches()) {
await new Promise((resolve) => _fetchQueue.push(resolve));
}
_activeFetches += 1;
try {
return await task();
} finally {
_activeFetches = Math.max(0, _activeFetches - 1);
const next = _fetchQueue.shift();
if (next) next();
}
}
function _scheduleCleanup() {
if (_cleanupTimer) return;
_cleanupTimer = setInterval(_runCleanup, CLEANUP_INTERVAL_MS);
}
function _runCleanup() {
try {
const now = Date.now();
for (const [url, entry] of _entries) {
if (entry.refcount <= 0 && now >= entry.expiresAt) {
_revokeEntry(entry);
_entries.delete(url);
}
}
} catch (e: any) {
console.debug?.(e);
}
}
function _revokeEntry(entry: any) {
try {
if (entry?.blobUrl?.startsWith("blob:")) URL.revokeObjectURL(entry.blobUrl);
} catch (e: any) {
console.debug?.(e);
}
}
function _evictLRU() {
let oldestUrl = null;
let oldestExpiry = Infinity;
for (const [url, entry] of _entries) {
if (entry.refcount > 0) continue;
if (entry.expiresAt < oldestExpiry) {
oldestExpiry = entry.expiresAt;
oldestUrl = url;
}
}
if (oldestUrl) {
_revokeEntry(_entries.get(oldestUrl));
_entries.delete(oldestUrl);
}
}
function _touch(entry: any, ttlMs = ACTIVE_TTL_MS) {
entry.expiresAt = Date.now() + ttlMs;
}
function _trimToLimit() {
while (_entries.size > _maxEntries()) {
const before = _entries.size;
_evictLRU();
if (_entries.size === before) break;
}
}
function _normalizeKey(src: any) {
try {
return String(src || "").trim();
} catch {
return "";
}
}
/**
* Mark a URL as errored. Future `hasError()` calls return true.
* @param {string} src
*/
function markError(src: any) {
try {
const key = _normalizeKey(src);
if (!key) return;
const existing = _entries.get(key);
if (existing) {
existing.hasError = true;
_touch(existing);
} else {
if (_entries.size >= _maxEntries()) _evictLRU();
_entries.set(key, {
blobUrl: null,
refcount: 0,
expiresAt: Date.now() + RELEASED_TTL_MS,
hasError: true,
});
_trimToLimit();
_scheduleCleanup();
}
} catch (e: any) {
console.debug?.(e);
}
}
/**
* Returns true if a previous load of src failed.
* @param {string} src
* @returns {boolean}
*/
function hasError(src: any) {
try {
const key = _normalizeKey(src);
return key ? !!_entries.get(key)?.hasError : false;
} catch (e: any) {
console.debug?.(e);
return false;
}
}
/**
* Acquire a blob URL for src (fetches if not cached, increments refcount).
* Returns null if the URL has a known error or the fetch fails.
* @param {string} src
* @param {{ signal?: AbortSignal|null }} [options]
* @returns {Promise<string|null>}
*/
async function acquireUrl(src: any, options: Record<string, any> = {}) {
try {
const key = _normalizeKey(src);
if (!key) return null;
const signal = options?.signal || null;
if (signal?.aborted) return null;
const existing = _entries.get(key);
if (existing?.hasError) return null;
if (existing?.blobUrl) {
existing.refcount += 1;
_touch(existing);
return existing.blobUrl;
}
const resp = await _withFetchSlot(() => {
if (signal?.aborted) return Promise.resolve(null);
return fetch(src, { credentials: "same-origin", ...(signal ? { signal } : {}) });
});
if (!resp) return null;
if (!resp.ok) {
markError(src);
return null;
}
const blob = await resp.blob();
const blobUrl = URL.createObjectURL(blob);
if (_entries.size >= _maxEntries()) _evictLRU();
_entries.set(key, {
blobUrl,
refcount: 1,
expiresAt: Date.now() + ACTIVE_TTL_MS,
hasError: false,
});
_trimToLimit();
_scheduleCleanup();
return blobUrl;
} catch (e: any) {
if (String(e?.name || "") === "AbortError") return null;
console.debug?.(e);
markError(src);
return null;
}
}
/**
* Decrement refcount for src. Once unused, keep it briefly for scroll-back
* reuse, then let cleanup revoke the blob URL.
* @param {string} src
*/
function releaseUrl(src: any) {
try {
const key = _normalizeKey(src);
if (!key) return;
const entry = _entries.get(key);
if (entry) {
entry.refcount = Math.max(0, entry.refcount - 1);
if (entry.refcount <= 0) _touch(entry, RELEASED_TTL_MS);
}
_runCleanup();
_trimToLimit();
} catch (e: any) {
console.debug?.(e);
}
}
function dispose() {
try {
if (_cleanupTimer) {
clearInterval(_cleanupTimer);
_cleanupTimer = null;
}
for (const entry of _entries.values()) _revokeEntry(entry);
_entries.clear();
} catch (e: any) {
console.debug?.(e);
}
}
try {
window.addEventListener("beforeunload", dispose, { once: true });
} catch (e: any) {
console.debug?.(e);
}
return { markError, hasError, acquireUrl, releaseUrl, dispose };
}
function _getOrCreate() {
try {
if ((window as any)[CACHE_KEY]) return (window as any)[CACHE_KEY];
} catch (e: any) {
console.debug?.(e);
}
try {
(window as any)[CACHE_KEY]?.dispose?.();
} catch (e: any) {
console.debug?.(e);
}
const cache = _createCache();
try {
(window as any)[CACHE_KEY] = cache;
} catch (e: any) {
console.debug?.(e);
}
return cache;
}
export const MediaBlobCache = _getOrCreate();