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