This commit is contained in:
2026-05-13 00:28:07 +08:00
parent ef4f91a75e
commit 01c5ab985a
101 changed files with 10635 additions and 2292 deletions

View 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();
}