diff --git a/app/api/canvas/text-edit/route.ts b/app/api/canvas/text-edit/route.ts index a014043db..941255f4d 100644 --- a/app/api/canvas/text-edit/route.ts +++ b/app/api/canvas/text-edit/route.ts @@ -29,8 +29,9 @@ export const runtime = "nodejs"; export const maxDuration = 1200; const logger = createLogger("canvas-text-edit"); -const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 20_000; +const TEXT_EDIT_SOURCE_ARCHIVE_TIMEOUT_MS = 120_000; const TEXT_EDIT_MODEL_FALLBACK_LIMIT = 3; +const TEXT_EDIT_GENERATION_TIMEOUT_MS = 180_000; const textItemSchema = z.object({ id: z.string().min(1), @@ -68,6 +69,13 @@ const SUPPORTED_TEXT_EDIT_PROVIDER_TYPES = new Set([ type TextEditModelCandidate = Awaited>[number]; +function isExplicitTextEditModel(candidate: TextEditModelCandidate) { + const capabilities = candidate.model.capabilities as Record | null; + const kind = String(capabilities?.kind || "").toLowerCase(); + const modelId = String(candidate.model.modelId || "").toLowerCase(); + return kind === "image-edit" || /(?:^|[/_-])edit(?:$|[/_-])/.test(modelId); +} + function getTextEditModelCandidates( models: Awaited> ) { @@ -87,11 +95,13 @@ function getTextEditModelCandidates( .sort((a, b) => { const aScore = (a.model.isDefaultTextEditModel ? 100 : 0) + + (isExplicitTextEditModel(a) ? 30 : 0) + (a.model.isDefault ? 10 : 0) + (a.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (a.model.displayName?.includes("火山") ? 3 : 0); const bScore = (b.model.isDefaultTextEditModel ? 100 : 0) + + (isExplicitTextEditModel(b) ? 30 : 0) + (b.model.isDefault ? 10 : 0) + (b.model.modelId?.toLowerCase().includes("seedream") ? 5 : 0) + (b.model.displayName?.includes("火山") ? 3 : 0); @@ -220,6 +230,13 @@ async function resolveTextEditSourceImageUrl(imageUrl: string) { } catch (error) { logger.warn("source_image_archive_failed", { error: error instanceof Error ? error.message : String(error), + sourceHost: (() => { + try { + return new URL(imageUrl).host; + } catch { + return "invalid-url"; + } + })(), }); return imageUrl; } @@ -495,6 +512,7 @@ async function generateTextEditImageWithFailover(params: { ], imageParams: { imageCount: 1, + timeoutMs: TEXT_EDIT_GENERATION_TIMEOUT_MS, }, }; if (selected.provider.type === "atlascloud" || selected.provider.type === "vip98k") { diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index c2449235a..78779dd46 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -56,6 +56,7 @@ const DEFAULT_SAFETY_TOLERANCE = "4"; const DEFAULT_TIMEOUT_MS = 600000; const MIN_TIMEOUT_MS = 30_000; const MAX_TIMEOUT_MS = 600_000; +const FAL_INPUT_IMAGE_DOWNLOAD_TIMEOUT_MS = 120_000; const FAL_GENERIC_MAX_EDGE = 4096; const FAL_GENERIC_MAX_PIXELS = 4096 * 4096; const GPT_IMAGE_2_MAX_EDGE = 3840; @@ -679,7 +680,9 @@ async function uploadImageUrlToFalStorage(params: { const { fal, url } = params; if (/fal\.(?:media|run)\//i.test(url) || /fal-ai/i.test(url)) return url; - const response = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + const response = await fetch(url, { + signal: AbortSignal.timeout(FAL_INPUT_IMAGE_DOWNLOAD_TIMEOUT_MS), + }); if (!response.ok) { throw new Error(`FAL input image download failed before storage upload: ${response.status}`); }