import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks'; import { readAssetBytes, resolveAssetReadUrl } from './assetReadUrlService'; type CachedMatch3DModelBytes = { accessedAt: number; promise: Promise; }; type Match3DModelBytesOptions = { expireSeconds?: number; signal?: AbortSignal; }; const MATCH3D_MODEL_BYTES_CACHE_LIMIT = 36; const match3dModelBytesCache = new Map(); function normalizeMatch3DModelSource(source: string | null | undefined) { return source?.trim() ?? ''; } function isExternalMatch3DModelSource(source: string) { return /^(?:https?:)?\/\//iu.test(source.trim()); } function trimMatch3DModelBytesCache() { if (match3dModelBytesCache.size <= MATCH3D_MODEL_BYTES_CACHE_LIMIT) { return; } const staleKeys = [...match3dModelBytesCache.entries()] .sort((left, right) => left[1].accessedAt - right[1].accessedAt) .slice(0, match3dModelBytesCache.size - MATCH3D_MODEL_BYTES_CACHE_LIMIT) .map(([source]) => source); staleKeys.forEach((source) => match3dModelBytesCache.delete(source)); } function waitWithAbort(promise: Promise, signal?: AbortSignal) { if (!signal) { return promise; } if (signal.aborted) { return Promise.reject(new DOMException('加载已取消', 'AbortError')); } return new Promise((resolve, reject) => { const handleAbort = () => { signal.removeEventListener('abort', handleAbort); reject(new DOMException('加载已取消', 'AbortError')); }; signal.addEventListener('abort', handleAbort, { once: true }); promise.then( (value) => { signal.removeEventListener('abort', handleAbort); resolve(value); }, (error) => { signal.removeEventListener('abort', handleAbort); reject(error); }, ); }); } export function resolveMatch3DGeneratedModelAssetSource( asset: Match3DGeneratedItemAsset, ) { // 中文注释:历史草稿可能同时保留已过期的 Rodin 外部 modelSrc 和后续修复出的平台 objectKey; // 试玩、正式游戏和预览都必须优先读取平台私有对象,避免继续请求过期外链。 const modelSrc = normalizeMatch3DModelSource(asset.modelSrc); const objectKey = normalizeMatch3DModelSource(asset.modelObjectKey); if (modelSrc && (!isExternalMatch3DModelSource(modelSrc) || !objectKey)) { return modelSrc; } return objectKey || modelSrc; } export function resolveMatch3DGeneratedImageViewSource( view: | NonNullable[number] | null | undefined, ) { const imageSrc = normalizeMatch3DModelSource(view?.imageSrc); const objectKey = normalizeMatch3DModelSource(view?.imageObjectKey); return objectKey || imageSrc; } export function getMatch3DGeneratedImageViewSources( asset: Match3DGeneratedItemAsset, ) { const viewSources = asset.imageViews ?.map(resolveMatch3DGeneratedImageViewSource) .filter((source) => source.length > 0) ?? []; if (viewSources.length > 0) { return [...new Set(viewSources)]; } const primarySource = normalizeMatch3DModelSource(asset.imageObjectKey) || normalizeMatch3DModelSource(asset.imageSrc); return primarySource ? [primarySource] : []; } export function resolveMatch3DGeneratedImageAssetSource( asset: Match3DGeneratedItemAsset, ) { return getMatch3DGeneratedImageViewSources(asset)[0] ?? ''; } export function getMatch3DGeneratedImageAssetSources( assets: readonly Match3DGeneratedItemAsset[] = [], ) { return [ ...new Set( assets.flatMap((asset) => getMatch3DGeneratedImageViewSources(asset)), ), ]; } export function hasMatch3DGeneratedImageAsset( assets: readonly Match3DGeneratedItemAsset[] | null | undefined, ) { return Boolean( assets?.some((asset) => getMatch3DGeneratedImageViewSources(asset).length > 0), ); } export function getMatch3DGeneratedModelAssetSources( assets: readonly Match3DGeneratedItemAsset[] = [], ) { return [ ...new Set( assets .map(resolveMatch3DGeneratedModelAssetSource) .filter((source) => source.length > 0), ), ]; } export function readMatch3DGeneratedModelBytes( source: string | null | undefined, options: Match3DModelBytesOptions = {}, ) { const normalizedSource = normalizeMatch3DModelSource(source); if (!normalizedSource) { return Promise.reject(new Error('抓大鹅 3D 模型路径不能为空')); } const cached = match3dModelBytesCache.get(normalizedSource); if (cached) { cached.accessedAt = Date.now(); return waitWithAbort(cached.promise, options.signal); } const entry: CachedMatch3DModelBytes = { accessedAt: Date.now(), promise: readAssetBytes(normalizedSource, { expireSeconds: options.expireSeconds, }).then(async (response) => { const bytes = await response.arrayBuffer(); if (bytes.byteLength <= 0) { throw new Error('抓大鹅 3D 模型内容为空'); } return bytes; }), }; match3dModelBytesCache.set(normalizedSource, entry); trimMatch3DModelBytesCache(); entry.promise.catch(() => { if (match3dModelBytesCache.get(normalizedSource) === entry) { match3dModelBytesCache.delete(normalizedSource); } }); return waitWithAbort(entry.promise, options.signal); } export async function preloadMatch3DGeneratedModelSources( sources: readonly string[], options: Omit = {}, ) { const normalizedSources = [ ...new Set( sources .map(normalizeMatch3DModelSource) .filter((source) => source.length > 0), ), ]; await Promise.allSettled( normalizedSources.map((source) => readMatch3DGeneratedModelBytes(source, { expireSeconds: options.expireSeconds, }), ), ); } export function preloadMatch3DGeneratedModelAssets( assets: readonly Match3DGeneratedItemAsset[] = [], options: Omit = {}, ) { return preloadMatch3DGeneratedModelSources( getMatch3DGeneratedModelAssetSources(assets), options, ); } export async function preloadMatch3DGeneratedImageAssets( assets: readonly Match3DGeneratedItemAsset[] = [], options: Omit = {}, ) { const sources = getMatch3DGeneratedImageAssetSources(assets); await Promise.allSettled( sources.map((source) => resolveAssetReadUrl(source, { expireSeconds: options.expireSeconds, }), ), ); } export async function preloadMatch3DGeneratedRuntimeAssets( assets: readonly Match3DGeneratedItemAsset[] = [], options: Omit = {}, ) { // 中文注释:新抓大鹅运行态以 2D 图片为主;3D 模型只作为历史草稿预览兼容。 await preloadMatch3DGeneratedImageAssets(assets, options); } export function clearMatch3DGeneratedModelBytesCache() { match3dModelBytesCache.clear(); }