From b5fdbd7c10837a77fcfe6ad1eb003b468dee7d00 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 6 Mar 2026 10:18:56 +0800 Subject: [PATCH] feat(realtime-canvas): migrate provider to BFL flux.2 --- .../settings/admin-settings-client.tsx | 179 +-- .../admin/realtime/model-capability/route.ts | 6 +- app/api/admin/settings/route.ts | 68 +- app/api/realtime/config/route.ts | 45 +- app/api/realtime/generate/route.ts | 1074 +++++------------ app/canvas/[id]/realtime/page.tsx | 2 +- components/canvas/RealtimeCanvasDialog.tsx | 6 +- lib/realtime/bfl-capability.ts | 159 +++ 8 files changed, 617 insertions(+), 922 deletions(-) create mode 100644 lib/realtime/bfl-capability.ts diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 6bad710d9..f0e085a82 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -102,8 +102,8 @@ import { import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); -const DEFAULT_RUNWARE_REALTIME_MODEL = "runware:101@1"; -const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1"; +const DEFAULT_BFL_REALTIME_MODEL = "flux-2-klein-4b"; +const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; function createEmptyCanvasSkillTag(): CanvasSkillTag { return { @@ -810,11 +810,9 @@ export function AdminSettingsClient() { // 实时画布API配置状态 const [realtimeCanvasConfig, setRealtimeCanvasConfig] = useState({ enabled: true, - falApiKey: "", - runwareModel: DEFAULT_RUNWARE_REALTIME_MODEL, - runwareStylizeModel: DEFAULT_RUNWARE_STYLIZE_MODEL, - runwareControlModel: "runware:25@1", - runwareControlPreprocessor: "canny" as "canny" | "lineart" | "depth", + bflApiKey: "", + bflBaseUrl: DEFAULT_BFL_BASE_URL, + bflModel: DEFAULT_BFL_REALTIME_MODEL, renderMode: "balanced" as "strict-structure" | "balanced" | "stylize", throttleInterval: 100, maxBuffering: 2, @@ -970,12 +968,12 @@ export function AdminSettingsClient() { if (realtimeCanvasConfigData?.setting?.value) { const config = realtimeCanvasConfigData.setting.value as { enabled?: boolean; + bflApiKey?: string; + bflBaseUrl?: string; + bflModel?: string; runwareApiKey?: string; falApiKey?: string; runwareModel?: string; - runwareStylizeModel?: string; - runwareControlModel?: string; - runwareControlPreprocessor?: "canny" | "lineart" | "depth"; renderMode?: "strict-structure" | "balanced" | "stylize"; throttleInterval?: number; maxBuffering?: number; @@ -990,15 +988,9 @@ export function AdminSettingsClient() { }; setRealtimeCanvasConfig({ enabled: config.enabled ?? true, - falApiKey: config.runwareApiKey || config.falApiKey || "", - runwareModel: config.runwareModel || DEFAULT_RUNWARE_REALTIME_MODEL, - runwareStylizeModel: config.runwareStylizeModel || DEFAULT_RUNWARE_STYLIZE_MODEL, - runwareControlModel: config.runwareControlModel || "runware:25@1", - runwareControlPreprocessor: - config.runwareControlPreprocessor === "lineart" || - config.runwareControlPreprocessor === "depth" - ? config.runwareControlPreprocessor - : "canny", + bflApiKey: config.bflApiKey || config.runwareApiKey || config.falApiKey || "", + bflBaseUrl: config.bflBaseUrl || DEFAULT_BFL_BASE_URL, + bflModel: config.bflModel || config.runwareModel || DEFAULT_BFL_REALTIME_MODEL, renderMode: config.renderMode === "strict-structure" || config.renderMode === "stylize" ? config.renderMode @@ -2031,36 +2023,29 @@ export function AdminSettingsClient() { // --- 实时画布API配置逻辑 --- const handleSaveRealtimeCanvasConfig = async () => { try { - const runwareApiKey = realtimeCanvasConfig.falApiKey.trim(); - const runwareModel = - realtimeCanvasConfig.runwareModel.trim() || DEFAULT_RUNWARE_REALTIME_MODEL; - const runwareStylizeModel = - realtimeCanvasConfig.runwareStylizeModel.trim() || DEFAULT_RUNWARE_STYLIZE_MODEL; - const runwareControlModel = - realtimeCanvasConfig.runwareControlModel.trim() || "runware:25@1"; + const bflApiKey = realtimeCanvasConfig.bflApiKey.trim(); + const bflBaseUrl = realtimeCanvasConfig.bflBaseUrl.trim() || DEFAULT_BFL_BASE_URL; + const bflModel = realtimeCanvasConfig.bflModel.trim() || DEFAULT_BFL_REALTIME_MODEL; - if (realtimeCanvasConfig.enabled && !runwareApiKey) { - toast.error("启用实时画布功能需要配置 Runware API Key"); + if (realtimeCanvasConfig.enabled && !bflApiKey) { + toast.error("启用实时画布功能需要配置 BFL API Key"); return; } if (realtimeCanvasConfig.enabled) { - for (const modelId of [runwareModel, runwareStylizeModel].filter(Boolean)) { - const probeResponse = await fetch("/api/admin/realtime/model-capability", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - apiKey: runwareApiKey, - modelId, - }), - }); - const probeData = await probeResponse.json().catch(() => null); - if (!probeResponse.ok || probeData?.supported === false) { - toast.error( - probeData?.message || "模型能力探针失败:当前模型不支持 seedImage,已阻止保存" - ); - return; - } + const probeResponse = await fetch("/api/admin/realtime/model-capability", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + apiKey: bflApiKey, + modelId: bflModel, + baseUrl: bflBaseUrl, + }), + }); + const probeData = await probeResponse.json().catch(() => null); + if (!probeResponse.ok || probeData?.supported === false) { + toast.error(probeData?.message || "模型能力探针失败,已阻止保存"); + return; } } @@ -2071,12 +2056,12 @@ export function AdminSettingsClient() { key: "realtime_canvas_config", value: { enabled: realtimeCanvasConfig.enabled, - runwareApiKey, - falApiKey: runwareApiKey, - runwareModel, - runwareStylizeModel, - runwareControlModel, - runwareControlPreprocessor: realtimeCanvasConfig.runwareControlPreprocessor, + bflApiKey, + bflBaseUrl, + bflModel, + runwareApiKey: bflApiKey, + falApiKey: bflApiKey, + runwareModel: bflModel, renderMode: realtimeCanvasConfig.renderMode, throttleInterval: Math.min(260, Math.max(60, Number(realtimeCanvasConfig.throttleInterval) || 100)), maxBuffering: Math.min(6, Math.max(1, Number(realtimeCanvasConfig.maxBuffering) || 2)), @@ -8359,7 +8344,7 @@ export function AdminSettingsClient() { 实时画布配置 - 配置实时画布功能的 Runware 参数、模型与 API Key。保存时会自动探针校验模型是否支持 seedImage。 + 配置实时画布功能的 BFL(FLUX.2)参数、模型与 API Key。保存时会自动探针校验模型可用性。 @@ -8384,67 +8369,50 @@ export function AdminSettingsClient() {
-
-
+ +
+ - setRealtimeCanvasConfig({ ...realtimeCanvasConfig, runwareModel: e.target.value }) + setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflModel: e.target.value }) } - placeholder={DEFAULT_RUNWARE_REALTIME_MODEL} + placeholder={DEFAULT_BFL_REALTIME_MODEL} disabled={!realtimeCanvasConfig.enabled} />
-
- - - setRealtimeCanvasConfig({ - ...realtimeCanvasConfig, - runwareStylizeModel: e.target.value, - }) - } - placeholder={DEFAULT_RUNWARE_STYLIZE_MODEL} - disabled={!realtimeCanvasConfig.enabled} - /> -
-
- - - setRealtimeCanvasConfig({ - ...realtimeCanvasConfig, - runwareControlModel: e.target.value, - }) - } - placeholder="runware:25@1" - disabled={!realtimeCanvasConfig.enabled} - /> -
-
+
-
- - -

- 推荐:主模型 {DEFAULT_RUNWARE_REALTIME_MODEL} + ControlNet {`runware:25@1`}。写实模型 {DEFAULT_RUNWARE_STYLIZE_MODEL} 作为回退链路。 + 推荐:实时草图模型使用 `flux-2-klein-4b`,高质量可切换 `flux-2-pro-preview`。

diff --git a/app/api/admin/realtime/model-capability/route.ts b/app/api/admin/realtime/model-capability/route.ts index cc9a8dcdd..d9166755d 100644 --- a/app/api/admin/realtime/model-capability/route.ts +++ b/app/api/admin/realtime/model-capability/route.ts @@ -1,11 +1,12 @@ import { z } from "zod"; import { requireAdmin } from "@/lib/middleware/admin"; import { ChatSDKError } from "@/lib/errors"; -import { probeRunwareSeedImageSupport } from "@/lib/realtime/runware-capability"; +import { probeBflModelSupport } from "@/lib/realtime/bfl-capability"; const schema = z.object({ apiKey: z.string().min(1), modelId: z.string().min(1), + baseUrl: z.string().optional(), }); function mapStatus(code: string) { @@ -19,9 +20,10 @@ export async function POST(request: Request) { const body = await request.json(); const data = schema.parse(body); - const result = await probeRunwareSeedImageSupport({ + const result = await probeBflModelSupport({ apiKey: data.apiKey, modelId: data.modelId, + baseUrl: data.baseUrl, }); if (!result.supported) { diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts index 032a5d9fd..1f8ac1ef1 100644 --- a/app/api/admin/settings/route.ts +++ b/app/api/admin/settings/route.ts @@ -6,16 +6,15 @@ import { upsertSystemSetting, } from "@/lib/db/queries"; import { ChatSDKError } from "@/lib/errors"; -import { probeRunwareSeedImageSupport } from "@/lib/realtime/runware-capability"; +import { probeBflModelSupport } from "@/lib/realtime/bfl-capability"; const settingSchema = z.object({ key: z.string(), value: z.any(), }); -const DEFAULT_RUNWARE_MODEL = "runware:101@1"; -const DEFAULT_RUNWARE_STYLIZE_MODEL = "runware:106@1"; -const DEFAULT_RUNWARE_CONTROL_MODEL = "runware:25@1"; +const DEFAULT_BFL_MODEL = "flux-2-klein-4b"; +const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; function isObjectLike(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -63,18 +62,11 @@ export async function POST(request: Request) { } const enabled = value.enabled !== false; - const runwareApiKey = String(value.runwareApiKey || value.falApiKey || value.apiKey || "").trim(); - const runwareModel = String(value.runwareModel || DEFAULT_RUNWARE_MODEL).trim() || DEFAULT_RUNWARE_MODEL; - const runwareStylizeModel = - String(value.runwareStylizeModel || DEFAULT_RUNWARE_STYLIZE_MODEL).trim() || - DEFAULT_RUNWARE_STYLIZE_MODEL; - const runwareControlModel = - String(value.runwareControlModel || DEFAULT_RUNWARE_CONTROL_MODEL).trim() || - DEFAULT_RUNWARE_CONTROL_MODEL; - const runwareControlPreprocessor = - value.runwareControlPreprocessor === "lineart" || value.runwareControlPreprocessor === "depth" - ? value.runwareControlPreprocessor - : "canny"; + const bflApiKey = String( + value.bflApiKey || value.runwareApiKey || value.falApiKey || value.apiKey || "" + ).trim(); + const bflModel = String(value.bflModel || value.runwareModel || DEFAULT_BFL_MODEL).trim() || DEFAULT_BFL_MODEL; + const bflBaseUrl = String(value.bflBaseUrl || DEFAULT_BFL_BASE_URL).trim() || DEFAULT_BFL_BASE_URL; const renderMode = value.renderMode === "strict-structure" || value.renderMode === "stylize" || @@ -83,34 +75,34 @@ export async function POST(request: Request) { : "balanced"; if (enabled) { - if (!runwareApiKey) { - return new ChatSDKError("bad_request:api", "启用实时画布必须配置 Runware API Key").toResponse(); + if (!bflApiKey) { + return new ChatSDKError("bad_request:api", "启用实时画布必须配置 BFL API Key").toResponse(); } - for (const modelId of [runwareModel, runwareStylizeModel].filter(Boolean)) { - const probeResult = await probeRunwareSeedImageSupport({ - apiKey: runwareApiKey, - modelId, - }); - if (!probeResult.supported) { - return Response.json( - { - code: probeResult.code, - message: probeResult.message, - }, - { status: mapProbeStatus(probeResult.code) } - ); - } + const probeResult = await probeBflModelSupport({ + apiKey: bflApiKey, + modelId: bflModel, + baseUrl: bflBaseUrl, + }); + if (!probeResult.supported) { + return Response.json( + { + code: probeResult.code, + message: probeResult.message, + }, + { status: mapProbeStatus(probeResult.code) } + ); } } nextValue = { ...value, - runwareApiKey, - falApiKey: runwareApiKey, - runwareModel, - runwareStylizeModel, - runwareControlModel, - runwareControlPreprocessor, + bflApiKey, + bflModel, + bflBaseUrl, + runwareApiKey: bflApiKey, + falApiKey: bflApiKey, + apiKey: bflApiKey, + runwareModel: bflModel, renderMode, }; } diff --git a/app/api/realtime/config/route.ts b/app/api/realtime/config/route.ts index c886af85a..b9264fe87 100644 --- a/app/api/realtime/config/route.ts +++ b/app/api/realtime/config/route.ts @@ -3,10 +3,11 @@ import { getSystemSetting } from "@/lib/db/queries"; type RealtimeCanvasConfig = { enabled?: boolean; + bflApiKey?: string; + bflModel?: string; + bflBaseUrl?: string; runwareApiKey?: string; runwareModel?: string; - runwareControlModel?: string; - runwareControlPreprocessor?: "canny" | "lineart" | "depth"; renderMode?: "strict-structure" | "balanced" | "stylize"; falApiKey?: string; apiKey?: string; @@ -38,15 +39,15 @@ const defaultConfig = { jpegQuality: 0.55, }, model: { - id: "runware:101@1", + id: "flux-2-klein-4b", defaults: { - steps: 8, + steps: 3, cfgScale: 7, strength: 0.32, imageSize: "square" as const, }, limits: { - steps: { min: 1, max: 12 }, + steps: { min: 1, max: 6 }, cfgScale: { min: 1, max: 14 }, strength: { min: 0.05, max: 1.0 }, }, @@ -77,20 +78,21 @@ export async function GET() { ); } - const runwareApiKey = String( - config.runwareApiKey || + const bflApiKey = String( + config.bflApiKey || + config.runwareApiKey || config.falApiKey || config.apiKey || - process.env.RUNWARE_API_KEY || - process.env.RUNWARE_KEY || + process.env.BFL_API_KEY || + process.env.BFL_KEY || "" ).trim(); - if (!runwareApiKey) { + if (!bflApiKey) { return Response.json( { - code: "RUNWARE_KEY_MISSING", - message: "未配置 Runware API Key(请在服务器环境变量或设置中配置)", + code: "BFL_KEY_MISSING", + message: "未配置 BFL API Key(请在服务器环境变量或设置中配置)", }, { status: 500 } ); @@ -138,13 +140,20 @@ export async function GET() { ); const imageSize = config.defaults?.imageSize === "square_hd" ? "square_hd" : "square"; const modelId = String( - config.runwareModel || process.env.RUNWARE_MODEL || defaultConfig.model.id + config.bflModel || + config.runwareModel || + process.env.BFL_MODEL || + process.env.RUNWARE_MODEL || + defaultConfig.model.id ).trim(); return Response.json({ ...defaultConfig, provider: { - type: "runware", + type: "bfl", + baseUrl: + String(config.bflBaseUrl || process.env.BFL_BASE_URL || "https://api.bfl.ai").trim() || + "https://api.bfl.ai", }, transport: { throttleInterval, @@ -171,13 +180,5 @@ export async function GET() { ? config.renderMode : "balanced", }, - control: { - model: String(config.runwareControlModel || "runware:25@1").trim() || "runware:25@1", - preprocessor: - config.runwareControlPreprocessor === "lineart" || - config.runwareControlPreprocessor === "depth" - ? config.runwareControlPreprocessor - : "canny", - }, }); } diff --git a/app/api/realtime/generate/route.ts b/app/api/realtime/generate/route.ts index 724a9ab0d..b0d095ea9 100644 --- a/app/api/realtime/generate/route.ts +++ b/app/api/realtime/generate/route.ts @@ -1,32 +1,17 @@ import { auth } from "@/app/(auth)/auth"; import { getSystemSetting } from "@/lib/db/queries"; -import { getRunwareWebSocketClient } from "@/lib/realtime/runware-ws"; type RealtimeCanvasConfig = { enabled?: boolean; + bflApiKey?: string; + bflModel?: string; + bflBaseUrl?: string; runwareApiKey?: string; runwareModel?: string; - runwareStylizeModel?: string; - runwareControlModel?: string; - runwareControlPreprocessor?: "canny" | "lineart" | "depth"; - renderMode?: "strict-structure" | "balanced" | "stylize"; - runwareUseWebSocket?: boolean; - falRealtimeEnabled?: boolean; - falRealtimeModel?: string; falApiKey?: string; apiKey?: string; negativePrompt?: string; - strictSketch?: boolean; maxInputResolution?: number; - defaults?: { - steps?: number; - cfgScale?: number; - strength?: number; - }; -}; - -type AngleEditConfig = { - falApiKey?: string; }; type GeneratePayload = { @@ -44,18 +29,27 @@ type GeneratePayload = { | null; prompt?: string; seed?: number; - strictSketch?: boolean; imageSize?: "square" | "square_hd"; - steps?: number; - cfgScale?: number; - strength?: number; +}; + +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 FALLBACK_FAL_REALTIME_MODEL = "fal-ai/flux-2/klein/realtime"; -const FALLBACK_RUNWARE_STYLIZE_MODEL = "runware:106@1"; -const FALLBACK_RUNWARE_CONTROL_MODEL = "runware:25@1"; +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)); @@ -70,35 +64,6 @@ function resolveSquareSize( 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; - if (Array.isArray(payload?.errors) && payload.errors.length > 0) { - const firstError = payload.errors[0]; - if (typeof firstError?.message === "string") return firstError.message; - if (typeof firstError?.code === "string") return firstError.code; - } - return ""; -} - function resolveSeed(seed: unknown) { const parsed = Number(seed); if (!Number.isFinite(parsed)) return undefined; @@ -107,126 +72,216 @@ function resolveSeed(seed: unknown) { return normalized; } -function shouldRetryWithoutMask(errorMessage: string) { - const normalized = errorMessage.toLowerCase(); - return ( - normalized.includes("unsupported use of 'maskimage'") || - normalized.includes("unsupported use of \"maskimage\"") || - normalized.includes("unsupported use of maskimage") || - normalized.includes("maskimage is not supported") - ); +function normalizeBaseUrl(raw: string | undefined) { + const next = String(raw || DEFAULT_BFL_BASE_URL).trim(); + return (next || DEFAULT_BFL_BASE_URL).replace(/\/+$/, ""); } -function shouldRetryWithoutNegativePrompt(errorMessage: string) { - const normalized = errorMessage.toLowerCase(); - return ( - normalized.includes("unsupportedarchitecturenegativeprompt") || - normalized.includes("negative prompt is not supported") || - normalized.includes("unsupported use of 'negativeprompt'") || - normalized.includes("unsupported use of \"negativeprompt\"") - ); +function encodeModelPath(modelId: string) { + return modelId + .split("/") + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join("/"); } -function shouldRetryWithoutControlNet(errorMessage: string) { - const normalized = errorMessage.toLowerCase(); - return ( - normalized.includes("unsupportedcontrolnetmodel") || - normalized.includes("unsupported use of 'controlnet'") || - normalized.includes("unsupported use of \"controlnet\"") || - normalized.includes("controlnet is not supported") - ); +function sanitizeText(raw: string) { + return raw.replace(/[\u2028\u2029]/g, " ").trim(); } -function resolveRenderModeSettings( - mode: "strict-structure" | "balanced" | "stylize" -) { - if (mode === "strict-structure") { - return { - controlWeight: 1, - seedStrengthMin: 0.14, - seedStrengthMax: 0.58, - }; - } - if (mode === "stylize") { - return { - controlWeight: 0.72, - seedStrengthMin: 0.28, - seedStrengthMax: 0.8, - }; - } - return { - controlWeight: 0.86, - seedStrengthMin: 0.2, - seedStrengthMax: 0.7, - }; -} +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, + ]; -function extractFalImageUrl(payload: any) { - const direct = - payload?.images?.[0]?.url || - payload?.image?.url || - payload?.output?.[0]?.url || - payload?.data?.images?.[0]?.url; - if (typeof direct === "string" && direct.trim()) { - return direct.trim(); + 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; } - const queue: any[] = [payload]; - const visited = new Set(); - while (queue.length > 0) { - const current = queue.shift(); - if (!current || typeof current !== "object" || visited.has(current)) continue; - visited.add(current); - for (const value of Object.values(current)) { - if (typeof value === "string" && /^https?:\/\//i.test(value)) { - return value; - } - if (value && typeof value === "object") { - queue.push(value); - } - } - } return ""; } -function extractRunwareOutputImage(payload: any, taskUUID: string) { - const resultList = Array.isArray(payload) - ? payload - : Array.isArray(payload?.data) - ? payload.data - : []; - const imageResult = - resultList.find( - (item: any) => - item?.taskUUID === 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; +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; +} + +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 { - imageResult, - imageDataURI, - imageURL, - outputImage, + taskId, + pollingUrl, + imageUrl, + errorMessage: extractBflErrorMessage(payload, `BFL 请求失败(${response.status})`), + status: response.status, }; } -function extractControlGuideImage(payload: any, taskUUID: string) { - const resultList = Array.isArray(payload) - ? payload - : Array.isArray(payload?.data) - ? payload.data - : []; - const guideResult = - resultList.find( - (item: any) => - item?.taskUUID === taskUUID && (item?.guideImageURL || item?.guideImageDataURI) - ) || - resultList.find((item: any) => item?.guideImageURL || item?.guideImageDataURI); - const guideImageDataURI = String(guideResult?.guideImageDataURI || "").trim(); - const guideImageURL = String(guideResult?.guideImageURL || "").trim(); - return guideImageDataURI || guideImageURL; +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) { @@ -246,14 +301,9 @@ export async function POST(request: Request) { if (!imageUrl) { return Response.json({ code: "BAD_REQUEST", message: "缺少 imageUrl" }, { status: 400 }); } - const maskImageUrl = String(body.maskImageUrl || "").trim(); - const [setting, angleEditSetting] = await Promise.all([ - getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null), - getSystemSetting({ key: "angle_edit_config" }).catch(() => null), - ]); + const setting = await getSystemSetting({ key: "realtime_canvas_config" }).catch(() => null); const config = (setting?.value || {}) as RealtimeCanvasConfig; - const angleEditConfig = (angleEditSetting?.value || {}) as AngleEditConfig; if (config.enabled === false) { return Response.json( @@ -262,615 +312,159 @@ export async function POST(request: Request) { ); } - const runwareApiKey = String( - config.runwareApiKey || + const bflApiKey = String( + config.bflApiKey || + config.runwareApiKey || config.falApiKey || config.apiKey || - process.env.RUNWARE_API_KEY || - process.env.RUNWARE_KEY || + process.env.BFL_API_KEY || + process.env.BFL_KEY || "" ).trim(); - const falApiKey = String( - config.falApiKey || - angleEditConfig.falApiKey || - process.env.FAL_KEY || - process.env.FAL_API_KEY || - "" - ).trim(); - if (!runwareApiKey) { + if (!bflApiKey) { return Response.json( { - code: "RUNWARE_KEY_MISSING", - message: "未配置 Runware API Key", + code: "BFL_KEY_MISSING", + message: "未配置 BFL API Key", }, { status: 500 } ); } - const fallbackSeedImageModel = "runware:101@1"; - const configuredModel = String( - config.runwareModel || process.env.RUNWARE_MODEL || fallbackSeedImageModel - ).trim(); - const model = configuredModel || fallbackSeedImageModel; - const configuredStylizeModel = String( - config.runwareStylizeModel || - process.env.RUNWARE_REALTIME_STYLIZE_MODEL || - FALLBACK_RUNWARE_STYLIZE_MODEL - ).trim(); - const stylizeModel = configuredStylizeModel || FALLBACK_RUNWARE_STYLIZE_MODEL; - const configuredControlModel = String( - config.runwareControlModel || - process.env.RUNWARE_REALTIME_CONTROL_MODEL || - FALLBACK_RUNWARE_CONTROL_MODEL - ).trim(); - const controlModel = configuredControlModel || FALLBACK_RUNWARE_CONTROL_MODEL; - const controlPreprocessor = - config.runwareControlPreprocessor === "lineart" || - config.runwareControlPreprocessor === "depth" - ? config.runwareControlPreprocessor - : "canny"; - const renderMode = - config.renderMode === "strict-structure" || - config.renderMode === "stylize" || - config.renderMode === "balanced" - ? config.renderMode - : "balanced"; - const renderModeSettings = resolveRenderModeSettings(renderMode); - const falRealtimeModel = String( - config.falRealtimeModel || process.env.FAL_REALTIME_MODEL || FALLBACK_FAL_REALTIME_MODEL - ).trim(); - const preferWebSocket = - config.runwareUseWebSocket !== false && - process.env.RUNWARE_REALTIME_USE_WS !== "0"; + 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 qualityProfile = body.qualityProfile === "preview" ? "preview" : "refine"; - 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 rawPrompt = String(body.prompt || "").trim(); - const hasUserPrompt = - rawPrompt.length > 0 && rawPrompt !== FALLBACK_REALTIME_PROMPT; - const positivePrompt = hasUserPrompt - ? [ - `User prompt: ${rawPrompt}`, - "Keep the object layout and perspective from the sketch.", - "Keep the exact silhouette and part topology from the sketch (same count of legs/supports and same frame structure).", - "The main object category must exactly match the user prompt.", - "Do not swap object class (for example, chair must remain chair, never animal).", - "Do not add bulky cushions, thick armrests, or boxy masses unless they clearly exist in the sketch.", - "Render as a full-color photorealistic product image with realistic material, lighting, and texture.", - "No sketch style, no line art outlines, no monochrome drawing.", - ].join(" ") - : FALLBACK_REALTIME_PROMPT; + const rawPrompt = sanitizeText(String(body.prompt || "").trim()) || FALLBACK_REALTIME_PROMPT; const negativePrompt = - String(config.negativePrompt || "").trim() || - "改变构图, 改变主体位置, 多余人物, 多余肢体, 重复物体, 镜像翻转, 错误透视, 模糊轮廓, 文字水印, 大面积背景噪点, 线稿, 线描, 草图, 黑白素描, 轮廓线, line art, sketch, outline drawing, monochrome drawing, wrong object category, unrelated subject, animal when furniture requested, furniture when animal requested"; - let fallbackWarning = ""; + 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 useFalRealtime = - config.falRealtimeEnabled === true || process.env.FAL_REALTIME_ENABLED === "1"; - - if (hasUserPrompt && qualityProfile === "refine" && falApiKey && useFalRealtime) { - try { - const fal = await import("@fal-ai/serverless-client"); - fal.config({ - credentials: falApiKey, - }); - - const falInput: Record = { - prompt: rawPrompt, - negative_prompt: negativePrompt, - image_url: imageUrl, - image_size: body.imageSize === "square_hd" ? "square_hd" : "square", - num_inference_steps: 6, - schedule_mu: 2.3, - output_feedback_strength: 0.95, - sync_mode: true, - }; - - const falResult: any = await fal.subscribe(falRealtimeModel, { - input: falInput, - logs: false, - timeout: 90_000, - }); - - const falImageUrl = extractFalImageUrl(falResult); - if (falImageUrl) { - console.log("[RealtimeFal] frame success", { - userId: session.user.id, - requestId: body.requestId || null, - model: falRealtimeModel, - promptLength: rawPrompt.length, - qualityProfile, - }); - return Response.json({ - ok: true, - provider: "fal", - model: falRealtimeModel, - transport: "fal-http", - imageUrl: falImageUrl, - cost: 0, - }); - } - console.warn("[RealtimeFal] empty output, fallback to Runware", { - userId: session.user.id, - requestId: body.requestId || null, - model: falRealtimeModel, - }); - } catch (error) { - fallbackWarning = - error instanceof Error && /unauthorized|forbidden/i.test(error.message) - ? "FAL 实时模型鉴权失败,当前已回退为 Runware 草图模式;请在系统设置更新有效的 FAL API Key。" - : "FAL 实时模型调用失败,当前已回退为 Runware 草图模式。"; - console.warn("[RealtimeFal] request failed, fallback to Runware", { - userId: session.user.id, - requestId: body.requestId || null, - model: falRealtimeModel, - error: error instanceof Error ? error.message : String(error), - }); - } - } - - type InferenceRequestResult = { - taskUUID: string; - modelId: string; - runtimeStrength: number; - runtimeCfgScale: number; - runtimeSteps: number; - effectiveStrictSketch: boolean; - runtimeSeed?: number; - usedMaskImage: boolean; - controlGuideImage: string; - controlNetModel: string; - path: "seed-image" | "controlnet"; - transport: "ws" | "http"; - upstreamStatus: number; - upstreamOk: boolean; - upstreamJson: any; - upstreamError: string; - wsFallbackError: string; - }; - - type RunwareTaskPayload = { - taskType: string; - taskUUID?: string; - [key: string]: unknown; - }; - - const sendRunwareTask = async (runwareTask: RunwareTaskPayload, timeoutMs = 90_000) => { - let transport: "ws" | "http" = "http"; - let upstreamStatus = 0; - let upstreamOk = false; - let upstreamJson: any = null; - let upstreamError = ""; - let wsFallbackError = ""; - - if (preferWebSocket) { - try { - const wsClient = getRunwareWebSocketClient(runwareApiKey); - upstreamJson = await wsClient.sendTask(runwareTask, timeoutMs); - upstreamError = parseRunwareError(upstreamJson); - upstreamStatus = upstreamError ? 502 : 200; - upstreamOk = !upstreamError; - transport = "ws"; - } catch (error) { - wsFallbackError = error instanceof Error ? error.message : "Runware WebSocket 调用失败"; - } - } - - if (!upstreamJson) { - 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([runwareTask]), - cache: "no-store", - }); - upstreamJson = await upstreamResponse.json().catch(() => null); - upstreamError = parseRunwareError(upstreamJson); - upstreamStatus = upstreamResponse.status; - upstreamOk = upstreamResponse.ok && !upstreamError; - transport = "http"; - } - - return { - transport, - upstreamStatus, - upstreamOk, - upstreamJson, - upstreamError, - wsFallbackError, - }; - }; - - const requestInference = async ( - modelId: string, - useMaskImage: boolean, - sourceImageUrl: string - ): Promise => { - const taskUUID = crypto.randomUUID(); - const usePromptStylize = hasUserPrompt; - const previewMode = qualityProfile === "preview"; - const isStylizePass = - usePromptStylize && - qualityProfile === "refine" && - Boolean(stylizeModel) && - modelId === stylizeModel; - const relaxStructureForPrompt = usePromptStylize && !isStylizePass; - const effectiveStrictSketch = strictSketch && !relaxStructureForPrompt; - const profileStrength = usePromptStylize - ? previewMode - ? clamp(Math.max(strength, 0.58), 0.05, 1) - : isStylizePass - ? clamp(Math.max(strength, 0.72), 0.05, 1) - : clamp(Math.max(strength, 0.9), 0.05, 1) - : previewMode - ? clamp(strength * 0.85, 0.05, 1) - : strength; - const profileCfgScale = usePromptStylize - ? previewMode - ? clamp(Math.max(5.5, Math.min(cfgScale, 8.5)), 1, 14) - : isStylizePass - ? clamp(Math.max(6.5, Math.min(cfgScale, 8.5)), 1, 14) - : clamp(Math.max(6, Math.min(cfgScale, 8.5)), 1, 14) - : previewMode - ? clamp(cfgScale * 0.9, 1, 14) - : cfgScale; - const profileSteps = usePromptStylize - ? previewMode - ? clamp(Math.max(steps, 6), 1, 12) - : isStylizePass - ? clamp(Math.max(steps, 8), 1, 10) - : clamp(Math.max(steps, 12), 1, 12) - : previewMode - ? clamp(Math.min(steps, 6), 1, 12) - : clamp(Math.max(steps, 6), 1, 12); - const runtimeStrength = effectiveStrictSketch - ? (() => { - const minStrength = usePromptStylize && !previewMode ? 0.2 : 0.12; - const maxStrength = - isStylizePass ? 0.62 : usePromptStylize && !previewMode ? 0.72 : 0.55; - return clamp(profileStrength, minStrength, maxStrength); - })() - : profileStrength; - const runtimeCfgScale = effectiveStrictSketch - ? clamp( - profileCfgScale, - isStylizePass ? 6.5 : usePromptStylize && !previewMode ? 5 : 6, - 14 - ) - : profileCfgScale; - const runtimeSeed = relaxStructureForPrompt ? undefined : seed; - const isRunware106 = modelId.toLowerCase().startsWith("runware:106@"); - const inferenceSize = isRunware106 ? 1024 : size; - const runtimeStrengthLimited = clamp( - runtimeStrength, - renderModeSettings.seedStrengthMin, - renderModeSettings.seedStrengthMax - ); - const runwareTask = { - taskType: "imageInference", - taskUUID, - model: modelId, - positivePrompt, - negativePrompt, - seedImage: sourceImageUrl, - width: inferenceSize, - height: inferenceSize, - strength: runtimeStrengthLimited, - CFGScale: runtimeCfgScale, - steps: profileSteps, - includeCost: true, - numberResults: 1, - outputType: "dataURI", - outputFormat: "JPG", - ...(useMaskImage && maskImageUrl ? { maskImage: maskImageUrl } : {}), - ...(typeof runtimeSeed === "number" ? { seed: runtimeSeed } : {}), - }; - - const upstream = await sendRunwareTask(runwareTask); - - return { - taskUUID, - modelId, - runtimeStrength: runtimeStrengthLimited, - runtimeCfgScale, - runtimeSteps: profileSteps, - effectiveStrictSketch, - runtimeSeed, - usedMaskImage: useMaskImage && Boolean(maskImageUrl), - controlGuideImage: "", - controlNetModel: "", - path: "seed-image" as const, - ...upstream, - }; - }; - - const requestWithMaskFallback = async ( - modelId: string, - useMaskImage: boolean, - sourceImageUrl: string - ) => { - let requestResult = await requestInference(modelId, useMaskImage, sourceImageUrl); - let maskEnabled = useMaskImage; - - if ( - maskEnabled && - shouldRetryWithoutMask(requestResult.upstreamError || "") - ) { - requestResult = await requestInference(modelId, false, sourceImageUrl); - maskEnabled = false; - } - - return { requestResult, maskEnabled }; - }; - - const requestControlNetGuide = async (sourceImageUrl: string) => { - const taskUUID = crypto.randomUUID(); - const controlTask = { - taskType: "imageControlNetPreProcess", - taskUUID, - inputImage: sourceImageUrl, - preProcessorType: controlPreprocessor, - }; - const upstream = await sendRunwareTask(controlTask, 45_000); - return { - taskUUID, - guideImage: extractControlGuideImage(upstream.upstreamJson, taskUUID), - ...upstream, - }; - }; - - const requestControlNetInference = async ( - modelId: string, - guideImage: string, - includeNegativePrompt: boolean - ): Promise => { - const taskUUID = crypto.randomUUID(); - const previewMode = qualityProfile === "preview"; - const profileSteps = previewMode - ? clamp(Math.max(steps, 6), 1, 12) - : clamp(Math.max(steps, 10), 1, 12); - const profileCfgScale = previewMode - ? clamp(Math.max(5, Math.min(cfgScale, 8)), 1, 14) - : clamp(Math.max(6, Math.min(cfgScale, 9)), 1, 14); - const controlWeight = previewMode - ? clamp(renderModeSettings.controlWeight - 0.06, 0.55, 1) - : renderModeSettings.controlWeight; - - const runwareTask: RunwareTaskPayload = { - taskType: "imageInference", - taskUUID, - model: modelId, - positivePrompt, - width: size, - height: size, - CFGScale: profileCfgScale, - steps: profileSteps, - includeCost: true, - numberResults: 1, - outputType: "dataURI", - outputFormat: "JPG", - ...(typeof seed === "number" ? { seed } : {}), - ...(includeNegativePrompt ? { negativePrompt } : {}), - controlNet: [ - { - model: controlModel, - guideImage, - weight: controlWeight, - startStep: 0, - endStep: profileSteps, - controlMode: "balanced", - }, - ], - }; - - const upstream = await sendRunwareTask(runwareTask, 120_000); - return { - taskUUID, - modelId, - runtimeStrength: 0, - runtimeCfgScale: profileCfgScale, - runtimeSteps: profileSteps, - effectiveStrictSketch: strictSketch, - runtimeSeed: typeof seed === "number" ? seed : undefined, - usedMaskImage: false, - controlGuideImage: guideImage, - controlNetModel: controlModel, - path: "controlnet" as const, - ...upstream, - }; - }; - - const runTwoPassStylize = false; - let maskEnabled = Boolean(maskImageUrl); - let requestResult: InferenceRequestResult; - let pathLabel: "seed-image" | "controlnet" = "seed-image"; - let controlGuideUsed = ""; - let controlPreprocessUsed = false; - let controlGuideFallback = false; - - const shouldUseControlNetPath = - hasUserPrompt && qualityProfile === "refine" && strictSketch; - - if (shouldUseControlNetPath) { - const guideResult = await requestControlNetGuide(imageUrl); - const preprocessOk = guideResult.upstreamOk && !guideResult.upstreamError; - const guideImage = preprocessOk && guideResult.guideImage ? guideResult.guideImage : imageUrl; - controlGuideUsed = guideImage; - controlPreprocessUsed = preprocessOk; - controlGuideFallback = !preprocessOk || !guideResult.guideImage; - - if (!preprocessOk && (guideResult.upstreamError || "").trim()) { - fallbackWarning = "ControlNet 预处理失败,已回退使用原始线稿引导。"; - } - - let controlResult = await requestControlNetInference(model, guideImage, true); - if (shouldRetryWithoutNegativePrompt(controlResult.upstreamError || "")) { - controlResult = await requestControlNetInference(model, guideImage, false); - } - - if (controlResult.upstreamOk && !controlResult.upstreamError) { - requestResult = controlResult; - maskEnabled = false; - pathLabel = "controlnet"; - } else if (shouldRetryWithoutControlNet(controlResult.upstreamError || "")) { - fallbackWarning = "当前模型不支持 ControlNet,已回退到 seedImage 模式。"; - const fallbackPass = await requestWithMaskFallback(stylizeModel || model, maskEnabled, imageUrl); - requestResult = fallbackPass.requestResult; - maskEnabled = fallbackPass.maskEnabled; - pathLabel = "seed-image"; - } else { - requestResult = controlResult; - maskEnabled = false; - pathLabel = "controlnet"; - } - } else if (runTwoPassStylize) { - const basePass = await requestWithMaskFallback(model, maskEnabled, imageUrl); - requestResult = basePass.requestResult; - maskEnabled = basePass.maskEnabled; - - if (requestResult.upstreamOk && !requestResult.upstreamError) { - const baseImage = extractRunwareOutputImage( - requestResult.upstreamJson, - requestResult.taskUUID - ).outputImage; - if (baseImage) { - console.log("[RealtimeRunware] two-pass base success", { - userId: session.user.id, - requestId: body.requestId || null, - baseModel: model, - stylizeModel, - baseImageType: baseImage.startsWith("data:") ? "dataURI" : "url", - }); - const stylizePass = await requestWithMaskFallback(stylizeModel, false, baseImage); - requestResult = stylizePass.requestResult; - maskEnabled = stylizePass.maskEnabled; - } - } - } else { - const initialModel = - hasUserPrompt && qualityProfile === "refine" ? stylizeModel || model : model; - const initialPass = await requestWithMaskFallback(initialModel, maskEnabled, imageUrl); - requestResult = initialPass.requestResult; - maskEnabled = initialPass.maskEnabled; - pathLabel = "seed-image"; - } - - if (pathLabel === "controlnet" && (!requestResult.upstreamOk || requestResult.upstreamError)) { - fallbackWarning = "ControlNet 推理失败,已自动回退为 seedImage 模式。"; - const fallbackPass = await requestWithMaskFallback(stylizeModel || model, maskEnabled, imageUrl); - requestResult = fallbackPass.requestResult; - maskEnabled = fallbackPass.maskEnabled; - pathLabel = "seed-image"; - } - - const shouldRetryWithSeedImageModel = - (requestResult.upstreamError || "") - .toLowerCase() - .includes("unsupported use of 'seedimage' parameter") && - model !== fallbackSeedImageModel; - - if (shouldRetryWithSeedImageModel) { - fallbackWarning = - "当前模型不支持草图引导,已自动切换到兼容的实时草图模型。"; - const fallbackResult = await requestWithMaskFallback( - fallbackSeedImageModel, - maskEnabled, - imageUrl - ); - requestResult = fallbackResult.requestResult; - maskEnabled = fallbackResult.maskEnabled; - } - - if (!requestResult.upstreamOk || requestResult.upstreamError) { + const imagePrompt = toImagePromptValue(imageUrl); + if (!imagePrompt) { return Response.json( { - code: "RUNWARE_REQUEST_FAILED", - message: - requestResult.upstreamError || - requestResult.wsFallbackError || - `Runware 请求失败(${requestResult.upstreamStatus})`, + code: "BAD_REQUEST", + message: "输入图像格式无效", + }, + { status: 400 } + ); + } + + const preferredImageField = + model.toLowerCase().includes("kontext") || model.toLowerCase().includes("edit") + ? "input_image" + : "image_prompt"; + const imageFieldCandidates = [preferredImageField, preferredImageField === "input_image" ? "image_prompt" : "input_image"]; + + let selectedImageField = 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 } ); } - const { imageResult, imageDataURI, imageURL, outputImage } = extractRunwareOutputImage( - requestResult.upstreamJson, - requestResult.taskUUID - ); - - if (!outputImage) { - return Response.json( - { code: "RUNWARE_EMPTY_OUTPUT", message: "Runware 未返回图像" }, - { 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; } - const cost = Number(imageResult?.cost || 0); - console.log("[RealtimeRunware] frame success", { + console.log("[RealtimeBFL] frame success", { userId: session.user.id, requestId: body.requestId || null, - taskUUID: requestResult.taskUUID, - model: requestResult.modelId, - transport: requestResult.transport, - seed: typeof requestResult.runtimeSeed === "number" ? requestResult.runtimeSeed : null, - hasUserPrompt, - promptLength: rawPrompt.length, - promptPreview: hasUserPrompt ? rawPrompt.slice(0, 32) : null, - qualityProfile, - steps: requestResult.runtimeSteps, - cfgScale: requestResult.runtimeCfgScale, - strength: requestResult.runtimeStrength, - strictSketchApplied: requestResult.effectiveStrictSketch, - maskEnabled, + model, + taskId: submitResult.taskId || null, + hasPrompt: Boolean(rawPrompt && rawPrompt !== FALLBACK_REALTIME_PROMPT), + imageField: selectedImageField, + qualityProfile: body.qualityProfile || "refine", + size, roi: body.roi || null, - cost, - renderMode, - path: pathLabel, - controlNetModel: pathLabel === "controlnet" ? requestResult.controlNetModel : null, - controlGuideType: - pathLabel === "controlnet" && controlGuideUsed - ? controlGuideUsed.startsWith("data:") - ? "dataURI" - : "url" - : null, - controlGuideFallback, - controlPreprocessUsed, - twoPassStylize: runTwoPassStylize, - fallbackWarning: fallbackWarning || null, + hasMask: Boolean(body.maskImageUrl), + warning: fallbackWarning || null, }); return Response.json({ ok: true, - provider: "runware", - model: requestResult.modelId, - transport: requestResult.transport, - taskUUID: requestResult.taskUUID, - path: pathLabel, - imageDataURI: imageDataURI || undefined, - imageUrl: imageURL || undefined, - cost, + 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, }); } diff --git a/app/canvas/[id]/realtime/page.tsx b/app/canvas/[id]/realtime/page.tsx index 19768a521..9596f20c4 100644 --- a/app/canvas/[id]/realtime/page.tsx +++ b/app/canvas/[id]/realtime/page.tsx @@ -54,7 +54,7 @@ export default function CanvasRealtimePage() { status: "completed", title: "实时画布结果", prompt, - model: "fal-ai/flux-2/klein/realtime", + model: "bfl/flux-2", }, connections: [], }); diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 2e514e441..7321b33e0 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -96,9 +96,9 @@ const defaultRealtimeConfig: RealtimeConfig = { jpegQuality: 0.55, }, model: { - id: "runware:101@1", + id: "flux-2-klein-4b", defaults: { - steps: 8, + steps: 3, cfgScale: 7, strength: 0.32, imageSize: "square", @@ -1414,7 +1414,7 @@ export function RealtimeCanvasDialog({ if (cancelled) return; connectionRef.current = { - provider: "runware", + provider: "bfl", modelId: resolvedConfig.model.id, reconnectToken: refreshToken, }; diff --git a/lib/realtime/bfl-capability.ts b/lib/realtime/bfl-capability.ts new file mode 100644 index 000000000..79a703627 --- /dev/null +++ b/lib/realtime/bfl-capability.ts @@ -0,0 +1,159 @@ +const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; +const DEFAULT_BFL_MODEL_ID = "flux-2-klein-4b"; +const DEFAULT_TIMEOUT_MS = 15_000; + +export type BflCapabilityCode = + | "SUPPORTED" + | "AUTH_FAILED" + | "MODEL_INVALID" + | "UPSTREAM_ERROR"; + +export type BflModelProbeResult = { + supported: boolean; + code: BflCapabilityCode; + message: string; + status?: number; +}; + +type BflProbeParams = { + apiKey: string; + modelId?: string; + baseUrl?: string; + timeoutMs?: number; +}; + +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 toErrorMessage(payload: any, fallback: string) { + const direct = String( + payload?.error?.message || + payload?.error || + payload?.message || + payload?.detail || + payload?.msg || + "" + ).trim(); + return direct || fallback; +} + +function looksLikeModelInvalid(message: string) { + const normalized = message.toLowerCase(); + return ( + normalized.includes("model") && + (normalized.includes("not found") || + normalized.includes("invalid") || + normalized.includes("unknown") || + normalized.includes("does not exist")) + ); +} + +function hasTaskIdentity(payload: any) { + return Boolean( + (typeof payload?.id === "string" && payload.id.trim()) || + (typeof payload?.polling_url === "string" && payload.polling_url.trim()) || + (typeof payload?.response?.id === "string" && payload.response.id.trim()) || + (typeof payload?.response?.polling_url === "string" && + payload.response.polling_url.trim()) + ); +} + +export async function probeBflModelSupport(params: BflProbeParams): Promise { + const apiKey = String(params.apiKey || "").trim(); + const modelId = String(params.modelId || DEFAULT_BFL_MODEL_ID).trim() || DEFAULT_BFL_MODEL_ID; + const baseUrl = normalizeBaseUrl(params.baseUrl); + const timeoutMs = Math.max(3_000, Number(params.timeoutMs || DEFAULT_TIMEOUT_MS)); + + if (!apiKey) { + return { + supported: false, + code: "AUTH_FAILED", + message: "缺少 BFL API Key。", + }; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const endpoint = `${baseUrl}/v1/${encodeModelPath(modelId)}`; + const response = await fetch(endpoint, { + method: "POST", + headers: { + "x-key": apiKey, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + prompt: "probe image generation capability", + width: 512, + height: 512, + safety_tolerance: 2, + }), + cache: "no-store", + signal: controller.signal, + }); + + const payload = await response.json().catch(() => null); + const message = toErrorMessage(payload, `BFL 探针请求失败(${response.status})`); + + if (response.ok && hasTaskIdentity(payload)) { + return { + supported: true, + code: "SUPPORTED", + message: "模型可用。", + status: response.status, + }; + } + + if (response.status === 401 || response.status === 403) { + return { + supported: false, + code: "AUTH_FAILED", + message: "BFL API Key 无效或无权限,请检查后重试。", + status: response.status, + }; + } + + if (response.status === 404 || looksLikeModelInvalid(message)) { + return { + supported: false, + code: "MODEL_INVALID", + message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`, + status: response.status, + }; + } + + return { + supported: false, + code: "UPSTREAM_ERROR", + message, + status: response.status, + }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { + supported: false, + code: "UPSTREAM_ERROR", + message: "BFL 探针请求超时,请稍后重试。", + }; + } + return { + supported: false, + code: "UPSTREAM_ERROR", + message: error instanceof Error ? error.message : "BFL 探针请求失败。", + }; + } finally { + clearTimeout(timer); + } +}