473 lines
13 KiB
TypeScript
473 lines
13 KiB
TypeScript
import { auth } from "@/app/(auth)/auth";
|
||
import { getSystemSetting } from "@/lib/db/queries";
|
||
|
||
type RealtimeCanvasConfig = {
|
||
enabled?: boolean;
|
||
bflApiKey?: string;
|
||
bflModel?: string;
|
||
bflBaseUrl?: string;
|
||
runwareApiKey?: string;
|
||
runwareModel?: string;
|
||
falApiKey?: string;
|
||
apiKey?: string;
|
||
negativePrompt?: string;
|
||
maxInputResolution?: number;
|
||
};
|
||
|
||
type GeneratePayload = {
|
||
requestId?: string;
|
||
imageUrl?: string;
|
||
maskImageUrl?: string;
|
||
qualityProfile?: "preview" | "refine";
|
||
roi?:
|
||
| {
|
||
x?: number;
|
||
y?: number;
|
||
width?: number;
|
||
height?: number;
|
||
}
|
||
| null;
|
||
prompt?: string;
|
||
seed?: number;
|
||
imageSize?: "square" | "square_hd";
|
||
};
|
||
|
||
type BflSubmitResponse = {
|
||
id?: string;
|
||
polling_url?: string;
|
||
response?: {
|
||
id?: string;
|
||
polling_url?: string;
|
||
result?: unknown;
|
||
};
|
||
result?: unknown;
|
||
sample?: string;
|
||
error?: unknown;
|
||
message?: string;
|
||
};
|
||
|
||
const FALLBACK_REALTIME_PROMPT =
|
||
"严格按照输入草图生成:保持主体轮廓、位置、比例和数量一致,只做材质与光影细化;禁止改变构图,禁止新增无关背景纹理。";
|
||
const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai";
|
||
const DEFAULT_BFL_MODEL = "flux-2-klein-4b";
|
||
|
||
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 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;
|
||
}
|
||
|
||
function normalizeBaseUrl(raw: string | undefined) {
|
||
const next = String(raw || DEFAULT_BFL_BASE_URL).trim();
|
||
return (next || DEFAULT_BFL_BASE_URL).replace(/\/+$/, "");
|
||
}
|
||
|
||
function encodeModelPath(modelId: string) {
|
||
return modelId
|
||
.split("/")
|
||
.filter(Boolean)
|
||
.map((part) => encodeURIComponent(part))
|
||
.join("/");
|
||
}
|
||
|
||
function sanitizeText(raw: string) {
|
||
return raw.replace(/[\u2028\u2029]/g, " ").trim();
|
||
}
|
||
|
||
function extractBflImageUrl(payload: any): string {
|
||
const candidates = [
|
||
payload?.sample,
|
||
payload?.result?.sample,
|
||
payload?.response?.result?.sample,
|
||
payload?.output?.[0],
|
||
payload?.result?.output?.[0],
|
||
payload?.images?.[0],
|
||
payload?.result?.images?.[0],
|
||
payload?.image_url,
|
||
payload?.result?.image_url,
|
||
payload?.url,
|
||
payload?.result?.url,
|
||
];
|
||
|
||
for (const candidate of candidates) {
|
||
const value = String(candidate || "").trim();
|
||
if (!value) continue;
|
||
if (value.startsWith("data:")) return value;
|
||
if (/^https?:\/\//i.test(value)) return value;
|
||
}
|
||
|
||
return "";
|
||
}
|
||
|
||
function extractBflErrorMessage(payload: any, fallback: string) {
|
||
const message = String(
|
||
payload?.error?.message ||
|
||
payload?.error ||
|
||
payload?.detail ||
|
||
payload?.message ||
|
||
payload?.response?.error?.message ||
|
||
payload?.response?.error ||
|
||
""
|
||
).trim();
|
||
return message || fallback;
|
||
}
|
||
|
||
function shouldRetryWithAlternateImageField(message: string) {
|
||
const normalized = message.toLowerCase();
|
||
return (
|
||
normalized.includes("image_prompt") ||
|
||
normalized.includes("input_image") ||
|
||
normalized.includes("unsupported parameter") ||
|
||
normalized.includes("unknown field") ||
|
||
normalized.includes("invalid request")
|
||
);
|
||
}
|
||
|
||
function delay(ms: number) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function toImagePromptValue(imageUrl: string) {
|
||
const trimmed = imageUrl.trim();
|
||
if (!trimmed) return "";
|
||
if (trimmed.startsWith("data:")) {
|
||
const commaIndex = trimmed.indexOf(",");
|
||
if (commaIndex > -1) {
|
||
return trimmed.slice(commaIndex + 1).trim();
|
||
}
|
||
}
|
||
return trimmed;
|
||
}
|
||
|
||
function getBflImageFieldCandidates() {
|
||
// BFL's current FLUX/Kontext editing APIs use `input_image` as the canonical field.
|
||
// Keep `image_prompt` only as a legacy fallback in case an older compatible endpoint is used.
|
||
return ["input_image", "image_prompt"] as const;
|
||
}
|
||
|
||
type SubmitTaskResult = {
|
||
taskId: string;
|
||
pollingUrl: string;
|
||
imageUrl: string;
|
||
errorMessage: string;
|
||
status: number;
|
||
};
|
||
|
||
async function submitBflTask(params: {
|
||
apiKey: string;
|
||
baseUrl: string;
|
||
model: string;
|
||
body: Record<string, unknown>;
|
||
}): Promise<SubmitTaskResult> {
|
||
const endpoint = `${params.baseUrl}/v1/${encodeModelPath(params.model)}`;
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"x-key": params.apiKey,
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
},
|
||
body: JSON.stringify(params.body),
|
||
cache: "no-store",
|
||
});
|
||
const payload = (await response.json().catch(() => null)) as BflSubmitResponse | null;
|
||
const imageUrl = extractBflImageUrl(payload);
|
||
const taskId = String(payload?.id || payload?.response?.id || "").trim();
|
||
const pollingUrl = String(payload?.polling_url || payload?.response?.polling_url || "").trim();
|
||
|
||
if (response.ok && (imageUrl || taskId || pollingUrl)) {
|
||
return {
|
||
taskId,
|
||
pollingUrl,
|
||
imageUrl,
|
||
errorMessage: "",
|
||
status: response.status,
|
||
};
|
||
}
|
||
|
||
return {
|
||
taskId,
|
||
pollingUrl,
|
||
imageUrl,
|
||
errorMessage: extractBflErrorMessage(payload, `BFL 请求失败(${response.status})`),
|
||
status: response.status,
|
||
};
|
||
}
|
||
|
||
async function pollBflResult(params: {
|
||
apiKey: string;
|
||
baseUrl: string;
|
||
pollingUrl?: string;
|
||
taskId?: string;
|
||
timeoutMs?: number;
|
||
}) {
|
||
const timeoutMs = Math.max(20_000, Number(params.timeoutMs || 100_000));
|
||
const deadline = Date.now() + timeoutMs;
|
||
let lastMessage = "";
|
||
|
||
while (Date.now() < deadline) {
|
||
const url = (() => {
|
||
const direct = String(params.pollingUrl || "").trim();
|
||
if (direct) {
|
||
if (/^https?:\/\//i.test(direct)) return direct;
|
||
if (direct.startsWith("/")) return `${params.baseUrl}${direct}`;
|
||
}
|
||
const taskId = String(params.taskId || "").trim();
|
||
return `${params.baseUrl}/v1/get_result?id=${encodeURIComponent(taskId)}`;
|
||
})();
|
||
|
||
const response = await fetch(url, {
|
||
method: "GET",
|
||
headers: {
|
||
"x-key": params.apiKey,
|
||
Accept: "application/json",
|
||
},
|
||
cache: "no-store",
|
||
});
|
||
const payload = await response.json().catch(() => null);
|
||
const imageUrl = extractBflImageUrl(payload);
|
||
if (imageUrl) return { imageUrl, errorMessage: "", status: response.status };
|
||
|
||
const status = String(
|
||
payload?.status ||
|
||
payload?.task_status ||
|
||
payload?.response?.status ||
|
||
payload?.response?.task_status ||
|
||
""
|
||
)
|
||
.trim()
|
||
.toLowerCase();
|
||
if (status) {
|
||
lastMessage = status;
|
||
}
|
||
|
||
if (status === "ready" || status === "success" || status === "succeeded" || status === "completed") {
|
||
return {
|
||
imageUrl: "",
|
||
errorMessage: "BFL 返回成功状态但未携带图像结果",
|
||
status: response.status,
|
||
};
|
||
}
|
||
|
||
if (status === "error" || status === "failed" || status === "cancelled") {
|
||
return {
|
||
imageUrl: "",
|
||
errorMessage: extractBflErrorMessage(payload, "BFL 任务执行失败"),
|
||
status: response.status,
|
||
};
|
||
}
|
||
|
||
if (!response.ok) {
|
||
return {
|
||
imageUrl: "",
|
||
errorMessage: extractBflErrorMessage(payload, `BFL 轮询失败(${response.status})`),
|
||
status: response.status,
|
||
};
|
||
}
|
||
|
||
await delay(1_200);
|
||
}
|
||
|
||
return {
|
||
imageUrl: "",
|
||
errorMessage: `BFL 轮询超时${lastMessage ? `(${lastMessage})` : ""}`,
|
||
status: 504,
|
||
};
|
||
}
|
||
|
||
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 bflApiKey = String(
|
||
config.bflApiKey ||
|
||
config.runwareApiKey ||
|
||
config.falApiKey ||
|
||
config.apiKey ||
|
||
process.env.BFL_API_KEY ||
|
||
process.env.BFL_KEY ||
|
||
""
|
||
).trim();
|
||
if (!bflApiKey) {
|
||
return Response.json(
|
||
{
|
||
code: "BFL_KEY_MISSING",
|
||
message: "未配置 BFL API Key",
|
||
},
|
||
{ status: 500 }
|
||
);
|
||
}
|
||
|
||
const model = String(
|
||
config.bflModel ||
|
||
config.runwareModel ||
|
||
process.env.BFL_MODEL ||
|
||
process.env.RUNWARE_MODEL ||
|
||
DEFAULT_BFL_MODEL
|
||
).trim() || DEFAULT_BFL_MODEL;
|
||
const baseUrl = normalizeBaseUrl(config.bflBaseUrl || process.env.BFL_BASE_URL);
|
||
const maxInputResolution = clamp(Number(config.maxInputResolution || 1024), 512, 1536);
|
||
const size = resolveSquareSize(body.imageSize, maxInputResolution);
|
||
const seed = resolveSeed(body.seed);
|
||
|
||
const rawPrompt = sanitizeText(String(body.prompt || "").trim()) || FALLBACK_REALTIME_PROMPT;
|
||
const negativePrompt =
|
||
sanitizeText(String(config.negativePrompt || "").trim()) ||
|
||
"改变构图, 改变主体位置, 多余人物, 重复物体, 镜像翻转, 错误透视, 模糊轮廓, 文字水印, 线稿, 草图";
|
||
const positivePrompt = sanitizeText(
|
||
[
|
||
rawPrompt,
|
||
"Keep exact object silhouette, placement and count from the input sketch.",
|
||
"Photorealistic material and lighting only, keep composition unchanged.",
|
||
`Negative constraints: ${negativePrompt}`,
|
||
].join(" ")
|
||
);
|
||
|
||
const imagePrompt = toImagePromptValue(imageUrl);
|
||
if (!imagePrompt) {
|
||
return Response.json(
|
||
{
|
||
code: "BAD_REQUEST",
|
||
message: "输入图像格式无效",
|
||
},
|
||
{ status: 400 }
|
||
);
|
||
}
|
||
|
||
const imageFieldCandidates = getBflImageFieldCandidates();
|
||
|
||
let selectedImageField: "input_image" | "image_prompt" = imageFieldCandidates[0];
|
||
let submitResult: SubmitTaskResult | null = null;
|
||
let fallbackWarning = "";
|
||
|
||
for (let index = 0; index < imageFieldCandidates.length; index += 1) {
|
||
selectedImageField = imageFieldCandidates[index];
|
||
const requestBody: Record<string, unknown> = {
|
||
prompt: positivePrompt,
|
||
width: size,
|
||
height: size,
|
||
output_format: "jpeg",
|
||
safety_tolerance: 2,
|
||
[selectedImageField]: imagePrompt,
|
||
...(typeof seed === "number" ? { seed } : {}),
|
||
};
|
||
|
||
submitResult = await submitBflTask({
|
||
apiKey: bflApiKey,
|
||
baseUrl,
|
||
model,
|
||
body: requestBody,
|
||
});
|
||
|
||
if (!submitResult.errorMessage) {
|
||
break;
|
||
}
|
||
|
||
if (index < imageFieldCandidates.length - 1 && shouldRetryWithAlternateImageField(submitResult.errorMessage)) {
|
||
fallbackWarning = "模型参数兼容性已自动切换,继续生成。";
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (!submitResult || submitResult.errorMessage) {
|
||
return Response.json(
|
||
{
|
||
code: "BFL_REQUEST_FAILED",
|
||
message: submitResult?.errorMessage || "BFL 请求失败",
|
||
},
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
|
||
let finalImageUrl = submitResult.imageUrl;
|
||
if (!finalImageUrl) {
|
||
const pollResult = await pollBflResult({
|
||
apiKey: bflApiKey,
|
||
baseUrl,
|
||
pollingUrl: submitResult.pollingUrl,
|
||
taskId: submitResult.taskId,
|
||
timeoutMs: 110_000,
|
||
});
|
||
if (!pollResult.imageUrl) {
|
||
return Response.json(
|
||
{
|
||
code: "BFL_EMPTY_OUTPUT",
|
||
message: pollResult.errorMessage || "BFL 未返回图像",
|
||
},
|
||
{ status: 502 }
|
||
);
|
||
}
|
||
finalImageUrl = pollResult.imageUrl;
|
||
}
|
||
|
||
console.log("[RealtimeBFL] frame success", {
|
||
userId: session.user.id,
|
||
requestId: body.requestId || null,
|
||
model,
|
||
taskId: submitResult.taskId || null,
|
||
hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT),
|
||
imageField: selectedImageField,
|
||
qualityProfile: body.qualityProfile || "refine",
|
||
size,
|
||
roi: body.roi || null,
|
||
hasMask: Boolean(body.maskImageUrl),
|
||
warning: fallbackWarning || null,
|
||
});
|
||
|
||
return Response.json({
|
||
ok: true,
|
||
provider: "bfl",
|
||
model,
|
||
transport: "http-poll",
|
||
taskUUID: submitResult.taskId || undefined,
|
||
imageDataURI: finalImageUrl.startsWith("data:")
|
||
? finalImageUrl
|
||
: !/^https?:\/\//i.test(finalImageUrl)
|
||
? `data:image/jpeg;base64,${finalImageUrl}`
|
||
: undefined,
|
||
imageUrl: /^https?:\/\//i.test(finalImageUrl) ? finalImageUrl : undefined,
|
||
warning: fallbackWarning || undefined,
|
||
});
|
||
}
|