1
This commit is contained in:
200
src/services/match3dGeneratedModelCache.ts
Normal file
200
src/services/match3dGeneratedModelCache.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
|
||||
import { readAssetBytes } from './assetReadUrlService';
|
||||
|
||||
type CachedMatch3DModelBytes = {
|
||||
accessedAt: number;
|
||||
promise: Promise<ArrayBuffer>;
|
||||
};
|
||||
|
||||
type Match3DModelBytesOptions = {
|
||||
expireSeconds?: number;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
const MATCH3D_MODEL_BYTES_CACHE_LIMIT = 36;
|
||||
const match3dModelBytesCache = new Map<string, CachedMatch3DModelBytes>();
|
||||
|
||||
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<T>(promise: Promise<T>, signal?: AbortSignal) {
|
||||
if (!signal) {
|
||||
return promise;
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(new DOMException('加载已取消', 'AbortError'));
|
||||
}
|
||||
|
||||
return new Promise<T>((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<Match3DGeneratedItemAsset['imageViews']>[number]
|
||||
| null
|
||||
| undefined,
|
||||
) {
|
||||
const imageSrc = normalizeMatch3DModelSource(view?.imageSrc);
|
||||
const objectKey = normalizeMatch3DModelSource(view?.imageObjectKey);
|
||||
return objectKey || imageSrc;
|
||||
}
|
||||
|
||||
export function getMatch3DGeneratedImageViewSources(
|
||||
asset: Match3DGeneratedItemAsset,
|
||||
) {
|
||||
const sources =
|
||||
asset.imageViews
|
||||
?.map(resolveMatch3DGeneratedImageViewSource)
|
||||
.filter((source) => source.length > 0) ?? [];
|
||||
const primarySource =
|
||||
normalizeMatch3DModelSource(asset.imageObjectKey) ||
|
||||
normalizeMatch3DModelSource(asset.imageSrc);
|
||||
return [...new Set(primarySource ? [primarySource, ...sources] : sources)];
|
||||
}
|
||||
|
||||
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 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<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
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<Match3DModelBytesOptions, 'signal'> = {},
|
||||
) {
|
||||
return preloadMatch3DGeneratedModelSources(
|
||||
getMatch3DGeneratedModelAssetSources(assets),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function clearMatch3DGeneratedModelBytesCache() {
|
||||
match3dModelBytesCache.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user