@umituz/react-native-ai-fal-provider 2.0.16 → 2.0.18
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/input-builders.types.ts +1 -1
- package/src/domain/types/provider.types.ts +1 -0
- package/src/exports/infrastructure.ts +0 -1
- package/src/infrastructure/builders/video-feature-builder.ts +4 -1
- package/src/infrastructure/services/fal-provider-subscription.ts +11 -60
- package/src/infrastructure/services/fal-provider.ts +5 -8
- package/src/infrastructure/services/fal-queue-operations.ts +10 -7
- package/src/infrastructure/services/fal-status-mapper.ts +7 -6
- package/src/infrastructure/services/request-store.ts +9 -15
- package/src/infrastructure/utils/cost-tracker.ts +2 -9
- package/src/infrastructure/utils/cost-tracking-executor.util.ts +3 -6
- package/src/infrastructure/utils/error-mapper.ts +0 -7
- package/src/infrastructure/utils/fal-storage.util.ts +14 -26
- package/src/infrastructure/utils/image-feature-builders.util.ts +2 -1
- package/src/infrastructure/utils/index.ts +0 -1
- package/src/infrastructure/utils/input-preprocessor.util.ts +4 -34
- package/src/infrastructure/utils/input-validator.util.ts +16 -10
- package/src/infrastructure/utils/type-guards.util.ts +4 -3
- package/src/infrastructure/validators/nsfw-validator.ts +0 -17
- package/src/init/createAiProviderInitModule.ts +2 -16
- package/src/presentation/hooks/use-fal-generation.ts +0 -5
- package/src/presentation/hooks/use-models.ts +1 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/react-native-ai-fal-provider",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.18",
|
|
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",
|
|
@@ -36,7 +36,10 @@ export function buildVideoFeatureInput(
|
|
|
36
36
|
const effectivePrompt = prompt || DEFAULT_VIDEO_PROMPTS[feature] || "Generate video";
|
|
37
37
|
|
|
38
38
|
if (isImageRequiredFeature(feature)) {
|
|
39
|
-
|
|
39
|
+
if (!sourceImageBase64 || sourceImageBase64.trim().length === 0) {
|
|
40
|
+
throw new Error(`${feature} requires a source image`);
|
|
41
|
+
}
|
|
42
|
+
return buildVideoFromImageInput(sourceImageBase64, {
|
|
40
43
|
prompt: effectivePrompt,
|
|
41
44
|
duration: options?.duration as number | undefined,
|
|
42
45
|
resolution: options?.resolution as string | undefined,
|
|
@@ -11,8 +11,6 @@ import { mapFalStatusToJobStatus } from "./fal-status-mapper";
|
|
|
11
11
|
import { validateNSFWContent } from "../validators/nsfw-validator";
|
|
12
12
|
import { NSFWContentError } from "./nsfw-content-error";
|
|
13
13
|
|
|
14
|
-
declare const __DEV__: boolean | undefined;
|
|
15
|
-
|
|
16
14
|
interface FalApiErrorDetail {
|
|
17
15
|
msg?: string;
|
|
18
16
|
type?: string;
|
|
@@ -59,22 +57,16 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
59
57
|
signal?: AbortSignal
|
|
60
58
|
): Promise<{ result: T; requestId: string | null }> {
|
|
61
59
|
const timeoutMs = options?.timeoutMs ?? DEFAULT_FAL_CONFIG.defaultTimeoutMs;
|
|
60
|
+
|
|
61
|
+
if (timeoutMs <= 0 || timeoutMs > 3600000) {
|
|
62
|
+
throw new Error(`Invalid timeout: ${timeoutMs}ms. Must be between 1 and 3600000ms (1 hour)`);
|
|
63
|
+
}
|
|
64
|
+
|
|
62
65
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
63
66
|
let currentRequestId: string | null = null;
|
|
64
67
|
let abortHandler: (() => void) | null = null;
|
|
68
|
+
let listenerAdded = false;
|
|
65
69
|
|
|
66
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
67
|
-
console.log("[FalProvider] Subscribe started:", {
|
|
68
|
-
model,
|
|
69
|
-
timeoutMs,
|
|
70
|
-
inputKeys: Object.keys(input),
|
|
71
|
-
hasImageUrl: !!input.image_url,
|
|
72
|
-
hasPrompt: !!input.prompt,
|
|
73
|
-
promptPreview: input.prompt ? String(input.prompt).substring(0, 50) + "..." : "N/A",
|
|
74
|
-
});
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Check if already aborted before starting
|
|
78
70
|
if (signal?.aborted) {
|
|
79
71
|
throw new Error("Request cancelled by user");
|
|
80
72
|
}
|
|
@@ -82,7 +74,6 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
82
74
|
let lastStatus = "";
|
|
83
75
|
|
|
84
76
|
try {
|
|
85
|
-
// Create promises array conditionally to avoid unnecessary abort promise creation
|
|
86
77
|
const promises: Promise<unknown>[] = [
|
|
87
78
|
fal.subscribe(model, {
|
|
88
79
|
input,
|
|
@@ -91,14 +82,13 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
91
82
|
onQueueUpdate: (update: { status: string; logs?: unknown[]; request_id?: string }) => {
|
|
92
83
|
currentRequestId = update.request_id ?? currentRequestId;
|
|
93
84
|
const jobStatus = mapFalStatusToJobStatus({
|
|
94
|
-
|
|
85
|
+
status: update.status as FalQueueStatus["status"],
|
|
95
86
|
requestId: currentRequestId ?? "",
|
|
87
|
+
logs: update.logs as FalQueueStatus["logs"],
|
|
88
|
+
queuePosition: undefined,
|
|
96
89
|
});
|
|
97
90
|
if (jobStatus.status !== lastStatus) {
|
|
98
91
|
lastStatus = jobStatus.status;
|
|
99
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
100
|
-
console.log("[FalProvider] Status:", jobStatus.status, "RequestId:", currentRequestId);
|
|
101
|
-
}
|
|
102
92
|
}
|
|
103
93
|
options?.onQueueUpdate?.(jobStatus);
|
|
104
94
|
},
|
|
@@ -110,55 +100,33 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
110
100
|
}),
|
|
111
101
|
];
|
|
112
102
|
|
|
113
|
-
// Add abort promise only if signal is provided and not already aborted
|
|
114
103
|
if (signal && !signal.aborted) {
|
|
115
104
|
const abortPromise = new Promise<never>((_, reject) => {
|
|
116
105
|
abortHandler = () => {
|
|
117
106
|
reject(new Error("Request cancelled by user"));
|
|
118
107
|
};
|
|
119
108
|
signal.addEventListener("abort", abortHandler);
|
|
109
|
+
listenerAdded = true;
|
|
120
110
|
});
|
|
121
111
|
promises.push(abortPromise);
|
|
122
112
|
}
|
|
123
113
|
|
|
124
114
|
const result = await Promise.race(promises);
|
|
125
115
|
|
|
126
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
127
|
-
console.log("[FalProvider] Subscribe completed:", {
|
|
128
|
-
model,
|
|
129
|
-
requestId: currentRequestId,
|
|
130
|
-
resultKeys: result ? Object.keys(result as object) : "null",
|
|
131
|
-
hasVideo: !!(result as Record<string, unknown>)?.video,
|
|
132
|
-
hasOutput: !!(result as Record<string, unknown>)?.output,
|
|
133
|
-
hasData: !!(result as Record<string, unknown>)?.data,
|
|
134
|
-
});
|
|
135
|
-
// Log full result structure for debugging
|
|
136
|
-
console.log("[FalProvider] Result structure:", JSON.stringify(result, null, 2).substring(0, 1000));
|
|
137
|
-
}
|
|
138
|
-
|
|
139
116
|
validateNSFWContent(result as Record<string, unknown>);
|
|
140
117
|
|
|
141
118
|
options?.onResult?.(result as T);
|
|
142
119
|
return { result: result as T, requestId: currentRequestId };
|
|
143
120
|
} catch (error) {
|
|
144
|
-
// Preserve NSFWContentError type
|
|
145
121
|
if (error instanceof NSFWContentError) {
|
|
146
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
147
|
-
console.error("[FalProvider] NSFW content detected");
|
|
148
|
-
}
|
|
149
122
|
throw error;
|
|
150
123
|
}
|
|
151
124
|
|
|
152
|
-
// Parse FAL error and throw with user-friendly message
|
|
153
125
|
const userMessage = parseFalError(error);
|
|
154
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
155
|
-
console.error("[FalProvider] Error:", userMessage);
|
|
156
|
-
}
|
|
157
126
|
throw new Error(userMessage);
|
|
158
127
|
} finally {
|
|
159
128
|
if (timeoutId) clearTimeout(timeoutId);
|
|
160
|
-
|
|
161
|
-
if (abortHandler && signal && !signal.aborted) {
|
|
129
|
+
if (listenerAdded && abortHandler && signal) {
|
|
162
130
|
signal.removeEventListener("abort", abortHandler);
|
|
163
131
|
}
|
|
164
132
|
}
|
|
@@ -174,36 +142,19 @@ export async function handleFalRun<T = unknown>(
|
|
|
174
142
|
): Promise<T> {
|
|
175
143
|
options?.onProgress?.({ progress: 10, status: "IN_PROGRESS" as const });
|
|
176
144
|
|
|
177
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
178
|
-
console.log("[FalProvider] run() model:", model, "inputKeys:", Object.keys(input));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
145
|
try {
|
|
182
146
|
const result = await fal.run(model, { input });
|
|
183
147
|
|
|
184
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
185
|
-
console.log("[FalProvider] run() raw result:", JSON.stringify(result, null, 2));
|
|
186
|
-
console.log("[FalProvider] run() result type:", typeof result);
|
|
187
|
-
console.log("[FalProvider] run() result keys:", result ? Object.keys(result as object) : "null");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
148
|
validateNSFWContent(result as Record<string, unknown>);
|
|
191
149
|
|
|
192
150
|
options?.onProgress?.({ progress: 100, status: "COMPLETED" as const });
|
|
193
151
|
return result as T;
|
|
194
152
|
} catch (error) {
|
|
195
|
-
// Preserve NSFWContentError type
|
|
196
153
|
if (error instanceof NSFWContentError) {
|
|
197
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
198
|
-
console.error("[FalProvider] run() NSFW content detected");
|
|
199
|
-
}
|
|
200
154
|
throw error;
|
|
201
155
|
}
|
|
202
156
|
|
|
203
157
|
const userMessage = parseFalError(error);
|
|
204
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
205
|
-
console.error("[FalProvider] run() Error:", userMessage);
|
|
206
|
-
}
|
|
207
158
|
throw new Error(userMessage);
|
|
208
159
|
}
|
|
209
160
|
}
|
|
@@ -21,8 +21,6 @@ import * as queueOps from "./fal-queue-operations";
|
|
|
21
21
|
import * as featureModels from "./fal-feature-models";
|
|
22
22
|
import { validateInput } from "../utils/input-validator.util";
|
|
23
23
|
|
|
24
|
-
declare const __DEV__: boolean | undefined;
|
|
25
|
-
|
|
26
24
|
export class FalProvider implements IAIProvider {
|
|
27
25
|
readonly providerId = "fal";
|
|
28
26
|
readonly providerName = "FAL AI";
|
|
@@ -46,9 +44,6 @@ export class FalProvider implements IAIProvider {
|
|
|
46
44
|
},
|
|
47
45
|
});
|
|
48
46
|
this.initialized = true;
|
|
49
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
50
|
-
console.log("[FalProvider] Initialized");
|
|
51
|
-
}
|
|
52
47
|
}
|
|
53
48
|
|
|
54
49
|
enableCostTracking(config?: CostTrackerConfig): void {
|
|
@@ -116,9 +111,6 @@ export class FalProvider implements IAIProvider {
|
|
|
116
111
|
|
|
117
112
|
const existing = getExistingRequest<T>(key);
|
|
118
113
|
if (existing) {
|
|
119
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
120
|
-
console.log(`[FalProvider] Dedup: returning existing promise for ${model}`);
|
|
121
|
-
}
|
|
122
114
|
return existing.promise;
|
|
123
115
|
}
|
|
124
116
|
|
|
@@ -142,6 +134,11 @@ export class FalProvider implements IAIProvider {
|
|
|
142
134
|
validateInput(model, input);
|
|
143
135
|
const processedInput = await preprocessInput(input);
|
|
144
136
|
|
|
137
|
+
const signal = options?.signal;
|
|
138
|
+
if (signal?.aborted) {
|
|
139
|
+
throw new Error("Request cancelled by user");
|
|
140
|
+
}
|
|
141
|
+
|
|
145
142
|
return executeWithCostTracking({
|
|
146
143
|
tracker: this.costTracker,
|
|
147
144
|
model,
|
|
@@ -37,14 +37,10 @@ export async function submitJob(model: string, input: Record<string, unknown>):
|
|
|
37
37
|
export async function getJobStatus(model: string, requestId: string): Promise<JobStatus> {
|
|
38
38
|
const status = await fal.queue.status(model, { requestId, logs: true });
|
|
39
39
|
|
|
40
|
-
// Validate the response structure before mapping
|
|
41
40
|
if (!isValidFalQueueStatus(status)) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
logs: [],
|
|
46
|
-
queuePosition: undefined,
|
|
47
|
-
};
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Invalid FAL queue status response for model ${model}, requestId ${requestId}`
|
|
43
|
+
);
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
return mapFalStatusToJobStatus(status);
|
|
@@ -52,5 +48,12 @@ export async function getJobStatus(model: string, requestId: string): Promise<Jo
|
|
|
52
48
|
|
|
53
49
|
export async function getJobResult<T = unknown>(model: string, requestId: string): Promise<T> {
|
|
54
50
|
const result = await fal.queue.result(model, { requestId });
|
|
51
|
+
|
|
52
|
+
if (!result || typeof result !== 'object') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid FAL queue result for model ${model}, requestId ${requestId}`
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
55
58
|
return result.data as T;
|
|
56
59
|
}
|
|
@@ -24,13 +24,14 @@ export function mapFalStatusToJobStatus(status: FalQueueStatus): JobStatus {
|
|
|
24
24
|
|
|
25
25
|
return {
|
|
26
26
|
status: mappedStatus,
|
|
27
|
-
logs: status.logs
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
logs: Array.isArray(status.logs)
|
|
28
|
+
? status.logs.map((log: FalLogEntry) => ({
|
|
29
|
+
message: log.message,
|
|
30
|
+
level: log.level ?? "info",
|
|
31
|
+
timestamp: log.timestamp ?? new Date().toISOString(),
|
|
32
|
+
}))
|
|
33
|
+
: [],
|
|
32
34
|
queuePosition: status.queuePosition ?? undefined,
|
|
33
|
-
// Preserve requestId from FalQueueStatus for use in hooks
|
|
34
35
|
requestId: status.requestId,
|
|
35
36
|
};
|
|
36
37
|
}
|
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
* Survives hot reloads for React Native development
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
declare const __DEV__: boolean | undefined;
|
|
7
|
-
|
|
8
6
|
export interface ActiveRequest<T = unknown> {
|
|
9
7
|
promise: Promise<T>;
|
|
10
8
|
abortController: AbortController;
|
|
@@ -13,9 +11,6 @@ export interface ActiveRequest<T = unknown> {
|
|
|
13
11
|
const STORE_KEY = "__FAL_PROVIDER_REQUESTS__";
|
|
14
12
|
type RequestStore = Map<string, ActiveRequest>;
|
|
15
13
|
|
|
16
|
-
// Counter for generating unique request IDs
|
|
17
|
-
let requestCounter = 0;
|
|
18
|
-
|
|
19
14
|
export function getRequestStore(): RequestStore {
|
|
20
15
|
if (!(globalThis as Record<string, unknown>)[STORE_KEY]) {
|
|
21
16
|
(globalThis as Record<string, unknown>)[STORE_KEY] = new Map();
|
|
@@ -27,18 +22,20 @@ export function getRequestStore(): RequestStore {
|
|
|
27
22
|
* Create a collision-resistant request key using combination of:
|
|
28
23
|
* - Model name
|
|
29
24
|
* - Input hash (for quick comparison)
|
|
30
|
-
* - Unique
|
|
25
|
+
* - Unique ID (guarantees uniqueness)
|
|
31
26
|
*/
|
|
32
27
|
export function createRequestKey(model: string, input: Record<string, unknown>): string {
|
|
33
28
|
const inputStr = JSON.stringify(input, Object.keys(input).sort());
|
|
34
|
-
// Use DJB2 hash for input fingerprinting
|
|
29
|
+
// Use DJB2 hash for input fingerprinting
|
|
35
30
|
let hash = 0;
|
|
36
31
|
for (let i = 0; i < inputStr.length; i++) {
|
|
37
32
|
const char = inputStr.charCodeAt(i);
|
|
38
33
|
hash = ((hash << 5) - hash + char) | 0;
|
|
39
34
|
}
|
|
40
|
-
//
|
|
41
|
-
const uniqueId =
|
|
35
|
+
// Use crypto.randomUUID() for guaranteed uniqueness without race conditions
|
|
36
|
+
const uniqueId = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
37
|
+
? crypto.randomUUID()
|
|
38
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
42
39
|
return `${model}:${hash.toString(36)}:${uniqueId}`;
|
|
43
40
|
}
|
|
44
41
|
|
|
@@ -56,10 +53,7 @@ export function removeRequest(key: string): void {
|
|
|
56
53
|
|
|
57
54
|
export function cancelAllRequests(): void {
|
|
58
55
|
const store = getRequestStore();
|
|
59
|
-
store.forEach((req
|
|
60
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
61
|
-
console.log(`[RequestStore] Cancelling: ${key}`);
|
|
62
|
-
}
|
|
56
|
+
store.forEach((req) => {
|
|
63
57
|
req.abortController.abort();
|
|
64
58
|
});
|
|
65
59
|
store.clear();
|
|
@@ -81,7 +75,7 @@ export function cleanupRequestStore(_maxAge: number = 300000): void {
|
|
|
81
75
|
|
|
82
76
|
// Requests are automatically removed when they complete (via finally block)
|
|
83
77
|
// This function exists for future enhancements like time-based cleanup
|
|
84
|
-
if (store.size > 50
|
|
85
|
-
|
|
78
|
+
if (store.size > 50) {
|
|
79
|
+
// Store size exceeds threshold - indicates potential memory leak
|
|
86
80
|
}
|
|
87
81
|
}
|
|
@@ -10,8 +10,6 @@ import type {
|
|
|
10
10
|
} from "../../domain/entities/cost-tracking.types";
|
|
11
11
|
import { findModelById } from "../../domain/constants/default-models.constants";
|
|
12
12
|
|
|
13
|
-
declare const __DEV__: boolean | undefined;
|
|
14
|
-
|
|
15
13
|
interface CostSummary {
|
|
16
14
|
totalEstimatedCost: number;
|
|
17
15
|
totalActualCost: number;
|
|
@@ -69,14 +67,8 @@ export class CostTracker {
|
|
|
69
67
|
currency: this.config.currency,
|
|
70
68
|
};
|
|
71
69
|
}
|
|
72
|
-
|
|
73
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
74
|
-
console.warn("[CostTracker] No pricing found for model:", modelId);
|
|
75
|
-
}
|
|
76
70
|
} catch (error) {
|
|
77
|
-
|
|
78
|
-
console.warn("[CostTracker] Error finding model:", modelId, error);
|
|
79
|
-
}
|
|
71
|
+
// Silently return default cost info on error
|
|
80
72
|
}
|
|
81
73
|
|
|
82
74
|
return {
|
|
@@ -164,6 +156,7 @@ export class CostTracker {
|
|
|
164
156
|
clearHistory(): void {
|
|
165
157
|
this.costHistory = [];
|
|
166
158
|
this.currentOperationCosts.clear();
|
|
159
|
+
this.operationCounter = 0;
|
|
167
160
|
}
|
|
168
161
|
|
|
169
162
|
getCostsByModel(modelId: string): GenerationCost[] {
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import type { CostTracker } from "./cost-tracker";
|
|
7
7
|
|
|
8
|
-
declare const __DEV__: boolean | undefined;
|
|
9
|
-
|
|
10
8
|
interface ExecuteWithCostTrackingOptions<T> {
|
|
11
9
|
tracker: CostTracker | null;
|
|
12
10
|
model: string;
|
|
@@ -37,9 +35,8 @@ export async function executeWithCostTracking<T>(
|
|
|
37
35
|
const requestId = getRequestId?.(result);
|
|
38
36
|
tracker.completeOperation(operationId, model, operation, requestId);
|
|
39
37
|
} catch (costError) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
38
|
+
// Cost tracking failure shouldn't break the operation
|
|
39
|
+
// Log for debugging but don't throw
|
|
43
40
|
}
|
|
44
41
|
|
|
45
42
|
return result;
|
|
@@ -47,7 +44,7 @@ export async function executeWithCostTracking<T>(
|
|
|
47
44
|
try {
|
|
48
45
|
tracker.failOperation(operationId);
|
|
49
46
|
} catch {
|
|
50
|
-
//
|
|
47
|
+
// Cost tracking cleanup failure on error path - ignore
|
|
51
48
|
}
|
|
52
49
|
throw error;
|
|
53
50
|
}
|
|
@@ -42,10 +42,3 @@ export function mapFalError(error: unknown): FalErrorInfo {
|
|
|
42
42
|
export function isFalErrorRetryable(error: unknown): boolean {
|
|
43
43
|
return categorizeFalError(error).retryable;
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
// Backward compatibility
|
|
47
|
-
export const falErrorMapper = {
|
|
48
|
-
mapToErrorInfo: mapFalError,
|
|
49
|
-
isRetryable: isFalErrorRetryable,
|
|
50
|
-
getErrorType: (error: unknown) => categorizeFalError(error).type,
|
|
51
|
-
};
|
|
@@ -7,12 +7,8 @@ import { fal } from "@fal-ai/client";
|
|
|
7
7
|
import {
|
|
8
8
|
base64ToTempFile,
|
|
9
9
|
deleteTempFile,
|
|
10
|
-
getFileSize,
|
|
11
|
-
detectMimeType,
|
|
12
10
|
} from "@umituz/react-native-design-system/filesystem";
|
|
13
11
|
|
|
14
|
-
declare const __DEV__: boolean | undefined;
|
|
15
|
-
|
|
16
12
|
/**
|
|
17
13
|
* Upload base64 image to FAL storage
|
|
18
14
|
* Uses design system's filesystem utilities for React Native compatibility
|
|
@@ -20,30 +16,22 @@ declare const __DEV__: boolean | undefined;
|
|
|
20
16
|
export async function uploadToFalStorage(base64: string): Promise<string> {
|
|
21
17
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
|
|
22
18
|
const tempUri = (await base64ToTempFile(base64));
|
|
23
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
24
|
-
const fileSize = getFileSize(tempUri);
|
|
25
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
26
|
-
const mimeType = detectMimeType(base64);
|
|
27
|
-
|
|
28
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
29
|
-
console.log("[FalStorage] Uploading image", {
|
|
30
|
-
size: `${(fileSize / 1024).toFixed(1)}KB`,
|
|
31
|
-
type: mimeType,
|
|
32
|
-
});
|
|
33
|
-
}
|
|
34
19
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(tempUri);
|
|
22
|
+
const blob = await response.blob();
|
|
23
|
+
const url = await fal.storage.upload(blob);
|
|
24
|
+
return url;
|
|
25
|
+
} finally {
|
|
26
|
+
if (tempUri) {
|
|
27
|
+
try {
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
29
|
+
await deleteTempFile(tempUri);
|
|
30
|
+
} catch {
|
|
31
|
+
// Silently ignore cleanup failures
|
|
32
|
+
}
|
|
33
|
+
}
|
|
44
34
|
}
|
|
45
|
-
|
|
46
|
-
return url;
|
|
47
35
|
}
|
|
48
36
|
|
|
49
37
|
/**
|
|
@@ -36,11 +36,12 @@ export function buildPhotoRestoreInput(
|
|
|
36
36
|
export function buildFaceSwapInput(
|
|
37
37
|
sourceBase64: string,
|
|
38
38
|
targetBase64: string,
|
|
39
|
-
|
|
39
|
+
options?: FaceSwapOptions,
|
|
40
40
|
): Record<string, unknown> {
|
|
41
41
|
return {
|
|
42
42
|
base_image_url: formatImageDataUri(sourceBase64),
|
|
43
43
|
swap_image_url: formatImageDataUri(targetBase64),
|
|
44
|
+
...(options?.enhanceFaces !== undefined && { enhance_faces: options.enhanceFaces }),
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import { uploadToFalStorage } from "./fal-storage.util";
|
|
7
7
|
|
|
8
|
-
declare const __DEV__: boolean | undefined;
|
|
9
|
-
|
|
10
8
|
const IMAGE_URL_KEYS = [
|
|
11
9
|
"image_url",
|
|
12
10
|
"second_image_url",
|
|
@@ -37,23 +35,11 @@ export async function preprocessInput(
|
|
|
37
35
|
for (const key of IMAGE_URL_KEYS) {
|
|
38
36
|
const value = result[key];
|
|
39
37
|
if (isBase64DataUri(value)) {
|
|
40
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
41
|
-
console.log(`[FalPreprocessor] Uploading ${key} to storage...`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
38
|
const uploadPromise = uploadToFalStorage(value)
|
|
45
39
|
.then((url) => {
|
|
46
40
|
result[key] = url;
|
|
47
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
48
|
-
console.log(`[FalPreprocessor] ${key} uploaded`, {
|
|
49
|
-
url: url.slice(0, 50) + "...",
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
41
|
})
|
|
53
42
|
.catch((error) => {
|
|
54
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
55
|
-
console.error(`[FalPreprocessor] Failed to upload ${key}:`, error);
|
|
56
|
-
}
|
|
57
43
|
throw new Error(`Failed to upload ${key}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
58
44
|
});
|
|
59
45
|
|
|
@@ -62,52 +48,36 @@ export async function preprocessInput(
|
|
|
62
48
|
}
|
|
63
49
|
|
|
64
50
|
// Handle image_urls array (for multi-person generation)
|
|
65
|
-
if (Array.isArray(result.image_urls)) {
|
|
51
|
+
if (Array.isArray(result.image_urls) && result.image_urls.length > 0) {
|
|
66
52
|
const imageUrls = result.image_urls as unknown[];
|
|
67
|
-
|
|
68
|
-
const processedUrls: string[] = new Array(imageUrls.length).fill("") as string[];
|
|
53
|
+
const processedUrls: string[] = [];
|
|
69
54
|
|
|
70
55
|
for (let i = 0; i < imageUrls.length; i++) {
|
|
71
56
|
const imageUrl = imageUrls[i];
|
|
72
57
|
if (isBase64DataUri(imageUrl)) {
|
|
73
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
74
|
-
console.log(`[FalPreprocessor] Uploading image_urls[${i}] to storage...`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Capture index in closure to ensure correct assignment
|
|
78
58
|
const index = i;
|
|
79
59
|
const uploadPromise = uploadToFalStorage(imageUrl)
|
|
80
60
|
.then((url) => {
|
|
81
61
|
processedUrls[index] = url;
|
|
82
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
83
|
-
console.log(`[FalPreprocessor] image_urls[${index}] uploaded`, {
|
|
84
|
-
url: url.slice(0, 50) + "...",
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
62
|
})
|
|
88
63
|
.catch((error) => {
|
|
89
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
90
|
-
console.error(`[FalPreprocessor] Failed to upload image_urls[${index}]:`, error);
|
|
91
|
-
}
|
|
92
64
|
throw new Error(`Failed to upload image_urls[${index}]: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
93
65
|
});
|
|
94
66
|
|
|
95
67
|
uploadPromises.push(uploadPromise);
|
|
96
68
|
} else if (typeof imageUrl === "string") {
|
|
97
69
|
processedUrls[i] = imageUrl;
|
|
70
|
+
} else {
|
|
71
|
+
processedUrls[i] = "";
|
|
98
72
|
}
|
|
99
73
|
}
|
|
100
74
|
|
|
101
|
-
// Always set processed URLs after all uploads complete
|
|
102
75
|
result.image_urls = processedUrls;
|
|
103
76
|
}
|
|
104
77
|
|
|
105
78
|
// Wait for ALL uploads to complete (both individual keys and array)
|
|
106
79
|
if (uploadPromises.length > 0) {
|
|
107
80
|
await Promise.all(uploadPromises);
|
|
108
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
109
|
-
console.log(`[FalPreprocessor] All images uploaded (${uploadPromises.length})`);
|
|
110
|
-
}
|
|
111
81
|
}
|
|
112
82
|
|
|
113
83
|
return result;
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import { isValidModelId, isValidPrompt } from "./type-guards.util";
|
|
7
7
|
|
|
8
|
-
declare const __DEV__: boolean | undefined;
|
|
9
|
-
|
|
10
8
|
export interface ValidationError {
|
|
11
9
|
field: string;
|
|
12
10
|
message: string;
|
|
@@ -75,18 +73,26 @@ export function validateInput(
|
|
|
75
73
|
|
|
76
74
|
for (const field of imageFields) {
|
|
77
75
|
const value = input[field];
|
|
78
|
-
if (value !== undefined
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
if (value !== undefined) {
|
|
77
|
+
if (typeof value !== "string") {
|
|
78
|
+
errors.push({
|
|
79
|
+
field,
|
|
80
|
+
message: `${field} must be a string`,
|
|
81
|
+
});
|
|
82
|
+
} else if (value.length > 0) {
|
|
83
|
+
const isValidUrl = value.startsWith('http://') || value.startsWith('https://');
|
|
84
|
+
const isValidBase64 = value.startsWith('data:image/');
|
|
85
|
+
if (!isValidUrl && !isValidBase64) {
|
|
86
|
+
errors.push({
|
|
87
|
+
field,
|
|
88
|
+
message: `${field} must be a valid URL or base64 data URI`,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
83
92
|
}
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
if (errors.length > 0) {
|
|
87
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
88
|
-
console.warn("[InputValidator] Validation errors:", errors);
|
|
89
|
-
}
|
|
90
96
|
throw new InputValidationError(errors);
|
|
91
97
|
}
|
|
92
98
|
}
|
|
@@ -92,9 +92,10 @@ export function isValidModelId(value: unknown): boolean {
|
|
|
92
92
|
return false;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// FAL model IDs
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
// FAL model IDs follow pattern: "owner/model-name" or "owner/model/version"
|
|
96
|
+
// Allow uppercase, dots, underscores, hyphens
|
|
97
|
+
const modelIdPattern = /^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_.]+(\/[a-zA-Z0-9-_.]+)?$/;
|
|
98
|
+
return modelIdPattern.test(value) && value.length >= 3;
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/**
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import { NSFWContentError } from "../services/nsfw-content-error";
|
|
7
7
|
|
|
8
|
-
declare const __DEV__: boolean | undefined;
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* Check if value indicates NSFW content
|
|
12
10
|
*/
|
|
@@ -33,9 +31,6 @@ export function validateNSFWContent(result: Record<string, unknown>): void {
|
|
|
33
31
|
if (nsfwConcepts && Array.isArray(nsfwConcepts)) {
|
|
34
32
|
const hasNSFW = nsfwConcepts.some((value) => value === true);
|
|
35
33
|
if (hasNSFW) {
|
|
36
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
37
|
-
console.log("[FalProvider] NSFW content detected (has_nsfw_concepts), rejecting result");
|
|
38
|
-
}
|
|
39
34
|
throw new NSFWContentError();
|
|
40
35
|
}
|
|
41
36
|
}
|
|
@@ -43,27 +38,18 @@ export function validateNSFWContent(result: Record<string, unknown>): void {
|
|
|
43
38
|
// Format 2: nsfw_detected boolean
|
|
44
39
|
const nsfwDetected = result?.nsfw_detected;
|
|
45
40
|
if (nsfwDetected !== undefined && isNSFWIndicator(nsfwDetected)) {
|
|
46
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
47
|
-
console.log("[FalProvider] NSFW content detected (nsfw_detected), rejecting result");
|
|
48
|
-
}
|
|
49
41
|
throw new NSFWContentError();
|
|
50
42
|
}
|
|
51
43
|
|
|
52
44
|
// Format 3: is_nsfw boolean
|
|
53
45
|
const isNsfw = result?.is_nsfw;
|
|
54
46
|
if (isNsfw !== undefined && isNSFWIndicator(isNsfw)) {
|
|
55
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
56
|
-
console.log("[FalProvider] NSFW content detected (is_nsfw), rejecting result");
|
|
57
|
-
}
|
|
58
47
|
throw new NSFWContentError();
|
|
59
48
|
}
|
|
60
49
|
|
|
61
50
|
// Format 4: nsfw_score number (> 0.5 threshold)
|
|
62
51
|
const nsfwScore = result?.nsfw_score as number | undefined;
|
|
63
52
|
if (typeof nsfwScore === "number" && nsfwScore > 0.5) {
|
|
64
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
65
|
-
console.log("[FalProvider] NSFW content detected (nsfw_score: " + nsfwScore + "), rejecting result");
|
|
66
|
-
}
|
|
67
53
|
throw new NSFWContentError();
|
|
68
54
|
}
|
|
69
55
|
|
|
@@ -72,9 +58,6 @@ export function validateNSFWContent(result: Record<string, unknown>): void {
|
|
|
72
58
|
if (policyViolation && typeof policyViolation === "object") {
|
|
73
59
|
const type = (policyViolation.type || "").toLowerCase();
|
|
74
60
|
if (type.includes("nsfw") || type.includes("adult") || type.includes("explicit")) {
|
|
75
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
76
|
-
console.log("[FalProvider] Content policy violation detected:", policyViolation);
|
|
77
|
-
}
|
|
78
61
|
throw new NSFWContentError();
|
|
79
62
|
}
|
|
80
63
|
}
|
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
|
|
6
6
|
import { falProvider } from '../infrastructure/services';
|
|
7
7
|
|
|
8
|
-
declare const __DEV__: boolean;
|
|
9
|
-
|
|
10
8
|
/**
|
|
11
9
|
* InitModule interface (from @umituz/react-native-design-system)
|
|
12
10
|
*/
|
|
@@ -92,30 +90,18 @@ export function createAiProviderInitModule(
|
|
|
92
90
|
const apiKey = getApiKey();
|
|
93
91
|
|
|
94
92
|
if (!apiKey) {
|
|
95
|
-
|
|
96
|
-
console.log('[createAiProviderInitModule] No API key - skipping');
|
|
97
|
-
}
|
|
98
|
-
return Promise.resolve(true); // Not an error, just skip
|
|
93
|
+
return Promise.resolve(false);
|
|
99
94
|
}
|
|
100
95
|
|
|
101
|
-
// Initialize FAL provider
|
|
102
96
|
falProvider.initialize({
|
|
103
97
|
apiKey,
|
|
104
98
|
videoFeatureModels,
|
|
105
99
|
imageFeatureModels,
|
|
106
100
|
});
|
|
107
101
|
|
|
108
|
-
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
109
|
-
console.log('[createAiProviderInitModule] FAL provider initialized');
|
|
110
|
-
}
|
|
111
|
-
|
|
112
102
|
return Promise.resolve(true);
|
|
113
103
|
} catch (error) {
|
|
114
|
-
|
|
115
|
-
console.error('[createAiProviderInitModule] Error:', error);
|
|
116
|
-
}
|
|
117
|
-
// Continue on error - AI provider is not critical
|
|
118
|
-
return Promise.resolve(true);
|
|
104
|
+
return Promise.resolve(false);
|
|
119
105
|
}
|
|
120
106
|
},
|
|
121
107
|
};
|
|
@@ -52,11 +52,9 @@ export function useFalGeneration<T = unknown>(
|
|
|
52
52
|
const result = await falProvider.subscribe<T>(modelEndpoint, input, {
|
|
53
53
|
timeoutMs: options?.timeoutMs,
|
|
54
54
|
onQueueUpdate: (status) => {
|
|
55
|
-
// Update requestId ref when we receive it from status
|
|
56
55
|
if (status.requestId) {
|
|
57
56
|
currentRequestIdRef.current = status.requestId;
|
|
58
57
|
}
|
|
59
|
-
// Map JobStatus to FalQueueStatus for backward compatibility
|
|
60
58
|
options?.onProgress?.({
|
|
61
59
|
status: status.status,
|
|
62
60
|
requestId: status.requestId ?? currentRequestIdRef.current ?? "",
|
|
@@ -95,9 +93,6 @@ export function useFalGeneration<T = unknown>(
|
|
|
95
93
|
if (falProvider.hasRunningRequest()) {
|
|
96
94
|
setIsCancelling(true);
|
|
97
95
|
falProvider.cancelCurrentRequest();
|
|
98
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
99
|
-
console.log("[useFalGeneration] Request cancelled");
|
|
100
|
-
}
|
|
101
96
|
}
|
|
102
97
|
}, []);
|
|
103
98
|
|
|
@@ -19,7 +19,7 @@ import type {
|
|
|
19
19
|
UseModelsReturn,
|
|
20
20
|
} from "../../domain/types/model-selection.types";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
export type { UseModelsReturn } from "../../domain/types/model-selection.types";
|
|
23
23
|
|
|
24
24
|
export interface UseModelsProps {
|
|
25
25
|
/** Model type to fetch */
|
|
@@ -56,11 +56,6 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
|
|
|
56
56
|
setSelectedModel(initial);
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
60
|
-
// eslint-disable-next-line no-console
|
|
61
|
-
console.log(`[useModels] Loaded ${fetchedModels.length} ${type} models`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
59
|
setIsLoading(false);
|
|
65
60
|
}, [type, config?.initialModelId, defaultModelId]);
|
|
66
61
|
|
|
@@ -73,11 +68,6 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
|
|
|
73
68
|
const model = models.find((m) => m.id === modelId);
|
|
74
69
|
if (model) {
|
|
75
70
|
setSelectedModel(model);
|
|
76
|
-
|
|
77
|
-
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
78
|
-
// eslint-disable-next-line no-console
|
|
79
|
-
console.log(`[useModels] Selected: ${model.name} (${model.id})`);
|
|
80
|
-
}
|
|
81
71
|
}
|
|
82
72
|
},
|
|
83
73
|
[models],
|