Files
Genarrative/server-node/src/services/customWorldAgentAssetBridgeService.ts
2026-04-21 18:27:46 +08:00

325 lines
9.4 KiB
TypeScript

import type {
CustomWorldRoleAssetSummary,
CustomWorldSceneAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
import {
getRoleAssetSummaryById,
rebuildRoleAssetCoverage,
mergeRoleAssetIntoDraftProfile,
} from './customWorldAgentRoleAssetStateService.js';
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
Boolean(item) && typeof item === 'object' && !Array.isArray(item),
)
: [];
}
type SyncRoleAssetsPayload = {
roleId: string;
portraitPath: string;
generatedVisualAssetId: string;
generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null;
};
type SceneKind = 'camp' | 'landmark';
type SyncSceneAssetsPayload = {
sceneId: string;
sceneKind: SceneKind;
imageSrc: string;
generatedSceneAssetId: string;
generatedScenePrompt?: string | null;
generatedSceneModel?: string | null;
};
export type SyncRoleAssetsResult = {
roleId: string;
updatedRole: Record<string, unknown>;
updatedAssetSummary: CustomWorldRoleAssetSummary;
draftProfile: Record<string, unknown>;
};
export type SceneAssetStudioContext = {
sceneId: string;
sceneKind: SceneKind;
sceneName: string;
sceneDescription: string;
imageSrc: string | null;
readyActCount: number;
missingActCount: number;
};
export type SyncSceneAssetsResult = {
sceneId: string;
sceneKind: SceneKind;
updatedScene: Record<string, unknown>;
updatedAssetSummaries: CustomWorldSceneAssetSummary[];
draftProfile: Record<string, unknown>;
};
function cloneRecord<T extends Record<string, unknown>>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function toSceneDescription(scene: Record<string, unknown>, sceneKind: SceneKind) {
if (sceneKind === 'camp') {
return (
toText(scene.description) ||
toText(scene.summary) ||
toText(scene.mood)
);
}
return (
toText(scene.description) ||
toText(scene.summary) ||
toText(scene.purpose) ||
toText(scene.mood)
);
}
function findSceneActsBySceneId(
draftProfile: Record<string, unknown>,
sceneId: string,
) {
return toRecordArray(draftProfile.sceneChapters)
.filter((chapter) => toText(chapter.sceneId) === sceneId)
.flatMap((chapter) => toRecordArray(chapter.acts));
}
function updateSceneChapterActsForScene(params: {
draftProfile: Record<string, unknown>;
sceneId: string;
imageSrc: string;
generatedSceneAssetId: string;
}) {
return toRecordArray(params.draftProfile.sceneChapters).map((chapter) => {
if (toText(chapter.sceneId) !== params.sceneId) {
return chapter;
}
return {
...chapter,
acts: toRecordArray(chapter.acts).map((act) => ({
...act,
backgroundImageSrc: params.imageSrc,
backgroundAssetId: params.generatedSceneAssetId,
})),
} satisfies Record<string, unknown>;
});
}
function buildSceneAssetFallbackSummary(params: {
sceneId: string;
sceneKind: SceneKind;
updatedScene: Record<string, unknown>;
imageSrc: string;
generatedSceneAssetId: string;
}) {
return {
sceneId: params.sceneId,
sceneName:
toText(params.updatedScene.name) ||
(params.sceneKind === 'camp' ? '开局营地' : '未命名场景'),
actId: null,
actTitle: params.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图',
imageSrc: params.imageSrc,
assetId: params.generatedSceneAssetId,
status: 'ready',
nextPointCost: 0,
} satisfies CustomWorldSceneAssetSummary;
}
export class CustomWorldAgentAssetBridgeService {
buildRoleAssetStudioContext(snapshot: unknown, roleId: string) {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法打开角色资产工坊。');
}
const playableRole = toRecordArray(profile.playableNpcs).find(
(item) => toText(item.id) === roleId,
);
const storyRole = toRecordArray(profile.storyNpcs).find(
(item) => toText(item.id) === roleId,
);
const role = playableRole ?? storyRole;
if (!role) {
throw new Error('未找到目标角色,无法进入角色资产工坊。');
}
const assetSummary = getRoleAssetSummaryById(profile, roleId);
if (!assetSummary) {
throw new Error('未找到目标角色的资产摘要。');
}
return {
roleId,
roleName: toText(role.name) || assetSummary.roleName,
roleKind: playableRole ? ('playable' as const) : ('story' as const),
startFrom:
assetSummary.status === 'missing' ? ('visual' as const) : ('animation' as const),
assetSummary,
};
}
applyRoleAssetPublishResult(
snapshot: unknown,
payload: SyncRoleAssetsPayload,
): SyncRoleAssetsResult {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法同步角色资产。');
}
const { draftProfile, updatedRole } = mergeRoleAssetIntoDraftProfile(
profile,
payload,
);
const assetSummary = getRoleAssetSummaryById(draftProfile, payload.roleId);
if (!assetSummary) {
throw new Error('角色资产同步后未能生成新的资产摘要。');
}
return {
roleId: payload.roleId,
updatedRole,
updatedAssetSummary: assetSummary,
draftProfile,
};
}
buildSceneAssetStudioContext(
snapshot: unknown,
sceneId: string,
sceneKind: SceneKind,
): SceneAssetStudioContext {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法打开场景资产工坊。');
}
const scene =
sceneKind === 'camp'
? toRecord(profile.camp)
: toRecordArray(profile.landmarks).find(
(item) => toText(item.id) === sceneId,
) ?? null;
if (!scene) {
throw new Error('未找到目标场景,无法进入场景资产工坊。');
}
const sceneActs = findSceneActsBySceneId(profile, sceneId);
const readyActCount = sceneActs.filter((act) =>
Boolean(toText(act.backgroundImageSrc) || toText(act.backgroundAssetId)),
).length;
return {
sceneId,
sceneKind,
sceneName:
toText(scene.name) || (sceneKind === 'camp' ? '开局营地' : '未命名场景'),
sceneDescription: toSceneDescription(scene, sceneKind),
imageSrc: toText(scene.imageSrc) || null,
readyActCount,
missingActCount: Math.max(0, sceneActs.length - readyActCount),
};
}
applySceneAssetPublishResult(
snapshot: unknown,
payload: SyncSceneAssetsPayload,
): SyncSceneAssetsResult {
const profile = toRecord(snapshot);
if (!profile) {
throw new Error('当前世界草稿为空,无法同步场景资产。');
}
const nextDraftProfile = cloneRecord(profile);
let updatedScene: Record<string, unknown> | null = null;
if (payload.sceneKind === 'camp') {
const currentCamp = toRecord(nextDraftProfile.camp);
if (!currentCamp || toText(currentCamp.id) !== payload.sceneId) {
throw new Error('目标营地不存在,无法同步场景资产。');
}
updatedScene = {
...currentCamp,
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
generatedScenePrompt: payload.generatedScenePrompt ?? null,
generatedSceneModel: payload.generatedSceneModel ?? null,
};
nextDraftProfile.camp = updatedScene;
} else {
let touched = false;
nextDraftProfile.landmarks = toRecordArray(nextDraftProfile.landmarks).map(
(item) => {
if (toText(item.id) !== payload.sceneId) {
return item;
}
touched = true;
updatedScene = {
...item,
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
generatedScenePrompt: payload.generatedScenePrompt ?? null,
generatedSceneModel: payload.generatedSceneModel ?? null,
};
return updatedScene;
},
);
if (!touched || !updatedScene) {
throw new Error('目标地点不存在,无法同步场景资产。');
}
}
nextDraftProfile.sceneChapters = updateSceneChapterActsForScene({
draftProfile: nextDraftProfile,
sceneId: payload.sceneId,
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
});
const updatedAssetSummaries = rebuildRoleAssetCoverage(
nextDraftProfile,
).sceneAssets.filter((entry) => entry.sceneId === payload.sceneId);
return {
sceneId: payload.sceneId,
sceneKind: payload.sceneKind,
updatedScene: updatedScene ?? {},
updatedAssetSummaries:
updatedAssetSummaries.length > 0
? updatedAssetSummaries
: [
buildSceneAssetFallbackSummary({
sceneId: payload.sceneId,
sceneKind: payload.sceneKind,
updatedScene: updatedScene ?? {},
imageSrc: payload.imageSrc,
generatedSceneAssetId: payload.generatedSceneAssetId,
}),
],
draftProfile: nextDraftProfile,
};
}
}