diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index 930815fe8..1c2b28bdf 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -28,6 +28,7 @@ interface FalImageParams { systemPrompt?: string; thinkingLevel?: string; syncMode?: boolean; + timeoutMs?: number; } interface UploadedImage { @@ -50,6 +51,8 @@ const DEFAULT_RESOLUTION = "1K"; const DEFAULT_OUTPUT_FORMAT = "png"; const DEFAULT_SAFETY_TOLERANCE = "4"; const DEFAULT_TIMEOUT_MS = 600000; +const MIN_TIMEOUT_MS = 30_000; +const MAX_TIMEOUT_MS = 600_000; const FAL_GENERIC_MAX_EDGE = 4096; const FAL_GENERIC_MAX_PIXELS = 4096 * 4096; const GPT_IMAGE_2_MAX_EDGE = 3840; @@ -434,6 +437,12 @@ function normalizeOptionalHttpUrl(value?: string) { return /^https?:\/\//i.test(normalized) ? normalized : ""; } +function normalizeTimeoutMs(value: unknown) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_TIMEOUT_MS; + return Math.max(MIN_TIMEOUT_MS, Math.min(MAX_TIMEOUT_MS, Math.round(numeric))); +} + function detectImageExtension(imageUrl: string, contentType?: string | null) { const type = (contentType || "").toLowerCase(); if (type.includes("jpeg") || type.includes("jpg")) return "jpg"; @@ -911,13 +920,14 @@ export function createFalImage({ apiKey, baseURL }: FalImageConfig) { }); const fal = await createFalClientFromApiKey(apiKey); + const timeoutMs = normalizeTimeoutMs(options.imageParams?.timeoutMs); let result: any; try { result = await fal.subscribe(request.falEndpointId, { input: request.input, logs: true, - timeout: DEFAULT_TIMEOUT_MS, + timeout: timeoutMs, onQueueUpdate: (update: any) => { if (update?.status === "IN_PROGRESS" && Array.isArray(update.logs)) { for (const log of update.logs) { diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 79d9b70a0..51f07b37a 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -292,6 +292,15 @@ function getCanvasProviderRuntimeParams(params: { }; } + if ( + params.providerType === "fal-image" && + isCanvasScaleAdjustDebugRequest(params.debugLabel, params.debugMeta) + ) { + return { + timeoutMs: CANVAS_SCALE_ADJUST_FINAL_PROVIDER_BATCH_TIMEOUT_MS, + }; + } + return {}; } @@ -3059,6 +3068,13 @@ async function applyScaleAdjustPatchCompositeToImages(params: { touchesTop, width: workspaceBox.width, }); + const staticPatchMaskRaw = staticPatchMask + ? await normalizeOpacityMaskToRaw({ + height: workspaceBox.height, + maskBuffer: staticPatchMask, + width: workspaceBox.width, + }) + : null; const regionClipPatchMask = maskRegionBoxes?.length ? buildScaleAdjustPatchCompositeMaskSvg({ featherPx, @@ -3177,12 +3193,22 @@ async function applyScaleAdjustPatchCompositeToImages(params: { imageBuffer: resizedGeneratedBuffer, width: workspaceBox.width, }) - : await sharp(resizedGeneratedBuffer, { failOn: "none" }) - .ensureAlpha() - .composite([{ input: patchMask, blend: "dest-in" }]) - .png({ compressionLevel: 6 }) - .toBuffer(); - const mergedBuffer = await sharp(sourceBuffer, { failOn: "none" }) + : activeClipPatchMaskRaw || staticPatchMaskRaw + ? await applyRawAlphaToImage({ + alphaMaskRaw: + activeClipPatchMaskRaw && staticPatchMaskRaw + ? multiplyAlphaMasks(staticPatchMaskRaw, activeClipPatchMaskRaw) + : activeClipPatchMaskRaw || staticPatchMaskRaw!, + height: workspaceBox.height, + imageBuffer: resizedGeneratedBuffer, + width: workspaceBox.width, + }) + : await sharp(resizedGeneratedBuffer, { failOn: "none" }) + .ensureAlpha() + .composite([{ input: patchMask, blend: "dest-in" }]) + .png({ compressionLevel: 6 }) + .toBuffer(); + const mergedPngBuffer = await sharp(sourceBuffer, { failOn: "none" }) .ensureAlpha() .composite([ { @@ -3193,20 +3219,25 @@ async function applyScaleAdjustPatchCompositeToImages(params: { ]) .png({ compressionLevel: 6 }) .toBuffer(); - const thumbnailBuffer = await sharp(mergedBuffer, { failOn: "none" }) + const mergedBuffer = await sharp(mergedPngBuffer, { failOn: "none" }) + .flatten({ background: "#ffffff" }) + .jpeg({ quality: 94, mozjpeg: true }) + .toBuffer(); + const thumbnailBuffer = await sharp(mergedPngBuffer, { failOn: "none" }) + .flatten({ background: "#ffffff" }) .resize(512, 512, { fit: "inside", withoutEnlargement: true }) - .png({ compressionLevel: 6 }) + .jpeg({ quality: 88, mozjpeg: true }) .toBuffer(); const suffix = `${Date.now()}-${index + 1}-${Math.random() .toString(36) .slice(2, 8)}`; const [originalUpload, thumbnailUpload] = await Promise.all([ uploadFile( - `canvas-scale-adjust-ai-patch-composited-${suffix}.png`, + `canvas-scale-adjust-ai-patch-composited-${suffix}.jpg`, bufferToArrayBuffer(mergedBuffer) ), uploadFile( - `canvas-scale-adjust-ai-patch-composited-thumb-${suffix}.png`, + `canvas-scale-adjust-ai-patch-composited-thumb-${suffix}.jpg`, bufferToArrayBuffer(thumbnailBuffer) ), ]); @@ -3293,26 +3324,31 @@ async function applyScaleAdjustProductLockCompositeToImages(params: { width: generatedWidth, }) : resizedOverlayBaseBuffer; - const mergedBuffer = await sharp(normalizedGeneratedBuffer, { + const mergedPngBuffer = await sharp(normalizedGeneratedBuffer, { failOn: "none", }) .composite([{ input: resizedOverlayBuffer, left: 0, top: 0 }]) .png({ compressionLevel: 6 }) .toBuffer(); - const thumbnailBuffer = await sharp(mergedBuffer, { failOn: "none" }) + const mergedBuffer = await sharp(mergedPngBuffer, { failOn: "none" }) + .flatten({ background: "#ffffff" }) + .jpeg({ quality: 94, mozjpeg: true }) + .toBuffer(); + const thumbnailBuffer = await sharp(mergedPngBuffer, { failOn: "none" }) + .flatten({ background: "#ffffff" }) .resize(512, 512, { fit: "inside", withoutEnlargement: true }) - .png({ compressionLevel: 6 }) + .jpeg({ quality: 88, mozjpeg: true }) .toBuffer(); const suffix = `${Date.now()}-${index + 1}-${Math.random() .toString(36) .slice(2, 8)}`; const [originalUpload, thumbnailUpload] = await Promise.all([ uploadFile( - `canvas-scale-adjust-product-lock-${suffix}.png`, + `canvas-scale-adjust-product-lock-${suffix}.jpg`, bufferToArrayBuffer(mergedBuffer) ), uploadFile( - `canvas-scale-adjust-product-lock-thumb-${suffix}.png`, + `canvas-scale-adjust-product-lock-thumb-${suffix}.jpg`, bufferToArrayBuffer(thumbnailBuffer) ), ]); diff --git a/lib/canvas/image-task-queue.ts b/lib/canvas/image-task-queue.ts index 21fbd0b0d..02ea5b804 100644 --- a/lib/canvas/image-task-queue.ts +++ b/lib/canvas/image-task-queue.ts @@ -59,7 +59,7 @@ interface QueueState { } const DEFAULT_RUNNING_TIMEOUT_MS = 10 * 60 * 1000; -const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 15 * 60 * 1000; +const SCALE_ADJUST_RUNNING_TIMEOUT_MS = 8 * 60 * 1000; const LOCAL_ACTIVE_RUNNING_HARD_TIMEOUT_MS = 30 * 60 * 1000; const PENDING_LOOKAHEAD_LIMIT = 100; const QUEUE_STATUS_PENDING_LOOKAHEAD_LIMIT = 500;