Your Name 2b6342bc7f
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: prioritize bfl input image editing
2026-03-07 22:09:06 +08:00

473 lines
13 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;
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,
});
}