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.
433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
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();
|
||
}
|