Files
Genarrative/src/services/match3dGeneratedModelCache.ts
高物 3cb3efb4d0 Prune stale docs and update .hermes content
Delete a large set of outdated documentation (many files under docs/ and .hermes/plans/, including audits, design, prd, technical, planning, assets, and todos). Update and consolidate .hermes content: refresh shared-memory pages (decision-log, development-workflow, document-map, pitfalls, project-overview, team-conventions) and several skills/references under .hermes/skills. Also modify AGENTS.md, README.md, UI_CODING_STANDARD.md, docs/README.md and .encoding-check-ignore. Purpose: clean up stale planning/audit material and keep current hermes documentation and related top-level docs in sync.
2026-05-15 06:24:07 +08:00

433 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Match3DGeneratedBackgroundAsset,
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 getMatch3DGeneratedRuntimeUiAssetSources(
assets: readonly Match3DGeneratedItemAsset[] = [],
backgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined = null,
) {
return [
...new Set(
[
backgroundAsset?.imageObjectKey,
backgroundAsset?.imageSrc,
backgroundAsset?.containerImageObjectKey,
backgroundAsset?.containerImageSrc,
...assets.flatMap((asset) => [
asset.backgroundAsset?.imageObjectKey,
asset.backgroundAsset?.imageSrc,
asset.backgroundAsset?.containerImageObjectKey,
asset.backgroundAsset?.containerImageSrc,
]),
]
.map(normalizeMatch3DModelSource)
.filter((source) => source.length > 0),
),
];
}
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,
itemSize: asset.itemSize ?? fallbackAsset.itemSize ?? null,
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<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[] = [],
backgroundAsset: Match3DGeneratedBackgroundAsset | null | undefined = null,
options: Omit<Match3DModelBytesOptions, 'signal'> = {},
) {
// 中文注释:新抓大鹅运行态以 2D 图片为主3D 模型只作为历史草稿预览兼容。
const normalizedAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(assets);
const sources = [
...new Set([
...getMatch3DGeneratedImageAssetSources(normalizedAssets),
...getMatch3DGeneratedRuntimeUiAssetSources(
normalizedAssets,
backgroundAsset,
),
]),
];
await Promise.allSettled(
sources.map((source) =>
resolveAssetReadUrl(source, {
expireSeconds: options.expireSeconds,
}),
),
);
}
export function clearMatch3DGeneratedModelBytesCache() {
match3dModelBytesCache.clear();
}