ae014ac881
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).
441 lines
14 KiB
TypeScript
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();
|
|
}
|