From b44930279c662e920dfed51e33b3f1784bc65821 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 7 Feb 2026 01:06:48 +1300 Subject: [PATCH] fix: remove dead generateWithFal, add upload size guard, add model cache refresh - Delete unused generateWithFal function (replaced by generateWithFalQueue) - Add 20MB size guard in uploadImageToFal to prevent memory spikes - Add refresh button to ModelSearchDialog that clears model and schema localStorage caches plus in-memory fetch cache - Wire "Try Again" error button to also clear stale caches Co-Authored-By: Claude Opus 4.6 --- src/app/api/generate/route.ts | 217 +------------------- src/components/modals/ModelSearchDialog.tsx | 61 +++++- 2 files changed, 62 insertions(+), 216 deletions(-) diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 47327d26..0ad17e70 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -795,6 +795,8 @@ async function getFalInputMapping(modelId: string, apiKey: string | null): Promi return result; } +const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; // 20 MB + /** * Upload a base64 data URL image to fal.ai CDN storage. * Returns the CDN URL to use in API requests instead of inline base64. @@ -807,6 +809,11 @@ async function uploadImageToFal(base64DataUrl: string, apiKey: string | null): P const match = base64DataUrl.match(/^data:([^;]+);base64,(.+)$/); if (!match) return base64DataUrl; + const estimatedBytes = Math.ceil(match[2].length * 3 / 4); + if (estimatedBytes > MAX_UPLOAD_SIZE) { + throw new Error(`Image too large to upload (${(estimatedBytes / (1024 * 1024)).toFixed(1)} MB, max ${MAX_UPLOAD_SIZE / (1024 * 1024)} MB)`); + } + const contentType = match[1]; const binaryData = Buffer.from(match[2], "base64"); @@ -829,212 +836,6 @@ async function uploadImageToFal(base64DataUrl: string, apiKey: string | null): P return url; } -/** - * Generate image using fal.ai API - */ -async function generateWithFal( - requestId: string, - apiKey: string | null, - input: GenerationInput -): Promise { - console.log(`[API:${requestId}] fal.ai generation - Model: ${input.model.id}, Images: ${input.images?.length || 0}, Prompt: ${input.prompt.length} chars`); - - const modelId = input.model.id; - const hasDynamicInputs = input.dynamicInputs && Object.keys(input.dynamicInputs).length > 0; - console.log(`[API:${requestId}] Dynamic inputs: ${hasDynamicInputs ? Object.keys(input.dynamicInputs!).join(", ") : "none"}, API key: ${apiKey ? "yes" : "no"}`); - - // Fetch schema for type coercion and input mapping (only one API call) - const { paramMap, arrayParams, schemaArrayParams, parameterTypes } = await getFalInputMapping(modelId, apiKey); - - // Build request body, coercing parameter types from schema - // If we have dynamic inputs, they take precedence (they already contain prompt, image_url, etc.) - const requestBody: Record = { - ...coerceParameterTypes(input.parameters, parameterTypes), - }; - - // Add dynamic inputs if provided (these come from schema-mapped connections) - // Filter out empty/null/undefined values to avoid sending invalid inputs to fal.ai - if (hasDynamicInputs) { - const filteredInputs: Record = {}; - for (const [key, value] of Object.entries(input.dynamicInputs!)) { - if (value !== null && value !== undefined && value !== '') { - // Wrap in array if schema expects array but we have a single value - if (schemaArrayParams.has(key) && !Array.isArray(value)) { - filteredInputs[key] = [value]; - } else { - filteredInputs[key] = value; - } - } - } - Object.assign(requestBody, filteredInputs); - } else { - // Fallback: use schema to map generic input names to model-specific parameter names - - // Map prompt input - if (input.prompt) { - const promptParam = paramMap.prompt || "prompt"; - requestBody[promptParam] = input.prompt; - } - - // Map image input - use array or string format based on schema - if (input.images && input.images.length > 0) { - const imageParam = paramMap.image || "image_url"; - if (arrayParams.has("image")) { - requestBody[imageParam] = input.images; - } else { - requestBody[imageParam] = input.images[0]; - } - } - - // Map any parameters that might need renaming (use coerced values) - const coercedParams = coerceParameterTypes(input.parameters, parameterTypes); - for (const [key, value] of Object.entries(coercedParams)) { - const mappedKey = paramMap[key] || key; - requestBody[mappedKey] = value; - } - } - - // Build headers - const headers: Record = { - "Content-Type": "application/json", - }; - if (apiKey) { - headers["Authorization"] = `Key ${apiKey}`; - } - - // POST to fal.run/{modelId} - // Use 10 minute timeout to handle long-running video generation - console.log(`[API:${requestId}] Calling fal.ai API with inputs: ${Object.keys(requestBody).join(", ")}`); - const response = await fetch(`https://fal.run/${modelId}`, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(10 * 60 * 1000), // 10 minute timeout - }); - - if (!response.ok) { - const errorText = await response.text(); - - let errorDetail = errorText || `HTTP ${response.status}`; - try { - const errorJson = JSON.parse(errorText); - // Handle various fal.ai error formats - if (typeof errorJson.error === 'object' && errorJson.error?.message) { - errorDetail = errorJson.error.message; - } else if (errorJson.detail) { - // Handle array of validation errors - if (Array.isArray(errorJson.detail)) { - errorDetail = errorJson.detail.map((d: { msg?: string; loc?: string[] }) => - d.msg || JSON.stringify(d) - ).join('; '); - } else { - errorDetail = errorJson.detail; - } - } else if (errorJson.message) { - errorDetail = errorJson.message; - } else if (typeof errorJson.error === 'string') { - errorDetail = errorJson.error; - } - } catch { - // Keep original text if not JSON - } - - // Handle rate limits - if (response.status === 429) { - return { - success: false, - error: `${input.model.name}: Rate limit exceeded. ${apiKey ? "Try again in a moment." : "Add an API key in settings for higher limits."}`, - }; - } - - return { - success: false, - error: `${input.model.name}: ${errorDetail}`, - }; - } - - const result = await response.json(); - - // fal.ai response can have different structures: - // - images: array with url field (image models) - // - image: object with url field (image models) - // - video: object with url field (video models) - // - output: string URL (some models) - let mediaUrl: string | null = null; - let isVideoModel = false; - - // Check for video output first (video models) - if (result.video && result.video.url) { - mediaUrl = result.video.url; - isVideoModel = true; - } else if (result.images && Array.isArray(result.images) && result.images.length > 0) { - mediaUrl = result.images[0].url; - } else if (result.image && result.image.url) { - mediaUrl = result.image.url; - } else if (result.output && typeof result.output === "string") { - // Some models return URL directly in output - mediaUrl = result.output; - } - - if (!mediaUrl) { - console.error(`[API:${requestId}] No media URL found in fal.ai response`); - return { - success: false, - error: "No media URL in response", - }; - } - - // Fetch the media and convert to base64 - console.log(`[API:${requestId}] Fetching output from: ${mediaUrl.substring(0, 80)}...`); - const mediaResponse = await fetch(mediaUrl); - - if (!mediaResponse.ok) { - return { - success: false, - error: `Failed to fetch output: ${mediaResponse.status}`, - }; - } - - // Determine MIME type from response - const contentType = mediaResponse.headers.get("content-type") || (isVideoModel ? "video/mp4" : "image/png"); - const isVideo = contentType.startsWith("video/") || isVideoModel; - - const mediaArrayBuffer = await mediaResponse.arrayBuffer(); - const mediaSizeBytes = mediaArrayBuffer.byteLength; - const mediaSizeMB = mediaSizeBytes / (1024 * 1024); - - console.log(`[API:${requestId}] Output: ${contentType}, ${mediaSizeMB.toFixed(2)}MB`); - - // For very large videos (>20MB), return URL directly instead of base64 - if (isVideo && mediaSizeMB > 20) { - console.log(`[API:${requestId}] SUCCESS - Returning URL for large video`); - return { - success: true, - outputs: [ - { - type: "video", - data: mediaUrl, // Return URL directly for very large videos - url: mediaUrl, - }, - ], - }; - } - - const mediaBase64 = Buffer.from(mediaArrayBuffer).toString("base64"); - console.log(`[API:${requestId}] SUCCESS - Returning ${isVideo ? "video" : "image"}`); - - return { - success: true, - outputs: [ - { - type: isVideo ? "video" : "image", - data: `data:${contentType};base64,${mediaBase64}`, - url: mediaUrl, - }, - ], - }; -} - /** * Generate using fal.ai Queue API * Uses async queue submission + polling (1s interval) instead of blocking fal.run. @@ -1051,7 +852,7 @@ async function generateWithFalQueue( const hasDynamicInputs = input.dynamicInputs && Object.keys(input.dynamicInputs).length > 0; console.log(`[API:${requestId}] Dynamic inputs: ${hasDynamicInputs ? Object.keys(input.dynamicInputs!).join(", ") : "none"}, API key: ${apiKey ? "yes" : "no"}`); - // Fetch schema for type coercion and input mapping (cached, same as generateWithFal) + // Fetch schema for type coercion and input mapping (cached) const { paramMap, arrayParams, schemaArrayParams, parameterTypes } = await getFalInputMapping(modelId, apiKey); // Build request body, coercing parameter types from schema @@ -1237,7 +1038,7 @@ async function generateWithFalQueue( const result = await resultResponse.json(); - // Extract video URL from result (same logic as generateWithFal) + // Extract video URL from result let mediaUrl: string | null = null; if (result.video && result.video.url) { diff --git a/src/components/modals/ModelSearchDialog.tsx b/src/components/modals/ModelSearchDialog.tsx index f8fd79d2..f6ef7246 100644 --- a/src/components/modals/ModelSearchDialog.tsx +++ b/src/components/modals/ModelSearchDialog.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { createPortal } from "react-dom"; import { useWorkflowStore, useProviderApiKeys } from "@/store/workflowStore"; -import { deduplicatedFetch } from "@/utils/deduplicatedFetch"; +import { deduplicatedFetch, clearFetchCache } from "@/utils/deduplicatedFetch"; import { useReactFlow } from "@xyflow/react"; import { ProviderType, RecentModel } from "@/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types"; @@ -136,6 +136,7 @@ export function ModelSearchDialog({ useState(initialCapabilityFilter || "all"); const [models, setModels] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const [error, setError] = useState(null); // Refs @@ -167,18 +168,20 @@ export function ModelSearchDialog({ }, [initialProvider]); // Fetch models - const fetchModels = useCallback(async () => { + const fetchModels = useCallback(async (bypassCache = false) => { // Increment version to track this request const thisVersion = ++requestVersionRef.current; // Build cache key from filters const cacheKey = `${providerFilter}:${capabilityFilter}:${debouncedSearch}`; - // Check localStorage cache first - const cached = getCachedModels(cacheKey); - if (cached) { - setModels(cached.models); - return; + // Check localStorage cache first (skip when bypassing) + if (!bypassCache) { + const cached = getCachedModels(cacheKey); + if (cached) { + setModels(cached.models); + return; + } } setIsLoading(true); @@ -200,6 +203,9 @@ export function ModelSearchDialog({ : "text-to-video,image-to-video"; params.set("capabilities", capabilities); } + if (bypassCache) { + params.set("refresh", "true"); + } // Build headers with API keys const headers: Record = {}; @@ -257,6 +263,23 @@ export function ModelSearchDialog({ } }, [isOpen, fetchModels]); + // Clear all caches and re-fetch models from scratch + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + try { + // Clear localStorage model cache + localStorage.removeItem(MODELS_CACHE_KEY); + // Clear localStorage schema cache (keep in sync with ModelParameters.tsx) + localStorage.removeItem("node-banana-schema-cache"); + // Clear in-memory deduplicatedFetch cache + clearFetchCache(); + // Re-fetch with cache bypass + await fetchModels(true); + } finally { + setIsRefreshing(false); + } + }, [fetchModels]); + // Focus search input when dialog opens useEffect(() => { if (isOpen && searchInputRef.current) { @@ -613,6 +636,28 @@ export function ModelSearchDialog({ + + {/* Refresh Cache */} + @@ -664,7 +709,7 @@ export function ModelSearchDialog({ {error}