Files
Genarrative/server-node/src/services/sceneImageService.test.ts
高物 50759f3c1e
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 09:54:17 +08:00

443 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { type AppConfig } from '../config.js';
import type { AppContext } from '../context.js';
import { generateSceneImage } from './sceneImageService.js';
const PNG_BUFFER = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
'base64',
);
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 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);
});
}
function sendJson(res: ServerResponse, payload: unknown) {
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.end(JSON.stringify(payload));
}
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('generateSceneImage uses wan2.2-t2i-flash text-to-image payload and saves the generated scene', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-scene-image-'),
);
const capturedRequests: Array<{
pathname: string;
bodyText?: string;
}> = [];
await withHttpServer(
(baseUrl) => async (req, res) => {
const url = new URL(req.url || '/', baseUrl);
const bodyText =
req.method === 'POST'
? (await readRequestBody(req)).toString('utf8')
: undefined;
capturedRequests.push({
pathname: url.pathname,
bodyText,
});
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/text2image/image-synthesis'
) {
sendJson(res, {
output: {
task_id: 'scene-task-1',
},
});
return;
}
if (
req.method === 'GET' &&
url.pathname === '/api/v1/tasks/scene-task-1'
) {
sendJson(res, {
output: {
task_status: 'SUCCEEDED',
results: [
{
url: `${baseUrl}/downloads/scene.png`,
actual_prompt: '整理后的场景提示词',
},
],
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/scene.png') {
res.statusCode = 200;
res.setHeader('Content-Type', 'image/png');
res.end(PNG_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const context = {
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
} as AppContext;
const result = await generateSceneImage(context, {
prompt: '海雾港口像素风场景',
negativePrompt: '模糊',
size: '1280*720',
model: 'wan2.7-image',
worldName: '潮雾群岛',
profileId: 'world-1',
landmarkName: '旧港灯塔',
landmarkId: 'landmark-1',
});
assert.equal(result.ok, true);
assert.match(result.imageSrc, /^\/generated-custom-world-scenes\//u);
assert.equal(result.actualPrompt, '整理后的场景提示词');
const createRequest = capturedRequests.find(
(entry) =>
entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
);
assert.ok(createRequest?.bodyText);
const createPayload = JSON.parse(createRequest.bodyText) as {
model: string;
input: {
prompt: string;
negative_prompt?: string;
};
parameters: Record<string, unknown>;
};
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
assert.equal(createPayload.input.prompt, '海雾港口像素风场景');
assert.equal(createPayload.input.negative_prompt, '模糊');
assert.equal(createPayload.parameters.size, '1280*720');
const savedImagePath = path.join(
tempRoot,
'public',
result.imageSrc.slice(1),
);
assert.equal(fs.existsSync(savedImagePath), true);
},
);
});
test('generateSceneImage builds the scene prompt on the server when the client only submits world and landmark context', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-scene-image-'),
);
const capturedRequests: Array<{
pathname: string;
bodyText?: string;
}> = [];
await withHttpServer(
(baseUrl) => async (req, res) => {
const url = new URL(req.url || '/', baseUrl);
const bodyText =
req.method === 'POST'
? (await readRequestBody(req)).toString('utf8')
: undefined;
capturedRequests.push({
pathname: url.pathname,
bodyText,
});
if (
req.method === 'POST' &&
url.pathname === '/api/v1/services/aigc/text2image/image-synthesis'
) {
sendJson(res, {
output: {
task_id: 'scene-task-2',
},
});
return;
}
if (
req.method === 'GET' &&
url.pathname === '/api/v1/tasks/scene-task-2'
) {
sendJson(res, {
output: {
task_status: 'SUCCEEDED',
results: [
{
url: `${baseUrl}/downloads/scene.png`,
actual_prompt: '服务端整理后的像素风提示词',
},
],
},
});
return;
}
if (req.method === 'GET' && url.pathname === '/downloads/scene.png') {
res.statusCode = 200;
res.setHeader('Content-Type', 'image/png');
res.end(PNG_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const context = {
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
} as AppContext;
const result = await generateSceneImage(context, {
worldName: '',
profileId: '',
landmarkName: '',
landmarkId: '',
userPrompt: '想让灯塔更偏暴风夜',
profile: {
id: 'world-3',
name: '潮雾群岛',
subtitle: '迷雾海界',
summary: '岛链被旧航道和风暴一起缠住。',
tone: '潮湿、压迫、带着未知回声',
playerGoal: '先找到断线的引路火',
settingText: '玩家在海雾和旧航道之间寻找可以靠岸的线索。',
},
landmark: {
id: 'landmark-3',
name: '旧港灯塔',
description: '灯塔外墙被海盐侵蚀,塔下平台还能勉强落脚。',
dangerLevel: 'high',
},
});
assert.equal(result.ok, true);
const createRequest = capturedRequests.find(
(entry) =>
entry.pathname === '/api/v1/services/aigc/text2image/image-synthesis',
);
assert.ok(createRequest?.bodyText);
const createPayload = JSON.parse(createRequest.bodyText) as {
input: {
prompt: string;
negative_prompt?: string;
};
};
assert.match(createPayload.input.prompt, //u);
assert.match(createPayload.input.prompt, //u);
assert.match(
createPayload.input.prompt,
//u,
);
assert.match(createPayload.input.prompt, //u);
assert.equal(
createPayload.input.negative_prompt,
'文字水印logoUI界面对话框边框人物近景特写多人合照模糊低清晰度畸形建筑现代车辆监控摄像头',
);
},
);
});
test('generateSceneImage uses qwen-image-2.0 edit flow when a reference image is provided', async () => {
const tempRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'genarrative-scene-image-'),
);
const publicDir = path.join(tempRoot, 'public');
fs.mkdirSync(path.join(publicDir, 'scene_bg'), { recursive: true });
fs.writeFileSync(
path.join(publicDir, 'scene_bg', 'reference-layout.png'),
PNG_BUFFER,
);
const capturedRequests: Array<{
pathname: string;
bodyText?: string;
}> = [];
await withHttpServer(
(baseUrl) => async (req, res) => {
const url = new URL(req.url || '/', baseUrl);
const bodyText =
req.method === 'POST'
? (await readRequestBody(req)).toString('utf8')
: undefined;
capturedRequests.push({
pathname: url.pathname,
bodyText,
});
if (
req.method === 'POST' &&
url.pathname ===
'/api/v1/services/aigc/multimodal-generation/generation'
) {
sendJson(res, {
output: {
choices: [
{
message: {
content: [
{
image: `${baseUrl}/downloads/reference-scene.png`,
},
],
},
},
],
},
});
return;
}
if (
req.method === 'GET' &&
url.pathname === '/downloads/reference-scene.png'
) {
res.statusCode = 200;
res.setHeader('Content-Type', 'image/png');
res.end(PNG_BUFFER);
return;
}
res.statusCode = 404;
res.end('not found');
},
async (dashScopeBaseUrl) => {
const context = {
config: createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`),
} as AppContext;
const result = await generateSceneImage(context, {
prompt: '废墟月台像素风场景',
negativePrompt: '模糊',
size: '1280*720',
worldName: '碎轨边境',
profileId: 'world-2',
landmarkName: '裂轨月台',
landmarkId: 'landmark-2',
referenceImageSrc: '/scene_bg/reference-layout.png',
});
assert.equal(result.ok, true);
assert.equal(result.model, 'qwen-image-2.0');
assert.match(result.taskId, /^scene-edit-/u);
assert.equal(
capturedRequests.some(
(entry) => entry.pathname === '/api/v1/tasks/scene-task-1',
),
false,
);
const createRequest = capturedRequests.find(
(entry) =>
entry.pathname ===
'/api/v1/services/aigc/multimodal-generation/generation',
);
assert.ok(createRequest?.bodyText);
const createPayload = JSON.parse(createRequest.bodyText) as {
model: string;
input: {
messages: Array<{
content: Array<{ text?: string; image?: string }>;
}>;
};
};
const content = createPayload.input.messages[0]?.content ?? [];
assert.equal(createPayload.model, 'qwen-image-2.0');
assert.match(content[0]?.image ?? '', /^data:image\/png;base64,/u);
assert.equal(content[1]?.text, '废墟月台像素风场景');
},
);
});