Your Name f25cfd3fab
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix(realtime-canvas): enforce sketch-preserving i2i defaults
2026-02-25 15:58:20 +08:00

245 lines
6.9 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 || 8),
1,
8
);
const cfgScale = clamp(
Number(body.cfgScale || config.defaults?.cfgScale || 6),
1,
12
);
const strength = clamp(
Number(body.strength || config.defaults?.strength || 0.4),
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,
});
}