@umituz/react-native-ai-fal-provider 3.2.30 → 3.2.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/infrastructure/services/fal-provider-subscription.ts +166 -49
- package/src/infrastructure/services/fal-provider.constants.ts +36 -0
- package/src/infrastructure/services/fal-provider.ts +64 -15
- package/src/infrastructure/utils/fal-storage.util.ts +123 -54
- package/src/infrastructure/utils/index.ts +3 -0
- package/src/infrastructure/utils/input-preprocessor.util.ts +82 -91
- package/src/infrastructure/utils/log-collector.ts +106 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-fal-provider",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.32",
|
|
4
4
|
"description": "FAL AI provider for React Native - implements IAIProvider interface for unified AI generation",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAL Provider Subscription Handlers
|
|
3
|
-
* Handles subscribe, run methods and cancellation
|
|
3
|
+
* Handles subscribe, run methods with retry, timeout, and cancellation
|
|
4
|
+
*
|
|
5
|
+
* Retry strategy for subscribe:
|
|
6
|
+
* - Retries on: network errors, timeouts, server errors (5xx)
|
|
7
|
+
* - NO retry on: auth, validation, NSFW, quota, user cancellation
|
|
8
|
+
* - Max 1 retry (2 total attempts) with 3s delay
|
|
9
|
+
* - Upload results are preserved (images already on CDN)
|
|
4
10
|
*/
|
|
5
11
|
|
|
6
12
|
import { fal, ApiError, ValidationError } from "@fal-ai/client";
|
|
@@ -10,12 +16,12 @@ import { mapFalStatusToJobStatus } from "./fal-status-mapper";
|
|
|
10
16
|
import { validateNSFWContent } from "../validators/nsfw-validator";
|
|
11
17
|
import { NSFWContentError } from "./nsfw-content-error";
|
|
12
18
|
import { isBase64DataUri } from "../utils/validators/data-uri-validator.util";
|
|
19
|
+
import { generationLogCollector } from "../utils/log-collector";
|
|
20
|
+
|
|
21
|
+
const TAG = 'fal-subscription';
|
|
22
|
+
|
|
23
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
13
24
|
|
|
14
|
-
/**
|
|
15
|
-
* Validate that FAL response images contain HTTPS URLs, never base64 data URIs.
|
|
16
|
-
* FAL models should always return CDN URLs. If base64 is returned, it means the model
|
|
17
|
-
* was called with sync_mode:true or wrong parameters. Throw an explicit error to catch early.
|
|
18
|
-
*/
|
|
19
25
|
function validateNoBase64InResponse(data: unknown): void {
|
|
20
26
|
if (!data || typeof data !== "object") return;
|
|
21
27
|
const record = data as Record<string, unknown>;
|
|
@@ -41,10 +47,6 @@ function validateNoBase64InResponse(data: unknown): void {
|
|
|
41
47
|
}
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
/**
|
|
45
|
-
* Unwrap fal.subscribe / fal.run Result<T> = { data: T, requestId: string }
|
|
46
|
-
* Throws if response format is unexpected - no silent fallbacks
|
|
47
|
-
*/
|
|
48
50
|
function unwrapFalResult<T>(rawResult: unknown): { data: T; requestId: string } {
|
|
49
51
|
if (!rawResult || typeof rawResult !== "object") {
|
|
50
52
|
throw new Error(
|
|
@@ -69,10 +71,6 @@ function unwrapFalResult<T>(rawResult: unknown): { data: T; requestId: string }
|
|
|
69
71
|
return { data: result.data as T, requestId: result.requestId };
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
/**
|
|
73
|
-
* Format fal-ai SDK errors into user-readable messages
|
|
74
|
-
* Uses proper @fal-ai/client error types (ApiError, ValidationError)
|
|
75
|
-
*/
|
|
76
74
|
function formatFalError(error: unknown): string {
|
|
77
75
|
if (error instanceof ValidationError) {
|
|
78
76
|
const details = error.fieldErrors;
|
|
@@ -83,7 +81,6 @@ function formatFalError(error: unknown): string {
|
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
if (error instanceof ApiError) {
|
|
86
|
-
// ApiError has .status, .body, .message
|
|
87
84
|
if (error.status === 401 || error.status === 403) {
|
|
88
85
|
return "Authentication failed. Please check your API key.";
|
|
89
86
|
}
|
|
@@ -103,23 +100,57 @@ function formatFalError(error: unknown): string {
|
|
|
103
100
|
return String(error);
|
|
104
101
|
}
|
|
105
102
|
|
|
103
|
+
// ─── Retry Logic ────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
106
105
|
/**
|
|
107
|
-
*
|
|
106
|
+
* Determine if a subscribe error is retryable.
|
|
107
|
+
*
|
|
108
|
+
* Retryable: network, timeout, server errors (500-504)
|
|
109
|
+
* NOT: auth (401/403), validation (400/422), quota (402),
|
|
110
|
+
* NSFW, user cancellation, rate limit (429 — FAL SDK already retries)
|
|
108
111
|
*/
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
options?: SubscribeOptions<T>,
|
|
113
|
-
signal?: AbortSignal
|
|
114
|
-
): Promise<{ result: T; requestId: string }> {
|
|
115
|
-
const timeoutMs = options?.timeoutMs ?? DEFAULT_FAL_CONFIG.defaultTimeoutMs;
|
|
112
|
+
function isRetryableSubscribeError(error: unknown): boolean {
|
|
113
|
+
// Never retry NSFW
|
|
114
|
+
if (error instanceof NSFWContentError) return false;
|
|
116
115
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
// Never retry user cancellation
|
|
117
|
+
if (error instanceof Error && error.message.includes("cancelled by user")) return false;
|
|
118
|
+
|
|
119
|
+
// ApiError — check status code
|
|
120
|
+
if (error instanceof ApiError) {
|
|
121
|
+
const status = error.status;
|
|
122
|
+
// 5xx server errors are retryable
|
|
123
|
+
if (status >= 500 && status <= 504) return true;
|
|
124
|
+
// Everything else (4xx) is not
|
|
125
|
+
return false;
|
|
121
126
|
}
|
|
122
127
|
|
|
128
|
+
// ValidationError is never retryable
|
|
129
|
+
if (error instanceof ValidationError) return false;
|
|
130
|
+
|
|
131
|
+
// Generic Error — check message patterns
|
|
132
|
+
if (error instanceof Error) {
|
|
133
|
+
const msg = error.message.toLowerCase();
|
|
134
|
+
if (msg.includes("network")) return true;
|
|
135
|
+
if (msg.includes("timeout") || msg.includes("timed out")) return true;
|
|
136
|
+
if (msg.includes("fetch")) return true;
|
|
137
|
+
if (msg.includes("econnrefused") || msg.includes("enotfound")) return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── Single Subscribe Attempt ───────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
async function singleSubscribeAttempt<T = unknown>(
|
|
146
|
+
model: string,
|
|
147
|
+
input: Record<string, unknown>,
|
|
148
|
+
options: SubscribeOptions<T> | undefined,
|
|
149
|
+
signal: AbortSignal | undefined,
|
|
150
|
+
timeoutMs: number,
|
|
151
|
+
attemptStart: number,
|
|
152
|
+
sessionId: string,
|
|
153
|
+
): Promise<{ result: T; requestId: string }> {
|
|
123
154
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
124
155
|
let abortHandler: (() => void) | null = null;
|
|
125
156
|
let listenerAdded = false;
|
|
@@ -146,9 +177,8 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
146
177
|
const statusWithPosition = `${jobStatus.status}:${jobStatus.queuePosition ?? ""}`;
|
|
147
178
|
if (statusWithPosition !== lastStatus) {
|
|
148
179
|
lastStatus = statusWithPosition;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
180
|
+
const elapsed = Date.now() - attemptStart;
|
|
181
|
+
generationLogCollector.log(sessionId, TAG, `[${elapsed}ms] Queue: ${jobStatus.status}${jobStatus.queuePosition ? ` (pos: ${jobStatus.queuePosition})` : ""}`);
|
|
152
182
|
if (options?.onProgress) {
|
|
153
183
|
if (jobStatus.status === "IN_QUEUE" || jobStatus.status === "IN_PROGRESS") {
|
|
154
184
|
options.onProgress({ progress: -1, status: jobStatus.status });
|
|
@@ -176,7 +206,6 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
176
206
|
listenerAdded = true;
|
|
177
207
|
});
|
|
178
208
|
promises.push(abortPromise);
|
|
179
|
-
// Check after listener is attached to close the race window
|
|
180
209
|
if (signal.aborted) {
|
|
181
210
|
throw new Error("Request cancelled by user");
|
|
182
211
|
}
|
|
@@ -185,42 +214,123 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
185
214
|
const rawResult = await Promise.race(promises);
|
|
186
215
|
const { data, requestId } = unwrapFalResult<T>(rawResult);
|
|
187
216
|
|
|
188
|
-
// Validate response for base64 data URIs
|
|
189
|
-
// Since we use subscribe, we should always get CDN URLs, not base64 data URIs
|
|
190
217
|
validateNoBase64InResponse(data);
|
|
191
218
|
validateNSFWContent(data as Record<string, unknown>);
|
|
192
219
|
|
|
193
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
194
|
-
console.log(`[fal-provider] Subscription completed successfully. Request ID: ${requestId}`);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
220
|
options?.onResult?.(data);
|
|
198
221
|
return { result: data, requestId };
|
|
199
|
-
} catch (error) {
|
|
200
|
-
if (error instanceof NSFWContentError) {
|
|
201
|
-
throw error;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const message = formatFalError(error);
|
|
205
|
-
throw new Error(message);
|
|
206
222
|
} finally {
|
|
207
|
-
if (timeoutId)
|
|
208
|
-
clearTimeout(timeoutId);
|
|
209
|
-
}
|
|
223
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
210
224
|
if (listenerAdded && abortHandler && signal) {
|
|
211
225
|
signal.removeEventListener("abort", abortHandler);
|
|
212
226
|
}
|
|
213
227
|
}
|
|
214
228
|
}
|
|
215
229
|
|
|
230
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Handle FAL subscription with timeout, cancellation, and retry.
|
|
234
|
+
*
|
|
235
|
+
* Retry is safe here because:
|
|
236
|
+
* - Input is already preprocessed (images uploaded to FAL CDN)
|
|
237
|
+
* - fal.subscribe is idempotent (same input → same generation)
|
|
238
|
+
* - Only retries on transient errors (network/timeout/5xx)
|
|
239
|
+
*/
|
|
240
|
+
export async function handleFalSubscription<T = unknown>(
|
|
241
|
+
model: string,
|
|
242
|
+
input: Record<string, unknown>,
|
|
243
|
+
sessionId: string,
|
|
244
|
+
options?: SubscribeOptions<T>,
|
|
245
|
+
signal?: AbortSignal,
|
|
246
|
+
): Promise<{ result: T; requestId: string }> {
|
|
247
|
+
const overallStart = Date.now();
|
|
248
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_FAL_CONFIG.defaultTimeoutMs;
|
|
249
|
+
const maxRetries = DEFAULT_FAL_CONFIG.subscribeMaxRetries;
|
|
250
|
+
const retryDelay = DEFAULT_FAL_CONFIG.subscribeRetryDelayMs;
|
|
251
|
+
|
|
252
|
+
generationLogCollector.log(sessionId, TAG, `Starting subscription for model: ${model}`, {
|
|
253
|
+
timeoutMs,
|
|
254
|
+
maxRetries,
|
|
255
|
+
inputKeys: Object.keys(input),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 3600000) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`Invalid timeout: ${timeoutMs}ms. Must be a positive integer between 1 and 3600000ms (1 hour)`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let lastError: unknown;
|
|
265
|
+
|
|
266
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
267
|
+
const attemptStart = Date.now();
|
|
268
|
+
|
|
269
|
+
if (attempt > 0) {
|
|
270
|
+
generationLogCollector.warn(sessionId, TAG, `Retry ${attempt}/${maxRetries} after ${retryDelay}ms delay...`);
|
|
271
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay));
|
|
272
|
+
|
|
273
|
+
// Check if cancelled during delay
|
|
274
|
+
if (signal?.aborted) {
|
|
275
|
+
throw new Error("Request cancelled by user");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
generationLogCollector.log(sessionId, TAG, `Attempt ${attempt + 1}/${maxRetries + 1} starting...`);
|
|
281
|
+
|
|
282
|
+
const result = await singleSubscribeAttempt<T>(
|
|
283
|
+
model, input, options, signal, timeoutMs, attemptStart, sessionId,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const totalElapsed = Date.now() - overallStart;
|
|
287
|
+
const suffix = attempt > 0 ? ` (succeeded on retry ${attempt})` : '';
|
|
288
|
+
generationLogCollector.log(sessionId, TAG, `Subscription completed in ${totalElapsed}ms${suffix}. Request ID: ${result.requestId}`);
|
|
289
|
+
|
|
290
|
+
return result;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
lastError = error;
|
|
293
|
+
const attemptElapsed = Date.now() - attemptStart;
|
|
294
|
+
|
|
295
|
+
// NSFW errors never retry — re-throw immediately
|
|
296
|
+
if (error instanceof NSFWContentError) {
|
|
297
|
+
generationLogCollector.warn(sessionId, TAG, `NSFW content detected after ${attemptElapsed}ms`);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const message = formatFalError(error);
|
|
302
|
+
|
|
303
|
+
// Check if retryable and we have attempts left
|
|
304
|
+
if (attempt < maxRetries && isRetryableSubscribeError(error)) {
|
|
305
|
+
generationLogCollector.warn(sessionId, TAG, `Attempt ${attempt + 1} failed after ${attemptElapsed}ms (retryable): ${message}`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Not retryable or no retries left
|
|
310
|
+
const totalElapsed = Date.now() - overallStart;
|
|
311
|
+
const retryInfo = attempt > 0 ? ` after ${attempt + 1} attempts` : '';
|
|
312
|
+
generationLogCollector.error(sessionId, TAG, `Subscription FAILED in ${totalElapsed}ms${retryInfo}: ${message}`);
|
|
313
|
+
throw new Error(message);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Should never reach here, but TypeScript needs it
|
|
318
|
+
throw lastError;
|
|
319
|
+
}
|
|
320
|
+
|
|
216
321
|
/**
|
|
217
|
-
* Handle FAL run with NSFW validation
|
|
322
|
+
* Handle FAL run with NSFW validation (no retry — runs are fast/synchronous)
|
|
218
323
|
*/
|
|
219
324
|
export async function handleFalRun<T = unknown>(
|
|
220
325
|
model: string,
|
|
221
326
|
input: Record<string, unknown>,
|
|
222
|
-
|
|
327
|
+
sessionId: string,
|
|
328
|
+
options?: RunOptions,
|
|
223
329
|
): Promise<T> {
|
|
330
|
+
const runTag = 'fal-run';
|
|
331
|
+
const startTime = Date.now();
|
|
332
|
+
generationLogCollector.log(sessionId, runTag, `Starting run for model: ${model}`);
|
|
333
|
+
|
|
224
334
|
options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" as const });
|
|
225
335
|
|
|
226
336
|
try {
|
|
@@ -230,14 +340,21 @@ export async function handleFalRun<T = unknown>(
|
|
|
230
340
|
validateNoBase64InResponse(data);
|
|
231
341
|
validateNSFWContent(data as Record<string, unknown>);
|
|
232
342
|
|
|
343
|
+
const elapsed = Date.now() - startTime;
|
|
344
|
+
generationLogCollector.log(sessionId, runTag, `Run completed in ${elapsed}ms`);
|
|
345
|
+
|
|
233
346
|
options?.onProgress?.({ progress: 100, status: "COMPLETED" as const });
|
|
234
347
|
return data;
|
|
235
348
|
} catch (error) {
|
|
349
|
+
const elapsed = Date.now() - startTime;
|
|
350
|
+
|
|
236
351
|
if (error instanceof NSFWContentError) {
|
|
352
|
+
generationLogCollector.warn(sessionId, runTag, `NSFW content detected after ${elapsed}ms`);
|
|
237
353
|
throw error;
|
|
238
354
|
}
|
|
239
355
|
|
|
240
356
|
const message = formatFalError(error);
|
|
357
|
+
generationLogCollector.error(sessionId, runTag, `Run FAILED after ${elapsed}ms for model ${model}: ${message}`);
|
|
241
358
|
throw new Error(message);
|
|
242
359
|
}
|
|
243
360
|
}
|
|
@@ -1,16 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAL Provider Constants
|
|
3
3
|
* Configuration and capability definitions for FAL AI provider
|
|
4
|
+
*
|
|
5
|
+
* Retry Strategy (layered):
|
|
6
|
+
* ┌──────────────────────────────────────────────────┐
|
|
7
|
+
* │ UPLOAD (fal.storage.upload) — per image │
|
|
8
|
+
* │ Timeout: 30s / attempt │
|
|
9
|
+
* │ Retries: 2 (3 total attempts) │
|
|
10
|
+
* │ Backoff: 1s → 2s (exponential) │
|
|
11
|
+
* │ Retries on: network, timeout │
|
|
12
|
+
* ├──────────────────────────────────────────────────┤
|
|
13
|
+
* │ SUBSCRIBE (fal.subscribe) — generation │
|
|
14
|
+
* │ Timeout: caller-defined (120s image / 300s video)│
|
|
15
|
+
* │ Retries: 1 (2 total attempts) │
|
|
16
|
+
* │ Backoff: 3s (fixed — server needs recovery time) │
|
|
17
|
+
* │ Retries on: network, timeout, server (5xx) │
|
|
18
|
+
* │ NO retry: auth, validation, NSFW, quota, cancel │
|
|
19
|
+
* ├──────────────────────────────────────────────────┤
|
|
20
|
+
* │ FAL SDK HTTP retry (fal.config) │
|
|
21
|
+
* │ Retries: 3 (internal HTTP-level only) │
|
|
22
|
+
* │ Backoff: 1s → 10s │
|
|
23
|
+
* └──────────────────────────────────────────────────┘
|
|
4
24
|
*/
|
|
5
25
|
|
|
6
26
|
import type { ProviderCapabilities } from "../../domain/types";
|
|
7
27
|
|
|
8
28
|
export const DEFAULT_FAL_CONFIG = {
|
|
29
|
+
/** FAL SDK HTTP-level retry */
|
|
9
30
|
maxRetries: 3,
|
|
10
31
|
baseDelay: 1000,
|
|
11
32
|
maxDelay: 10000,
|
|
33
|
+
|
|
34
|
+
/** Subscribe defaults */
|
|
12
35
|
defaultTimeoutMs: 300000,
|
|
13
36
|
pollInterval: 2500,
|
|
37
|
+
|
|
38
|
+
/** Subscribe retry — retries the entire fal.subscribe call on transient failures */
|
|
39
|
+
subscribeMaxRetries: 1,
|
|
40
|
+
subscribeRetryDelayMs: 3000,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export const UPLOAD_CONFIG = {
|
|
44
|
+
/** Timeout per individual upload attempt */
|
|
45
|
+
timeoutMs: 30_000,
|
|
46
|
+
/** Max retries (2 = 3 total attempts) */
|
|
47
|
+
maxRetries: 2,
|
|
48
|
+
/** Initial backoff delay (doubles each retry) */
|
|
49
|
+
baseDelayMs: 1_000,
|
|
14
50
|
} as const;
|
|
15
51
|
|
|
16
52
|
export const FAL_CAPABILITIES: ProviderCapabilities = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAL Provider - Implements IAIProvider interface
|
|
3
|
+
* Each subscribe/run call creates an isolated log session via sessionId.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
import { fal } from "@fal-ai/client";
|
|
@@ -11,6 +12,8 @@ import type {
|
|
|
11
12
|
import { DEFAULT_FAL_CONFIG, FAL_CAPABILITIES } from "./fal-provider.constants";
|
|
12
13
|
import { handleFalSubscription, handleFalRun } from "./fal-provider-subscription";
|
|
13
14
|
import { preprocessInput } from "../utils";
|
|
15
|
+
import { generationLogCollector } from "../utils/log-collector";
|
|
16
|
+
import type { LogEntry } from "../utils/log-collector";
|
|
14
17
|
import {
|
|
15
18
|
createRequestKey, getExistingRequest, storeRequest,
|
|
16
19
|
removeRequest, cancelRequest, cancelAllRequests, hasActiveRequests,
|
|
@@ -25,6 +28,8 @@ export class FalProvider implements IAIProvider {
|
|
|
25
28
|
private apiKey: string | null = null;
|
|
26
29
|
private initialized = false;
|
|
27
30
|
private lastRequestKey: string | null = null;
|
|
31
|
+
/** Tracks the active sessionId so callers can retrieve logs */
|
|
32
|
+
private activeSessionId: string | null = null;
|
|
28
33
|
|
|
29
34
|
initialize(config: AIProviderConfig): void {
|
|
30
35
|
this.apiKey = config.apiKey;
|
|
@@ -74,7 +79,10 @@ export class FalProvider implements IAIProvider {
|
|
|
74
79
|
async submitJob(model: string, input: Record<string, unknown>): Promise<JobSubmission> {
|
|
75
80
|
this.validateInit();
|
|
76
81
|
validateInput(model, input);
|
|
77
|
-
const
|
|
82
|
+
const sessionId = generationLogCollector.startSession();
|
|
83
|
+
this.activeSessionId = sessionId;
|
|
84
|
+
generationLogCollector.log(sessionId, 'fal-provider', `submitJob() for model: ${model}`);
|
|
85
|
+
const processedInput = await preprocessInput(input, sessionId);
|
|
78
86
|
return queueOps.submitJob(model, processedInput);
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -93,14 +101,28 @@ export class FalProvider implements IAIProvider {
|
|
|
93
101
|
input: Record<string, unknown>,
|
|
94
102
|
options?: SubscribeOptions<T>,
|
|
95
103
|
): Promise<T> {
|
|
104
|
+
const TAG = 'fal-provider';
|
|
105
|
+
const totalStart = Date.now();
|
|
96
106
|
this.validateInit();
|
|
97
107
|
validateInput(model, input);
|
|
98
108
|
|
|
99
|
-
|
|
109
|
+
// Start a fresh log session for this generation
|
|
110
|
+
const sessionId = generationLogCollector.startSession();
|
|
111
|
+
this.activeSessionId = sessionId;
|
|
112
|
+
generationLogCollector.log(sessionId, TAG, `subscribe() called for model: ${model}`);
|
|
113
|
+
|
|
114
|
+
const preprocessStart = Date.now();
|
|
115
|
+
const processedInput = await preprocessInput(input, sessionId);
|
|
116
|
+
const preprocessElapsed = Date.now() - preprocessStart;
|
|
117
|
+
generationLogCollector.log(sessionId, TAG, `Preprocessing done in ${preprocessElapsed}ms`);
|
|
118
|
+
|
|
100
119
|
const key = createRequestKey(model, processedInput);
|
|
101
120
|
|
|
102
121
|
const existing = getExistingRequest<T>(key);
|
|
103
|
-
if (existing)
|
|
122
|
+
if (existing) {
|
|
123
|
+
generationLogCollector.log(sessionId, TAG, `Dedup hit - returning existing request`);
|
|
124
|
+
return existing.promise;
|
|
125
|
+
}
|
|
104
126
|
|
|
105
127
|
const abortController = new AbortController();
|
|
106
128
|
|
|
@@ -114,19 +136,22 @@ export class FalProvider implements IAIProvider {
|
|
|
114
136
|
this.lastRequestKey = key;
|
|
115
137
|
storeRequest(key, { promise, abortController, createdAt: Date.now() });
|
|
116
138
|
|
|
117
|
-
handleFalSubscription<T>(model, processedInput, options, abortController.signal)
|
|
118
|
-
.then((res) =>
|
|
119
|
-
|
|
139
|
+
handleFalSubscription<T>(model, processedInput, sessionId, options, abortController.signal)
|
|
140
|
+
.then((res) => {
|
|
141
|
+
const totalElapsed = Date.now() - totalStart;
|
|
142
|
+
generationLogCollector.log(sessionId, TAG, `Generation SUCCESS in ${totalElapsed}ms (preprocess: ${preprocessElapsed}ms)`);
|
|
143
|
+
resolvePromise(res.result);
|
|
144
|
+
})
|
|
145
|
+
.catch((error) => {
|
|
146
|
+
const totalElapsed = Date.now() - totalStart;
|
|
147
|
+
generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${error instanceof Error ? error.message : String(error)}`);
|
|
148
|
+
rejectPromise(error);
|
|
149
|
+
})
|
|
120
150
|
.finally(() => {
|
|
121
151
|
try {
|
|
122
152
|
removeRequest(key);
|
|
123
153
|
} catch (cleanupError) {
|
|
124
|
-
|
|
125
|
-
console.error(
|
|
126
|
-
`[fal-provider] Error removing request: ${key}`,
|
|
127
|
-
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
|
128
|
-
);
|
|
129
|
-
}
|
|
154
|
+
generationLogCollector.warn(sessionId, TAG, `Error removing request: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
130
155
|
}
|
|
131
156
|
});
|
|
132
157
|
|
|
@@ -136,25 +161,30 @@ export class FalProvider implements IAIProvider {
|
|
|
136
161
|
async run<T = unknown>(model: string, input: Record<string, unknown>, options?: RunOptions): Promise<T> {
|
|
137
162
|
this.validateInit();
|
|
138
163
|
validateInput(model, input);
|
|
139
|
-
|
|
164
|
+
|
|
165
|
+
const sessionId = generationLogCollector.startSession();
|
|
166
|
+
this.activeSessionId = sessionId;
|
|
167
|
+
generationLogCollector.log(sessionId, 'fal-provider', `run() for model: ${model}`);
|
|
168
|
+
|
|
169
|
+
const processedInput = await preprocessInput(input, sessionId);
|
|
140
170
|
|
|
141
171
|
const signal = options?.signal;
|
|
142
172
|
if (signal?.aborted) {
|
|
143
173
|
throw new Error("Request cancelled by user");
|
|
144
174
|
}
|
|
145
175
|
|
|
146
|
-
return handleFalRun<T>(model, processedInput, options);
|
|
176
|
+
return handleFalRun<T>(model, processedInput, sessionId, options);
|
|
147
177
|
}
|
|
148
178
|
|
|
149
179
|
reset(): void {
|
|
150
180
|
cancelAllRequests();
|
|
151
181
|
this.lastRequestKey = null;
|
|
182
|
+
this.activeSessionId = null;
|
|
152
183
|
this.apiKey = null;
|
|
153
184
|
this.initialized = false;
|
|
154
185
|
}
|
|
155
186
|
|
|
156
187
|
cancelCurrentRequest(): void {
|
|
157
|
-
// Cancel only this provider instance's last request, not all global requests
|
|
158
188
|
if (this.lastRequestKey) {
|
|
159
189
|
cancelRequest(this.lastRequestKey);
|
|
160
190
|
this.lastRequestKey = null;
|
|
@@ -164,6 +194,25 @@ export class FalProvider implements IAIProvider {
|
|
|
164
194
|
hasRunningRequest(): boolean {
|
|
165
195
|
return hasActiveRequests();
|
|
166
196
|
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get all log entries collected during the current/last generation session.
|
|
200
|
+
* Returns empty array if no active session.
|
|
201
|
+
*/
|
|
202
|
+
getSessionLogs(): LogEntry[] {
|
|
203
|
+
if (!this.activeSessionId) return [];
|
|
204
|
+
return generationLogCollector.getEntries(this.activeSessionId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* End the current log session and return all entries. Clears the buffer.
|
|
209
|
+
*/
|
|
210
|
+
endLogSession(): LogEntry[] {
|
|
211
|
+
if (!this.activeSessionId) return [];
|
|
212
|
+
const entries = generationLogCollector.endSession(this.activeSessionId);
|
|
213
|
+
this.activeSessionId = null;
|
|
214
|
+
return entries;
|
|
215
|
+
}
|
|
167
216
|
}
|
|
168
217
|
|
|
169
218
|
export const falProvider = new FalProvider();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FAL Storage Utility
|
|
3
3
|
* Handles image uploads to FAL storage (React Native compatible)
|
|
4
|
+
* Features: timeout protection, retry with exponential backoff, session-scoped logging
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { fal } from "@fal-ai/client";
|
|
@@ -9,15 +10,71 @@ import {
|
|
|
9
10
|
deleteTempFile,
|
|
10
11
|
} from "@umituz/react-native-design-system/filesystem";
|
|
11
12
|
import { getErrorMessage } from './helpers/error-helpers.util';
|
|
13
|
+
import { generationLogCollector } from './log-collector';
|
|
14
|
+
import { UPLOAD_CONFIG } from '../services/fal-provider.constants';
|
|
15
|
+
|
|
16
|
+
const TAG = 'fal-storage';
|
|
17
|
+
|
|
18
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
19
|
+
let timeoutId: ReturnType<typeof setTimeout>;
|
|
20
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
21
|
+
timeoutId = setTimeout(() => {
|
|
22
|
+
reject(new Error(`Upload timeout after ${ms}ms: ${label}`));
|
|
23
|
+
}, ms);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return Promise.race([promise, timeoutPromise]).finally(() => {
|
|
27
|
+
clearTimeout(timeoutId);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function withRetry<T>(
|
|
32
|
+
fn: () => Promise<T>,
|
|
33
|
+
sessionId: string,
|
|
34
|
+
label: string,
|
|
35
|
+
maxRetries: number = UPLOAD_CONFIG.maxRetries,
|
|
36
|
+
baseDelay: number = UPLOAD_CONFIG.baseDelayMs,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
let lastError: unknown;
|
|
39
|
+
|
|
40
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
41
|
+
try {
|
|
42
|
+
if (attempt > 0) {
|
|
43
|
+
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
44
|
+
generationLogCollector.warn(sessionId, TAG, `Retry ${attempt}/${maxRetries} for ${label} after ${delay}ms`);
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
46
|
+
}
|
|
47
|
+
return await fn();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
lastError = error;
|
|
50
|
+
const errorMsg = getErrorMessage(error);
|
|
51
|
+
const isTransient =
|
|
52
|
+
errorMsg.toLowerCase().includes('network') ||
|
|
53
|
+
errorMsg.includes('timeout') ||
|
|
54
|
+
errorMsg.includes('timed out') ||
|
|
55
|
+
errorMsg.includes('ECONNREFUSED') ||
|
|
56
|
+
errorMsg.includes('ENOTFOUND') ||
|
|
57
|
+
errorMsg.includes('fetch');
|
|
58
|
+
|
|
59
|
+
if (attempt < maxRetries && isTransient) {
|
|
60
|
+
generationLogCollector.warn(sessionId, TAG, `Attempt ${attempt + 1} failed for ${label}: ${errorMsg}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw lastError;
|
|
68
|
+
}
|
|
12
69
|
|
|
13
70
|
/**
|
|
14
71
|
* Upload base64 image to FAL storage
|
|
15
|
-
* Uses design system's filesystem utilities for React Native compatibility
|
|
16
72
|
*/
|
|
17
|
-
export async function uploadToFalStorage(base64: string): Promise<string> {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
73
|
+
export async function uploadToFalStorage(base64: string, sessionId: string): Promise<string> {
|
|
74
|
+
const startTime = Date.now();
|
|
75
|
+
const sizeKB = Math.round(base64.length / 1024);
|
|
76
|
+
const actualSizeKB = Math.round(sizeKB * 0.75); // base64 inflates ~33%
|
|
77
|
+
generationLogCollector.log(sessionId, TAG, `Starting upload (~${actualSizeKB}KB actual)`);
|
|
21
78
|
|
|
22
79
|
const tempUri = await base64ToTempFile(base64);
|
|
23
80
|
|
|
@@ -26,66 +83,86 @@ export async function uploadToFalStorage(base64: string): Promise<string> {
|
|
|
26
83
|
}
|
|
27
84
|
|
|
28
85
|
try {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
86
|
+
const url = await withRetry(
|
|
87
|
+
async () => {
|
|
88
|
+
const response = await fetch(tempUri);
|
|
89
|
+
const blob = await response.blob();
|
|
90
|
+
generationLogCollector.log(sessionId, TAG, `Blob created (${blob.size} bytes), uploading to FAL CDN...`);
|
|
91
|
+
return withTimeout(
|
|
92
|
+
fal.storage.upload(blob),
|
|
93
|
+
UPLOAD_CONFIG.timeoutMs,
|
|
94
|
+
`image (~${actualSizeKB}KB)`,
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
sessionId,
|
|
98
|
+
'upload',
|
|
99
|
+
);
|
|
36
100
|
|
|
101
|
+
const elapsed = Date.now() - startTime;
|
|
102
|
+
generationLogCollector.log(sessionId, TAG, `Upload complete in ${elapsed}ms`, { url, actualSizeKB, elapsed });
|
|
37
103
|
return url;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const elapsed = Date.now() - startTime;
|
|
106
|
+
generationLogCollector.error(sessionId, TAG, `Upload FAILED after ${elapsed}ms: ${getErrorMessage(error)}`, { actualSizeKB, elapsed });
|
|
107
|
+
throw error;
|
|
38
108
|
} finally {
|
|
39
109
|
try {
|
|
40
110
|
await deleteTempFile(tempUri);
|
|
41
111
|
} catch (cleanupError) {
|
|
42
|
-
|
|
43
|
-
console.warn(
|
|
44
|
-
`[fal-storage] Failed to delete temp file: ${tempUri}`,
|
|
45
|
-
getErrorMessage(cleanupError)
|
|
46
|
-
);
|
|
47
|
-
// Don't throw - cleanup errors shouldn't fail the upload
|
|
112
|
+
generationLogCollector.warn(sessionId, TAG, `Failed to delete temp file: ${getErrorMessage(cleanupError)}`);
|
|
48
113
|
}
|
|
49
114
|
}
|
|
50
115
|
}
|
|
51
116
|
|
|
52
117
|
/**
|
|
53
|
-
* Upload a local file
|
|
54
|
-
* Directly fetches the file as a blob — no base64 intermediate step
|
|
118
|
+
* Upload a local file to FAL storage
|
|
55
119
|
*/
|
|
56
|
-
export async function uploadLocalFileToFalStorage(fileUri: string): Promise<string> {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
console.log(`[fal-storage] Starting local file upload to FAL: ${fileUri}`);
|
|
60
|
-
}
|
|
120
|
+
export async function uploadLocalFileToFalStorage(fileUri: string, sessionId: string): Promise<string> {
|
|
121
|
+
const startTime = Date.now();
|
|
122
|
+
generationLogCollector.log(sessionId, TAG, `Starting local file upload: ${fileUri}`);
|
|
61
123
|
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
124
|
+
try {
|
|
125
|
+
const url = await withRetry(
|
|
126
|
+
async () => {
|
|
127
|
+
const response = await fetch(fileUri);
|
|
128
|
+
const blob = await response.blob();
|
|
129
|
+
generationLogCollector.log(sessionId, TAG, `Local file blob (${blob.size} bytes), uploading...`);
|
|
130
|
+
return withTimeout(
|
|
131
|
+
fal.storage.upload(blob),
|
|
132
|
+
UPLOAD_CONFIG.timeoutMs,
|
|
133
|
+
`local file`,
|
|
134
|
+
);
|
|
135
|
+
},
|
|
136
|
+
sessionId,
|
|
137
|
+
'local file upload',
|
|
138
|
+
);
|
|
69
139
|
|
|
140
|
+
const elapsed = Date.now() - startTime;
|
|
141
|
+
generationLogCollector.log(sessionId, TAG, `Local file upload complete in ${elapsed}ms`, { url, elapsed });
|
|
70
142
|
return url;
|
|
71
143
|
} catch (error) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
144
|
+
const elapsed = Date.now() - startTime;
|
|
145
|
+
generationLogCollector.error(sessionId, TAG, `Local file upload FAILED after ${elapsed}ms: ${getErrorMessage(error)}`, { elapsed });
|
|
146
|
+
throw error;
|
|
75
147
|
}
|
|
76
148
|
}
|
|
77
149
|
|
|
78
150
|
/**
|
|
79
151
|
* Upload multiple images to FAL storage in parallel
|
|
80
|
-
* Uses Promise.allSettled to handle partial failures gracefully
|
|
81
|
-
* @throws {Error} if any upload fails, with details about all failures
|
|
82
|
-
* Note: Successful uploads before the first failure are NOT cleaned up automatically
|
|
83
|
-
* as FAL storage doesn't provide a delete API. Monitor orphaned uploads externally.
|
|
84
152
|
*/
|
|
85
153
|
export async function uploadMultipleToFalStorage(
|
|
86
154
|
images: string[],
|
|
155
|
+
sessionId: string,
|
|
87
156
|
): Promise<string[]> {
|
|
88
|
-
const
|
|
157
|
+
const startTime = Date.now();
|
|
158
|
+
generationLogCollector.log(sessionId, TAG, `Starting batch upload of ${images.length} image(s)`);
|
|
159
|
+
|
|
160
|
+
const results = await Promise.allSettled(
|
|
161
|
+
images.map((img, i) => {
|
|
162
|
+
generationLogCollector.log(sessionId, TAG, `[${i}/${images.length}] Queuing upload (~${Math.round(img.length * 0.75 / 1024)}KB)`);
|
|
163
|
+
return uploadToFalStorage(img, sessionId);
|
|
164
|
+
})
|
|
165
|
+
);
|
|
89
166
|
|
|
90
167
|
const successfulUploads: string[] = [];
|
|
91
168
|
const failures: Array<{ index: number; error: unknown }> = [];
|
|
@@ -98,27 +175,19 @@ export async function uploadMultipleToFalStorage(
|
|
|
98
175
|
}
|
|
99
176
|
});
|
|
100
177
|
|
|
101
|
-
|
|
178
|
+
const elapsed = Date.now() - startTime;
|
|
179
|
+
|
|
102
180
|
if (failures.length > 0) {
|
|
103
181
|
const errorMessage = failures
|
|
104
|
-
.map(({ index, error }) =>
|
|
105
|
-
`Image ${index}: ${getErrorMessage(error)}`
|
|
106
|
-
)
|
|
182
|
+
.map(({ index, error }) => `Image ${index}: ${getErrorMessage(error)}`)
|
|
107
183
|
.join('; ');
|
|
108
184
|
|
|
109
|
-
|
|
110
|
-
if (successfulUploads.length > 0) {
|
|
111
|
-
console.warn(
|
|
112
|
-
`[fal-storage] ${successfulUploads.length} upload(s) succeeded before failure. ` +
|
|
113
|
-
'These files remain in FAL storage and may need manual cleanup:',
|
|
114
|
-
successfulUploads
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
185
|
+
generationLogCollector.error(sessionId, TAG, `Batch upload FAILED: ${successfulUploads.length}/${images.length} in ${elapsed}ms`, { elapsed });
|
|
118
186
|
throw new Error(
|
|
119
|
-
`Failed to upload ${failures.length} of ${images.length} image(s): ${errorMessage}`
|
|
187
|
+
`Failed to upload ${failures.length} of ${images.length} image(s) (${elapsed}ms): ${errorMessage}`
|
|
120
188
|
);
|
|
121
189
|
}
|
|
122
190
|
|
|
191
|
+
generationLogCollector.log(sessionId, TAG, `Batch upload complete: ${images.length}/${images.length} in ${elapsed}ms`, { elapsed });
|
|
123
192
|
return successfulUploads;
|
|
124
193
|
}
|
|
@@ -41,6 +41,9 @@ export {
|
|
|
41
41
|
|
|
42
42
|
export { preprocessInput } from "./input-preprocessor.util";
|
|
43
43
|
|
|
44
|
+
export { generationLogCollector } from "./log-collector";
|
|
45
|
+
export type { LogEntry } from "./log-collector";
|
|
46
|
+
|
|
44
47
|
export { FalGenerationStateManager } from "./fal-generation-state-manager.util";
|
|
45
48
|
export type { GenerationState } from "./fal-generation-state-manager.util";
|
|
46
49
|
|
|
@@ -1,183 +1,174 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Input Preprocessor Utility
|
|
3
3
|
* Detects and uploads base64/local file images to FAL storage before API calls
|
|
4
|
+
*
|
|
5
|
+
* Upload strategy:
|
|
6
|
+
* - Array fields (image_urls): SEQUENTIAL uploads to avoid bandwidth contention
|
|
7
|
+
* - Individual fields (image_url, face_image_url): parallel (typically only 1)
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import { uploadToFalStorage, uploadLocalFileToFalStorage } from "./fal-storage.util";
|
|
7
11
|
import { getErrorMessage } from './helpers/error-helpers.util';
|
|
8
12
|
import { IMAGE_URL_FIELDS } from './constants/image-fields.constants';
|
|
9
13
|
import { isImageDataUri as isBase64DataUri } from './validators/data-uri-validator.util';
|
|
14
|
+
import { generationLogCollector } from './log-collector';
|
|
15
|
+
|
|
16
|
+
const TAG = 'preprocessor';
|
|
10
17
|
|
|
11
|
-
/**
|
|
12
|
-
* Check if a value is a local file URI (file:// or content://)
|
|
13
|
-
*/
|
|
14
18
|
function isLocalFileUri(value: unknown): value is string {
|
|
15
19
|
return typeof value === "string" && (
|
|
16
20
|
value.startsWith("file://") || value.startsWith("content://")
|
|
17
21
|
);
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Classify a network error into a user-friendly message.
|
|
26
|
+
* Technical details are preserved in Firestore logs/session subcollection.
|
|
27
|
+
*/
|
|
28
|
+
function classifyUploadError(errorMsg: string): string {
|
|
29
|
+
const lower = errorMsg.toLowerCase();
|
|
30
|
+
|
|
31
|
+
if (lower.includes('timed out') || lower.includes('timeout')) {
|
|
32
|
+
return 'Photo upload took too long. Please try again on a stronger connection (WiFi recommended).';
|
|
33
|
+
}
|
|
34
|
+
if (lower.includes('network request failed') || lower.includes('network') || lower.includes('fetch')) {
|
|
35
|
+
return 'Photo upload failed due to network issues. Please check your internet connection and try again.';
|
|
36
|
+
}
|
|
37
|
+
if (lower.includes('econnrefused') || lower.includes('enotfound')) {
|
|
38
|
+
return 'Could not reach the upload server. Please check your internet connection and try again.';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return errorMsg;
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
/**
|
|
21
45
|
* Preprocess input by uploading base64/local file images to FAL storage.
|
|
22
46
|
* Also strips sync_mode to prevent base64 data URI responses.
|
|
23
47
|
* Returns input with HTTPS URLs instead of base64/local URIs.
|
|
48
|
+
*
|
|
49
|
+
* Array fields are uploaded SEQUENTIALLY to avoid bandwidth contention
|
|
50
|
+
* on slow mobile connections (prevents simultaneous upload failures).
|
|
24
51
|
*/
|
|
25
52
|
export async function preprocessInput(
|
|
26
53
|
input: Record<string, unknown>,
|
|
54
|
+
sessionId: string,
|
|
27
55
|
): Promise<Record<string, unknown>> {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
}
|
|
56
|
+
const startTime = Date.now();
|
|
57
|
+
const inputKeys = Object.keys(input);
|
|
58
|
+
generationLogCollector.log(sessionId, TAG, `Starting preprocessing...`, { keys: inputKeys });
|
|
33
59
|
|
|
34
60
|
const result = { ...input };
|
|
35
61
|
const uploadPromises: Promise<unknown>[] = [];
|
|
36
62
|
|
|
37
|
-
// SAFETY: Strip sync_mode to prevent base64 data URI responses
|
|
38
|
-
// FAL returns base64 when sync_mode:true — we always want CDN URLs
|
|
39
63
|
if ("sync_mode" in result) {
|
|
40
64
|
delete result.sync_mode;
|
|
41
|
-
|
|
42
|
-
console.warn(
|
|
43
|
-
"[preprocessInput] Stripped sync_mode from input. " +
|
|
44
|
-
"sync_mode:true returns base64 data URIs which break Firestore persistence. " +
|
|
45
|
-
"Use falProvider.subscribe() for CDN URLs."
|
|
46
|
-
);
|
|
47
|
-
}
|
|
65
|
+
generationLogCollector.warn(sessionId, TAG, `Stripped sync_mode from input`);
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
// Handle individual image URL keys
|
|
68
|
+
// Handle individual image URL keys (parallel — typically only 1 field)
|
|
69
|
+
let individualUploadCount = 0;
|
|
51
70
|
for (const key of IMAGE_URL_FIELDS) {
|
|
52
71
|
const value = result[key];
|
|
53
72
|
|
|
54
|
-
// Upload base64 data URIs to FAL storage
|
|
55
73
|
if (isBase64DataUri(value)) {
|
|
56
|
-
|
|
74
|
+
individualUploadCount++;
|
|
75
|
+
generationLogCollector.log(sessionId, TAG, `Found base64 field: ${key} (${Math.round(String(value).length / 1024)}KB)`);
|
|
76
|
+
const uploadPromise = uploadToFalStorage(value, sessionId)
|
|
57
77
|
.then((url) => {
|
|
58
78
|
result[key] = url;
|
|
59
79
|
return url;
|
|
60
80
|
})
|
|
61
81
|
.catch((error) => {
|
|
62
82
|
const errorMessage = `Failed to upload ${key}: ${getErrorMessage(error)}`;
|
|
63
|
-
|
|
83
|
+
generationLogCollector.error(sessionId, TAG, errorMessage);
|
|
64
84
|
throw new Error(errorMessage);
|
|
65
85
|
});
|
|
66
|
-
|
|
67
86
|
uploadPromises.push(uploadPromise);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const uploadPromise = uploadLocalFileToFalStorage(value)
|
|
87
|
+
} else if (isLocalFileUri(value)) {
|
|
88
|
+
individualUploadCount++;
|
|
89
|
+
generationLogCollector.log(sessionId, TAG, `Found local file field: ${key}`);
|
|
90
|
+
const uploadPromise = uploadLocalFileToFalStorage(value, sessionId)
|
|
72
91
|
.then((url) => {
|
|
73
92
|
result[key] = url;
|
|
74
93
|
return url;
|
|
75
94
|
})
|
|
76
95
|
.catch((error) => {
|
|
77
96
|
const errorMessage = `Failed to upload local file ${key}: ${getErrorMessage(error)}`;
|
|
78
|
-
|
|
97
|
+
generationLogCollector.error(sessionId, TAG, errorMessage);
|
|
79
98
|
throw new Error(errorMessage);
|
|
80
99
|
});
|
|
81
|
-
|
|
82
100
|
uploadPromises.push(uploadPromise);
|
|
83
101
|
}
|
|
84
102
|
}
|
|
85
103
|
|
|
104
|
+
if (individualUploadCount > 0) {
|
|
105
|
+
generationLogCollector.log(sessionId, TAG, `${individualUploadCount} individual field upload(s) queued`);
|
|
106
|
+
}
|
|
86
107
|
|
|
87
|
-
// Handle image URL arrays
|
|
108
|
+
// Handle image URL arrays — SEQUENTIAL to avoid bandwidth contention
|
|
88
109
|
for (const arrayField of ["image_urls", "input_image_urls", "reference_image_urls"] as const) {
|
|
89
110
|
if (Array.isArray(result[arrayField]) && (result[arrayField] as unknown[]).length > 0) {
|
|
90
111
|
const imageUrls = result[arrayField] as unknown[];
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
generationLogCollector.log(sessionId, TAG, `Processing ${arrayField}: ${imageUrls.length} item(s) (sequential)`);
|
|
113
|
+
|
|
114
|
+
const processedUrls: string[] = [];
|
|
115
|
+
const arrayStartTime = Date.now();
|
|
93
116
|
|
|
94
117
|
for (let i = 0; i < imageUrls.length; i++) {
|
|
95
118
|
const imageUrl = imageUrls[i];
|
|
96
119
|
|
|
97
120
|
if (!imageUrl) {
|
|
98
|
-
|
|
99
|
-
continue;
|
|
121
|
+
throw new Error(`${arrayField}[${i}] is null or undefined`);
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
if (isBase64DataUri(imageUrl)) {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
125
|
+
const sizeKB = Math.round(String(imageUrl).length / 1024);
|
|
126
|
+
generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: base64 (${sizeKB}KB) - uploading...`);
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const url = await uploadToFalStorage(imageUrl, sessionId);
|
|
130
|
+
processedUrls.push(url);
|
|
131
|
+
generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: upload OK`);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
const elapsed = Date.now() - arrayStartTime;
|
|
134
|
+
const technicalMsg = getErrorMessage(error);
|
|
135
|
+
generationLogCollector.error(sessionId, TAG, `${arrayField}[${i}] upload FAILED after ${elapsed}ms: ${technicalMsg}`);
|
|
136
|
+
throw new Error(classifyUploadError(technicalMsg));
|
|
137
|
+
}
|
|
112
138
|
} else if (typeof imageUrl === "string") {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
errors.push(`${arrayField}[${i}] has invalid type: ${typeof imageUrl}`);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (errors.length > 0) {
|
|
120
|
-
throw new Error(`Image URL validation failed:\n${errors.join('\n')}`);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (uploadTasks.length === 0) {
|
|
124
|
-
throw new Error(`${arrayField} array must contain at least one valid image URL`);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const uploadResults = await Promise.allSettled(
|
|
128
|
-
uploadTasks.map((task) => Promise.resolve(task.url))
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const processedUrls: string[] = [];
|
|
132
|
-
const uploadErrors: string[] = [];
|
|
133
|
-
|
|
134
|
-
uploadResults.forEach((uploadResult, index) => {
|
|
135
|
-
if (uploadResult.status === 'fulfilled') {
|
|
136
|
-
processedUrls.push(uploadResult.value);
|
|
139
|
+
generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: already URL - pass through`);
|
|
140
|
+
processedUrls.push(imageUrl);
|
|
137
141
|
} else {
|
|
138
|
-
|
|
139
|
-
`Upload ${index} failed: ${getErrorMessage(uploadResult.reason)}`
|
|
140
|
-
);
|
|
142
|
+
throw new Error(`${arrayField}[${i}] has invalid type: ${typeof imageUrl}`);
|
|
141
143
|
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
if (uploadErrors.length > 0) {
|
|
145
|
-
console.warn(
|
|
146
|
-
`[input-preprocessor] ${processedUrls.length} of ${uploadTasks.length} uploads succeeded. ` +
|
|
147
|
-
'Successful uploads remain in FAL storage.'
|
|
148
|
-
);
|
|
149
|
-
throw new Error(`Image upload failures:\n${uploadErrors.join('\n')}`);
|
|
150
144
|
}
|
|
151
145
|
|
|
146
|
+
const arrayElapsed = Date.now() - arrayStartTime;
|
|
147
|
+
generationLogCollector.log(sessionId, TAG, `${arrayField}: all ${processedUrls.length} upload(s) succeeded in ${arrayElapsed}ms`);
|
|
152
148
|
result[arrayField] = processedUrls;
|
|
153
149
|
}
|
|
154
150
|
}
|
|
155
151
|
|
|
156
|
-
// Wait for ALL
|
|
157
|
-
// Use Promise.allSettled to handle partial failures gracefully
|
|
152
|
+
// Wait for ALL individual field uploads
|
|
158
153
|
if (uploadPromises.length > 0) {
|
|
154
|
+
generationLogCollector.log(sessionId, TAG, `Waiting for ${uploadPromises.length} individual field upload(s)...`);
|
|
159
155
|
const individualUploadResults = await Promise.allSettled(uploadPromises);
|
|
160
156
|
|
|
161
157
|
const failedUploads = individualUploadResults.filter(
|
|
162
|
-
(
|
|
158
|
+
(r) => r.status === 'rejected'
|
|
163
159
|
);
|
|
164
160
|
|
|
165
161
|
if (failedUploads.length > 0) {
|
|
166
162
|
const successCount = individualUploadResults.length - failedUploads.length;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
'Successful uploads remain in FAL storage.'
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
const errorMessages = failedUploads.map((result) =>
|
|
173
|
-
result.status === 'rejected'
|
|
174
|
-
? (getErrorMessage(result.reason))
|
|
175
|
-
: 'Unknown error'
|
|
163
|
+
const errorMessages = failedUploads.map((r) =>
|
|
164
|
+
r.status === 'rejected' ? getErrorMessage(r.reason) : 'Unknown error'
|
|
176
165
|
);
|
|
177
|
-
|
|
178
|
-
throw new Error(
|
|
166
|
+
generationLogCollector.error(sessionId, TAG, `Individual uploads: ${successCount}/${individualUploadResults.length} succeeded`, { errors: errorMessages });
|
|
167
|
+
throw new Error(classifyUploadError(errorMessages[0]));
|
|
179
168
|
}
|
|
180
169
|
}
|
|
181
170
|
|
|
171
|
+
const totalElapsed = Date.now() - startTime;
|
|
172
|
+
generationLogCollector.log(sessionId, TAG, `Preprocessing complete in ${totalElapsed}ms`);
|
|
182
173
|
return result;
|
|
183
174
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation Log Collector
|
|
3
|
+
* Session-scoped log collection — each generation gets its own isolated session.
|
|
4
|
+
* Supports concurrent generations without data corruption.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const sessionId = collector.startSession();
|
|
8
|
+
* collector.log(sessionId, 'tag', 'message');
|
|
9
|
+
* const entries = collector.endSession(sessionId);
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface LogEntry {
|
|
13
|
+
readonly timestamp: number;
|
|
14
|
+
readonly elapsed: number;
|
|
15
|
+
readonly level: 'info' | 'warn' | 'error';
|
|
16
|
+
readonly tag: string;
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly data?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Session {
|
|
22
|
+
readonly startTime: number;
|
|
23
|
+
entries: LogEntry[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let sessionCounter = 0;
|
|
27
|
+
|
|
28
|
+
class GenerationLogCollector {
|
|
29
|
+
private sessions = new Map<string, Session>();
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start a new isolated logging session. Returns session ID.
|
|
33
|
+
*/
|
|
34
|
+
startSession(): string {
|
|
35
|
+
const id = `session_${++sessionCounter}_${Date.now()}`;
|
|
36
|
+
this.sessions.set(id, { startTime: Date.now(), entries: [] });
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
log(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
|
|
41
|
+
this.addEntry(sessionId, 'info', tag, message, data);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
warn(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
|
|
45
|
+
this.addEntry(sessionId, 'warn', tag, message, data);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
error(sessionId: string, tag: string, message: string, data?: Record<string, unknown>): void {
|
|
49
|
+
this.addEntry(sessionId, 'error', tag, message, data);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get all entries for a session (non-destructive).
|
|
54
|
+
*/
|
|
55
|
+
getEntries(sessionId: string): LogEntry[] {
|
|
56
|
+
return [...(this.sessions.get(sessionId)?.entries ?? [])];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* End session and return all entries. Removes the session.
|
|
61
|
+
*/
|
|
62
|
+
endSession(sessionId: string): LogEntry[] {
|
|
63
|
+
const session = this.sessions.get(sessionId);
|
|
64
|
+
if (!session) return [];
|
|
65
|
+
const entries = [...session.entries];
|
|
66
|
+
this.sessions.delete(sessionId);
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private addEntry(
|
|
71
|
+
sessionId: string,
|
|
72
|
+
level: LogEntry['level'],
|
|
73
|
+
tag: string,
|
|
74
|
+
message: string,
|
|
75
|
+
data?: Record<string, unknown>,
|
|
76
|
+
): void {
|
|
77
|
+
const session = this.sessions.get(sessionId);
|
|
78
|
+
if (!session) {
|
|
79
|
+
// Session not started or already ended — still output to console, but don't buffer
|
|
80
|
+
this.consoleOutput(level, tag, message, data);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
session.entries.push({
|
|
86
|
+
timestamp: now,
|
|
87
|
+
elapsed: now - session.startTime,
|
|
88
|
+
level,
|
|
89
|
+
tag,
|
|
90
|
+
message,
|
|
91
|
+
...(data && { data }),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.consoleOutput(level, tag, message, data);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private consoleOutput(level: LogEntry['level'], tag: string, message: string, data?: Record<string, unknown>): void {
|
|
98
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
99
|
+
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
|
|
100
|
+
fn(`[${tag}] ${message}`, data ?? '');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Module-level singleton — safe for concurrent sessions via session IDs */
|
|
106
|
+
export const generationLogCollector = new GenerationLogCollector();
|