443 lines
12 KiB
TypeScript
443 lines
12 KiB
TypeScript
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,
|
||
'文字,水印,logo,UI界面,对话框,边框,人物近景特写,多人合照,模糊,低清晰度,畸形建筑,现代车辆,监控摄像头',
|
||
);
|
||
},
|
||
);
|
||
});
|
||
|
||
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, '废墟月台像素风场景');
|
||
},
|
||
);
|
||
});
|