@umituz/react-native-ai-fal-provider 3.2.36 → 3.2.38

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-fal-provider",
3
- "version": "3.2.36",
3
+ "version": "3.2.38",
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",
@@ -13,6 +13,7 @@ export type {
13
13
  ReplaceBackgroundOptions,
14
14
  VideoFromImageOptions,
15
15
  TextToVideoOptions,
16
+ TextToVoiceOptions,
16
17
  FaceSwapOptions,
17
18
  } from "./input-builders.types";
18
19
 
@@ -54,6 +54,22 @@ export interface TextToVideoOptions {
54
54
  readonly resolution?: string;
55
55
  }
56
56
 
57
+ /**
58
+ * Options for text-to-voice generation (TTS)
59
+ */
60
+ export interface TextToVoiceOptions {
61
+ /** Text content to convert to speech (required) */
62
+ readonly text: string;
63
+ /** Voice preset name (model-specific, e.g., "aria", "marcus") */
64
+ readonly voice?: string;
65
+ /** Language code (e.g., "en", "es", "fr") */
66
+ readonly language?: string;
67
+ /** Exaggeration factor for voice expressiveness (0.0 - 1.0) */
68
+ readonly exaggeration?: number;
69
+ /** CFG/pace control weight */
70
+ readonly cfgWeight?: number;
71
+ }
72
+
57
73
  export interface FaceSwapOptions {
58
74
  readonly enhanceFaces?: boolean;
59
75
  }
@@ -33,6 +33,7 @@ export type {
33
33
  ReplaceBackgroundOptions,
34
34
  VideoFromImageOptions,
35
35
  TextToVideoOptions,
36
+ TextToVoiceOptions,
36
37
  ImageFeatureType,
37
38
  VideoFeatureType,
38
39
  AIProviderConfig,
@@ -310,7 +310,9 @@ export async function handleFalSubscription<T = unknown>(
310
310
  const totalElapsed = Date.now() - overallStart;
311
311
  const retryInfo = attempt > 0 ? ` after ${attempt + 1} attempts` : '';
312
312
  generationLogCollector.error(sessionId, TAG, `Subscription FAILED in ${totalElapsed}ms${retryInfo}: ${message}`);
313
- throw new Error(message);
313
+ // Re-throw original error to preserve type info (ApiError, ValidationError, etc.)
314
+ // so downstream mapFalError() can categorize by HTTP status code
315
+ throw error;
314
316
  }
315
317
  }
316
318
 
@@ -334,7 +336,10 @@ export async function handleFalRun<T = unknown>(
334
336
  options?.onProgress?.({ progress: -1, status: "IN_PROGRESS" as const });
335
337
 
336
338
  try {
337
- const rawResult = await fal.run(model, { input });
339
+ const rawResult = await fal.run(model, {
340
+ input,
341
+ ...(options?.signal && { abortSignal: options.signal }),
342
+ });
338
343
  const { data } = unwrapFalResult<T>(rawResult);
339
344
 
340
345
  validateNoBase64InResponse(data);
@@ -355,6 +360,7 @@ export async function handleFalRun<T = unknown>(
355
360
 
356
361
  const message = formatFalError(error);
357
362
  generationLogCollector.error(sessionId, runTag, `Run FAILED after ${elapsed}ms for model ${model}: ${message}`);
358
- throw new Error(message);
363
+ // Re-throw original error to preserve type info for downstream mapFalError()
364
+ throw error;
359
365
  }
360
366
  }
@@ -12,6 +12,7 @@ import type {
12
12
  import { DEFAULT_FAL_CONFIG, FAL_CAPABILITIES } from "./fal-provider.constants";
13
13
  import { handleFalSubscription, handleFalRun } from "./fal-provider-subscription";
14
14
  import { preprocessInput } from "../utils";
15
+ import { getErrorMessage } from "../utils/helpers/error-helpers.util";
15
16
  import { generationLogCollector } from "../utils/log-collector";
16
17
  import type { LogEntry } from "../utils/log-collector";
17
18
  import {
@@ -79,8 +80,15 @@ export class FalProvider implements IAIProvider {
79
80
  validateInput(model, input);
80
81
  const sessionId = generationLogCollector.startSession();
81
82
  generationLogCollector.log(sessionId, 'fal-provider', `submitJob() for model: ${model}`);
82
- const processedInput = await preprocessInput(input, sessionId);
83
- return queueOps.submitJob(model, processedInput);
83
+ try {
84
+ const processedInput = await preprocessInput(input, sessionId);
85
+ const result = await queueOps.submitJob(model, processedInput);
86
+ generationLogCollector.endSession(sessionId);
87
+ return result;
88
+ } catch (error) {
89
+ generationLogCollector.endSession(sessionId);
90
+ throw error;
91
+ }
84
92
  }
85
93
 
86
94
  async getJobStatus(model: string, requestId: string): Promise<JobStatus> {
@@ -145,14 +153,17 @@ export class FalProvider implements IAIProvider {
145
153
  })
146
154
  .catch((error) => {
147
155
  const totalElapsed = Date.now() - totalStart;
148
- generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${error instanceof Error ? error.message : String(error)}`);
156
+ generationLogCollector.error(sessionId, TAG, `Generation FAILED in ${totalElapsed}ms: ${getErrorMessage(error)}`);
157
+ // End the log session on failure — consumer can't access sessionId from errors,
158
+ // so auto-cleanup prevents memory leak from orphaned sessions
159
+ generationLogCollector.endSession(sessionId);
149
160
  rejectPromise(error);
150
161
  })
151
162
  .finally(() => {
152
163
  try {
153
164
  removeRequest(key);
154
165
  } catch (cleanupError) {
155
- generationLogCollector.warn(sessionId, TAG, `Error removing request: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
166
+ generationLogCollector.warn(sessionId, TAG, `Error removing request: ${getErrorMessage(cleanupError)}`);
156
167
  }
157
168
  });
158
169
 
@@ -170,15 +181,21 @@ export class FalProvider implements IAIProvider {
170
181
 
171
182
  const signal = options?.signal;
172
183
  if (signal?.aborted) {
184
+ generationLogCollector.endSession(sessionId);
173
185
  throw new Error("Request cancelled by user");
174
186
  }
175
187
 
176
- const result = await handleFalRun<T>(model, processedInput, sessionId, options);
177
- // Attach providerSessionId to result for concurrent-safe log retrieval
178
- if (result && typeof result === 'object') {
179
- Object.defineProperty(result, '__providerSessionId', { value: sessionId, enumerable: false });
188
+ try {
189
+ const result = await handleFalRun<T>(model, processedInput, sessionId, options);
190
+ // Attach providerSessionId to result for concurrent-safe log retrieval
191
+ if (result && typeof result === 'object') {
192
+ Object.defineProperty(result, '__providerSessionId', { value: sessionId, enumerable: false });
193
+ }
194
+ return result;
195
+ } catch (error) {
196
+ generationLogCollector.endSession(sessionId);
197
+ throw error;
180
198
  }
181
- return result;
182
199
  }
183
200
 
184
201
  reset(): void {
@@ -51,16 +51,27 @@ function sortKeys(obj: unknown): unknown {
51
51
  }
52
52
 
53
53
  /**
54
- * Create a deterministic request key using model and input hash
54
+ * Create a deterministic request key using model and input hash.
55
+ * Uses dual 32-bit hashes (FNV-1a + DJB2) for collision resistance.
56
+ * 64-bit combined hash space makes accidental collisions extremely unlikely.
55
57
  */
56
58
  export function createRequestKey(model: string, input: Record<string, unknown>): string {
57
59
  const inputStr = JSON.stringify(sortKeys(input));
58
- let hash = 0;
60
+
61
+ // FNV-1a hash
62
+ let h1 = 0x811c9dc5;
59
63
  for (let i = 0; i < inputStr.length; i++) {
60
- const char = inputStr.charCodeAt(i);
61
- hash = ((hash << 5) - hash + char) | 0;
64
+ h1 ^= inputStr.charCodeAt(i);
65
+ h1 = Math.imul(h1, 0x01000193);
62
66
  }
63
- return `${model}:${hash.toString(36)}`;
67
+
68
+ // DJB2 hash (independent seed)
69
+ let h2 = 5381;
70
+ for (let i = 0; i < inputStr.length; i++) {
71
+ h2 = ((h2 << 5) + h2 + inputStr.charCodeAt(i)) | 0;
72
+ }
73
+
74
+ return `${model}:${(h1 >>> 0).toString(36)}_${(h2 >>> 0).toString(36)}`;
64
75
  }
65
76
 
66
77
  export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
@@ -47,13 +47,13 @@ async function withRetry<T>(
47
47
  return await fn();
48
48
  } catch (error) {
49
49
  lastError = error;
50
- const errorMsg = getErrorMessage(error);
50
+ const errorMsg = getErrorMessage(error).toLowerCase();
51
51
  const isTransient =
52
- errorMsg.toLowerCase().includes('network') ||
52
+ errorMsg.includes('network') ||
53
53
  errorMsg.includes('timeout') ||
54
54
  errorMsg.includes('timed out') ||
55
- errorMsg.includes('ECONNREFUSED') ||
56
- errorMsg.includes('ENOTFOUND') ||
55
+ errorMsg.includes('econnrefused') ||
56
+ errorMsg.includes('enotfound') ||
57
57
  errorMsg.includes('fetch');
58
58
 
59
59
  if (attempt < maxRetries && isTransient) {
@@ -135,6 +135,19 @@ export async function preprocessInput(
135
135
  generationLogCollector.error(sessionId, TAG, `${arrayField}[${i}] upload FAILED after ${elapsed}ms: ${technicalMsg}`);
136
136
  throw new Error(classifyUploadError(technicalMsg));
137
137
  }
138
+ } else if (isLocalFileUri(imageUrl)) {
139
+ generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: local file - uploading...`);
140
+
141
+ try {
142
+ const url = await uploadLocalFileToFalStorage(imageUrl, sessionId);
143
+ processedUrls.push(url);
144
+ generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: local file upload OK`);
145
+ } catch (error) {
146
+ const elapsed = Date.now() - arrayStartTime;
147
+ const technicalMsg = getErrorMessage(error);
148
+ generationLogCollector.error(sessionId, TAG, `${arrayField}[${i}] local file upload FAILED after ${elapsed}ms: ${technicalMsg}`);
149
+ throw new Error(classifyUploadError(technicalMsg));
150
+ }
138
151
  } else if (typeof imageUrl === "string") {
139
152
  generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: already URL - pass through`);
140
153
  processedUrls.push(imageUrl);
@@ -35,11 +35,11 @@ export function calculateVideoCredits(
35
35
  : COSTS.VIDEO_720P_PER_SECOND;
36
36
  let cost = costPerSec * duration;
37
37
  if (hasImageInput) cost += COSTS.IMAGE_INPUT;
38
- return Math.ceil((cost * MARKUP) / CREDIT_PRICE);
38
+ return Math.max(1, Math.ceil((cost * MARKUP) / CREDIT_PRICE));
39
39
  }
40
40
 
41
41
  export function calculateImageCredits(): number {
42
- return Math.ceil((COSTS.IMAGE * MARKUP) / CREDIT_PRICE);
42
+ return Math.max(1, Math.ceil((COSTS.IMAGE * MARKUP) / CREDIT_PRICE));
43
43
  }
44
44
 
45
45
  export function calculateCreditsFromConfig(
@@ -55,10 +55,14 @@ export function validateNSFWContent(result: Record<string, unknown>): void {
55
55
  throw new NSFWContentError();
56
56
  }
57
57
 
58
- // Format 5: content_policy_violation object
59
- const policyViolation = result?.content_policy_violation as { type: string; severity?: string } | undefined;
58
+ // Format 5: content_policy_violation — boolean true or object with type field
59
+ const policyViolation = result?.content_policy_violation;
60
+ if (policyViolation === true) {
61
+ throw new NSFWContentError();
62
+ }
60
63
  if (policyViolation && typeof policyViolation === "object") {
61
- const type = (policyViolation.type || "").toLowerCase();
64
+ const typed = policyViolation as { type?: string; severity?: string };
65
+ const type = (typed.type || "").toLowerCase();
62
66
  if (type.includes("nsfw") || type.includes("adult") || type.includes("explicit")) {
63
67
  throw new NSFWContentError();
64
68
  }
@@ -12,24 +12,23 @@ import { falProvider } from '../infrastructure/services';
12
12
  */
13
13
  export function initializeFalProvider(config: {
14
14
  apiKey: string | undefined;
15
+ /** When true (default), sets this provider as the active/default provider */
16
+ setAsActive?: boolean;
15
17
  }): boolean {
16
- try {
17
- const { apiKey } = config;
18
+ const { apiKey, setAsActive = true } = config;
18
19
 
19
- if (!apiKey) {
20
- return false;
21
- }
20
+ if (!apiKey) {
21
+ return false;
22
+ }
22
23
 
23
- falProvider.initialize({ apiKey });
24
+ falProvider.initialize({ apiKey });
24
25
 
25
- if (!providerRegistry.hasProvider(falProvider.providerId)) {
26
- providerRegistry.register(falProvider);
27
- }
26
+ if (!providerRegistry.hasProvider(falProvider.providerId)) {
27
+ providerRegistry.register(falProvider);
28
+ }
29
+ if (setAsActive) {
28
30
  providerRegistry.setActiveProvider(falProvider.providerId);
29
-
30
- return true;
31
- } catch (error) {
32
- console.error('[initializeFalProvider] Initialization failed:', error);
33
- throw error;
34
31
  }
32
+
33
+ return true;
35
34
  }
@@ -50,6 +50,7 @@ export function useFalGeneration<T = unknown>(
50
50
  const [error, setError] = useState<FalErrorInfo | null>(null);
51
51
  const [isLoading, setIsLoading] = useState(false);
52
52
  const [isCancelling, setIsCancelling] = useState(false);
53
+ const [requestId, setRequestId] = useState<string | null>(null);
53
54
 
54
55
  const stateManagerRef = useRef<FalGenerationStateManager<T> | null>(null);
55
56
  const optionsRef = useRef(options);
@@ -76,14 +77,12 @@ export function useFalGeneration<T = unknown>(
76
77
  stateManagerRef.current = null;
77
78
  }
78
79
 
79
- // Cancel any running requests
80
- if (falProvider.hasRunningRequest()) {
81
- try {
82
- falProvider.cancelCurrentRequest();
83
- } catch (error) {
84
- console.warn('[useFalGeneration] Error cancelling request on unmount:', error);
85
- }
86
- }
80
+ // On unmount, do NOT cancel via falProvider.cancelCurrentRequest() —
81
+ // it only tracks the LAST started request across ALL hook instances.
82
+ // If another component started a generation after us, cancelling here
83
+ // would kill THEIR request, not ours. Instead, rely on checkMounted()
84
+ // to silently discard results for this unmounted component.
85
+ // The user-initiated cancel() function still works for explicit cancellation.
87
86
  };
88
87
  }, []); // Empty deps - only run on mount/unmount
89
88
 
@@ -97,12 +96,16 @@ export function useFalGeneration<T = unknown>(
97
96
  setError(null);
98
97
  setData(null);
99
98
  stateManager.setCurrentRequestId(null);
99
+ setRequestId(null);
100
100
  setIsCancelling(false);
101
101
 
102
102
  try {
103
103
  const result = await falProvider.subscribe<T>(modelEndpoint, input, {
104
104
  timeoutMs: optionsRef.current?.timeoutMs,
105
105
  onQueueUpdate: (status: JobStatus) => {
106
+ if (status.requestId && status.requestId !== stateManager.getCurrentRequestId()) {
107
+ setRequestId(status.requestId);
108
+ }
106
109
  const falStatus = convertJobStatusToFalQueueStatus(
107
110
  status,
108
111
  stateManager.getCurrentRequestId()
@@ -153,11 +156,10 @@ export function useFalGeneration<T = unknown>(
153
156
  setError(null);
154
157
  setIsLoading(false);
155
158
  setIsCancelling(false);
159
+ setRequestId(null);
156
160
  stateManagerRef.current?.clearLastRequest();
157
161
  }, [cancel]);
158
162
 
159
- const requestId = stateManagerRef.current?.getCurrentRequestId() ?? null;
160
-
161
163
  return {
162
164
  data,
163
165
  error,