fix: route fal nano banana edit requests
This commit is contained in:
parent
3f1e4b24c6
commit
7b69e7dcee
@ -9,10 +9,13 @@ import { requireAdmin } from "@/lib/middleware/admin";
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdmin();
|
||||
const imageEditCount = FAL_IMAGE_MODEL_PRESETS.filter(
|
||||
(preset) => preset.capabilities?.kind === "image-edit"
|
||||
).length;
|
||||
return NextResponse.json({
|
||||
presets: FAL_IMAGE_MODEL_PRESETS,
|
||||
counts: {
|
||||
imageEdit: FAL_IMAGE_MODEL_PRESETS.length,
|
||||
imageEdit: imageEditCount,
|
||||
total: FAL_IMAGE_MODEL_PRESETS.length,
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
export const FAL_IMAGE_BASE_URL = "https://fal.run";
|
||||
export const FAL_NANO_BANANA_2_MODEL_ID = "fal-ai/nano-banana-2";
|
||||
export const FAL_NANO_BANANA_2_EDIT_MODEL_ID = "fal-ai/nano-banana-2/edit";
|
||||
export const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit";
|
||||
|
||||
export interface FalImageModelPreset {
|
||||
@ -14,6 +16,152 @@ export interface FalImageModelPreset {
|
||||
}
|
||||
|
||||
export const FAL_IMAGE_MODEL_PRESETS: FalImageModelPreset[] = [
|
||||
{
|
||||
modelId: FAL_NANO_BANANA_2_MODEL_ID,
|
||||
displayName: "FAL Nano Banana 2",
|
||||
description:
|
||||
"FAL 官方 Nano Banana 2 文生图模型。无参考图时用于生成图片;如传入参考图,系统会自动切换到官方 /edit 端点。",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
capabilities: {
|
||||
provider: "fal",
|
||||
kind: "text-to-image",
|
||||
endpoint: FAL_NANO_BANANA_2_MODEL_ID,
|
||||
editEndpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
|
||||
apiFormat: "fal-nano-banana-2",
|
||||
officialUrl: "https://fal.ai/models/fal-ai/nano-banana-2/api",
|
||||
editOfficialUrl: "https://fal.ai/models/fal-ai/nano-banana-2/edit/api",
|
||||
requiredFields: ["prompt"],
|
||||
optionalFields: [
|
||||
"num_images",
|
||||
"aspect_ratio",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"resolution",
|
||||
"limit_generations",
|
||||
"enable_web_search",
|
||||
"image_urls",
|
||||
],
|
||||
supportsMultiImageReference: true,
|
||||
autoRouteEditEndpoint: true,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "fal",
|
||||
endpoint: FAL_NANO_BANANA_2_MODEL_ID,
|
||||
editEndpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
|
||||
apiFormat: "fal-nano-banana-2",
|
||||
imageSize: "1K",
|
||||
aspectRatio: "auto",
|
||||
outputFormat: "png",
|
||||
safetyTolerance: "4",
|
||||
limitGenerations: true,
|
||||
},
|
||||
imageGenConfig: {
|
||||
maxImages: 4,
|
||||
supportsSize: true,
|
||||
supports1K: true,
|
||||
supports2K: true,
|
||||
supports4K: true,
|
||||
supportsPromptExtend: true,
|
||||
supportsNegativePrompt: false,
|
||||
supportsWatermark: false,
|
||||
supportsSeed: true,
|
||||
supportedAspectRatios: [
|
||||
"auto",
|
||||
"21:9",
|
||||
"16:9",
|
||||
"3:2",
|
||||
"4:3",
|
||||
"5:4",
|
||||
"1:1",
|
||||
"4:5",
|
||||
"3:4",
|
||||
"2:3",
|
||||
"9:16",
|
||||
"4:1",
|
||||
"1:4",
|
||||
"8:1",
|
||||
"1:8",
|
||||
],
|
||||
supportedImageSizes: ["0.5K", "1K", "2K", "4K"],
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "purple",
|
||||
creditCostPerImage: 0,
|
||||
creditCostPerVideo: 0,
|
||||
},
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
modelId: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
|
||||
displayName: "FAL Nano Banana 2 Edit",
|
||||
description:
|
||||
"FAL 官方 Nano Banana 2 图像编辑模型,支持最多 14 张参考图和自然语言编辑。",
|
||||
supportsVision: true,
|
||||
supportsImageGen: true,
|
||||
capabilities: {
|
||||
provider: "fal",
|
||||
kind: "image-edit",
|
||||
endpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
|
||||
apiFormat: "fal-nano-banana-2-edit",
|
||||
officialUrl: "https://fal.ai/models/fal-ai/nano-banana-2/edit/api",
|
||||
requiredFields: ["prompt", "image_urls"],
|
||||
optionalFields: [
|
||||
"num_images",
|
||||
"aspect_ratio",
|
||||
"output_format",
|
||||
"safety_tolerance",
|
||||
"resolution",
|
||||
"limit_generations",
|
||||
"enable_web_search",
|
||||
],
|
||||
supportsMultiImageReference: true,
|
||||
maxReferenceImages: 14,
|
||||
},
|
||||
defaultParams: {
|
||||
provider: "fal",
|
||||
endpoint: FAL_NANO_BANANA_2_EDIT_MODEL_ID,
|
||||
apiFormat: "fal-nano-banana-2-edit",
|
||||
imageSize: "1K",
|
||||
aspectRatio: "auto",
|
||||
outputFormat: "png",
|
||||
safetyTolerance: "4",
|
||||
limitGenerations: true,
|
||||
},
|
||||
imageGenConfig: {
|
||||
maxImages: 4,
|
||||
supportsSize: true,
|
||||
supports1K: true,
|
||||
supports2K: true,
|
||||
supports4K: true,
|
||||
supportsPromptExtend: true,
|
||||
supportsNegativePrompt: false,
|
||||
supportsWatermark: false,
|
||||
supportsSeed: true,
|
||||
supportedAspectRatios: [
|
||||
"auto",
|
||||
"21:9",
|
||||
"16:9",
|
||||
"3:2",
|
||||
"4:3",
|
||||
"5:4",
|
||||
"1:1",
|
||||
"4:5",
|
||||
"3:4",
|
||||
"2:3",
|
||||
"9:16",
|
||||
"4:1",
|
||||
"1:4",
|
||||
"8:1",
|
||||
"1:8",
|
||||
],
|
||||
supportedImageSizes: ["0.5K", "1K", "2K", "4K"],
|
||||
enableLoadingPlaceholder: true,
|
||||
loadingColor: "purple",
|
||||
creditCostPerImage: 0,
|
||||
creditCostPerVideo: 0,
|
||||
},
|
||||
sortOrder: 11,
|
||||
},
|
||||
{
|
||||
modelId: FAL_GPT_IMAGE_2_EDIT_MODEL_ID,
|
||||
displayName: "FAL GPT Image 2 Edit",
|
||||
|
||||
@ -15,6 +15,7 @@ interface FalImageParams {
|
||||
imageCount?: number;
|
||||
outputFormat?: "png" | "jpg" | "jpeg" | "webp";
|
||||
safetyTolerance?: string | number;
|
||||
seed?: string | number;
|
||||
enableWebSearch?: boolean;
|
||||
quality?: string;
|
||||
maskImageUrl?: string;
|
||||
@ -32,6 +33,11 @@ const DEFAULT_RESOLUTION = "1K";
|
||||
const DEFAULT_OUTPUT_FORMAT = "png";
|
||||
const DEFAULT_SAFETY_TOLERANCE = "4";
|
||||
const DEFAULT_TIMEOUT_MS = 180000;
|
||||
const FAL_NANO_BANANA_MODEL_IDS = new Set([
|
||||
"fal-ai/nano-banana",
|
||||
"fal-ai/nano-banana-2",
|
||||
"fal-ai/nano-banana-pro",
|
||||
]);
|
||||
const FAL_GPT_IMAGE_2_EDIT_MODEL_ID = "openai/gpt-image-2/edit";
|
||||
const GPT_IMAGE_2_EDIT_IMAGE_SIZE_PRESETS = new Set([
|
||||
"auto",
|
||||
@ -156,6 +162,10 @@ function normalizeAspectRatio(aspectRatio?: string) {
|
||||
"3:4",
|
||||
"2:3",
|
||||
"9:16",
|
||||
"4:1",
|
||||
"1:4",
|
||||
"8:1",
|
||||
"1:8",
|
||||
].includes(normalized)
|
||||
) {
|
||||
return normalized === "auto" ? "auto" : ratio;
|
||||
@ -167,7 +177,7 @@ function normalizeAspectRatio(aspectRatio?: string) {
|
||||
function normalizeResolution(imageSize?: string) {
|
||||
if (!imageSize) return DEFAULT_RESOLUTION;
|
||||
const normalized = imageSize.trim().toUpperCase();
|
||||
if (["1K", "2K", "4K"].includes(normalized)) return normalized;
|
||||
if (["0.5K", "1K", "2K", "4K"].includes(normalized)) return normalized;
|
||||
return DEFAULT_RESOLUTION;
|
||||
}
|
||||
|
||||
@ -186,10 +196,28 @@ function normalizeSafetyTolerance(value?: string | number) {
|
||||
return DEFAULT_SAFETY_TOLERANCE;
|
||||
}
|
||||
|
||||
function normalizeSeed(value?: string | number) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const seed = Number(value);
|
||||
if (!Number.isInteger(seed) || seed < 0) return undefined;
|
||||
return seed;
|
||||
}
|
||||
|
||||
function isFalGptImage2EditModel(modelId: string) {
|
||||
return modelId.trim().toLowerCase() === FAL_GPT_IMAGE_2_EDIT_MODEL_ID;
|
||||
}
|
||||
|
||||
function normalizeFalEndpointId(modelId: string, hasInputImages: boolean) {
|
||||
const endpoint = modelId.trim();
|
||||
const normalized = endpoint.toLowerCase();
|
||||
if (!hasInputImages) return endpoint;
|
||||
if (normalized.endsWith("/edit")) return endpoint;
|
||||
if (FAL_NANO_BANANA_MODEL_IDS.has(normalized)) {
|
||||
return `${endpoint}/edit`;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
function normalizeGptImage2EditQuality(value?: string) {
|
||||
if (!value) return "high";
|
||||
const normalized = value.trim().toLowerCase();
|
||||
@ -395,6 +423,26 @@ function formatMarkdown(images: UploadedImage[], resolution: string) {
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function summarizeFalError(error: any) {
|
||||
const status = error?.status || error?.statusCode || error?.response?.status;
|
||||
const body =
|
||||
error?.body ||
|
||||
error?.responseBody ||
|
||||
error?.data ||
|
||||
error?.response?.data ||
|
||||
error?.cause?.data;
|
||||
const message =
|
||||
error?.message ||
|
||||
(typeof body === "string" ? body : "") ||
|
||||
"FAL request failed";
|
||||
return {
|
||||
name: error?.name,
|
||||
status,
|
||||
message,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
return {
|
||||
provider: "fal-image",
|
||||
@ -410,9 +458,16 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
const resolution = normalizeResolution(options.imageParams?.imageSize);
|
||||
const outputFormat = normalizeOutputFormat(options.imageParams?.outputFormat);
|
||||
const safetyTolerance = normalizeSafetyTolerance(options.imageParams?.safetyTolerance);
|
||||
const seed = normalizeSeed(options.imageParams?.seed);
|
||||
const imageCount = Math.max(1, Math.min(6, Number(options.imageParams?.imageCount || 1)));
|
||||
const activeModelId = options.overrideModelId || modelId;
|
||||
const isGptImage2Edit = isFalGptImage2EditModel(activeModelId);
|
||||
const falEndpointId = isGptImage2Edit
|
||||
? activeModelId
|
||||
: normalizeFalEndpointId(activeModelId, imageUrls.length > 0);
|
||||
if (!isGptImage2Edit && falEndpointId.toLowerCase().endsWith("/edit") && imageUrls.length === 0) {
|
||||
throw new Error("FAL 图片编辑模型需要至少一张可访问的参考图片 URL");
|
||||
}
|
||||
const gptImage2EditSize = normalizeGptImage2EditImageSize(
|
||||
options.imageParams?.imageSize,
|
||||
options.imageParams?.aspectRatio
|
||||
@ -423,14 +478,16 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
|
||||
console.log("[Fal Image] Submitting request", {
|
||||
model: activeModelId,
|
||||
endpoint: falEndpointId,
|
||||
baseURL,
|
||||
imageCount,
|
||||
aspectRatio,
|
||||
resolution: outputSizeLabel,
|
||||
hasInputImages: imageUrls.length > 0,
|
||||
});
|
||||
|
||||
const fal = await import("@fal-ai/serverless-client");
|
||||
fal.config({
|
||||
const { createFalClient } = await import("@fal-ai/client");
|
||||
const fal = createFalClient({
|
||||
credentials: normalizeFalCredentials(apiKey),
|
||||
});
|
||||
|
||||
@ -458,6 +515,10 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
input.sync_mode = options.imageParams.syncMode;
|
||||
}
|
||||
|
||||
if (seed !== undefined) {
|
||||
input.seed = seed;
|
||||
}
|
||||
|
||||
const openaiApiKey = String(options.imageParams?.openaiApiKey || "").trim();
|
||||
if (openaiApiKey) {
|
||||
input.openai_api_key = openaiApiKey;
|
||||
@ -470,31 +531,42 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) {
|
||||
resolution,
|
||||
output_format: outputFormat,
|
||||
safety_tolerance: safetyTolerance,
|
||||
limit_generations: true,
|
||||
};
|
||||
|
||||
if (imageUrls.length > 0) {
|
||||
input.image_urls = imageUrls;
|
||||
}
|
||||
|
||||
if (seed !== undefined) {
|
||||
input.seed = seed;
|
||||
}
|
||||
|
||||
if (typeof options.imageParams?.enableWebSearch === "boolean") {
|
||||
input.enable_web_search = options.imageParams.enableWebSearch;
|
||||
}
|
||||
}
|
||||
|
||||
const result: any = await fal.subscribe(activeModelId, {
|
||||
input,
|
||||
logs: true,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
onQueueUpdate: (update: any) => {
|
||||
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
|
||||
for (const log of update.logs) {
|
||||
if (log?.message) {
|
||||
console.log(`[Fal Image][${activeModelId}]`, log.message);
|
||||
let result: any;
|
||||
try {
|
||||
result = await fal.subscribe(falEndpointId, {
|
||||
input,
|
||||
logs: true,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
onQueueUpdate: (update: any) => {
|
||||
if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) {
|
||||
for (const log of update.logs) {
|
||||
if (log?.message) {
|
||||
console.log(`[Fal Image][${falEndpointId}]`, log.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Fal Image] Request failed:", summarizeFalError(error));
|
||||
throw error;
|
||||
}
|
||||
|
||||
let outputUrls = filterSourceImages(collectImageUrls(result), imageUrls);
|
||||
outputUrls = await filterSourceImagesByContent(outputUrls, imageUrls);
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.35.3",
|
||||
"@drawnix/drawnix": "0.4.0-2",
|
||||
"@fal-ai/client": "^1.10.0",
|
||||
"@fal-ai/serverless-client": "^0.15.0",
|
||||
"@icons-pack/react-simple-icons": "^13.7.0",
|
||||
"@langchain/core": "^1.1.19",
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -47,6 +47,9 @@ importers:
|
||||
'@drawnix/drawnix':
|
||||
specifier: 0.4.0-2
|
||||
version: 0.4.0-2(react-dom@19.0.0-rc-45804af1-20241021(react@19.0.0-rc-45804af1-20241021))(react@19.0.0-rc-45804af1-20241021)
|
||||
'@fal-ai/client':
|
||||
specifier: ^1.10.0
|
||||
version: 1.10.0
|
||||
'@fal-ai/serverless-client':
|
||||
specifier: ^0.15.0
|
||||
version: 0.15.0
|
||||
@ -1323,6 +1326,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fal-ai/client@1.10.0':
|
||||
resolution: {integrity: sha512-sQWnBc6cdIzK8nFybrVKal0rLeJS2vqrrNxx4Hcc0SorndkfkMXL3TIAiIfiF/AlZuVoRpazpNg6n8K81WHzOQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@fal-ai/serverless-client@0.15.0':
|
||||
resolution: {integrity: sha512-4Vuocu0342OijAN6xO/lwohDV7h90LbkTnOAEwH+pYvMFVC6RYmHS4GILc/wnOWBTw+iFlZFEKlljEVolkjVfg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -7615,6 +7622,12 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.1':
|
||||
optional: true
|
||||
|
||||
'@fal-ai/client@1.10.0':
|
||||
dependencies:
|
||||
'@msgpack/msgpack': 3.1.3
|
||||
eventsource-parser: 1.1.2
|
||||
robot3: 0.4.1
|
||||
|
||||
'@fal-ai/serverless-client@0.15.0':
|
||||
dependencies:
|
||||
'@msgpack/msgpack': 3.1.3
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user