import { auth } from "@/app/(auth)/auth"; import { getSystemSetting } from "@/lib/db/queries"; type RealtimeCanvasConfig = { enabled?: boolean; runwareApiKey?: string; runwareModel?: string; falApiKey?: string; apiKey?: string; negativePrompt?: string; strictSketch?: boolean; maxInputResolution?: number; defaults?: { steps?: number; cfgScale?: number; strength?: number; }; }; type GeneratePayload = { requestId?: string; imageUrl?: string; prompt?: string; seed?: number; strictSketch?: boolean; imageSize?: "square" | "square_hd"; steps?: number; cfgScale?: number; strength?: number; }; function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function resolveSquareSize( imageSize: "square" | "square_hd" | undefined, maxInputResolution: number ) { const preferred = imageSize === "square_hd" ? 1280 : 1024; const capped = Math.min(preferred, maxInputResolution); return clamp(Math.round(capped / 64) * 64, 512, 1536); } function parseRunwareError(payload: any) { if (!payload) return ""; if (Array.isArray(payload)) { const match = payload.find( (item) => typeof item?.error === "string" || typeof item?.message === "string" || (Array.isArray(item?.errors) && item.errors.length > 0) ); if (!match) return ""; if (typeof match.error === "string") return match.error; if (typeof match.message === "string") return match.message; if (Array.isArray(match.errors) && match.errors.length > 0) { const firstError = match.errors[0]; if (typeof firstError === "string") return firstError; if (typeof firstError?.message === "string") return firstError.message; } return ""; } if (typeof payload?.error === "string") return payload.error; if (typeof payload?.message === "string") return payload.message; return ""; } function resolveSeed(seed: unknown) { const parsed = Number(seed); if (!Number.isFinite(parsed)) return undefined; const normalized = Math.floor(parsed); if (normalized < 0 || normalized > 2147483647) return undefined; return normalized; } export async function POST(request: Request) { const session = await auth(); if (!session?.user?.id) { return Response.json({ code: "UNAUTHORIZED", message: "请先登录" }, { status: 401 }); } let body: GeneratePayload; try { body = (await request.json()) as GeneratePayload; } catch { return Response.json({ code: "BAD_REQUEST", message: "无效 JSON" }, { status: 400 }); } const imageUrl = String(body.imageUrl || "").trim(); if (!imageUrl) { return Response.json({ code: "BAD_REQUEST", message: "缺少 imageUrl" }, { status: 400 }); } const setting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null); const config = (setting?.value || {}) as RealtimeCanvasConfig; if (config.enabled === false) { return Response.json( { code: "FEATURE_DISABLED", message: "实时画布功能未开启" }, { status: 403 } ); } const runwareApiKey = String( config.runwareApiKey || config.falApiKey || config.apiKey || process.env.RUNWARE_API_KEY || process.env.RUNWARE_KEY || "" ).trim(); if (!runwareApiKey) { return Response.json( { code: "RUNWARE_KEY_MISSING", message: "未配置 Runware API Key", }, { status: 500 } ); } const fallbackSeedImageModel = "runware:101@1"; const model = String( config.runwareModel || process.env.RUNWARE_MODEL || fallbackSeedImageModel ).trim(); const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536); const size = resolveSquareSize(body.imageSize, maxInputResolution); const steps = clamp( Number(body.steps || config.defaults?.steps || 8), 1, 12 ); const cfgScale = clamp( Number(body.cfgScale || config.defaults?.cfgScale || 7), 1, 14 ); const strength = clamp( Number(body.strength || config.defaults?.strength || 0.32), 0.05, 1 ); const strictSketch = Boolean(body.strictSketch ?? config.strictSketch ?? true); const seed = resolveSeed(body.seed); const positivePrompt = String(body.prompt || "").trim() || "严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。"; const negativePrompt = String(config.negativePrompt || "").trim() || "改变构图, 改变主体位置, 多余人物, 多余肢体, 重复物体, 镜像翻转, 错误透视, 模糊轮廓, 文字水印, 大面积背景噪点"; const requestInference = async (modelId: string) => { const taskUUID = crypto.randomUUID(); const runtimeStrength = strictSketch ? clamp(strength, 0.12, 0.55) : strength; const runtimeCfgScale = strictSketch ? clamp(cfgScale, 6, 14) : cfgScale; const runwareRequest = [ { taskType: "imageInference", taskUUID, model: modelId, positivePrompt, negativePrompt, seedImage: imageUrl, width: size, height: size, strength: runtimeStrength, CFGScale: runtimeCfgScale, steps, includeCost: true, numberResults: 1, outputType: "dataURI", outputFormat: "JPG", ...(typeof seed === "number" ? { seed } : {}), }, ]; const upstreamResponse = await fetch("https://api.runware.ai/v1", { method: "POST", headers: { Authorization: `Bearer ${runwareApiKey}`, "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(runwareRequest), cache: "no-store", }); const upstreamJson = await upstreamResponse.json().catch(() => null); const upstreamError = parseRunwareError(upstreamJson); return { taskUUID, modelId, runtimeStrength, runtimeCfgScale, upstreamResponse, upstreamJson, upstreamError, }; }; let requestResult = await requestInference(model); const shouldRetryWithSeedImageModel = (requestResult.upstreamError || "") .toLowerCase() .includes("unsupported use of 'seedimage' parameter") && model !== fallbackSeedImageModel; if (shouldRetryWithSeedImageModel) { requestResult = await requestInference(fallbackSeedImageModel); } if (!requestResult.upstreamResponse.ok || requestResult.upstreamError) { return Response.json( { code: "RUNWARE_REQUEST_FAILED", message: requestResult.upstreamError || `Runware 请求失败(${requestResult.upstreamResponse.status})`, }, { status: 502 } ); } const resultList = Array.isArray(requestResult.upstreamJson) ? requestResult.upstreamJson : Array.isArray(requestResult.upstreamJson?.data) ? requestResult.upstreamJson.data : []; const imageResult = resultList.find( (item: any) => item?.taskUUID === requestResult.taskUUID && (item?.imageDataURI || item?.imageURL) ) || resultList.find((item: any) => item?.imageDataURI || item?.imageURL); const imageDataURI = String(imageResult?.imageDataURI || "").trim(); const imageURL = String(imageResult?.imageURL || "").trim(); const outputImage = imageDataURI || imageURL; if (!outputImage) { return Response.json( { code: "RUNWARE_EMPTY_OUTPUT", message: "Runware 未返回图像" }, { status: 502 } ); } const cost = Number(imageResult?.cost || 0); console.log("[RealtimeRunware] frame success", { userId: session.user.id, requestId: body.requestId || null, taskUUID: requestResult.taskUUID, model: requestResult.modelId, seed: typeof seed === "number" ? seed : null, steps, cfgScale: requestResult.runtimeCfgScale, strength: requestResult.runtimeStrength, cost, }); return Response.json({ ok: true, provider: "runware", model: requestResult.modelId, taskUUID: requestResult.taskUUID, imageDataURI: imageDataURI || undefined, imageUrl: imageURL || undefined, cost, }); }