diff --git a/app/api/admin/fal-image-models/route.ts b/app/api/admin/fal-image-models/route.ts index 41f7a44de..d816748bf 100644 --- a/app/api/admin/fal-image-models/route.ts +++ b/app/api/admin/fal-image-models/route.ts @@ -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, }, }); diff --git a/lib/ai/fal-image-models.ts b/lib/ai/fal-image-models.ts index 2a129c6e8..7c4a7388e 100644 --- a/lib/ai/fal-image-models.ts +++ b/lib/ai/fal-image-models.ts @@ -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", diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index aec903d48..5e8d4c483 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -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); diff --git a/package.json b/package.json index f682264e5..4f21a868a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a76b17af..1f6e5fc8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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