@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 +1 -1
- package/src/domain/types/index.ts +1 -0
- package/src/domain/types/input-builders.types.ts +16 -0
- package/src/exports/domain.ts +1 -0
- package/src/infrastructure/services/fal-provider-subscription.ts +9 -3
- package/src/infrastructure/services/fal-provider.ts +26 -9
- package/src/infrastructure/services/request-store.ts +16 -5
- package/src/infrastructure/utils/fal-storage.util.ts +4 -4
- package/src/infrastructure/utils/input-preprocessor.util.ts +13 -0
- package/src/infrastructure/utils/pricing/fal-pricing.util.ts +2 -2
- package/src/infrastructure/validators/nsfw-validator.ts +7 -3
- package/src/init/initializeFalProvider.ts +13 -14
- package/src/presentation/hooks/use-fal-generation.ts +12 -10
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.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",
|
|
@@ -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
|
}
|
package/src/exports/domain.ts
CHANGED
|
@@ -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
|
|
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, {
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
60
|
+
|
|
61
|
+
// FNV-1a hash
|
|
62
|
+
let h1 = 0x811c9dc5;
|
|
59
63
|
for (let i = 0; i < inputStr.length; i++) {
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
h1 ^= inputStr.charCodeAt(i);
|
|
65
|
+
h1 = Math.imul(h1, 0x01000193);
|
|
62
66
|
}
|
|
63
|
-
|
|
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.
|
|
52
|
+
errorMsg.includes('network') ||
|
|
53
53
|
errorMsg.includes('timeout') ||
|
|
54
54
|
errorMsg.includes('timed out') ||
|
|
55
|
-
errorMsg.includes('
|
|
56
|
-
errorMsg.includes('
|
|
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
|
|
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
|
|
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
|
-
|
|
17
|
-
const { apiKey } = config;
|
|
18
|
+
const { apiKey, setAsActive = true } = config;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
if (!apiKey) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
falProvider.initialize({ apiKey });
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|