479 lines
14 KiB
TypeScript
479 lines
14 KiB
TypeScript
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 ``;
|
||
}
|
||
|
||
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,
|
||
};
|
||
},
|
||
}),
|
||
};
|
||
}
|