Files
Genarrative/src/services/match3dGeneratedModelCache.ts
T
JenkenB ae014ac881 Switch to VectorEngine gpt-image-2 and edits
Replace uses of the legacy `gpt-image-2-all` model with `gpt-image-2` and standardize image workflows: no-reference generation uses POST /v1/images/generations, any-reference flows use POST /v1/images/edits with multipart `image` parts. Update SKILLs, generation scripts, decision logs, and docs to reflect the contract change and edits-vs-generations guidance. Apply corresponding changes across backend (api-server match3d/puzzle modules, openai image adapter, mappers, telemetry, spacetime client/module), frontend components and services (Match3D, Puzzle, CreativeImageInputPanel, runtime shells), and add new spritesheet/parser files and tests. Also add media/logo.png. These changes align repository code and documentation with the VectorEngine image API contract and update generation/upload handling (green-screen -> alpha processing, spritesheet handling, and related tests).
2026-05-22 03:06:41 +08:00

441 lines
14 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?.uiSpritesheetImageObjectKey,
backgroundAsset?.uiSpritesheetImageSrc,
backgroundAsset?.itemSpritesheetImageObjectKey,
backgroundAsset?.itemSpritesheetImageSrc,
backgroundAsset?.containerImageObjectKey,
backgroundAsset?.containerImageSrc,
...assets.flatMap((asset) => [
asset.backgroundAsset?.imageObjectKey,
asset.backgroundAsset?.imageSrc,
asset.backgroundAsset?.uiSpritesheetImageObjectKey,
asset.backgroundAsset?.uiSpritesheetImageSrc,
asset.backgroundAsset?.itemSpritesheetImageObjectKey,
asset.backgroundAsset?.itemSpritesheetImageSrc,
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();
}