ai-chatbot/lib/ai/providers/bfl-image.ts
Your Name ccc2e45b12
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
Add BFL image runtime provider
2026-03-21 01:36:53 +08:00

479 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import sharp from "sharp";
import { uploadFile } from "@/lib/storage/service";
interface BflImageConfig {
apiKey: string;
baseURL: string;
}
type UploadedImage = {
thumbnail: string;
original: string;
};
type BflImageParams = {
aspectRatio?: string;
imageSize?: string;
seed?: number;
};
type BflSubmitResponse = {
id?: string;
status?: string;
task_status?: string;
polling_url?: string;
response?: {
id?: string;
status?: string;
task_status?: string;
polling_url?: string;
result?: unknown;
};
result?: unknown;
sample?: string;
output?: unknown;
images?: unknown;
image_url?: string;
error?: unknown;
message?: string;
detail?: string;
};
const DEFAULT_TIMEOUT_MS = 180000;
const SUPPORTED_ASPECT_RATIOS = [
"1:1",
"2:3",
"3:2",
"3:4",
"4:3",
"4:5",
"5:4",
"9:16",
"16:9",
] as const;
function normalizeBaseURL(baseURL: string) {
const normalized = String(baseURL || "").trim().replace(/\/+$/, "");
if (!normalized) return "https://api.bfl.ai";
return /\/v1$/i.test(normalized) ? normalized.slice(0, -3) : normalized;
}
function encodeModelPath(modelId: string) {
return modelId
.split("/")
.filter(Boolean)
.map((part) => encodeURIComponent(part))
.join("/");
}
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
const normalized = String(imageSize || "").trim().toUpperCase();
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
return "2K";
}
function normalizeAspectRatio(value?: string) {
const normalized = String(value || "").trim();
if (!normalized || normalized.toLowerCase() === "auto") return "auto";
if ((SUPPORTED_ASPECT_RATIOS as readonly string[]).includes(normalized)) return normalized;
return undefined;
}
function parseAspectRatioValue(ratio: string) {
const [width, height] = ratio.split(":").map((item) => Number(item));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 1;
}
return width / height;
}
function roundToMultipleOf32(value: number) {
return Math.max(32, Math.round(value / 32) * 32);
}
function resolveBflDimensions(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
const baseMap: Record<typeof imageSize, number> = {
"1K": 1024,
"2K": 1536,
"4K": 2048,
};
const base = baseMap[imageSize] || 1536;
const ratioValue = parseAspectRatioValue(aspectRatio);
if (ratioValue >= 1) {
return {
width: base,
height: roundToMultipleOf32(base / ratioValue),
};
}
return {
width: roundToMultipleOf32(base * ratioValue),
height: base,
};
}
async function inferAspectRatioFromImageSource(imageUrl: string): Promise<string | null> {
const normalized = String(imageUrl || "").trim();
if (!normalized) return null;
try {
let buffer: Buffer | null = null;
if (normalized.startsWith("data:")) {
const match = normalized.match(/^data:([A-Za-z0-9.+/-]+);base64,(.+)$/);
if (!match) return null;
buffer = Buffer.from(match[2], "base64");
} else if (/^https?:\/\//i.test(normalized)) {
const response = await fetch(normalized, { cache: "no-store" });
if (!response.ok) return null;
buffer = Buffer.from(await response.arrayBuffer());
}
if (!buffer?.length) return null;
const metadata = await sharp(buffer).metadata();
if (!metadata.width || !metadata.height || metadata.height <= 0) return null;
const actualRatio = metadata.width / metadata.height;
let closest = "1:1";
let minDiff = Number.POSITIVE_INFINITY;
for (const candidate of SUPPORTED_ASPECT_RATIOS) {
const diff = Math.abs(parseAspectRatioValue(candidate) - actualRatio);
if (diff < minDiff) {
minDiff = diff;
closest = candidate;
}
}
return closest;
} catch (error) {
console.warn("[BFL Image] Failed to infer aspect ratio:", error);
return null;
}
}
function detectImageExtension(imageUrl: string, contentType?: string | null) {
const type = String(contentType || "").toLowerCase();
if (type.includes("jpeg") || type.includes("jpg")) return "jpg";
if (type.includes("webp")) return "webp";
if (type.includes("png")) return "png";
const cleanUrl = imageUrl.split("?")[0]?.toLowerCase() || "";
if (cleanUrl.endsWith(".jpg") || cleanUrl.endsWith(".jpeg")) return "jpg";
if (cleanUrl.endsWith(".webp")) return "webp";
if (cleanUrl.endsWith(".png")) return "png";
return "png";
}
function generateFilename(prefix: string, extension: string) {
const timestamp = Date.now();
const random = Math.random().toString(36).slice(2, 8);
return `${prefix}-${timestamp}-${random}.${extension}`;
}
async function createThumbnail(buffer: Buffer) {
try {
return await sharp(buffer)
.resize(400, 400, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
} catch (error) {
console.error("[BFL Image] Failed to create thumbnail:", error);
return buffer;
}
}
async function uploadGeneratedBuffer(
buffer: Buffer,
extension: string,
): Promise<UploadedImage> {
const thumbnailBuffer = await createThumbnail(buffer);
const originalFilename = generateFilename("bfl-image-orig", extension);
const originalArrayBuffer = buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength
) as ArrayBuffer;
const originalUploaded = await uploadFile(originalFilename, originalArrayBuffer);
const thumbFilename = generateFilename("bfl-image-thumb", "jpg");
const thumbArrayBuffer = thumbnailBuffer.buffer.slice(
thumbnailBuffer.byteOffset,
thumbnailBuffer.byteOffset + thumbnailBuffer.byteLength
) as ArrayBuffer;
const thumbUploaded = await uploadFile(thumbFilename, thumbArrayBuffer);
return {
thumbnail: thumbUploaded.url,
original: originalUploaded.url,
};
}
async function downloadAndUploadImage(imageUrl: string): Promise<UploadedImage> {
const response = await fetch(imageUrl, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to download generated image: ${response.status}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const extension = detectImageExtension(imageUrl, response.headers.get("content-type"));
return uploadGeneratedBuffer(buffer, extension);
}
function extractPromptAndImages(messages: Array<{ role: string; content: any }>) {
const lastUserMessage = messages.filter((message) => message.role === "user").pop();
if (!lastUserMessage) {
throw new Error("No user message found");
}
let prompt = "";
const imageUrls: string[] = [];
const pushImageUrl = (value?: string) => {
if (!value) return;
const normalized = String(value).trim();
if (!normalized) return;
if (!imageUrls.includes(normalized)) {
imageUrls.push(normalized);
}
};
if (typeof lastUserMessage.content === "string") {
prompt = lastUserMessage.content.trim();
} else if (Array.isArray(lastUserMessage.content)) {
for (const part of lastUserMessage.content) {
if (part?.type === "text" && typeof part.text === "string") {
prompt = `${prompt} ${part.text}`.trim();
} else if (part?.type === "image_url" && part.image_url?.url) {
pushImageUrl(part.image_url.url);
} else if (part?.type === "file") {
pushImageUrl(part.url || part.data);
} else if (part?.type === "image") {
pushImageUrl(part.image || part.url);
}
}
}
if (!prompt) {
throw new Error("No prompt found in user message");
}
return { prompt, imageUrls };
}
function extractBflImageUrl(payload: any): string {
const candidates = [
payload?.sample,
payload?.result?.sample,
payload?.response?.result?.sample,
payload?.output?.[0],
payload?.result?.output?.[0],
payload?.images?.[0],
payload?.result?.images?.[0],
payload?.image_url,
payload?.result?.image_url,
payload?.url,
payload?.result?.url,
];
for (const candidate of candidates) {
const value = String(candidate || "").trim();
if (!value) continue;
if (value.startsWith("data:")) return value;
if (/^https?:\/\//i.test(value)) return value;
}
return "";
}
function extractErrorMessage(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;
}
async function submitBflImageRequest(params: {
baseURL: string;
apiKey: string;
model: string;
prompt: string;
imageUrl: string;
width: number;
height: number;
seed?: number;
}) {
const endpoint = `${normalizeBaseURL(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({
prompt: params.prompt,
input_image: params.imageUrl,
width: params.width,
height: params.height,
output_format: "jpeg",
safety_tolerance: 2,
...(typeof params.seed === "number" ? { seed: params.seed } : {}),
}),
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 {
imageUrl,
taskId,
pollingUrl,
errorMessage: "",
};
}
return {
imageUrl,
taskId,
pollingUrl,
errorMessage: extractErrorMessage(payload, `BFL 请求失败(${response.status})`),
};
}
async function pollBflResult(params: {
baseURL: string;
apiKey: string;
pollingUrl?: string;
taskId?: string;
}) {
const deadline = Date.now() + DEFAULT_TIMEOUT_MS;
while (Date.now() < deadline) {
const pollingUrl =
String(params.pollingUrl || "").trim() ||
`${normalizeBaseURL(params.baseURL)}/v1/get_result?id=${encodeURIComponent(String(params.taskId || ""))}`;
const response = await fetch(pollingUrl, {
method: "GET",
headers: {
"x-key": params.apiKey,
Accept: "application/json",
},
cache: "no-store",
});
const payload = (await response.json().catch(() => null)) as BflSubmitResponse | null;
const imageUrl = extractBflImageUrl(payload);
if (imageUrl) return imageUrl;
const status = String(
payload?.status ||
payload?.task_status ||
payload?.response?.status ||
payload?.response?.task_status ||
""
)
.trim()
.toLowerCase();
if (status === "failed" || status === "error" || status === "cancelled") {
throw new Error(extractErrorMessage(payload, "BFL 任务执行失败"));
}
await new Promise((resolve) => setTimeout(resolve, 1200));
}
throw new Error("BFL 轮询超时");
}
function formatMarkdown(image: UploadedImage, imageSize: string) {
return `![thumb|||${image.original}|||${imageSize}](${image.thumbnail})`;
}
export function createBflImage({ apiKey, baseURL }: BflImageConfig) {
return {
provider: "bfl-image",
chat: (modelId: string) => ({
provider: "bfl-image",
doGenerate: async (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: BflImageParams;
overrideModelId?: string;
}) => {
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
if (imageUrls.length === 0) {
throw new Error("BFL 图片编辑模型需要至少上传一张图片。");
}
const activeModelId = options.overrideModelId || modelId;
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
const aspectRatio =
normalizedAspectRatio === "auto"
? await inferAspectRatioFromImageSource(imageUrls[0] || "")
: normalizedAspectRatio;
const { width, height } = resolveBflDimensions(aspectRatio || "1:1", imageSize);
const submitResult = await submitBflImageRequest({
baseURL,
apiKey,
model: activeModelId,
prompt,
imageUrl: imageUrls[0],
width,
height,
seed: options.imageParams?.seed,
});
if (submitResult.errorMessage) {
throw new Error(submitResult.errorMessage);
}
const finalImageUrl =
submitResult.imageUrl ||
(await pollBflResult({
baseURL,
apiKey,
pollingUrl: submitResult.pollingUrl,
taskId: submitResult.taskId,
}));
if (!finalImageUrl) {
throw new Error("BFL 未返回可用图片");
}
const uploaded = await downloadAndUploadImage(finalImageUrl);
const text = formatMarkdown(uploaded, imageSize);
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: text.length,
},
};
},
doStream: async function* (options: {
messages: Array<{ role: string; content: any }>;
imageParams?: BflImageParams;
overrideModelId?: string;
}) {
const result = await this.doGenerate(options);
yield {
type: "text-delta",
textDelta: result.text,
};
},
}),
};
}