Your Name 24275d9a3a
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: improve realtime canvas sketch fidelity for runware
2026-02-25 16:27:35 +08:00

273 lines
8.0 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;
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,
});
}