Your Name 80e826039f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix(realtime-canvas): use runware seedImage-compatible model
2026-02-25 15:48:16 +08:00

244 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { auth } from "@/app/(auth)/auth";
import { getSystemSetting } from "@/lib/db/queries";
type RealtimeCanvasConfig = {
enabled?: boolean;
runwareApiKey?: string;
runwareModel?: string;
falApiKey?: string;
apiKey?: string;
maxInputResolution?: number;
defaults?: {
steps?: number;
cfgScale?: number;
strength?: number;
};
};
type GeneratePayload = {
requestId?: string;
imageUrl?: string;
prompt?: string;
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 "";
}
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 || 4),
1,
8
);
const cfgScale = clamp(
Number(body.cfgScale || config.defaults?.cfgScale || 3.5),
1,
12
);
const strength = clamp(
Number(body.strength || config.defaults?.strength || 0.85),
0.05,
1
);
const positivePrompt =
String(body.prompt || "").trim() || "将草图渲染为高质感产品设计图,保持结构和主轮廓一致。";
const requestInference = async (modelId: string) => {
const taskUUID = crypto.randomUUID();
const runwareRequest = [
{
taskType: "imageInference",
taskUUID,
model: modelId,
positivePrompt,
seedImage: imageUrl,
width: size,
height: size,
strength,
CFGScale: cfgScale,
steps,
numberResults: 1,
outputType: "dataURI",
outputFormat: "JPG",
},
];
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,
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,
cost,
});
return Response.json({
ok: true,
provider: "runware",
model: requestResult.modelId,
taskUUID: requestResult.taskUUID,
imageDataURI: imageDataURI || undefined,
imageUrl: imageURL || undefined,
cost,
});
}