@@ -25,6 +25,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
|
||||
text: '你刚才那句话是什么意思?',
|
||||
},
|
||||
],
|
||||
combatContext: {
|
||||
summary: '你刚和柳无声短兵相接,胜负已分,但话还没有说完。',
|
||||
logLines: [
|
||||
'你侧身避开他的第一刀,反手逼退一步。',
|
||||
'柳无声被逼到桌角,终于没有继续出手。',
|
||||
],
|
||||
battleOutcome: 'victory',
|
||||
},
|
||||
playerMessage: '你能说得再明白一点吗?',
|
||||
npcState: {
|
||||
affinity: 4,
|
||||
@@ -60,6 +68,14 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
|
||||
text: '你刚才那句话是什么意思?',
|
||||
},
|
||||
]);
|
||||
assert.equal(
|
||||
payload.combatContext?.summary,
|
||||
'你刚和柳无声短兵相接,胜负已分,但话还没有说完。',
|
||||
);
|
||||
assert.deepEqual(payload.combatContext?.logLines, [
|
||||
'你侧身避开他的第一刀,反手逼退一步。',
|
||||
'柳无声被逼到桌角,终于没有继续出手。',
|
||||
]);
|
||||
assert.equal(payload.questOfferContext?.turnCount, 2);
|
||||
assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1');
|
||||
assert.equal(payload.chatDirective?.remainingTurns, 3);
|
||||
|
||||
@@ -46,6 +46,12 @@ const npcChatQuestOfferContextSchema = z.object({
|
||||
turnCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const npcChatCombatContextSchema = z.object({
|
||||
summary: z.string().trim().min(1),
|
||||
logLines: z.array(z.string().trim().min(1)).default([]),
|
||||
battleOutcome: z.enum(['victory', 'spar_complete']),
|
||||
});
|
||||
|
||||
export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
|
||||
conversationSummary: z.string().optional().default(''),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
@@ -73,6 +79,7 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
|
||||
.extend({
|
||||
conversationHistory: z.array(jsonObjectSchema).optional(),
|
||||
dialogue: z.array(jsonObjectSchema).optional(),
|
||||
combatContext: npcChatCombatContextSchema.nullable().optional(),
|
||||
playerMessage: z.string().trim().min(1),
|
||||
npcState: jsonObjectSchema,
|
||||
npcInitiatesConversation: z.boolean().optional(),
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import type { AppConfig } from '../config.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
|
||||
function createTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-auto-assets-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir: path.join(projectRoot, 'logs'),
|
||||
dataDir: path.join(projectRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: 'pg-mem://auto-assets',
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
templateParamKey: '',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: '',
|
||||
accessTokenEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
callbackPath: '',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: '',
|
||||
mockUnionId: '',
|
||||
mockDisplayName: '',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('auto asset service populates role visuals and scene act backgrounds', async () => {
|
||||
const config = createTestConfig('populate');
|
||||
const service = new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
|
||||
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
|
||||
);
|
||||
|
||||
const result = await service.populateDraftAssets({
|
||||
draftProfile: {
|
||||
name: '雾港列岛',
|
||||
subtitle: '守灯人与失序航道',
|
||||
summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。',
|
||||
tone: '冷峻、克制、海风里带着锈味',
|
||||
playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。',
|
||||
majorFactions: [],
|
||||
coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'role-playable',
|
||||
name: '沈砺',
|
||||
title: '失职守灯人',
|
||||
role: '可扮演角色',
|
||||
publicIdentity: '曾经的守灯人,如今回到失序海域前线。',
|
||||
currentPressure: '必须在旧友和旧职责之间重新站位。',
|
||||
relationToPlayer: '玩家本人',
|
||||
threadIds: ['thread-main'],
|
||||
summary: '他是玩家在这次风暴里的第一视角。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [
|
||||
{
|
||||
id: 'role-story-1',
|
||||
name: '林潮',
|
||||
title: '码头引路人',
|
||||
role: '场景角色',
|
||||
publicIdentity: '码头上最懂回潮时间的人。',
|
||||
currentPressure: '决定今晚要不要让人进港。',
|
||||
relationToPlayer: '先帮一把,再继续试探。',
|
||||
threadIds: ['thread-main'],
|
||||
summary: '他是第一幕的引路人。',
|
||||
},
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'scene-dock',
|
||||
name: '潮汐码头',
|
||||
purpose: '承接第一章的主要碰撞。',
|
||||
mood: '潮声压低,封锁正在加重。',
|
||||
importance: '这里是玩家开局必须接住的门槛。',
|
||||
characterIds: ['role-story-1'],
|
||||
threadIds: ['thread-main'],
|
||||
summary: '码头上的第一次碰撞会直接决定后续节奏。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-main',
|
||||
title: '旧航道争夺',
|
||||
type: 'main',
|
||||
conflict: '守灯会与沉船商盟正在争夺旧航道解释权',
|
||||
characterIds: ['role-playable', 'role-story-1'],
|
||||
landmarkIds: ['scene-dock'],
|
||||
summary: '整条主线都围绕旧航道解释权改写展开。',
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-dock',
|
||||
sceneId: 'scene-dock',
|
||||
sceneName: '潮汐码头',
|
||||
title: '潮汐码头章节',
|
||||
summary: '三幕推进码头章节。',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
linkedLandmarkIds: ['scene-dock'],
|
||||
acts: [
|
||||
{
|
||||
id: 'dock-act-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '先由林潮把玩家带进港口节拍。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-story-1', 'role-playable'],
|
||||
primaryNpcId: 'role-story-1',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '接住第一幕入口压力',
|
||||
transitionHook: '下一幕开始会有人继续封锁码头。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
{
|
||||
id: 'dock-act-2',
|
||||
title: '封锁加压',
|
||||
summary: '第二幕把封锁真正抬上台面。',
|
||||
stageCoverage: ['expansion', 'turning_point'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-story-1', 'role-playable'],
|
||||
primaryNpcId: 'role-story-1',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '把冲突推高',
|
||||
transitionHook: '第三幕要把下一跳抛给玩家。',
|
||||
advanceRule: 'after_active_step_complete',
|
||||
},
|
||||
{
|
||||
id: 'dock-act-3',
|
||||
title: '潮线收束',
|
||||
summary: '第三幕负责把这章收住。',
|
||||
stageCoverage: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-story-1', 'role-playable'],
|
||||
primaryNpcId: 'role-story-1',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '完成章节收束',
|
||||
transitionHook: '把下一跳交给玩家。',
|
||||
advanceRule: 'after_chapter_resolution',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
worldHook: '雾港列岛',
|
||||
playerPremise: '被迫返乡的失职守灯人',
|
||||
openingSituation: '玩家正站在即将熄灭的旧灯塔上。',
|
||||
iconicElements: ['潮雾钟声', '盐火灯塔'],
|
||||
sourceAnchorSummary: '海岛悬疑,冷峻克制。',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.assetCoverage.allRoleAssetsReady, true);
|
||||
assert.equal(result.assetCoverage.allSceneAssetsReady, true);
|
||||
assert.equal(result.assetCoverage.sceneAssets.length, 3);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.ok(
|
||||
result.draftProfile.playableNpcs.every(
|
||||
(role) => typeof role.imageSrc === 'string' && typeof role.generatedVisualAssetId === 'string',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.draftProfile.playableNpcs.every((role) =>
|
||||
role.imageSrc?.endsWith('.png') ?? false,
|
||||
),
|
||||
);
|
||||
const playableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc;
|
||||
assert.ok(playableImageSrc);
|
||||
const playableImageMetadata = await sharp(
|
||||
path.join(config.publicDir, playableImageSrc.replace(/^\/+/u, '')),
|
||||
).metadata();
|
||||
assert.equal(playableImageMetadata.width, 1024);
|
||||
assert.equal(playableImageMetadata.height, 1024);
|
||||
assert.ok(
|
||||
result.draftProfile.sceneChapters.every((chapter) =>
|
||||
chapter.acts.every(
|
||||
(act) =>
|
||||
typeof act.backgroundImageSrc === 'string' &&
|
||||
typeof act.backgroundAssetId === 'string',
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.draftProfile.sceneChapters.every((chapter) =>
|
||||
chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('auto asset service degrades gracefully when asset generators fail', async () => {
|
||||
const config = createTestConfig('degrade');
|
||||
const service = new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
async () => {
|
||||
throw new Error('visual generator unavailable');
|
||||
},
|
||||
async () => {
|
||||
throw new Error('scene generator unavailable');
|
||||
},
|
||||
);
|
||||
|
||||
const result = await service.populateDraftAssets({
|
||||
draftProfile: {
|
||||
name: '雾港列岛',
|
||||
subtitle: '守灯人与失序航道',
|
||||
summary: '潮雾、盐火和旧航道互相绞紧的海岛世界。',
|
||||
tone: '冷峻、克制、海风里带着锈味',
|
||||
playerGoal: '先在旧灯塔一带站稳,再找出谁在提前布网。',
|
||||
majorFactions: [],
|
||||
coreConflicts: ['守灯会与沉船商盟正在争夺旧航道解释权'],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'role-playable',
|
||||
name: '沈砺',
|
||||
title: '失职守灯人',
|
||||
role: '可扮演角色',
|
||||
publicIdentity: '曾经的守灯人,如今回到失序海域前线。',
|
||||
currentPressure: '必须在旧友和旧职责之间重新站位。',
|
||||
relationToPlayer: '玩家本人',
|
||||
threadIds: ['thread-main'],
|
||||
summary: '他是玩家在这次风暴里的第一视角。',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'scene-dock',
|
||||
name: '潮汐码头',
|
||||
purpose: '承接第一章的主要碰撞。',
|
||||
mood: '潮声压低,封锁正在加重。',
|
||||
importance: '这里是玩家开局必须接住的门槛。',
|
||||
characterIds: ['role-playable'],
|
||||
threadIds: ['thread-main'],
|
||||
summary: '码头上的第一次碰撞会直接决定后续节奏。',
|
||||
},
|
||||
],
|
||||
factions: [],
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-main',
|
||||
title: '旧航道争夺',
|
||||
type: 'main',
|
||||
conflict: '守灯会与沉船商盟正在争夺旧航道解释权',
|
||||
characterIds: ['role-playable'],
|
||||
landmarkIds: ['scene-dock'],
|
||||
summary: '整条主线都围绕旧航道解释权改写展开。',
|
||||
},
|
||||
],
|
||||
chapters: [],
|
||||
sceneChapters: [
|
||||
{
|
||||
id: 'scene-chapter-dock',
|
||||
sceneId: 'scene-dock',
|
||||
sceneName: '潮汐码头',
|
||||
title: '潮汐码头章节',
|
||||
summary: '单章测试。',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
linkedLandmarkIds: ['scene-dock'],
|
||||
acts: [
|
||||
{
|
||||
id: 'dock-act-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '先接住入口。',
|
||||
stageCoverage: ['opening'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-playable'],
|
||||
primaryNpcId: 'role-playable',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '接住入口压力',
|
||||
transitionHook: '继续推进。',
|
||||
advanceRule: 'after_primary_contact',
|
||||
},
|
||||
{
|
||||
id: 'dock-act-2',
|
||||
title: '封锁加压',
|
||||
summary: '继续抬高冲突。',
|
||||
stageCoverage: ['turning_point'],
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: ['role-playable'],
|
||||
primaryNpcId: 'role-playable',
|
||||
linkedThreadIds: ['thread-main'],
|
||||
actGoal: '继续推进',
|
||||
transitionHook: '继续推进。',
|
||||
advanceRule: 'after_active_step_complete',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
worldHook: '雾港列岛',
|
||||
playerPremise: '被迫返乡的失职守灯人',
|
||||
openingSituation: '玩家正站在即将熄灭的旧灯塔上。',
|
||||
iconicElements: ['潮雾钟声'],
|
||||
sourceAnchorSummary: '海岛悬疑,冷峻克制。',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.assetCoverage.allRoleAssetsReady, true);
|
||||
assert.equal(result.assetCoverage.allSceneAssetsReady, true);
|
||||
assert.deepEqual(result.warnings, []);
|
||||
assert.ok(
|
||||
result.draftProfile.playableNpcs.every((role) =>
|
||||
role.imageSrc?.endsWith('.png') ?? false,
|
||||
),
|
||||
);
|
||||
const fallbackPlayableImageSrc = result.draftProfile.playableNpcs[0]?.imageSrc;
|
||||
assert.ok(fallbackPlayableImageSrc);
|
||||
const fallbackPlayableImageMetadata = await sharp(
|
||||
path.join(config.publicDir, fallbackPlayableImageSrc.replace(/^\/+/u, '')),
|
||||
).metadata();
|
||||
assert.equal(fallbackPlayableImageMetadata.width, 1024);
|
||||
assert.equal(fallbackPlayableImageMetadata.height, 1024);
|
||||
assert.ok(
|
||||
result.draftProfile.sceneChapters.every((chapter) =>
|
||||
chapter.acts.every((act) => act.backgroundImageSrc?.endsWith('.png') ?? false),
|
||||
),
|
||||
);
|
||||
});
|
||||
771
server-node/src/services/customWorldAgentAutoAssetService.ts
Normal file
771
server-node/src/services/customWorldAgentAutoAssetService.ts
Normal file
@@ -0,0 +1,771 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import type {
|
||||
CustomWorldAssetCoverageSummary,
|
||||
CustomWorldFoundationDraftCharacter,
|
||||
CustomWorldFoundationDraftProfile,
|
||||
CustomWorldFoundationDraftSceneAct,
|
||||
CustomWorldSceneAssetSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
import {
|
||||
buildNpcVisualNegativePrompt,
|
||||
buildNpcVisualPrompt,
|
||||
} from '../prompts/characterAssetPrompts.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
|
||||
type DraftProgressPayload = {
|
||||
phaseLabel: string;
|
||||
phaseDetail: string;
|
||||
progress: number;
|
||||
};
|
||||
|
||||
type DraftProgressCallback = (
|
||||
payload: DraftProgressPayload,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export type CharacterVisualGenerator = (params: {
|
||||
role: CustomWorldFoundationDraftCharacter;
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
}) => Promise<{
|
||||
imageSrc: string;
|
||||
generatedVisualAssetId: string;
|
||||
}>;
|
||||
|
||||
export type SceneActBackgroundGenerator = (params: {
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
sceneName: string;
|
||||
act: CustomWorldFoundationDraftSceneAct;
|
||||
primaryRoleName: string;
|
||||
supportRoleNames: string[];
|
||||
}) => Promise<{
|
||||
imageSrc: string;
|
||||
assetId: string;
|
||||
}>;
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string, fallback: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
|
||||
.replace(/^-+|-+$/gu, '')
|
||||
.slice(0, 48);
|
||||
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function normalizeDashScopeBaseUrl(value: string) {
|
||||
return value.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function createGeneratedAssetId(prefix: string) {
|
||||
return `${prefix}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
|
||||
async function writePlaceholderPng(params: {
|
||||
outputPath: string;
|
||||
width: number;
|
||||
height: number;
|
||||
rgb: [number, number, number];
|
||||
}) {
|
||||
const [r, g, b] = params.rgb;
|
||||
await sharp({
|
||||
create: {
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
channels: 3,
|
||||
background: { r, g, b },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toFile(params.outputPath);
|
||||
}
|
||||
|
||||
function collectStringsByKey(
|
||||
value: unknown,
|
||||
targetKey: string,
|
||||
results: string[],
|
||||
) {
|
||||
if (typeof value === 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, results));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
Object.entries(value).forEach(([key, nestedValue]) => {
|
||||
if (
|
||||
key === targetKey &&
|
||||
typeof nestedValue === 'string' &&
|
||||
nestedValue.trim()
|
||||
) {
|
||||
results.push(nestedValue.trim());
|
||||
return;
|
||||
}
|
||||
|
||||
collectStringsByKey(nestedValue, targetKey, results);
|
||||
});
|
||||
}
|
||||
|
||||
function findFirstStringByKey(value: unknown, targetKey: string) {
|
||||
const results: string[] = [];
|
||||
collectStringsByKey(value, targetKey, results);
|
||||
return results[0] ?? '';
|
||||
}
|
||||
|
||||
function extractTaskId(payload: Record<string, unknown>) {
|
||||
return findFirstStringByKey(payload, 'task_id');
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: Record<string, unknown>) {
|
||||
const urls: string[] = [];
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
function buildRoleVisualSeedText(
|
||||
role: CustomWorldFoundationDraftCharacter,
|
||||
draftProfile: CustomWorldFoundationDraftProfile,
|
||||
) {
|
||||
return [
|
||||
`世界:${draftProfile.name}`,
|
||||
`世界摘要:${draftProfile.summary}`,
|
||||
`角色名:${role.name}`,
|
||||
`称号:${role.title}`,
|
||||
`身份:${role.role}`,
|
||||
`公开身份:${role.publicIdentity}`,
|
||||
role.publicMask ? `第一印象:${role.publicMask}` : '',
|
||||
`当前压力:${role.currentPressure}`,
|
||||
role.hiddenHook ? `隐藏钩子:${role.hiddenHook}` : '',
|
||||
`与玩家关系:${role.relationToPlayer}`,
|
||||
`角色摘要:${role.summary}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createFallbackCharacterVisual(params: {
|
||||
config: AppConfig;
|
||||
role: CustomWorldFoundationDraftCharacter;
|
||||
}) {
|
||||
const assetId = createGeneratedAssetId('draft-role-visual');
|
||||
const roleSegment = sanitizeSegment(params.role.id || params.role.name, 'role');
|
||||
const relativeDir = path.join(
|
||||
'generated-characters',
|
||||
roleSegment,
|
||||
'visual',
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(params.config.publicDir, relativeDir);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = 'master.png';
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
await writePlaceholderPng({
|
||||
outputPath: filePath,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
rgb: [78, 134, 220],
|
||||
});
|
||||
|
||||
return {
|
||||
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
|
||||
generatedVisualAssetId: assetId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSceneActPrompt(params: {
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
sceneName: string;
|
||||
act: CustomWorldFoundationDraftSceneAct;
|
||||
primaryRoleName: string;
|
||||
supportRoleNames: string[];
|
||||
}) {
|
||||
return [
|
||||
`这是世界《${params.draftProfile.name}》中的场景幕背景图。`,
|
||||
`场景:${params.sceneName}`,
|
||||
`幕标题:${params.act.title}`,
|
||||
`幕摘要:${params.act.summary}`,
|
||||
`幕目标:${params.act.actGoal}`,
|
||||
`过渡钩子:${params.act.transitionHook}`,
|
||||
`主角色:${params.primaryRoleName || '待补主角色'}`,
|
||||
params.supportRoleNames.length > 0
|
||||
? `辅助角色:${params.supportRoleNames.join('、')}`
|
||||
: '',
|
||||
`世界气质:${params.draftProfile.tone}`,
|
||||
`要求:只生成环境背景,不出现角色立绘、站位 UI、对白框、按钮或文字。`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async function createDashScopeTextToImageTask(params: {
|
||||
config: AppConfig;
|
||||
prompt: string;
|
||||
negativePrompt?: string;
|
||||
size: string;
|
||||
model: string;
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl)}/services/aigc/text2image/image-synthesis`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.config.dashScope.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: {
|
||||
prompt: params.prompt,
|
||||
...(params.negativePrompt
|
||||
? { negative_prompt: params.negativePrompt }
|
||||
: {}),
|
||||
},
|
||||
parameters: {
|
||||
n: 1,
|
||||
size: params.size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseText || '创建图像生成任务失败。');
|
||||
}
|
||||
|
||||
const payload = JSON.parse(responseText) as Record<string, unknown>;
|
||||
const taskId = extractTaskId(payload);
|
||||
if (!taskId) {
|
||||
throw new Error('图像生成任务未返回 task_id。');
|
||||
}
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
async function waitForDashScopeImage(params: {
|
||||
config: AppConfig;
|
||||
taskId: string;
|
||||
}) {
|
||||
const deadline = Date.now() + params.config.dashScope.requestTimeoutMs;
|
||||
const baseUrl = normalizeDashScopeBaseUrl(params.config.dashScope.baseUrl);
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const pollResponse = await fetch(`${baseUrl}/tasks/${params.taskId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.config.dashScope.apiKey}`,
|
||||
},
|
||||
});
|
||||
const pollText = await pollResponse.text();
|
||||
if (!pollResponse.ok) {
|
||||
throw new Error(pollText || '查询图像生成任务失败。');
|
||||
}
|
||||
|
||||
const pollPayload = JSON.parse(pollText) as Record<string, unknown>;
|
||||
const status = findFirstStringByKey(pollPayload, 'task_status').trim();
|
||||
if (status === 'SUCCEEDED') {
|
||||
const imageUrl = extractImageUrls(pollPayload)[0] ?? '';
|
||||
const actualPrompt = findFirstStringByKey(pollPayload, 'actual_prompt').trim();
|
||||
if (!imageUrl) {
|
||||
throw new Error('图像生成任务成功,但未返回图片地址。');
|
||||
}
|
||||
|
||||
return {
|
||||
imageUrl,
|
||||
actualPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'FAILED' || status === 'UNKNOWN') {
|
||||
throw new Error(pollText || '图像生成任务失败。');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
throw new Error('图像生成任务超时。');
|
||||
}
|
||||
|
||||
async function saveRemoteImage(params: {
|
||||
config: AppConfig;
|
||||
imageUrl: string;
|
||||
relativeDir: string;
|
||||
fileBaseName: string;
|
||||
manifest: Record<string, unknown>;
|
||||
}) {
|
||||
const response = await fetch(params.imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('下载生成图片失败。');
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const extension = contentType.includes('png')
|
||||
? 'png'
|
||||
: contentType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const outputDir = path.join(params.config.publicDir, params.relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `${params.fileBaseName}.${extension}`;
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(params.manifest, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return `/${path.join(params.relativeDir, fileName).replace(/\\/gu, '/')}`;
|
||||
}
|
||||
|
||||
function findRoleById(
|
||||
draftProfile: CustomWorldFoundationDraftProfile,
|
||||
roleId: string,
|
||||
) {
|
||||
return [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].find(
|
||||
(role) => role.id === roleId,
|
||||
);
|
||||
}
|
||||
|
||||
export class CustomWorldAgentAutoAssetService {
|
||||
constructor(
|
||||
private readonly config: AppConfig | null = null,
|
||||
private readonly characterVisualGenerator?: CharacterVisualGenerator | null,
|
||||
private readonly sceneActBackgroundGenerator?: SceneActBackgroundGenerator | null,
|
||||
) {}
|
||||
|
||||
async populateDraftAssets(params: {
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
onProgress?: DraftProgressCallback;
|
||||
}): Promise<{
|
||||
draftProfile: CustomWorldFoundationDraftProfile;
|
||||
assetCoverage: CustomWorldAssetCoverageSummary;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const nextDraftProfile: CustomWorldFoundationDraftProfile = JSON.parse(
|
||||
JSON.stringify(params.draftProfile),
|
||||
) as CustomWorldFoundationDraftProfile;
|
||||
const roles = [...nextDraftProfile.playableNpcs, ...nextDraftProfile.storyNpcs];
|
||||
const sceneAssetSummaries: CustomWorldSceneAssetSummary[] = [];
|
||||
const warnings: string[] = [];
|
||||
const totalRoleCount = roles.length;
|
||||
const totalActCount = nextDraftProfile.sceneChapters.reduce(
|
||||
(sum, chapter) => sum + chapter.acts.length,
|
||||
0,
|
||||
);
|
||||
let completedRoleCount = 0;
|
||||
let completedActCount = 0;
|
||||
|
||||
for (const role of roles) {
|
||||
if (!role.imageSrc || !role.generatedVisualAssetId) {
|
||||
try {
|
||||
const generatedVisual = this.characterVisualGenerator
|
||||
? await this.characterVisualGenerator({
|
||||
role,
|
||||
draftProfile: nextDraftProfile,
|
||||
})
|
||||
: this.config
|
||||
? await createFallbackCharacterVisual({
|
||||
config: this.config,
|
||||
role,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (generatedVisual) {
|
||||
role.imageSrc = generatedVisual.imageSrc;
|
||||
role.generatedVisualAssetId = generatedVisual.generatedVisualAssetId;
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
const fallbackVisual = this.config
|
||||
? await createFallbackCharacterVisual({
|
||||
config: this.config,
|
||||
role,
|
||||
})
|
||||
: null;
|
||||
if (fallbackVisual) {
|
||||
role.imageSrc = fallbackVisual.imageSrc;
|
||||
role.generatedVisualAssetId =
|
||||
fallbackVisual.generatedVisualAssetId;
|
||||
} else {
|
||||
warnings.push(
|
||||
`角色主形象生成失败:${role.name}(${error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
// 角色主形象属于增强链路,主生成与回退都失败时仅记录告警,不阻断世界底稿主链。
|
||||
warnings.push(
|
||||
`角色主形象生成失败:${role.name}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
completedRoleCount += 1;
|
||||
if (params.onProgress) {
|
||||
await params.onProgress({
|
||||
phaseLabel: '生成角色主形象',
|
||||
phaseDetail: `正在生成角色主形象 ${completedRoleCount}/${totalRoleCount}:${role.name}。`,
|
||||
progress:
|
||||
97 +
|
||||
Math.min(
|
||||
1,
|
||||
Math.round((completedRoleCount / Math.max(1, totalRoleCount)) * 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const sceneChapter of nextDraftProfile.sceneChapters) {
|
||||
for (const act of sceneChapter.acts) {
|
||||
let imageSrc = toText(act.backgroundImageSrc) || null;
|
||||
let assetId = toText(act.backgroundAssetId) || null;
|
||||
const primaryRole = findRoleById(
|
||||
nextDraftProfile,
|
||||
act.primaryNpcId || act.encounterNpcIds[0] || '',
|
||||
);
|
||||
const supportRoleNames = act.encounterNpcIds
|
||||
.slice(1)
|
||||
.map((roleId) => findRoleById(nextDraftProfile, roleId)?.name || '')
|
||||
.filter(Boolean);
|
||||
if (!imageSrc && this.sceneActBackgroundGenerator) {
|
||||
try {
|
||||
const result = await this.sceneActBackgroundGenerator({
|
||||
draftProfile: nextDraftProfile,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
act,
|
||||
primaryRoleName: primaryRole?.name || '',
|
||||
supportRoleNames,
|
||||
});
|
||||
imageSrc = result.imageSrc;
|
||||
assetId = result.assetId;
|
||||
act.backgroundImageSrc = result.imageSrc;
|
||||
act.backgroundAssetId = result.assetId;
|
||||
} catch (error) {
|
||||
try {
|
||||
const fallbackScene = this.config
|
||||
? await CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(
|
||||
this.config,
|
||||
)({
|
||||
draftProfile: nextDraftProfile,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
act,
|
||||
primaryRoleName: primaryRole?.name || '',
|
||||
supportRoleNames,
|
||||
})
|
||||
: null;
|
||||
if (fallbackScene) {
|
||||
imageSrc = fallbackScene.imageSrc;
|
||||
assetId = fallbackScene.assetId;
|
||||
act.backgroundImageSrc = fallbackScene.imageSrc;
|
||||
act.backgroundAssetId = fallbackScene.assetId;
|
||||
} else {
|
||||
warnings.push(
|
||||
`幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
// 幕图失败允许草稿继续生成;只有主生成与回退都失败时才保留缺口告警。
|
||||
warnings.push(
|
||||
`幕背景图生成失败:${sceneChapter.sceneName} / ${act.title}(${fallbackError instanceof Error ? fallbackError.message : error instanceof Error ? error.message : 'unknown error'})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sceneAssetSummaries.push({
|
||||
sceneId: sceneChapter.sceneId,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
imageSrc,
|
||||
assetId,
|
||||
status: imageSrc ? 'ready' : 'missing',
|
||||
nextPointCost: imageSrc ? 0 : 12,
|
||||
});
|
||||
|
||||
completedActCount += 1;
|
||||
if (params.onProgress) {
|
||||
await params.onProgress({
|
||||
phaseLabel: '生成幕背景图',
|
||||
phaseDetail: `正在生成幕背景图 ${completedActCount}/${totalActCount}:${sceneChapter.sceneName} · ${act.title}。`,
|
||||
progress:
|
||||
98 +
|
||||
Math.min(
|
||||
1,
|
||||
Math.round((completedActCount / Math.max(1, totalActCount)) * 1),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const roleAssets = roles.map((role) => ({
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
roleKind: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id)
|
||||
? ('playable' as const)
|
||||
: ('story' as const),
|
||||
priorityTier: nextDraftProfile.playableNpcs.some((entry) => entry.id === role.id)
|
||||
? ('hero' as const)
|
||||
: ('featured' as const),
|
||||
portraitPath: role.imageSrc || null,
|
||||
generatedVisualAssetId: role.generatedVisualAssetId || null,
|
||||
generatedAnimationSetId: role.generatedAnimationSetId || null,
|
||||
status: role.imageSrc && role.generatedVisualAssetId ? 'visual_ready' : 'missing',
|
||||
missingAnimations: [],
|
||||
nextPointCost: role.imageSrc && role.generatedVisualAssetId ? 0 : 20,
|
||||
}));
|
||||
|
||||
return {
|
||||
draftProfile: nextDraftProfile,
|
||||
assetCoverage: {
|
||||
roleAssets,
|
||||
sceneAssets: sceneAssetSummaries,
|
||||
allRoleAssetsReady:
|
||||
roleAssets.length > 0 &&
|
||||
roleAssets.every((entry) => entry.status !== 'missing'),
|
||||
allSceneAssetsReady:
|
||||
sceneAssetSummaries.length > 0 &&
|
||||
sceneAssetSummaries.every((entry) => entry.status === 'ready'),
|
||||
},
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
static createFallbackCharacterVisualGenerator(config: AppConfig): CharacterVisualGenerator {
|
||||
return async ({ role, draftProfile }) => {
|
||||
const assetId = createGeneratedAssetId('draft-role-visual');
|
||||
const roleSegment = sanitizeSegment(role.id || role.name, 'role');
|
||||
const relativeDir = path.join(
|
||||
'generated-characters',
|
||||
roleSegment,
|
||||
'visual',
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(config.publicDir, relativeDir);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = 'master.png';
|
||||
await writePlaceholderPng({
|
||||
outputPath: path.join(outputDir, fileName),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
rgb: [78, 134, 220],
|
||||
});
|
||||
const finalPrompt = buildNpcVisualPrompt(
|
||||
buildRoleVisualSeedText(role, draftProfile),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
prompt: finalPrompt,
|
||||
fallback: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
|
||||
generatedVisualAssetId: assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
static createDashScopeCharacterVisualGenerator(
|
||||
config: AppConfig,
|
||||
): CharacterVisualGenerator {
|
||||
return async ({ role, draftProfile }) => {
|
||||
const prompt = buildNpcVisualPrompt(
|
||||
buildRoleVisualSeedText(role, draftProfile),
|
||||
);
|
||||
const assetId = `draft-role-visual-${Date.now().toString(36)}`;
|
||||
const roleSegment = sanitizeSegment(role.id || role.name, 'role');
|
||||
const taskId = await createDashScopeTextToImageTask({
|
||||
config,
|
||||
prompt,
|
||||
negativePrompt: buildNpcVisualNegativePrompt(),
|
||||
size: '1024*1024',
|
||||
model: config.dashScope.imageModel || 'qwen-image-2.0',
|
||||
});
|
||||
const { imageUrl, actualPrompt } = await waitForDashScopeImage({
|
||||
config,
|
||||
taskId,
|
||||
});
|
||||
const relativeDir = path.join(
|
||||
'generated-characters',
|
||||
roleSegment,
|
||||
'visual',
|
||||
assetId,
|
||||
);
|
||||
const imageSrc = await saveRemoteImage({
|
||||
config,
|
||||
imageUrl,
|
||||
relativeDir,
|
||||
fileBaseName: 'master',
|
||||
manifest: {
|
||||
assetId,
|
||||
taskId,
|
||||
roleId: role.id,
|
||||
roleName: role.name,
|
||||
prompt,
|
||||
actualPrompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
generatedVisualAssetId: assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
static createFallbackSceneActBackgroundGenerator(
|
||||
config: AppConfig,
|
||||
): SceneActBackgroundGenerator {
|
||||
return async ({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
}) => {
|
||||
const finalPrompt = buildSceneActPrompt({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
});
|
||||
const assetId = createGeneratedAssetId('draft-scene-act');
|
||||
const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene');
|
||||
const actSegment = sanitizeSegment(act.id || act.title, 'act');
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-scenes',
|
||||
sceneSegment,
|
||||
actSegment,
|
||||
assetId,
|
||||
);
|
||||
const outputDir = path.join(config.publicDir, relativeDir);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = 'scene.png';
|
||||
await writePlaceholderPng({
|
||||
outputPath: path.join(outputDir, fileName),
|
||||
width: 1280,
|
||||
height: 720,
|
||||
rgb: [34, 52, 88],
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(outputDir, 'manifest.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
assetId,
|
||||
sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
prompt: finalPrompt,
|
||||
fallback: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
return {
|
||||
imageSrc: `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`,
|
||||
assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
static createDashScopeSceneActBackgroundGenerator(
|
||||
config: AppConfig,
|
||||
): SceneActBackgroundGenerator {
|
||||
return async ({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
}) => {
|
||||
const prompt = buildSceneActPrompt({
|
||||
draftProfile,
|
||||
sceneName,
|
||||
act,
|
||||
primaryRoleName,
|
||||
supportRoleNames,
|
||||
});
|
||||
const assetId = createGeneratedAssetId('draft-scene-act');
|
||||
const sceneSegment = sanitizeSegment(act.sceneId || sceneName, 'scene');
|
||||
const actSegment = sanitizeSegment(act.id || act.title, 'act');
|
||||
const taskId = await createDashScopeTextToImageTask({
|
||||
config,
|
||||
prompt,
|
||||
size: '1280*720',
|
||||
model: config.dashScope.imageModel || 'wan2.2-t2i-flash',
|
||||
});
|
||||
const { imageUrl, actualPrompt } = await waitForDashScopeImage({
|
||||
config,
|
||||
taskId,
|
||||
});
|
||||
const relativeDir = path.join(
|
||||
'generated-custom-world-scenes',
|
||||
sceneSegment,
|
||||
actSegment,
|
||||
assetId,
|
||||
);
|
||||
const imageSrc = await saveRemoteImage({
|
||||
config,
|
||||
imageUrl,
|
||||
relativeDir,
|
||||
fileBaseName: 'scene',
|
||||
manifest: {
|
||||
assetId,
|
||||
taskId,
|
||||
sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
prompt,
|
||||
actualPrompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
imageSrc,
|
||||
assetId,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1024,8 +1024,8 @@ function buildWorldWarnings(profile: CustomWorldFoundationDraftProfile) {
|
||||
if (totalCharacters < 3) {
|
||||
warnings.push('关键角色数量还偏少,建议继续补角色关系网。');
|
||||
}
|
||||
if (profile.landmarks.length < 4) {
|
||||
warnings.push('关键地点仍然偏少,第一版游历路径还不够饱满。');
|
||||
if (profile.landmarks.length < 2) {
|
||||
warnings.push('关键地点仍然偏少,第一版场景章节还不够饱满。');
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
CustomWorldFoundationDraftFaction,
|
||||
CustomWorldFoundationDraftLandmark,
|
||||
CustomWorldFoundationDraftProfile,
|
||||
CustomWorldFoundationDraftSceneChapter,
|
||||
CustomWorldFoundationDraftThread,
|
||||
EightAnchorContent,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
@@ -575,10 +576,25 @@ function buildCharacters(params: {
|
||||
|
||||
return dedupeStrings(
|
||||
characters.map((entry) => entry.name),
|
||||
5,
|
||||
FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT,
|
||||
).map((name) => characters.find((entry) => entry.name === name)!);
|
||||
}
|
||||
|
||||
function splitDraftCharacters(params: {
|
||||
characters: CustomWorldFoundationDraftCharacter[];
|
||||
playableCount: number;
|
||||
storyCount: number;
|
||||
}) {
|
||||
const playableNpcs = params.characters.slice(0, params.playableCount);
|
||||
const storyNpcs = params.characters
|
||||
.slice(params.playableCount, params.playableCount + params.storyCount);
|
||||
|
||||
return {
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCamp(params: {
|
||||
openingSituation: string;
|
||||
worldHook: string;
|
||||
@@ -776,9 +792,9 @@ function buildChapter(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 3;
|
||||
const FOUNDATION_DRAFT_STORY_COUNT = 6;
|
||||
const FOUNDATION_DRAFT_LANDMARK_COUNT = 4;
|
||||
const FOUNDATION_DRAFT_PLAYABLE_COUNT = 1;
|
||||
const FOUNDATION_DRAFT_STORY_COUNT = 8;
|
||||
const FOUNDATION_DRAFT_LANDMARK_COUNT = 2;
|
||||
const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE = 2;
|
||||
const FOUNDATION_LANDMARK_BATCH_SIZE = 2;
|
||||
const FOUNDATION_ROLE_DETAIL_BATCH_SIZE = 2;
|
||||
@@ -798,6 +814,153 @@ type MergeableNamedRecord = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
function buildFallbackSceneActStageCoverage(index: number, actCount: number) {
|
||||
if (actCount <= 2) {
|
||||
return index === 0
|
||||
? (['opening', 'expansion'] as const)
|
||||
: (['turning_point', 'climax', 'aftermath'] as const);
|
||||
}
|
||||
|
||||
if (actCount === 3) {
|
||||
return index === 0
|
||||
? (['opening'] as const)
|
||||
: index === 1
|
||||
? (['expansion', 'turning_point'] as const)
|
||||
: (['climax', 'aftermath'] as const);
|
||||
}
|
||||
|
||||
if (actCount === 4) {
|
||||
return index === 0
|
||||
? (['opening'] as const)
|
||||
: index === 1
|
||||
? (['expansion'] as const)
|
||||
: index === 2
|
||||
? (['turning_point'] as const)
|
||||
: (['climax', 'aftermath'] as const);
|
||||
}
|
||||
|
||||
return (
|
||||
[
|
||||
['opening'],
|
||||
['expansion'],
|
||||
['turning_point'],
|
||||
['climax'],
|
||||
['aftermath'],
|
||||
][index] ?? ['aftermath']
|
||||
) as readonly string[];
|
||||
}
|
||||
|
||||
function buildSceneChaptersFromDraft(params: {
|
||||
landmarks: CustomWorldFoundationDraftLandmark[];
|
||||
playableNpcs: CustomWorldFoundationDraftCharacter[];
|
||||
storyNpcs: CustomWorldFoundationDraftCharacter[];
|
||||
threads: CustomWorldFoundationDraftThread[];
|
||||
}): CustomWorldFoundationDraftSceneChapter[] {
|
||||
const leadPlayable = params.playableNpcs[0] ?? null;
|
||||
const sceneRoles = params.storyNpcs;
|
||||
|
||||
return params.landmarks.slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT).map((landmark, index) => {
|
||||
const linkedThreadIds =
|
||||
landmark.threadIds.length > 0
|
||||
? landmark.threadIds.slice(0, 3)
|
||||
: params.threads
|
||||
.filter((thread) => thread.landmarkIds.includes(landmark.id))
|
||||
.map((thread) => thread.id)
|
||||
.slice(0, 3);
|
||||
const baseNpcIds = landmark.characterIds.length > 0
|
||||
? landmark.characterIds
|
||||
: sceneRoles.slice(index * 3, index * 3 + 3).map((role) => role.id);
|
||||
const uniqueNpcIds = [...new Set(baseNpcIds)].filter(Boolean);
|
||||
const primaryIds = uniqueNpcIds.slice(0, 3);
|
||||
const fallbackPrimaryIds = sceneRoles
|
||||
.filter((role) => !primaryIds.includes(role.id))
|
||||
.slice(0, 3 - primaryIds.length)
|
||||
.map((role) => role.id);
|
||||
const actPrimaryIds = [...primaryIds, ...fallbackPrimaryIds].slice(0, 3);
|
||||
const supportPool = [
|
||||
...uniqueNpcIds,
|
||||
...sceneRoles.map((role) => role.id),
|
||||
...(leadPlayable ? [leadPlayable.id] : []),
|
||||
].filter(Boolean);
|
||||
|
||||
const acts = actPrimaryIds.map((primaryNpcId, actIndex) => {
|
||||
const supportIds = supportPool.filter((roleId) => roleId !== primaryNpcId);
|
||||
const orderedEncounterNpcIds = [
|
||||
primaryNpcId,
|
||||
...supportIds.slice(0, 2),
|
||||
];
|
||||
const primaryRole =
|
||||
sceneRoles.find((role) => role.id === primaryNpcId) ?? leadPlayable;
|
||||
const supportRoles = orderedEncounterNpcIds
|
||||
.slice(1)
|
||||
.map((roleId) =>
|
||||
sceneRoles.find((role) => role.id === roleId) ??
|
||||
(leadPlayable?.id === roleId ? leadPlayable : null),
|
||||
)
|
||||
.filter((role): role is CustomWorldFoundationDraftCharacter => Boolean(role));
|
||||
|
||||
return {
|
||||
id: `${landmark.id}-act-${actIndex + 1}`,
|
||||
title:
|
||||
actIndex === 0
|
||||
? `${landmark.name}起势`
|
||||
: actIndex === 1
|
||||
? `${landmark.name}承压`
|
||||
: `${landmark.name}收束`,
|
||||
summary: clampText(
|
||||
[
|
||||
actIndex === 0
|
||||
? `这一幕先由${primaryRole?.name || '主角色'}把玩家带进${landmark.name}的当前压力。`
|
||||
: actIndex === 1
|
||||
? `${primaryRole?.name || '主角色'}会把${landmark.name}的冲突真正抬上台面。`
|
||||
: `${primaryRole?.name || '主角色'}会负责把这一章收束并抛出下一跳。`,
|
||||
landmark.summary,
|
||||
].join(' '),
|
||||
120,
|
||||
),
|
||||
stageCoverage: buildFallbackSceneActStageCoverage(actIndex, 3),
|
||||
backgroundImageSrc: null,
|
||||
backgroundAssetId: null,
|
||||
encounterNpcIds: orderedEncounterNpcIds,
|
||||
primaryNpcId,
|
||||
linkedThreadIds,
|
||||
actGoal:
|
||||
actIndex === 0
|
||||
? `让玩家先接住${landmark.name}的入口压力`
|
||||
: actIndex === 1
|
||||
? `把${landmark.name}的冲突推到不可回避`
|
||||
: `把${landmark.name}这一章收住并抛向下一跳`,
|
||||
transitionHook:
|
||||
actIndex === 0
|
||||
? `${supportRoles[0]?.name || '另一名角色'}会在这一幕后继续加压。`
|
||||
: actIndex === 1
|
||||
? `这一幕结束后,${primaryRole?.name || '主角色'}会逼玩家接住最终选择。`
|
||||
: '这一幕结束后要把下一步去向和关系压力一起抛给玩家。',
|
||||
advanceRule:
|
||||
actIndex === 0
|
||||
? 'after_primary_contact'
|
||||
: actIndex === 2
|
||||
? 'after_chapter_resolution'
|
||||
: 'after_active_step_complete',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: `scene-chapter-${landmark.id}`,
|
||||
sceneId: landmark.id,
|
||||
sceneName: landmark.name,
|
||||
title: `${landmark.name}章节`,
|
||||
summary: clampText(
|
||||
`${landmark.name}会按三幕推进:先起势、再承压、最后收束。`,
|
||||
120,
|
||||
),
|
||||
linkedThreadIds,
|
||||
linkedLandmarkIds: [landmark.id],
|
||||
acts,
|
||||
} satisfies CustomWorldFoundationDraftSceneChapter;
|
||||
});
|
||||
}
|
||||
|
||||
function getNamedRecordKey(value: unknown) {
|
||||
return toText(value).replace(/\s+/gu, '');
|
||||
}
|
||||
@@ -1533,6 +1696,12 @@ function convertRuntimeProfileToFoundationDraft(params: {
|
||||
landmarks,
|
||||
threads,
|
||||
});
|
||||
const sceneChapters = buildSceneChaptersFromDraft({
|
||||
landmarks,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
threads,
|
||||
});
|
||||
const anchorRecord = toRecord(params.anchorPack);
|
||||
|
||||
return {
|
||||
@@ -1571,6 +1740,7 @@ function convertRuntimeProfileToFoundationDraft(params: {
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
sceneChapters,
|
||||
worldHook:
|
||||
clampText(params.intent.worldHook || params.profile.summary, 72) ||
|
||||
params.profile.summary,
|
||||
@@ -1793,7 +1963,12 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
threads: baseThreads,
|
||||
coreConflicts,
|
||||
iconicElements,
|
||||
}).slice(0, 5);
|
||||
}).slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT + FOUNDATION_DRAFT_STORY_COUNT);
|
||||
const { playableNpcs, storyNpcs } = splitDraftCharacters({
|
||||
characters,
|
||||
playableCount: FOUNDATION_DRAFT_PLAYABLE_COUNT,
|
||||
storyCount: FOUNDATION_DRAFT_STORY_COUNT,
|
||||
});
|
||||
const camp = buildCamp({
|
||||
openingSituation,
|
||||
worldHook,
|
||||
@@ -1803,12 +1978,12 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
intent,
|
||||
camp,
|
||||
factions,
|
||||
characters,
|
||||
characters: [...playableNpcs, ...storyNpcs],
|
||||
threads: baseThreads,
|
||||
coreConflicts,
|
||||
iconicElements,
|
||||
openingSituation,
|
||||
}).slice(0, 6);
|
||||
}).slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT);
|
||||
const threads = finalizeThreads({
|
||||
threads: baseThreads.slice(0, 4),
|
||||
characters,
|
||||
@@ -1818,7 +1993,7 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
worldName,
|
||||
openingSituation,
|
||||
playerGoal,
|
||||
characters,
|
||||
characters: [...playableNpcs, ...storyNpcs],
|
||||
landmarks,
|
||||
threads,
|
||||
});
|
||||
@@ -1851,8 +2026,8 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
playerGoal,
|
||||
majorFactions: factions.map((entry) => entry.name),
|
||||
coreConflicts,
|
||||
playableNpcs: characters,
|
||||
storyNpcs: [],
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
landmarks,
|
||||
camp,
|
||||
themePack: null,
|
||||
@@ -1860,6 +2035,12 @@ export class CustomWorldAgentFoundationDraftService {
|
||||
factions,
|
||||
threads,
|
||||
chapters: [chapter],
|
||||
sceneChapters: buildSceneChaptersFromDraft({
|
||||
landmarks,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
threads,
|
||||
}),
|
||||
worldHook,
|
||||
playerPremise,
|
||||
openingSituation,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import { badRequest, notFound } from '../errors.js';
|
||||
import { prepareEventStreamResponse } from '../http.js';
|
||||
import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js';
|
||||
import {
|
||||
buildPendingClarifications,
|
||||
@@ -274,10 +275,12 @@ function buildWelcomeMessage(params: {
|
||||
function buildFoundationDraftAssistantMessage(params: {
|
||||
relatedOperationId: string;
|
||||
draftProfile: unknown;
|
||||
warnings?: string[];
|
||||
}) {
|
||||
const profile = normalizeFoundationDraftProfile(params.draftProfile);
|
||||
const leadCharacter = profile?.playableNpcs[0];
|
||||
const leadLandmark = profile?.landmarks[0];
|
||||
const warnings = (params.warnings ?? []).filter(Boolean);
|
||||
|
||||
return {
|
||||
id: `message-${crypto.randomBytes(8).toString('hex')}`,
|
||||
@@ -288,6 +291,12 @@ function buildFoundationDraftAssistantMessage(params: {
|
||||
'',
|
||||
`当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`,
|
||||
`建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`,
|
||||
...(warnings.length > 0
|
||||
? [
|
||||
'',
|
||||
`这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`,
|
||||
]
|
||||
: []),
|
||||
].join('\n'),
|
||||
createdAt: new Date().toISOString(),
|
||||
relatedOperationId: params.relatedOperationId,
|
||||
@@ -332,6 +341,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
|
||||
private readonly assetBridgeService: CustomWorldAgentAssetBridgeService;
|
||||
|
||||
private readonly autoAssetService: CustomWorldAgentAutoAssetService | null;
|
||||
|
||||
private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService;
|
||||
|
||||
constructor(
|
||||
@@ -339,6 +350,7 @@ export class CustomWorldAgentOrchestrator {
|
||||
llmClient: UpstreamLlmClient | null = null,
|
||||
options: {
|
||||
singleTurnLlmClient?: UpstreamLlmClient | null;
|
||||
autoAssetService?: CustomWorldAgentAutoAssetService | null;
|
||||
} = {},
|
||||
) {
|
||||
this.foundationDraftService = new CustomWorldAgentFoundationDraftService(
|
||||
@@ -350,6 +362,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
);
|
||||
this.changeSummaryService = new CustomWorldAgentChangeSummaryService();
|
||||
this.assetBridgeService = new CustomWorldAgentAssetBridgeService();
|
||||
this.autoAssetService =
|
||||
options.autoAssetService ?? null;
|
||||
this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService(
|
||||
(options.singleTurnLlmClient ?? llmClient) ?? undefined,
|
||||
);
|
||||
@@ -844,9 +858,9 @@ export class CustomWorldAgentOrchestrator {
|
||||
try {
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'running',
|
||||
phaseLabel: '生成世界底稿',
|
||||
phaseDetail: '正在根据已确认设定编译第一版世界结构。',
|
||||
progress: 38,
|
||||
phaseLabel: '整理世界骨架',
|
||||
phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。',
|
||||
progress: 12,
|
||||
});
|
||||
|
||||
await sleep(30);
|
||||
@@ -890,19 +904,44 @@ export class CustomWorldAgentOrchestrator {
|
||||
},
|
||||
});
|
||||
|
||||
const draftWithAssets = this.autoAssetService
|
||||
? await this.autoAssetService.populateDraftAssets({
|
||||
draftProfile,
|
||||
onProgress: async (progress) => {
|
||||
await this.sessionStore.updateOperation(
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
{
|
||||
status: 'running',
|
||||
phaseLabel: progress.phaseLabel,
|
||||
phaseDetail: progress.phaseDetail,
|
||||
progress: progress.progress,
|
||||
},
|
||||
);
|
||||
},
|
||||
})
|
||||
: {
|
||||
draftProfile,
|
||||
assetCoverage: rebuildRoleAssetCoverage(draftProfile),
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
phaseLabel: '编译草稿卡',
|
||||
phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。',
|
||||
progress: 98,
|
||||
});
|
||||
|
||||
const draftCards = this.draftCompiler.compileDraftCards(draftProfile);
|
||||
const assetCoverage = rebuildRoleAssetCoverage(draftProfile);
|
||||
const draftCards = this.draftCompiler.compileDraftCards(
|
||||
draftWithAssets.draftProfile,
|
||||
);
|
||||
const assetCoverage = draftWithAssets.assetCoverage;
|
||||
const nextStage = 'object_refining' as const;
|
||||
const nextSuggestedActions = buildSuggestedActions({
|
||||
stage: nextStage,
|
||||
isReady: true,
|
||||
draftProfile,
|
||||
draftProfile: draftWithAssets.draftProfile,
|
||||
draftCards,
|
||||
});
|
||||
|
||||
@@ -910,7 +949,8 @@ export class CustomWorldAgentOrchestrator {
|
||||
stage: nextStage,
|
||||
creatorIntent,
|
||||
anchorPack,
|
||||
draftProfile: draftProfile as unknown as Record<string, unknown>,
|
||||
draftProfile:
|
||||
draftWithAssets.draftProfile as unknown as Record<string, unknown>,
|
||||
draftCards,
|
||||
assetCoverage,
|
||||
pendingClarifications: [],
|
||||
@@ -925,22 +965,34 @@ export class CustomWorldAgentOrchestrator {
|
||||
sessionId,
|
||||
buildFoundationDraftAssistantMessage({
|
||||
relatedOperationId: operationId,
|
||||
draftProfile,
|
||||
draftProfile: draftWithAssets.draftProfile,
|
||||
warnings: draftWithAssets.warnings,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'completed',
|
||||
phaseLabel: '世界底稿已生成',
|
||||
phaseDetail: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`,
|
||||
phaseDetail:
|
||||
draftWithAssets.warnings.length > 0
|
||||
? `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。`
|
||||
: `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`,
|
||||
progress: 100,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
const currentOperation = await this.sessionStore.getOperation(
|
||||
userId,
|
||||
sessionId,
|
||||
operationId,
|
||||
);
|
||||
await this.sessionStore.updateOperation(userId, sessionId, operationId, {
|
||||
status: 'failed',
|
||||
phaseLabel: '底稿生成失败',
|
||||
phaseDetail: '这一轮没有成功把设定编成世界底稿。',
|
||||
phaseLabel:
|
||||
currentOperation?.phaseLabel?.trim() || '底稿生成失败',
|
||||
phaseDetail:
|
||||
currentOperation?.phaseDetail?.trim() ||
|
||||
'这一轮没有成功把设定编成世界底稿。',
|
||||
progress: 100,
|
||||
error:
|
||||
error instanceof Error ? error.message : 'draft foundation failed',
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
|
||||
@@ -88,6 +94,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
};
|
||||
}
|
||||
|
||||
function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-agent-phase3-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir: path.join(projectRoot, 'logs'),
|
||||
dataDir: path.join(projectRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: `pg-mem://${testName}`,
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
templateParamKey: '',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: '',
|
||||
accessTokenEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
callbackPath: '',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: '',
|
||||
mockUnionId: '',
|
||||
mockDisplayName: '',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackAutoAssetService(testName: string) {
|
||||
const config = createAutoAssetTestConfig(testName);
|
||||
return new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
|
||||
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
userId: string,
|
||||
@@ -161,6 +263,7 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('draft'),
|
||||
});
|
||||
const userId = 'user-phase3-draft';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
@@ -179,6 +282,16 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
|
||||
const draftProfile = snapshot?.draftProfile as Record<string, unknown> | undefined;
|
||||
const playableNpcs = Array.isArray(draftProfile?.playableNpcs)
|
||||
? draftProfile?.playableNpcs
|
||||
: [];
|
||||
const storyNpcs = Array.isArray(draftProfile?.storyNpcs)
|
||||
? draftProfile?.storyNpcs
|
||||
: [];
|
||||
const sceneChapters = Array.isArray(draftProfile?.sceneChapters)
|
||||
? draftProfile?.sceneChapters
|
||||
: [];
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.equal(snapshot?.stage, 'object_refining');
|
||||
@@ -189,6 +302,23 @@ test('phase3 ready session can execute draft_foundation and expose card detail',
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'landmark'));
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'thread'));
|
||||
assert.ok(snapshot?.draftCards.some((card) => card.kind === 'chapter'));
|
||||
assert.equal(playableNpcs.length, 1);
|
||||
assert.ok(storyNpcs.length >= 4);
|
||||
assert.equal(sceneChapters.length, 2);
|
||||
assert.ok(
|
||||
sceneChapters.every(
|
||||
(entry) => Array.isArray((entry as { acts?: unknown[] }).acts) && ((entry as { acts?: unknown[] }).acts?.length ?? 0) === 3,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
playableNpcs.every(
|
||||
(entry) =>
|
||||
typeof (entry as { imageSrc?: unknown }).imageSrc === 'string' &&
|
||||
typeof (entry as { generatedVisualAssetId?: unknown }).generatedVisualAssetId === 'string',
|
||||
),
|
||||
);
|
||||
assert.ok((snapshot?.assetCoverage.sceneAssets.length ?? 0) >= 6);
|
||||
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
|
||||
assert.equal(
|
||||
typeof (snapshot?.draftProfile as Record<string, unknown>)?.name,
|
||||
'string',
|
||||
@@ -221,6 +351,7 @@ test('phase3 draft_foundation rejects not-ready session', async () => {
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('not-ready'),
|
||||
});
|
||||
const userId = 'user-phase3-not-ready';
|
||||
const createdSession = await orchestrator.createSession(userId, {
|
||||
@@ -241,6 +372,7 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('summary'),
|
||||
});
|
||||
const userId = 'user-phase3-summary';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
@@ -264,10 +396,70 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
|
||||
customWorldAgentSessions: sessionStore,
|
||||
});
|
||||
const draft = items.find((item) => item.sessionId === readySession.sessionId);
|
||||
const compiledProfile = normalizeFoundationDraftProfile(
|
||||
(
|
||||
await orchestrator.getSessionSnapshot(userId, readySession.sessionId)
|
||||
)?.draftProfile,
|
||||
);
|
||||
const totalRoleCount = [
|
||||
...new Set(
|
||||
[
|
||||
...(compiledProfile?.playableNpcs ?? []),
|
||||
...(compiledProfile?.storyNpcs ?? []),
|
||||
].map((entry) => entry.id),
|
||||
),
|
||||
].length;
|
||||
|
||||
assert.ok(draft);
|
||||
assert.ok((draft?.playableNpcCount ?? 0) >= 3);
|
||||
assert.ok((draft?.landmarkCount ?? 0) >= 4);
|
||||
assert.equal(draft?.playableNpcCount ?? 0, totalRoleCount);
|
||||
assert.equal(draft?.landmarkCount ?? 0, 2);
|
||||
assert.match(draft?.summary ?? '', /潮雾|守灯|航道/u);
|
||||
assert.match(draft?.subtitle ?? '', /守灯|冲突|列岛/u);
|
||||
});
|
||||
|
||||
test('phase3 draft foundation still completes when auto asset generation fails', async () => {
|
||||
const runtimeRepository = createRuntimeRepositoryStub();
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const autoAssetService = new CustomWorldAgentAutoAssetService(
|
||||
createAutoAssetTestConfig('asset-failure'),
|
||||
async () => {
|
||||
throw new Error('visual service timeout');
|
||||
},
|
||||
async () => {
|
||||
throw new Error('scene service timeout');
|
||||
},
|
||||
);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService,
|
||||
});
|
||||
const userId = 'user-phase3-asset-failure';
|
||||
const readySession = await createReadySession(orchestrator, userId);
|
||||
|
||||
const response = await orchestrator.executeAction(
|
||||
userId,
|
||||
readySession.sessionId,
|
||||
{
|
||||
action: 'draft_foundation',
|
||||
},
|
||||
);
|
||||
const operation = await waitForOperation(
|
||||
orchestrator,
|
||||
userId,
|
||||
readySession.sessionId,
|
||||
response.operation.operationId,
|
||||
);
|
||||
const snapshot = await orchestrator.getSessionSnapshot(userId, readySession.sessionId);
|
||||
|
||||
assert.equal(operation?.status, 'completed');
|
||||
assert.doesNotMatch(operation?.phaseDetail ?? '', /资产补齐待后续处理/u);
|
||||
assert.ok(snapshot?.draftCards.length);
|
||||
assert.ok(
|
||||
snapshot?.messages.every(
|
||||
(message) =>
|
||||
message.role !== 'assistant' || !message.text.includes('资产补齐未完成'),
|
||||
),
|
||||
);
|
||||
assert.equal(snapshot?.assetCoverage.allRoleAssetsReady, true);
|
||||
assert.equal(snapshot?.assetCoverage.allSceneAssetsReady, true);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
|
||||
import type { AppConfig } from '../config.js';
|
||||
import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js';
|
||||
import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js';
|
||||
import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js';
|
||||
import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
||||
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
|
||||
@@ -88,6 +93,102 @@ function createRuntimeRepositoryStub(): RuntimeRepositoryPort {
|
||||
};
|
||||
}
|
||||
|
||||
function createAutoAssetTestConfig(testName: string): AppConfig {
|
||||
const projectRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`),
|
||||
);
|
||||
|
||||
return {
|
||||
nodeEnv: 'test',
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
logsDir: path.join(projectRoot, 'logs'),
|
||||
dataDir: path.join(projectRoot, 'data'),
|
||||
rawEnv: {},
|
||||
databaseUrl: `pg-mem://${testName}`,
|
||||
serverAddr: ':0',
|
||||
logLevel: 'silent',
|
||||
editorApiEnabled: true,
|
||||
assetsApiEnabled: true,
|
||||
jwtSecret: 'test',
|
||||
jwtExpiresIn: '7d',
|
||||
jwtIssuer: 'test',
|
||||
llm: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
model: 'test-model',
|
||||
},
|
||||
dashScope: {
|
||||
baseUrl: 'https://example.invalid',
|
||||
apiKey: '',
|
||||
imageModel: 'test-image-model',
|
||||
requestTimeoutMs: 1000,
|
||||
},
|
||||
smsAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
endpoint: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
signName: '',
|
||||
templateCode: '',
|
||||
templateParamKey: '',
|
||||
countryCode: '86',
|
||||
schemeName: '',
|
||||
codeLength: 6,
|
||||
codeType: 1,
|
||||
validTimeSeconds: 300,
|
||||
intervalSeconds: 60,
|
||||
duplicatePolicy: 1,
|
||||
caseAuthPolicy: 1,
|
||||
returnVerifyCode: false,
|
||||
mockVerifyCode: '123456',
|
||||
maxSendPerPhonePerDay: 20,
|
||||
maxSendPerIpPerHour: 30,
|
||||
maxVerifyFailuresPerPhonePerHour: 12,
|
||||
maxVerifyFailuresPerIpPerHour: 24,
|
||||
captchaTtlSeconds: 180,
|
||||
captchaTriggerVerifyFailuresPerPhone: 3,
|
||||
captchaTriggerVerifyFailuresPerIp: 5,
|
||||
blockPhoneFailureThreshold: 6,
|
||||
blockIpFailureThreshold: 10,
|
||||
blockPhoneDurationMinutes: 30,
|
||||
blockIpDurationMinutes: 30,
|
||||
},
|
||||
wechatAuth: {
|
||||
enabled: false,
|
||||
provider: 'mock',
|
||||
appId: '',
|
||||
appSecret: '',
|
||||
authorizeEndpoint: '',
|
||||
accessTokenEndpoint: '',
|
||||
userInfoEndpoint: '',
|
||||
callbackPath: '',
|
||||
defaultRedirectPath: '/',
|
||||
mockUserId: '',
|
||||
mockUnionId: '',
|
||||
mockDisplayName: '',
|
||||
mockAvatarUrl: '',
|
||||
},
|
||||
authSession: {
|
||||
refreshCookieName: 'refresh_token',
|
||||
refreshSessionTtlDays: 30,
|
||||
refreshCookieSecure: false,
|
||||
refreshCookieSameSite: 'Lax',
|
||||
refreshCookiePath: '/',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackAutoAssetService(testName: string) {
|
||||
const config = createAutoAssetTestConfig(testName);
|
||||
return new CustomWorldAgentAutoAssetService(
|
||||
config,
|
||||
CustomWorldAgentAutoAssetService.createFallbackCharacterVisualGenerator(config),
|
||||
CustomWorldAgentAutoAssetService.createFallbackSceneActBackgroundGenerator(config),
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForOperation(
|
||||
orchestrator: CustomWorldAgentOrchestrator,
|
||||
userId: string,
|
||||
@@ -178,6 +279,7 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('generate-role-assets'),
|
||||
});
|
||||
const userId = 'user-phase5-generate-role-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
@@ -217,6 +319,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in
|
||||
message.text.includes('角色资产工坊'),
|
||||
),
|
||||
);
|
||||
const preparedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
|
||||
(entry) => entry.roleId === characterIds[0],
|
||||
);
|
||||
assert.equal(preparedAssetSummary?.status, 'visual_ready');
|
||||
});
|
||||
|
||||
test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => {
|
||||
@@ -224,6 +330,7 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
|
||||
const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository);
|
||||
const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, {
|
||||
singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(),
|
||||
autoAssetService: createFallbackAutoAssetService('sync-role-assets'),
|
||||
});
|
||||
const userId = 'user-phase5-sync-role-assets';
|
||||
const session = await createObjectRefiningSession(orchestrator, userId);
|
||||
|
||||
@@ -82,3 +82,48 @@ test('role asset summary treats idle and die as optional', () => {
|
||||
assert.equal(summary.status, 'complete');
|
||||
assert.deepEqual(summary.missingAnimations, []);
|
||||
});
|
||||
|
||||
test('role asset coverage includes scene act background readiness', async () => {
|
||||
const { rebuildRoleAssetCoverage } = await import(
|
||||
'./customWorldAgentRoleAssetStateService.js'
|
||||
);
|
||||
|
||||
const coverage = rebuildRoleAssetCoverage({
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'role-playable',
|
||||
name: '沈砺',
|
||||
threadIds: ['thread-1'],
|
||||
imageSrc: '/generated/role-playable.png',
|
||||
generatedVisualAssetId: 'visual-role-playable',
|
||||
skills: [],
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
sceneChapters: [
|
||||
{
|
||||
sceneId: 'scene-dock',
|
||||
sceneName: '潮汐码头',
|
||||
acts: [
|
||||
{
|
||||
id: 'scene-dock-act-1',
|
||||
title: '雾里靠岸',
|
||||
backgroundImageSrc: '/generated/scene-dock-act-1.png',
|
||||
backgroundAssetId: 'scene-act-asset-1',
|
||||
},
|
||||
{
|
||||
id: 'scene-dock-act-2',
|
||||
title: '封锁加压',
|
||||
backgroundImageSrc: '',
|
||||
backgroundAssetId: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(coverage.sceneAssets.length, 2);
|
||||
assert.equal(coverage.sceneAssets[0]?.status, 'ready');
|
||||
assert.equal(coverage.sceneAssets[1]?.status, 'missing');
|
||||
assert.equal(coverage.allSceneAssetsReady, false);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
CustomWorldAssetPriorityTier,
|
||||
CustomWorldRoleAssetStatus,
|
||||
CustomWorldRoleAssetSummary,
|
||||
CustomWorldSceneAssetSummary,
|
||||
} from '../../../packages/shared/src/contracts/customWorldAgent.js';
|
||||
|
||||
const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const;
|
||||
@@ -26,6 +27,19 @@ type DraftRoleRecord = {
|
||||
|
||||
type DraftRoleKind = 'playable' | 'story';
|
||||
|
||||
type DraftSceneActRecord = {
|
||||
id: string;
|
||||
title: string;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundAssetId?: string | null;
|
||||
};
|
||||
|
||||
type DraftSceneChapterRecord = {
|
||||
sceneId: string;
|
||||
sceneName: string;
|
||||
acts: DraftSceneActRecord[];
|
||||
};
|
||||
|
||||
type MergeRoleAssetIntoDraftProfilePayload = {
|
||||
roleId: string;
|
||||
portraitPath: string;
|
||||
@@ -66,6 +80,17 @@ function toAnimationMap(value: unknown) {
|
||||
return toRecord(value);
|
||||
}
|
||||
|
||||
function normalizeSceneActs(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => ({
|
||||
id: toText(item.id) || `act-${index + 1}`,
|
||||
title: toText(item.title) || `第 ${index + 1} 幕`,
|
||||
backgroundImageSrc: toText(item.backgroundImageSrc) || null,
|
||||
backgroundAssetId: toText(item.backgroundAssetId) || null,
|
||||
}))
|
||||
.filter((item) => Boolean(item.id));
|
||||
}
|
||||
|
||||
function hasAnimationAsset(entryValue: unknown) {
|
||||
const entry = toRecord(entryValue);
|
||||
if (!entry) {
|
||||
@@ -194,6 +219,31 @@ function collectDraftRoles(profileInput: unknown) {
|
||||
];
|
||||
}
|
||||
|
||||
function collectDraftSceneChapters(profileInput: unknown) {
|
||||
const profile = toRecord(profileInput);
|
||||
if (!profile) {
|
||||
return [] as DraftSceneChapterRecord[];
|
||||
}
|
||||
|
||||
return toRecordArray(profile.sceneChapters)
|
||||
.map((item, index) => {
|
||||
const sceneId = toText(item.sceneId);
|
||||
const sceneName = toText(item.sceneName) || toText(item.title);
|
||||
const acts = normalizeSceneActs(item.acts);
|
||||
|
||||
if (!sceneId || acts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sceneId,
|
||||
sceneName: sceneName || `场景 ${index + 1}`,
|
||||
acts,
|
||||
} satisfies DraftSceneChapterRecord;
|
||||
})
|
||||
.filter((item): item is DraftSceneChapterRecord => Boolean(item));
|
||||
}
|
||||
|
||||
export function resolveRoleAssetStatusLabel(
|
||||
status: CustomWorldRoleAssetStatus,
|
||||
) {
|
||||
@@ -267,14 +317,36 @@ export function rebuildRoleAssetCoverage(
|
||||
const roleAssets = collectDraftRoles(draftProfile).map((entry) =>
|
||||
buildRoleAssetSummary(entry),
|
||||
);
|
||||
const sceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters(
|
||||
draftProfile,
|
||||
).flatMap((sceneChapter) =>
|
||||
sceneChapter.acts.map((act) => {
|
||||
const imageSrc = act.backgroundImageSrc ?? null;
|
||||
const assetId = act.backgroundAssetId ?? null;
|
||||
const ready = Boolean(imageSrc || assetId);
|
||||
|
||||
return {
|
||||
sceneId: sceneChapter.sceneId,
|
||||
sceneName: sceneChapter.sceneName,
|
||||
actId: act.id,
|
||||
actTitle: act.title,
|
||||
imageSrc,
|
||||
assetId,
|
||||
status: ready ? 'ready' : 'missing',
|
||||
nextPointCost: ready ? 0 : 12,
|
||||
} satisfies CustomWorldSceneAssetSummary;
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
roleAssets,
|
||||
sceneAssets: [],
|
||||
sceneAssets,
|
||||
allRoleAssetsReady:
|
||||
roleAssets.length > 0 &&
|
||||
roleAssets.every((entry) => entry.status === 'complete'),
|
||||
allSceneAssetsReady: false,
|
||||
roleAssets.every((entry) => entry.status !== 'missing'),
|
||||
allSceneAssetsReady:
|
||||
sceneAssets.length > 0 &&
|
||||
sceneAssets.every((entry) => entry.status === 'ready'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -453,13 +453,18 @@ function buildCompatibleAssetCoverage(
|
||||
) {
|
||||
const derivedCoverage = rebuildRoleAssetCoverage(draftProfile);
|
||||
const existingCoverage = toRecord(record.assetCoverage);
|
||||
const sceneAssets = Array.isArray(existingCoverage?.sceneAssets)
|
||||
? existingCoverage.sceneAssets
|
||||
: [];
|
||||
const sceneAssets =
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.sceneAssets
|
||||
: Array.isArray(existingCoverage?.sceneAssets)
|
||||
? existingCoverage.sceneAssets
|
||||
: [];
|
||||
const allSceneAssetsReady =
|
||||
typeof existingCoverage?.allSceneAssetsReady === 'boolean'
|
||||
? existingCoverage.allSceneAssetsReady
|
||||
: false;
|
||||
derivedCoverage.sceneAssets.length > 0
|
||||
? derivedCoverage.allSceneAssetsReady
|
||||
: typeof existingCoverage?.allSceneAssetsReady === 'boolean'
|
||||
? existingCoverage.allSceneAssetsReady
|
||||
: false;
|
||||
|
||||
return {
|
||||
...derivedCoverage,
|
||||
|
||||
278
server-node/src/services/customWorldCoverAssetService.test.ts
Normal file
278
server-node/src/services/customWorldCoverAssetService.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {
|
||||
createServer,
|
||||
type IncomingMessage,
|
||||
type ServerResponse,
|
||||
} from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { type AppConfig } from '../config.js';
|
||||
import type { AppContext } from '../context.js';
|
||||
import {
|
||||
generateCustomWorldCoverImage,
|
||||
uploadCustomWorldCoverImage,
|
||||
} from './customWorldCoverAssetService.js';
|
||||
|
||||
function createTestConfig(
|
||||
projectRoot: string,
|
||||
dashScopeBaseUrl: string,
|
||||
): AppConfig {
|
||||
return {
|
||||
projectRoot,
|
||||
publicDir: path.join(projectRoot, 'public'),
|
||||
dashScope: {
|
||||
baseUrl: dashScopeBaseUrl,
|
||||
apiKey: 'test-dashscope-key',
|
||||
imageModel: 'wan2.2-t2i-flash',
|
||||
requestTimeoutMs: 5_000,
|
||||
},
|
||||
} as AppConfig;
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, payload: unknown) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function readRequestBody(req: IncomingMessage) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function withHttpServer<T>(
|
||||
buildHandler: (
|
||||
baseUrl: string,
|
||||
) => (req: IncomingMessage, res: ServerResponse) => void | Promise<void>,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
let handler: (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
) => void | Promise<void> = () => undefined;
|
||||
const server = createServer((req, res) => {
|
||||
Promise.resolve(handler(req, res)).catch((error) => {
|
||||
res.statusCode = 500;
|
||||
res.end(error instanceof Error ? error.stack : String(error));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('failed to resolve test server address');
|
||||
}
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
handler = buildHandler(baseUrl);
|
||||
|
||||
try {
|
||||
return await run(baseUrl);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('uploadCustomWorldCoverImage crops to 16:9 and saves a compressed webp cover', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-cover-upload-'),
|
||||
);
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, 'http://127.0.0.1:9999/api/v1'),
|
||||
} as AppContext;
|
||||
|
||||
const inputBuffer = await sharp({
|
||||
create: {
|
||||
width: 2400,
|
||||
height: 1800,
|
||||
channels: 3,
|
||||
background: { r: 40, g: 78, b: 132 },
|
||||
},
|
||||
})
|
||||
.jpeg({ quality: 92 })
|
||||
.toBuffer();
|
||||
const imageDataUrl = `data:image/jpeg;base64,${inputBuffer.toString('base64')}`;
|
||||
|
||||
const result = await uploadCustomWorldCoverImage(context, {
|
||||
profileId: 'world-1',
|
||||
worldName: '潮雾群岛',
|
||||
imageDataUrl,
|
||||
cropRect: {
|
||||
x: 240,
|
||||
y: 225,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.sourceType, 'uploaded');
|
||||
assert.match(result.imageSrc, /^\/generated-custom-world-covers\//u);
|
||||
|
||||
const savedPath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
||||
assert.equal(fs.existsSync(savedPath), true);
|
||||
const metadata = await sharp(savedPath).metadata();
|
||||
assert.equal(metadata.format, 'webp');
|
||||
assert.equal(metadata.width, 1600);
|
||||
assert.equal(metadata.height, 900);
|
||||
assert.ok(fs.statSync(savedPath).size <= Math.floor(1.5 * 1024 * 1024));
|
||||
});
|
||||
|
||||
test('generateCustomWorldCoverImage sends opening act and role images as reference images', async () => {
|
||||
const tempRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'genarrative-cover-generate-'),
|
||||
);
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(path.join(publicDir, 'images', 'scene'), { recursive: true });
|
||||
fs.mkdirSync(path.join(publicDir, 'images', 'roles'), { recursive: true });
|
||||
|
||||
const referenceBuffer = await sharp({
|
||||
create: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
channels: 3,
|
||||
background: { r: 80, g: 120, b: 160 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
fs.writeFileSync(
|
||||
path.join(publicDir, 'images', 'scene', 'opening.png'),
|
||||
referenceBuffer,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(publicDir, 'images', 'roles', 'lead.png'),
|
||||
referenceBuffer,
|
||||
);
|
||||
|
||||
const capturedBodies: string[] = [];
|
||||
|
||||
await withHttpServer(
|
||||
(baseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', baseUrl);
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/multimodal-generation/generation'
|
||||
) {
|
||||
capturedBodies.push((await readRequestBody(req)).toString('utf8'));
|
||||
sendJson(res, {
|
||||
output: {
|
||||
results: [
|
||||
{
|
||||
url: `${baseUrl}/downloads/cover.png`,
|
||||
actual_prompt: '整理后的封面提示词',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/cover.png') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.end(referenceBuffer);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const context = {
|
||||
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
|
||||
} as AppContext;
|
||||
|
||||
const result = await generateCustomWorldCoverImage(context, {
|
||||
profile: {
|
||||
id: 'world-1',
|
||||
name: '潮雾群岛',
|
||||
subtitle: '旧航道与沉钟回响',
|
||||
summary: '用于验证封面参考素材收集。',
|
||||
tone: '潮湿、压抑',
|
||||
playerGoal: '查明旧航道真相',
|
||||
settingText: '旧港与潮雾正在失衡。',
|
||||
camp: null,
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '沉钟码头',
|
||||
description: '海雾压进旧码头。',
|
||||
imageSrc: '/images/scene/opening.png',
|
||||
},
|
||||
],
|
||||
playableNpcs: [
|
||||
{
|
||||
id: 'playable-1',
|
||||
name: '林潮',
|
||||
title: '守潮人',
|
||||
role: '可扮演角色',
|
||||
description: '站在最前面的主角色。',
|
||||
imageSrc: '/images/roles/lead.png',
|
||||
},
|
||||
],
|
||||
storyNpcs: [],
|
||||
sceneChapterBlueprints: [
|
||||
{
|
||||
id: 'scene-chapter-1',
|
||||
sceneId: 'landmark-1',
|
||||
title: '沉钟码头',
|
||||
summary: '玩家第一次登上旧码头。',
|
||||
acts: [
|
||||
{
|
||||
id: 'act-1',
|
||||
title: '雾里靠岸',
|
||||
summary: '第一幕潮声压低,玩家刚踏上栈桥。',
|
||||
backgroundImageSrc: '/images/scene/opening.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
userPrompt: '像正式作品封面。',
|
||||
referenceImageSrc: '',
|
||||
characterRoleIds: ['playable-1'],
|
||||
size: '1600*900',
|
||||
});
|
||||
|
||||
assert.equal(result.sourceType, 'generated');
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(capturedBodies.length, 1);
|
||||
const createPayload = JSON.parse(capturedBodies[0] ?? '{}') as {
|
||||
input?: {
|
||||
messages?: Array<{
|
||||
content?: Array<{ image?: string; text?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const content =
|
||||
createPayload.input?.messages?.[0]?.content?.map((item) =>
|
||||
item.image ? 'image' : item.text ? 'text' : 'unknown',
|
||||
) ?? [];
|
||||
assert.ok(content.filter((item) => item === 'image').length >= 2);
|
||||
assert.equal(content[content.length - 1], 'text');
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import sharp from 'sharp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { AppContext } from '../context.js';
|
||||
@@ -33,6 +34,21 @@ const coverLandmarkSchema = z.object({
|
||||
imageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverActSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
title: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
backgroundImageSrc: z.string().trim().optional().default(''),
|
||||
});
|
||||
|
||||
const coverSceneChapterSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
sceneId: z.string().trim().optional().default(''),
|
||||
title: z.string().trim().optional().default(''),
|
||||
summary: z.string().trim().optional().default(''),
|
||||
acts: z.array(coverActSchema).optional().default([]),
|
||||
});
|
||||
|
||||
const coverProfileSchema = z.object({
|
||||
id: z.string().trim().optional().default(''),
|
||||
name: z.string().trim().optional().default(''),
|
||||
@@ -44,6 +60,11 @@ const coverProfileSchema = z.object({
|
||||
camp: coverCampSchema.nullable().optional(),
|
||||
landmarks: z.array(coverLandmarkSchema).optional().default([]),
|
||||
playableNpcs: z.array(coverRoleSchema).optional().default([]),
|
||||
storyNpcs: z.array(coverRoleSchema).optional().default([]),
|
||||
sceneChapterBlueprints: z
|
||||
.array(coverSceneChapterSchema)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const customWorldCoverImageSchema = z.object({
|
||||
@@ -58,10 +79,26 @@ export const customWorldCoverUploadSchema = z.object({
|
||||
profileId: z.string().trim().optional().default(''),
|
||||
worldName: z.string().trim().optional().default(''),
|
||||
imageDataUrl: z.string().trim().min(1),
|
||||
cropRect: z.object({
|
||||
x: z.number().finite().min(0),
|
||||
y: z.number().finite().min(0),
|
||||
width: z.number().finite().positive(),
|
||||
height: z.number().finite().positive(),
|
||||
}),
|
||||
});
|
||||
|
||||
type CoverProfile = z.infer<typeof coverProfileSchema>;
|
||||
|
||||
const COVER_OUTPUT_WIDTH = 1600;
|
||||
const COVER_OUTPUT_HEIGHT = 900;
|
||||
const COVER_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const COVER_OUTPUT_MAX_BYTES = Math.floor(1.5 * 1024 * 1024);
|
||||
|
||||
type ParsedImageDataUrl = {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
function parseImageDataUrl(source: string) {
|
||||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||||
if (!matched) {
|
||||
@@ -74,6 +111,160 @@ function parseImageDataUrl(source: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function clampCoverText(value: string, maxLength: number) {
|
||||
return value.trim().replace(/\s+/gu, ' ').slice(0, maxLength);
|
||||
}
|
||||
|
||||
function resolveOpeningAct(profile: CoverProfile) {
|
||||
return profile.sceneChapterBlueprints[0]?.acts[0] ?? null;
|
||||
}
|
||||
|
||||
function collectCoverReferenceImageSrcs(
|
||||
profile: CoverProfile,
|
||||
requestedRoleIds: string[],
|
||||
explicitReferenceImageSrc: string,
|
||||
) {
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const sceneImageSrc = clampCoverText(
|
||||
resolveOpeningAct(profile)?.backgroundImageSrc ?? '',
|
||||
240,
|
||||
);
|
||||
const roleImageSrcs = selectedRoles
|
||||
.map((role) => clampCoverText(role.imageSrc, 240))
|
||||
.filter(Boolean);
|
||||
const campImageSrc = clampCoverText(profile.camp?.imageSrc ?? '', 240);
|
||||
const landmarkImageSrc = profile.landmarks
|
||||
.map((landmark) => clampCoverText(landmark.imageSrc, 240))
|
||||
.filter(Boolean)[0] ?? '';
|
||||
|
||||
return [
|
||||
clampCoverText(explicitReferenceImageSrc, 240),
|
||||
sceneImageSrc,
|
||||
...roleImageSrcs,
|
||||
campImageSrc,
|
||||
landmarkImageSrc,
|
||||
].filter(
|
||||
(source) =>
|
||||
Boolean(source) && (source.startsWith('/') || source.startsWith('data:')),
|
||||
);
|
||||
}
|
||||
|
||||
function buildCoverPromptContext(profile: CoverProfile, requestedRoleIds: string[]) {
|
||||
const openingAct = resolveOpeningAct(profile);
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const roleSummary = selectedRoles
|
||||
.map((role) =>
|
||||
[
|
||||
clampCoverText(role.name, 18),
|
||||
clampCoverText(role.title || role.role, 24),
|
||||
clampCoverText(role.description, 72),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
const storyRoleSummary = profile.storyNpcs
|
||||
.slice(0, 4)
|
||||
.map((role) =>
|
||||
[clampCoverText(role.name, 18), clampCoverText(role.title || role.role, 24)]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
|
||||
return {
|
||||
openingActTitle: clampCoverText(openingAct?.title ?? '', 24),
|
||||
openingActSummary: clampCoverText(openingAct?.summary ?? '', 96),
|
||||
roleSummary,
|
||||
storyRoleSummary,
|
||||
landmarkSummary: profile.landmarks
|
||||
.slice(0, 3)
|
||||
.map((landmark) =>
|
||||
[
|
||||
clampCoverText(landmark.name, 18),
|
||||
clampCoverText(landmark.description, 72),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';'),
|
||||
};
|
||||
}
|
||||
|
||||
async function optimizeUploadedCoverImage(
|
||||
parsedDataUrl: ParsedImageDataUrl,
|
||||
cropRect: z.infer<typeof customWorldCoverUploadSchema>['cropRect'],
|
||||
) {
|
||||
if (parsedDataUrl.buffer.byteLength > COVER_UPLOAD_MAX_BYTES) {
|
||||
throw badRequest('上传封面原图不能超过 10 MB。');
|
||||
}
|
||||
|
||||
const image = sharp(parsedDataUrl.buffer, { failOn: 'none' });
|
||||
const metadata = await image.metadata();
|
||||
const sourceWidth = metadata.width ?? 0;
|
||||
const sourceHeight = metadata.height ?? 0;
|
||||
|
||||
if (sourceWidth <= 0 || sourceHeight <= 0) {
|
||||
throw badRequest('无法解析上传封面的尺寸。');
|
||||
}
|
||||
|
||||
const normalizedCrop = {
|
||||
left: Math.max(0, Math.min(sourceWidth - 1, Math.floor(cropRect.x))),
|
||||
top: Math.max(0, Math.min(sourceHeight - 1, Math.floor(cropRect.y))),
|
||||
width: Math.max(1, Math.min(sourceWidth, Math.floor(cropRect.width))),
|
||||
height: Math.max(1, Math.min(sourceHeight, Math.floor(cropRect.height))),
|
||||
};
|
||||
normalizedCrop.width = Math.min(
|
||||
normalizedCrop.width,
|
||||
sourceWidth - normalizedCrop.left,
|
||||
);
|
||||
normalizedCrop.height = Math.min(
|
||||
normalizedCrop.height,
|
||||
sourceHeight - normalizedCrop.top,
|
||||
);
|
||||
|
||||
if (
|
||||
normalizedCrop.width <= 0 ||
|
||||
normalizedCrop.height <= 0 ||
|
||||
normalizedCrop.width / normalizedCrop.height < 1.7 ||
|
||||
normalizedCrop.width / normalizedCrop.height > 1.8
|
||||
) {
|
||||
throw badRequest('上传封面裁剪区域必须保持 16:9。');
|
||||
}
|
||||
|
||||
const encodeWithQuality = async (quality: number) =>
|
||||
image
|
||||
.extract(normalizedCrop)
|
||||
.resize(COVER_OUTPUT_WIDTH, COVER_OUTPUT_HEIGHT, {
|
||||
fit: 'cover',
|
||||
position: 'centre',
|
||||
})
|
||||
.webp({ quality, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
let optimizedBuffer = await encodeWithQuality(90);
|
||||
for (
|
||||
let quality = 84;
|
||||
optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES && quality >= 60;
|
||||
quality -= 8
|
||||
) {
|
||||
optimizedBuffer = await encodeWithQuality(quality);
|
||||
}
|
||||
|
||||
if (optimizedBuffer.byteLength > COVER_OUTPUT_MAX_BYTES) {
|
||||
throw badRequest('上传封面压缩后仍超过体积限制,请缩小裁剪范围或更换图片。');
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimeType: 'image/webp',
|
||||
extension: 'webp',
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveReferenceImageAsDataUrl(rootDir: string, source: string) {
|
||||
const trimmedSource = source.trim();
|
||||
if (!trimmedSource) {
|
||||
@@ -207,15 +398,7 @@ function buildCustomWorldCoverImagePrompt(
|
||||
} = {},
|
||||
) {
|
||||
const openingScene = profile.camp ?? profile.landmarks[0] ?? null;
|
||||
const selectedRoles = resolveSelectedRoles(profile, requestedRoleIds);
|
||||
const roleSummary = selectedRoles
|
||||
.map((role) =>
|
||||
[role.name, role.title || role.role, role.description]
|
||||
.filter(Boolean)
|
||||
.join(' / '),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(';');
|
||||
const promptContext = buildCoverPromptContext(profile, requestedRoleIds);
|
||||
|
||||
return [
|
||||
'为 16:9 横版 RPG 作品生成一张高完成度封面图,用于创作列表与作品详情头图。',
|
||||
@@ -231,9 +414,13 @@ function buildCustomWorldCoverImagePrompt(
|
||||
profile.summary ? `世界概述:${profile.summary}。` : '',
|
||||
profile.tone ? `整体基调:${profile.tone}。` : '',
|
||||
profile.playerGoal ? `主线目标:${profile.playerGoal}。` : '',
|
||||
promptContext.openingActTitle ? `开局第一幕标题:${promptContext.openingActTitle}。` : '',
|
||||
promptContext.openingActSummary ? `开局第一幕摘要:${promptContext.openingActSummary}。` : '',
|
||||
openingScene?.name ? `开局场景:${openingScene.name}。` : '',
|
||||
openingScene?.description ? `场景描述:${openingScene.description}。` : '',
|
||||
roleSummary ? `需要出现的角色主形象:${roleSummary}。` : '',
|
||||
promptContext.landmarkSummary ? `关键场景素材:${promptContext.landmarkSummary}。` : '',
|
||||
promptContext.roleSummary ? `需要出现的角色主形象:${promptContext.roleSummary}。` : '',
|
||||
promptContext.storyRoleSummary ? `可辅助参考的场景角色:${promptContext.storyRoleSummary}。` : '',
|
||||
userPrompt ? `额外要求:${userPrompt}。` : '',
|
||||
'整体观感要像一张正式作品封面,主体明确,氛围饱满,人物与场景统一。',
|
||||
]
|
||||
@@ -286,7 +473,7 @@ async function createCoverImageFromReference(params: {
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
size: string;
|
||||
referenceImage: string;
|
||||
referenceImages: string[];
|
||||
}) {
|
||||
const response = await fetch(
|
||||
`${params.baseUrl}/services/aigc/multimodal-generation/generation`,
|
||||
@@ -303,7 +490,7 @@ async function createCoverImageFromReference(params: {
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ image: params.referenceImage },
|
||||
...params.referenceImages.map((image) => ({ image })),
|
||||
{ text: params.prompt },
|
||||
],
|
||||
},
|
||||
@@ -419,11 +606,10 @@ export async function uploadCustomWorldCoverImage(
|
||||
throw badRequest('上传封面必须是有效图片 Data URL。');
|
||||
}
|
||||
|
||||
const extension = parsedDataUrl.mimeType.includes('png')
|
||||
? 'png'
|
||||
: parsedDataUrl.mimeType.includes('webp')
|
||||
? 'webp'
|
||||
: 'jpg';
|
||||
const optimizedImage = await optimizeUploadedCoverImage(
|
||||
parsedDataUrl,
|
||||
payload.cropRect,
|
||||
);
|
||||
const assetId = `custom-cover-upload-${Date.now()}`;
|
||||
const worldSegment = sanitizeSegment(
|
||||
payload.profileId || payload.worldName,
|
||||
@@ -436,8 +622,8 @@ export async function uploadCustomWorldCoverImage(
|
||||
);
|
||||
const outputDir = path.join(context.config.publicDir, relativeDir);
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const fileName = `cover.${extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), parsedDataUrl.buffer);
|
||||
const fileName = `cover.${optimizedImage.extension}`;
|
||||
fs.writeFileSync(path.join(outputDir, fileName), optimizedImage.buffer);
|
||||
|
||||
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
|
||||
fs.writeFileSync(
|
||||
@@ -447,6 +633,8 @@ export async function uploadCustomWorldCoverImage(
|
||||
assetId,
|
||||
sourceType: 'uploaded',
|
||||
imageSrc,
|
||||
size: `${COVER_OUTPUT_WIDTH}*${COVER_OUTPUT_HEIGHT}`,
|
||||
outputBytes: optimizedImage.buffer.byteLength,
|
||||
worldName: payload.worldName,
|
||||
profileId: payload.profileId,
|
||||
createdAt: new Date().toISOString(),
|
||||
@@ -468,29 +656,33 @@ export async function generateCustomWorldCoverImage(
|
||||
input: z.infer<typeof customWorldCoverImageSchema>,
|
||||
) {
|
||||
const payload = customWorldCoverImageSchema.parse(input);
|
||||
const referenceImageSources = collectCoverReferenceImageSrcs(
|
||||
payload.profile,
|
||||
payload.characterRoleIds,
|
||||
payload.referenceImageSrc,
|
||||
).slice(0, 6);
|
||||
const prompt = buildCustomWorldCoverImagePrompt(
|
||||
payload.profile,
|
||||
payload.characterRoleIds,
|
||||
payload.userPrompt,
|
||||
{
|
||||
hasReferenceImage: Boolean(payload.referenceImageSrc.trim()),
|
||||
hasReferenceImage: referenceImageSources.length > 0,
|
||||
},
|
||||
);
|
||||
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
|
||||
const referenceImage = payload.referenceImageSrc.trim()
|
||||
? await resolveReferenceImageAsDataUrl(
|
||||
context.config.projectRoot,
|
||||
payload.referenceImageSrc,
|
||||
)
|
||||
: '';
|
||||
const referenceImages = await Promise.all(
|
||||
referenceImageSources.map((source) =>
|
||||
resolveReferenceImageAsDataUrl(context.config.projectRoot, source),
|
||||
),
|
||||
);
|
||||
|
||||
if (referenceImage) {
|
||||
if (referenceImages.length > 0) {
|
||||
const referenceResult = await createCoverImageFromReference({
|
||||
baseUrl,
|
||||
apiKey: context.config.dashScope.apiKey,
|
||||
prompt,
|
||||
size: payload.size,
|
||||
referenceImage,
|
||||
referenceImages,
|
||||
});
|
||||
|
||||
return saveGeneratedCoverAsset({
|
||||
|
||||
@@ -95,14 +95,17 @@ function resolveDraftSummary(session: CustomWorldAgentSessionRecord) {
|
||||
function resolveDraftCounts(session: CustomWorldAgentSessionRecord) {
|
||||
const draftProfile = normalizeFoundationDraftProfile(session.draftProfile);
|
||||
if (draftProfile) {
|
||||
return {
|
||||
playableNpcCount: [
|
||||
...new Set(
|
||||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
// 草稿列表里的“角色”展示的是当前草稿中全部可编辑角色,而不是仅限可扮演角色。
|
||||
const totalRoleCount = [
|
||||
...new Set(
|
||||
[...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map(
|
||||
(entry) => entry.id,
|
||||
),
|
||||
].length,
|
||||
),
|
||||
].length;
|
||||
|
||||
return {
|
||||
playableNpcCount: totalRoleCount,
|
||||
landmarkCount: draftProfile.landmarks.length,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user