300 lines
8.7 KiB
TypeScript
300 lines
8.7 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/image-generation/generation'
|
|
) {
|
|
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/image-generation/generation',
|
|
);
|
|
assert.ok(createRequest?.bodyText);
|
|
|
|
const createPayload = JSON.parse(createRequest.bodyText) as {
|
|
model: string;
|
|
input: {
|
|
messages: Array<{
|
|
content: Array<{ text?: string; image?: string }>;
|
|
}>;
|
|
};
|
|
parameters: {
|
|
negative_prompt?: string;
|
|
};
|
|
};
|
|
|
|
const content = createPayload.input.messages[0]?.content ?? [];
|
|
assert.equal(createPayload.model, 'wan2.2-t2i-flash');
|
|
assert.equal(content[0]?.text, '海雾港口像素风场景');
|
|
assert.equal(content.length, 1);
|
|
assert.equal(createPayload.parameters.negative_prompt, '模糊');
|
|
|
|
const savedImagePath = path.join(tempRoot, 'public', result.imageSrc.slice(1));
|
|
assert.equal(fs.existsSync(savedImagePath), true);
|
|
},
|
|
);
|
|
});
|
|
|
|
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, '废墟月台像素风场景');
|
|
},
|
|
);
|
|
});
|