Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
b44930279c
  1. 217
      src/app/api/generate/route.ts
  2. 61
      src/components/modals/ModelSearchDialog.tsx

217
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<GenerationOutput> {
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<string, unknown> = {
...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<string, unknown> = {};
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<string, string> = {
"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) {

61
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<CapabilityFilter>(initialCapabilityFilter || "all");
const [models, setModels] = useState<ProviderModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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<string, string> = {};
@ -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({
<option value="image">Image</option>
<option value="video">Video</option>
</select>
{/* Refresh Cache */}
<button
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="Refresh models & schemas"
className="p-2 rounded text-neutral-400 hover:text-neutral-200 hover:bg-neutral-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
className={`w-4 h-4${isRefreshing ? " animate-spin" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0113.292-6.036M20 15a8 8 0 01-13.292 6.036"
/>
</svg>
</button>
</div>
</div>
@ -664,7 +709,7 @@ export function ModelSearchDialog({
{error}
</p>
<button
onClick={fetchModels}
onClick={handleRefresh}
className="px-3 py-1.5 text-sm bg-neutral-700 hover:bg-neutral-600 text-neutral-200 rounded transition-colors"
>
Try Again

Loading…
Cancel
Save