feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -0,0 +1,193 @@
import fs from 'node:fs';
import path from 'node:path';
import { z } from 'zod';
import type { AppContext } from '../context.js';
import { badRequest } from '../errors.js';
import { extractApiErrorMessage } from '../http.js';
export const sceneImageSchema = z.object({
prompt: z.string().trim().min(1),
negativePrompt: z.string().trim().optional().default(''),
size: z.string().trim().optional().default('1280*720'),
model: z.string().trim().optional().default(''),
worldName: z.string().trim().optional().default(''),
profileId: z.string().trim().optional().default(''),
landmarkName: z.string().trim().optional().default(''),
landmarkId: z.string().trim().optional().default(''),
});
function ensurePayload(
payload: z.infer<typeof sceneImageSchema>,
defaultModel: string,
) {
if (!payload.landmarkName && !payload.landmarkId) {
throw badRequest('landmarkName 或 landmarkId 至少要提供一个');
}
return {
...payload,
model: payload.model || defaultModel,
};
}
export async function generateSceneImage(
context: AppContext,
input: z.infer<typeof sceneImageSchema>,
) {
const payload = ensurePayload(input, context.config.dashScope.imageModel);
const baseUrl = context.config.dashScope.baseUrl.replace(/\/+$/u, '');
const createResponse = await fetch(
`${baseUrl}/services/aigc/text2image/image-synthesis`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
'Content-Type': 'application/json',
'X-DashScope-Async': 'enable',
},
body: JSON.stringify({
model: payload.model,
input: {
prompt: payload.prompt,
...(payload.negativePrompt
? { negative_prompt: payload.negativePrompt }
: {}),
},
parameters: {
n: 1,
size: payload.size,
prompt_extend: true,
watermark: false,
},
}),
},
);
const createText = await createResponse.text();
if (!createResponse.ok) {
throw badRequest(
extractApiErrorMessage(createText, '创建场景图片生成任务失败'),
);
}
const createPayload = JSON.parse(createText) as {
output?: {
task_id?: string;
};
};
const taskId = createPayload.output?.task_id?.trim();
if (!taskId) {
throw badRequest('场景图片生成任务未返回 task_id');
}
const deadline = Date.now() + context.config.dashScope.requestTimeoutMs;
let imageUrl = '';
let actualPrompt = '';
while (Date.now() < deadline) {
const pollResponse = await fetch(`${baseUrl}/tasks/${taskId}`, {
headers: {
Authorization: `Bearer ${context.config.dashScope.apiKey}`,
},
});
const pollText = await pollResponse.text();
if (!pollResponse.ok) {
throw badRequest(
extractApiErrorMessage(pollText, '查询场景图片任务失败'),
);
}
const pollPayload = JSON.parse(pollText) as {
output?: {
task_status?: string;
results?: Array<{
url?: string;
actual_prompt?: string;
}>;
};
};
const status = pollPayload.output?.task_status?.trim();
if (status === 'SUCCEEDED') {
imageUrl =
pollPayload.output?.results?.find((item) => item.url?.trim())?.url?.trim() || '';
actualPrompt =
pollPayload.output?.results?.find((item) => item.url?.trim())?.actual_prompt?.trim() || '';
break;
}
if (status === 'FAILED' || status === 'UNKNOWN') {
throw badRequest(
extractApiErrorMessage(pollText, '场景图片生成任务失败'),
);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!imageUrl) {
throw badRequest('场景图片生成超时或未返回图片地址');
}
const imageResponse = await fetch(imageUrl);
if (!imageResponse.ok) {
throw badRequest('下载生成图片失败');
}
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
const contentType = imageResponse.headers.get('content-type') || '';
const extension = contentType.includes('png')
? 'png'
: contentType.includes('webp')
? 'webp'
: 'jpg';
const assetId = `custom-scene-${Date.now()}`;
const worldSegment = (payload.profileId || payload.worldName || 'world')
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
.slice(0, 48);
const landmarkSegment = (payload.landmarkId || payload.landmarkName || 'landmark')
.replace(/[^\w\u4e00-\u9fa5-]+/gu, '-')
.slice(0, 48);
const relativeDir = path.join(
'generated-custom-world-scenes',
worldSegment || 'world',
landmarkSegment || 'landmark',
assetId,
);
const outputDir = path.join(context.config.publicDir, relativeDir);
fs.mkdirSync(outputDir, { recursive: true });
const fileName = `scene.${extension}`;
fs.writeFileSync(path.join(outputDir, fileName), imageBuffer);
const imageSrc = `/${relativeDir.replace(/\\/gu, '/')}/${fileName}`;
fs.writeFileSync(
path.join(outputDir, 'manifest.json'),
`${JSON.stringify(
{
assetId,
taskId,
model: payload.model,
size: payload.size,
prompt: payload.prompt,
negativePrompt: payload.negativePrompt,
actualPrompt,
imageSrc,
worldName: payload.worldName,
landmarkName: payload.landmarkName,
createdAt: new Date().toISOString(),
},
null,
2,
)}\n`,
);
return {
ok: true,
imageSrc,
assetId,
taskId,
model: payload.model,
size: payload.size,
prompt: payload.prompt,
actualPrompt,
};
}