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), ); } function findMatch3DBackgroundMusicCarrier( assets: readonly Match3DGeneratedItemAsset[], ) { return assets.find((asset) => asset.backgroundMusic?.audioSrc?.trim()); } function findMatch3DBackgroundMusicMetadataCarrier( assets: readonly Match3DGeneratedItemAsset[], ) { return assets.find( (asset) => asset.backgroundMusicTitle?.trim() || asset.backgroundMusicStyle?.trim() || asset.backgroundMusicPrompt?.trim(), ); } /** * 抓大鹅背景音乐当前暂存在 generatedItemAssets 里,但它表达的是作品级音乐。 * 归一化到首个素材,避免前端只读首项时把已生成音乐显示成“暂无音乐”。 */ export function normalizeMatch3DGeneratedItemAssetsForRuntime( assets: readonly Match3DGeneratedItemAsset[] | null | undefined, ) { if (!assets?.length) { return []; } const musicCarrier = findMatch3DBackgroundMusicCarrier(assets); const metadataCarrier = musicCarrier ?? findMatch3DBackgroundMusicMetadataCarrier(assets); if (!musicCarrier && !metadataCarrier) { return [...assets]; } return assets.map((asset, index) => { if (index !== 0) { if ( !asset.backgroundMusic && !asset.backgroundMusicTitle && !asset.backgroundMusicStyle && !asset.backgroundMusicPrompt ) { return asset; } return { ...asset, backgroundMusic: null, backgroundMusicTitle: null, backgroundMusicStyle: null, backgroundMusicPrompt: null, }; } return { ...asset, backgroundMusic: asset.backgroundMusic ?? musicCarrier?.backgroundMusic ?? null, backgroundMusicTitle: asset.backgroundMusicTitle ?? metadataCarrier?.backgroundMusicTitle ?? musicCarrier?.backgroundMusic?.title ?? null, backgroundMusicStyle: asset.backgroundMusicStyle ?? metadataCarrier?.backgroundMusicStyle ?? null, backgroundMusicPrompt: asset.backgroundMusicPrompt ?? metadataCarrier?.backgroundMusicPrompt ?? musicCarrier?.backgroundMusic?.prompt ?? null, }; }); } export function mergeMatch3DGeneratedItemAssetsForRuntime( primaryAssets: readonly Match3DGeneratedItemAsset[] | null | undefined, fallbackAssets: readonly Match3DGeneratedItemAsset[] | null | undefined, ) { const primary = primaryAssets ?? []; const fallback = fallbackAssets ?? []; if (primary.length <= 0) { return normalizeMatch3DGeneratedItemAssetsForRuntime(fallback); } if (fallback.length <= 0) { return normalizeMatch3DGeneratedItemAssetsForRuntime(primary); } const fallbackById = new Map(fallback.map((asset) => [asset.itemId, asset])); const merged = primary.map((asset) => { const fallbackAsset = fallbackById.get(asset.itemId); if (!fallbackAsset) { return asset; } const hasPrimaryImage = getMatch3DGeneratedImageViewSources(asset).length > 0; const hasPrimaryModel = resolveMatch3DGeneratedModelAssetSource(asset).length > 0; return { ...asset, itemName: asset.itemName.trim() || fallbackAsset.itemName, imageSrc: asset.imageSrc?.trim() ? asset.imageSrc : (fallbackAsset.imageSrc ?? null), imageObjectKey: asset.imageObjectKey?.trim() ? asset.imageObjectKey : (fallbackAsset.imageObjectKey ?? null), imageViews: asset.imageViews && asset.imageViews.length > 0 ? asset.imageViews : (fallbackAsset.imageViews ?? []), modelSrc: asset.modelSrc?.trim() ? asset.modelSrc : (fallbackAsset.modelSrc ?? null), modelObjectKey: asset.modelObjectKey?.trim() ? asset.modelObjectKey : (fallbackAsset.modelObjectKey ?? null), modelFileName: asset.modelFileName?.trim() ? asset.modelFileName : (fallbackAsset.modelFileName ?? null), taskUuid: asset.taskUuid?.trim() ? asset.taskUuid : (fallbackAsset.taskUuid ?? null), subscriptionKey: asset.subscriptionKey?.trim() ? asset.subscriptionKey : (fallbackAsset.subscriptionKey ?? null), backgroundMusic: asset.backgroundMusic ?? fallbackAsset.backgroundMusic ?? null, backgroundMusicTitle: asset.backgroundMusicTitle ?? fallbackAsset.backgroundMusicTitle ?? null, backgroundMusicStyle: asset.backgroundMusicStyle ?? fallbackAsset.backgroundMusicStyle ?? null, backgroundMusicPrompt: asset.backgroundMusicPrompt ?? fallbackAsset.backgroundMusicPrompt ?? null, backgroundAsset: asset.backgroundAsset ?? fallbackAsset.backgroundAsset ?? null, clickSound: asset.clickSound ?? fallbackAsset.clickSound ?? null, soundPrompt: asset.soundPrompt ?? fallbackAsset.soundPrompt ?? null, status: !hasPrimaryImage && !hasPrimaryModel && fallbackAsset.status ? fallbackAsset.status : asset.status, error: asset.error ?? fallbackAsset.error ?? null, }; }); for (const fallbackAsset of fallback) { if (!merged.some((asset) => asset.itemId === fallbackAsset.itemId)) { merged.push(fallbackAsset); } } return normalizeMatch3DGeneratedItemAssetsForRuntime(merged); } 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( normalizeMatch3DGeneratedItemAssetsForRuntime(assets), options, ); } export function clearMatch3DGeneratedModelBytesCache() { match3dModelBytesCache.clear(); }