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; }): Promise { 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 = { 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, }); }