@@ -8384,67 +8369,50 @@ export function AdminSettingsClient() {
-
-
- Runware 模型 ID *
+ BFL API Base URL
+
+ setRealtimeCanvasConfig({ ...realtimeCanvasConfig, bflBaseUrl: e.target.value })
+ }
+ placeholder={DEFAULT_BFL_BASE_URL}
+ disabled={!realtimeCanvasConfig.enabled}
+ />
+
+
+
+
+ FLUX.2 模型 ID *
- 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}
/>
-
- 写实增强模型 ID
-
- setRealtimeCanvasConfig({
- ...realtimeCanvasConfig,
- runwareStylizeModel: e.target.value,
- })
- }
- placeholder={DEFAULT_RUNWARE_STYLIZE_MODEL}
- disabled={!realtimeCanvasConfig.enabled}
- />
-
-
- ControlNet 模型 ID
-
- setRealtimeCanvasConfig({
- ...realtimeCanvasConfig,
- runwareControlModel: e.target.value,
- })
- }
- placeholder="runware:25@1"
- disabled={!realtimeCanvasConfig.enabled}
- />
-
-
+
实时渲染模式
-
- ControlNet 预处理
-
-
- 推荐:主模型 {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);
+ }
+}