1
This commit is contained in:
340
server-node/src/modules/assets/characterAssetRoutes.test.ts
Normal file
340
server-node/src/modules/assets/characterAssetRoutes.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import express from 'express';
|
||||
|
||||
import type { AppConfig } from '../../config.js';
|
||||
import { createCharacterAssetRoutes } from './characterAssetRoutes.js';
|
||||
|
||||
const PNG_BUFFER = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
const MP4_BUFFER = Buffer.from('mock-video');
|
||||
|
||||
function createTestConfig(
|
||||
projectRoot: string,
|
||||
dashScopeBaseUrl: string,
|
||||
): AppConfig {
|
||||
return {
|
||||
projectRoot,
|
||||
assetsApiEnabled: true,
|
||||
rawEnv: {
|
||||
DASHSCOPE_BASE_URL: dashScopeBaseUrl,
|
||||
DASHSCOPE_API_KEY: 'test-dashscope-key',
|
||||
},
|
||||
} 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function withAssetRouteServer<T>(
|
||||
config: AppConfig,
|
||||
run: (baseUrl: string) => Promise<T>,
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '25mb' }));
|
||||
app.use(createCharacterAssetRoutes(config));
|
||||
|
||||
const server = await new Promise<import('node:http').Server>((resolve) => {
|
||||
const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('failed to resolve asset route server address');
|
||||
}
|
||||
|
||||
return await run(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('character visual generation converts public reference images into data urls before calling DashScope', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-visual-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'reference.png'), PNG_BUFFER);
|
||||
|
||||
let createPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/image-generation/generation'
|
||||
) {
|
||||
createPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'visual-task-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/visual-task-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
results: [
|
||||
{
|
||||
url: `${dashScopeBaseUrl}/downloads/visual.png`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/visual.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 config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
|
||||
await withAssetRouteServer(config, async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-visual/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
sourceMode: 'image-to-image',
|
||||
promptText: '潮雾港向导',
|
||||
characterBriefText: '旧港守望者',
|
||||
referenceImageDataUrls: ['/reference.png'],
|
||||
candidateCount: 1,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
size: '1024*1536',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
drafts: Array<{ imageSrc: string }>;
|
||||
};
|
||||
assert.equal(payload.drafts.length, 1);
|
||||
|
||||
const createPayload = JSON.parse(createPayloadText) as {
|
||||
input: {
|
||||
messages: Array<{
|
||||
content: Array<{ text?: string; image?: string }>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const content = createPayload.input.messages[0]?.content ?? [];
|
||||
assert.match(content[1]?.image ?? '', /^data:image\/png;base64,/u);
|
||||
|
||||
const savedDraftPath = path.join(tempRoot, 'public', payload.drafts[0]!.imageSrc.slice(1));
|
||||
assert.equal(fs.existsSync(savedDraftPath), true);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('character animation image-to-video flow uploads a public visual source and submits the resolved oss url', async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'genarrative-character-video-'));
|
||||
const publicDir = path.join(tempRoot, 'public');
|
||||
fs.mkdirSync(publicDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(publicDir, 'visual.png'), PNG_BUFFER);
|
||||
|
||||
let uploadCalled = false;
|
||||
let videoSynthesisPayloadText = '';
|
||||
|
||||
await withHttpServer(
|
||||
(dashScopeBaseUrl) => async (req, res) => {
|
||||
const url = new URL(req.url || '/', dashScopeBaseUrl);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/uploads') {
|
||||
sendJson(res, {
|
||||
data: {
|
||||
upload_host: `${dashScopeBaseUrl}/upload`,
|
||||
upload_dir: 'uploads/test-dir',
|
||||
policy: 'policy',
|
||||
signature: 'signature',
|
||||
oss_access_key_id: 'oss-key',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/upload') {
|
||||
uploadCalled = true;
|
||||
await readRequestBody(req);
|
||||
res.statusCode = 200;
|
||||
res.end('ok');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
req.method === 'POST' &&
|
||||
url.pathname === '/api/v1/services/aigc/video-generation/video-synthesis'
|
||||
) {
|
||||
videoSynthesisPayloadText = (await readRequestBody(req)).toString('utf8');
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_id: 'video-task-1',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/v1/tasks/video-task-1') {
|
||||
sendJson(res, {
|
||||
output: {
|
||||
task_status: 'SUCCEEDED',
|
||||
video_url: `${dashScopeBaseUrl}/downloads/preview.mp4`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/downloads/preview.mp4') {
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'video/mp4');
|
||||
res.end(MP4_BUFFER);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
},
|
||||
async (dashScopeBaseUrl) => {
|
||||
const config = createTestConfig(tempRoot, `${dashScopeBaseUrl}/api/v1`);
|
||||
await withAssetRouteServer(config, async (assetBaseUrl) => {
|
||||
const response = await fetch(
|
||||
`${assetBaseUrl}/api/assets/character-animation/generate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
characterId: 'harbor-guide',
|
||||
strategy: 'image-to-video',
|
||||
animation: 'idle',
|
||||
promptText: '站立观察海面',
|
||||
characterBriefText: '旧港守望者',
|
||||
visualSource: '/visual.png',
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
frameCount: 8,
|
||||
fps: 8,
|
||||
durationSeconds: 4,
|
||||
loop: true,
|
||||
useChromaKey: true,
|
||||
resolution: '720P',
|
||||
imageSequenceModel: 'wan2.7-image-pro',
|
||||
videoModel: 'wan2.7-i2v',
|
||||
referenceVideoModel: 'wan2.7-r2v',
|
||||
motionTransferModel: 'wan2.2-animate-move',
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
previewVideoPath: string;
|
||||
};
|
||||
assert.equal(uploadCalled, true);
|
||||
|
||||
const videoPayload = JSON.parse(videoSynthesisPayloadText) as {
|
||||
input: {
|
||||
media: Array<{ type: string; url: string }>;
|
||||
};
|
||||
};
|
||||
assert.equal(videoPayload.input.media[0]?.type, 'first_frame');
|
||||
assert.match(videoPayload.input.media[0]?.url ?? '', /^oss:\/\/uploads\/test-dir\//u);
|
||||
|
||||
const savedVideoPath = path.join(tempRoot, 'public', payload.previewVideoPath.slice(1));
|
||||
assert.equal(fs.existsSync(savedVideoPath), true);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -867,35 +867,40 @@ async function handleGenerateCharacterVisuals(
|
||||
let activePrompt = '';
|
||||
try {
|
||||
const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText);
|
||||
const normalizedReferenceImages = await Promise.all(
|
||||
referenceImageDataUrls.map((image) =>
|
||||
resolveMediaSourceAsDataUrl(rootDir, image),
|
||||
),
|
||||
);
|
||||
activePrompt = finalPrompt;
|
||||
const content = [
|
||||
{ text: finalPrompt },
|
||||
...referenceImageDataUrls.map((image) => ({ image })),
|
||||
];
|
||||
const createTaskResponse = await proxyJsonRequest(
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
apiKey,
|
||||
{
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
...normalizedReferenceImages.map((image) => ({ image })),
|
||||
];
|
||||
const createTaskResponse = await proxyJsonRequest(
|
||||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||||
apiKey,
|
||||
{
|
||||
model,
|
||||
input: {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
n: candidateCount,
|
||||
size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
n: candidateCount,
|
||||
size,
|
||||
prompt_extend: true,
|
||||
watermark: false,
|
||||
{
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
},
|
||||
{
|
||||
'X-DashScope-Async': 'enable',
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
if (
|
||||
createTaskResponse.statusCode < 200 ||
|
||||
@@ -1178,6 +1183,15 @@ async function handleGenerateCharacterAnimation(
|
||||
frameCount,
|
||||
useChromaKey,
|
||||
);
|
||||
const normalizedVisualSource = await resolveMediaSourceAsDataUrl(
|
||||
rootDir,
|
||||
visualSource,
|
||||
);
|
||||
const normalizedReferenceImages = await Promise.all(
|
||||
referenceImageDataUrls.map((image) =>
|
||||
resolveMediaSourceAsDataUrl(rootDir, image),
|
||||
),
|
||||
);
|
||||
activePrompt = finalPrompt;
|
||||
activeModel = imageSequenceModel;
|
||||
const createTaskResponse = await proxyJsonRequest(
|
||||
@@ -1191,8 +1205,8 @@ async function handleGenerateCharacterAnimation(
|
||||
role: 'user',
|
||||
content: [
|
||||
{ text: finalPrompt },
|
||||
{ image: visualSource },
|
||||
...referenceImageDataUrls.map((image) => ({ image })),
|
||||
{ image: normalizedVisualSource },
|
||||
...normalizedReferenceImages.map((image) => ({ image })),
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user