234 lines
6.8 KiB
TypeScript
234 lines
6.8 KiB
TypeScript
import type { Match3DGeneratedItemAsset } from '../../packages/shared/src/contracts/match3dWorks';
|
|
import { readAssetBytes, resolveAssetReadUrl } 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 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<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 async function preloadMatch3DGeneratedImageAssets(
|
|
assets: readonly Match3DGeneratedItemAsset[] = [],
|
|
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
|
) {
|
|
const sources = getMatch3DGeneratedImageAssetSources(assets);
|
|
await Promise.allSettled(
|
|
sources.map((source) =>
|
|
resolveAssetReadUrl(source, {
|
|
expireSeconds: options.expireSeconds,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
export async function preloadMatch3DGeneratedRuntimeAssets(
|
|
assets: readonly Match3DGeneratedItemAsset[] = [],
|
|
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
|
|
) {
|
|
// 中文注释:新抓大鹅运行态以 2D 图片为主;3D 模型只作为历史草稿预览兼容。
|
|
await preloadMatch3DGeneratedImageAssets(assets, options);
|
|
}
|
|
|
|
export function clearMatch3DGeneratedModelBytesCache() {
|
|
match3dModelBytesCache.clear();
|
|
}
|