@umituz/react-native-ai-fal-provider 3.2.51 → 3.2.53
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 -4
- package/src/domain/services/ErrorClassificationService.ts +201 -0
- package/src/domain/services/ImageProcessingService.ts +89 -0
- package/src/domain/services/PricingService.ts +101 -0
- package/src/domain/services/ValidationService.ts +132 -0
- package/src/domain/services/index.ts +13 -0
- package/src/index.ts +24 -22
- package/src/infrastructure/utils/fal-error-handler.util.ts +14 -146
- package/src/infrastructure/utils/fal-storage.util.ts +2 -3
- package/src/infrastructure/utils/helpers/index.ts +2 -4
- package/src/infrastructure/utils/image-helpers.util.ts +13 -27
- package/src/infrastructure/utils/input-preprocessor.util.ts +6 -12
- package/src/infrastructure/utils/input-validator.util.ts +17 -156
- package/src/infrastructure/utils/pricing/fal-pricing.util.ts +26 -53
- package/src/infrastructure/utils/type-guards/index.ts +2 -2
- package/src/shared/helpers.ts +149 -0
- package/src/shared/index.ts +8 -0
- package/src/shared/type-guards.ts +122 -0
- package/src/shared/validators.ts +281 -0
- package/src/infrastructure/utils/helpers/calculation-helpers.util.ts +0 -168
- package/src/infrastructure/utils/helpers/error-helpers.util.ts +0 -65
- package/src/infrastructure/utils/helpers/object-helpers.util.ts +0 -44
- package/src/infrastructure/utils/helpers/timing-helpers.util.ts +0 -11
- package/src/infrastructure/utils/type-guards/model-type-guards.util.ts +0 -56
- package/src/infrastructure/utils/type-guards/validation-guards.util.ts +0 -101
- package/src/infrastructure/utils/validators/data-uri-validator.util.ts +0 -91
- package/src/infrastructure/utils/validators/string-validator.util.ts +0 -64
|
@@ -1,174 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* FAL Error Handler
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* FAL Error Handler (Infrastructure Layer)
|
|
3
|
+
* Delegates to domain ErrorClassificationService for error handling logic
|
|
4
|
+
*
|
|
5
|
+
* This file now serves as a thin adapter layer for backward compatibility.
|
|
6
|
+
* The actual error handling logic has been moved to the domain layer.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import {
|
|
9
|
+
import { ErrorClassificationService } from "../../domain/services/ErrorClassificationService";
|
|
8
10
|
import type { FalErrorInfo, FalErrorCategory } from "../../domain/entities/error.types";
|
|
9
|
-
import { FalErrorType } from "../../domain/entities/error.types";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* HTTP status code to error type mapping
|
|
13
|
-
*/
|
|
14
|
-
const STATUS_TO_ERROR_TYPE: Record<number, FalErrorType> = {
|
|
15
|
-
400: FalErrorType.VALIDATION,
|
|
16
|
-
401: FalErrorType.AUTHENTICATION,
|
|
17
|
-
402: FalErrorType.QUOTA_EXCEEDED,
|
|
18
|
-
403: FalErrorType.AUTHENTICATION,
|
|
19
|
-
404: FalErrorType.MODEL_NOT_FOUND,
|
|
20
|
-
422: FalErrorType.VALIDATION,
|
|
21
|
-
429: FalErrorType.RATE_LIMIT,
|
|
22
|
-
500: FalErrorType.API_ERROR,
|
|
23
|
-
502: FalErrorType.API_ERROR,
|
|
24
|
-
503: FalErrorType.API_ERROR,
|
|
25
|
-
504: FalErrorType.API_ERROR,
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const RETRYABLE_TYPES = new Set<FalErrorType>([
|
|
29
|
-
FalErrorType.NETWORK,
|
|
30
|
-
FalErrorType.TIMEOUT,
|
|
31
|
-
FalErrorType.RATE_LIMIT,
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Message-based error type detection (for non-ApiError errors)
|
|
36
|
-
*/
|
|
37
|
-
const MESSAGE_PATTERNS: Array<{ type: FalErrorType; patterns: string[] }> = [
|
|
38
|
-
{ type: FalErrorType.NETWORK, patterns: ["network", "fetch", "econnrefused", "enotfound", "etimedout"] },
|
|
39
|
-
{ type: FalErrorType.TIMEOUT, patterns: ["timeout", "timed out"] },
|
|
40
|
-
{ type: FalErrorType.CONTENT_POLICY, patterns: ["nsfw", "content_policy", "content policy", "policy violation"] },
|
|
41
|
-
{ type: FalErrorType.IMAGE_TOO_SMALL, patterns: ["image_too_small", "image dimensions are too small", "minimum dimensions"] },
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Categorize error using @fal-ai/client error types
|
|
46
|
-
* Priority: ApiError status code > message pattern matching
|
|
47
|
-
*/
|
|
48
|
-
function categorizeError(error: unknown): FalErrorCategory {
|
|
49
|
-
// 1. ApiError (includes ValidationError) - use HTTP status code
|
|
50
|
-
if (error instanceof ApiError) {
|
|
51
|
-
const typeFromStatus = STATUS_TO_ERROR_TYPE[error.status];
|
|
52
|
-
if (typeFromStatus) {
|
|
53
|
-
return {
|
|
54
|
-
type: typeFromStatus,
|
|
55
|
-
messageKey: typeFromStatus,
|
|
56
|
-
retryable: RETRYABLE_TYPES.has(typeFromStatus),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
// Unknown status code - still an API error
|
|
60
|
-
return { type: FalErrorType.API_ERROR, messageKey: "api_error", retryable: false };
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// 2. Standard Error - match message patterns
|
|
64
|
-
const message = (error instanceof Error ? error.message : error != null ? String(error) : "unknown").toLowerCase();
|
|
65
|
-
|
|
66
|
-
for (const { type, patterns } of MESSAGE_PATTERNS) {
|
|
67
|
-
if (patterns.some((p) => message.includes(p))) {
|
|
68
|
-
return { type, messageKey: type, retryable: RETRYABLE_TYPES.has(type) };
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// 3. No match - UNKNOWN, not retryable
|
|
73
|
-
return { type: FalErrorType.UNKNOWN, messageKey: "unknown", retryable: false };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Extract user-readable message from error
|
|
78
|
-
* Uses @fal-ai/client types for structured extraction
|
|
79
|
-
*/
|
|
80
|
-
function extractMessage(error: unknown): string {
|
|
81
|
-
// ValidationError - extract field-level messages
|
|
82
|
-
if (error instanceof ValidationError) {
|
|
83
|
-
const fieldErrors = error.fieldErrors;
|
|
84
|
-
if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
|
|
85
|
-
// Safely extract messages from field errors with validation
|
|
86
|
-
const messages = fieldErrors
|
|
87
|
-
.map((e) => (e && typeof e === 'object' && 'msg' in e && typeof e.msg === 'string' ? e.msg : null))
|
|
88
|
-
.filter((msg): msg is string => msg !== null);
|
|
89
|
-
if (messages.length > 0) return messages.join("; ");
|
|
90
|
-
}
|
|
91
|
-
return error.message;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ApiError - extract from body or message
|
|
95
|
-
if (error instanceof ApiError) {
|
|
96
|
-
// body may contain detail array - validate structure before access
|
|
97
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
98
|
-
const body = error.body;
|
|
99
|
-
|
|
100
|
-
// Type guard for detail array structure
|
|
101
|
-
interface DetailItem {
|
|
102
|
-
msg?: string;
|
|
103
|
-
}
|
|
104
|
-
interface BodyWithDetail {
|
|
105
|
-
detail?: DetailItem[];
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const validatedBody = body as BodyWithDetail | undefined;
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
validatedBody &&
|
|
112
|
-
validatedBody.detail &&
|
|
113
|
-
Array.isArray(validatedBody.detail) &&
|
|
114
|
-
validatedBody.detail.length > 0
|
|
115
|
-
) {
|
|
116
|
-
const firstItem = validatedBody.detail[0];
|
|
117
|
-
if (
|
|
118
|
-
firstItem &&
|
|
119
|
-
typeof firstItem === "object" &&
|
|
120
|
-
typeof firstItem.msg === "string" &&
|
|
121
|
-
firstItem.msg
|
|
122
|
-
) {
|
|
123
|
-
return firstItem.msg;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return error.message;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Standard Error
|
|
130
|
-
if (error instanceof Error) {
|
|
131
|
-
return error.message;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return error != null ? String(error) : "Unknown error";
|
|
135
|
-
}
|
|
136
11
|
|
|
137
12
|
/**
|
|
138
13
|
* Map error to FalErrorInfo with full categorization
|
|
14
|
+
* Delegates to domain ErrorClassificationService
|
|
139
15
|
*/
|
|
140
16
|
export function mapFalError(error: unknown): FalErrorInfo {
|
|
141
|
-
|
|
142
|
-
const message = extractMessage(error);
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
type: category.type,
|
|
146
|
-
messageKey: `fal.errors.${category.messageKey}`,
|
|
147
|
-
retryable: category.retryable,
|
|
148
|
-
originalError: message,
|
|
149
|
-
originalErrorName: error instanceof Error ? error.name : undefined,
|
|
150
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
151
|
-
statusCode: error instanceof ApiError ? error.status : undefined,
|
|
152
|
-
};
|
|
17
|
+
return ErrorClassificationService.mapError(error);
|
|
153
18
|
}
|
|
154
19
|
|
|
155
20
|
/**
|
|
156
21
|
* Parse FAL error and return user-friendly message
|
|
22
|
+
* Delegates to domain ErrorClassificationService
|
|
157
23
|
*/
|
|
158
24
|
export function parseFalError(error: unknown): string {
|
|
159
|
-
return extractMessage(error);
|
|
25
|
+
return ErrorClassificationService.extractMessage(error);
|
|
160
26
|
}
|
|
161
27
|
|
|
162
28
|
/**
|
|
163
29
|
* Categorize FAL error
|
|
30
|
+
* Delegates to domain ErrorClassificationService
|
|
164
31
|
*/
|
|
165
32
|
export function categorizeFalError(error: unknown): FalErrorCategory {
|
|
166
|
-
return categorizeError(error);
|
|
33
|
+
return ErrorClassificationService.categorizeError(error);
|
|
167
34
|
}
|
|
168
35
|
|
|
169
36
|
/**
|
|
170
37
|
* Check if FAL error is retryable
|
|
38
|
+
* Delegates to domain ErrorClassificationService
|
|
171
39
|
*/
|
|
172
40
|
export function isFalErrorRetryable(error: unknown): boolean {
|
|
173
|
-
return
|
|
41
|
+
return ErrorClassificationService.isRetryable(error);
|
|
174
42
|
}
|
|
@@ -9,8 +9,7 @@ import {
|
|
|
9
9
|
base64ToTempFile,
|
|
10
10
|
deleteTempFile,
|
|
11
11
|
} from "@umituz/react-native-design-system/filesystem";
|
|
12
|
-
import { getErrorMessage } from
|
|
13
|
-
import { getElapsedTime, getActualSizeKB } from './helpers';
|
|
12
|
+
import { getErrorMessage, getElapsedTime, getActualSizeKB, sleep } from "../../shared/helpers";
|
|
14
13
|
import { generationLogCollector } from './log-collector';
|
|
15
14
|
import { UPLOAD_CONFIG } from '../services/fal-provider.constants';
|
|
16
15
|
|
|
@@ -43,7 +42,7 @@ async function withRetry<T>(
|
|
|
43
42
|
if (attempt > 0) {
|
|
44
43
|
const delay = baseDelay * Math.pow(2, attempt - 1);
|
|
45
44
|
generationLogCollector.warn(sessionId, TAG, `Retry ${attempt}/${maxRetries} for ${label} after ${delay}ms`);
|
|
46
|
-
await
|
|
45
|
+
await sleep(delay);
|
|
47
46
|
}
|
|
48
47
|
return await fn();
|
|
49
48
|
} catch (error) {
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Helper Utilities - Centralized Exports
|
|
3
|
+
* Re-exports from shared utilities layer for backward compatibility
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
export * from '
|
|
6
|
-
export * from './object-helpers.util';
|
|
7
|
-
export * from './calculation-helpers.util';
|
|
8
|
-
export * from './error-helpers.util';
|
|
6
|
+
export * from '../../../shared/helpers';
|
|
@@ -1,47 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Image Helper Utilities
|
|
3
|
-
*
|
|
2
|
+
* Image Helper Utilities (Infrastructure Layer)
|
|
3
|
+
* Delegates to domain ImageProcessingService for image data manipulation
|
|
4
|
+
*
|
|
5
|
+
* This file now serves as a thin adapter layer for backward compatibility.
|
|
6
|
+
* The actual image processing logic has been moved to the domain layer.
|
|
4
7
|
*/
|
|
5
8
|
|
|
9
|
+
import { ImageProcessingService } from "../../domain/services/ImageProcessingService";
|
|
10
|
+
|
|
6
11
|
/**
|
|
7
12
|
* Format image as data URI if not already formatted
|
|
13
|
+
* Delegates to domain ImageProcessingService
|
|
8
14
|
*/
|
|
9
15
|
export function formatImageDataUri(base64: string): string {
|
|
10
|
-
|
|
11
|
-
return base64;
|
|
12
|
-
}
|
|
13
|
-
return `data:image/jpeg;base64,${base64}`;
|
|
16
|
+
return ImageProcessingService.formatImageDataUri(base64);
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Extract base64 from data URI
|
|
18
|
-
*
|
|
21
|
+
* Delegates to domain ImageProcessingService
|
|
19
22
|
*/
|
|
20
23
|
export function extractBase64(dataUri: string): string {
|
|
21
|
-
|
|
22
|
-
return dataUri;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// Find the first comma which separates header from data
|
|
26
|
-
const commaIndex = dataUri.indexOf(",");
|
|
27
|
-
if (commaIndex === -1) {
|
|
28
|
-
throw new Error(`Invalid data URI format (no comma separator): ${dataUri.substring(0, 50)}...`);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// Extract everything after the first comma
|
|
32
|
-
const base64Part = dataUri.substring(commaIndex + 1);
|
|
33
|
-
|
|
34
|
-
if (!base64Part || base64Part.length === 0) {
|
|
35
|
-
throw new Error(`Empty base64 data in URI: ${dataUri.substring(0, 50)}...`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return base64Part;
|
|
24
|
+
return ImageProcessingService.extractBase64(dataUri);
|
|
39
25
|
}
|
|
40
26
|
|
|
41
27
|
/**
|
|
42
28
|
* Get file extension from data URI
|
|
29
|
+
* Delegates to domain ImageProcessingService
|
|
43
30
|
*/
|
|
44
31
|
export function getDataUriExtension(dataUri: string): string | null {
|
|
45
|
-
|
|
46
|
-
return match ? match[1] : null;
|
|
32
|
+
return ImageProcessingService.getDataUriExtension(dataUri);
|
|
47
33
|
}
|
|
@@ -8,19 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { uploadToFalStorage, uploadLocalFileToFalStorage } from "./fal-storage.util";
|
|
11
|
-
import { getErrorMessage } from
|
|
11
|
+
import { getErrorMessage, getElapsedTime } from "../../shared/helpers";
|
|
12
12
|
import { IMAGE_URL_FIELDS } from './constants/image-fields.constants';
|
|
13
|
-
import { isImageDataUri as isBase64DataUri } from
|
|
13
|
+
import { isLocalFileUri, isImageDataUri as isBase64DataUri } from "../../shared/type-guards";
|
|
14
14
|
import { generationLogCollector } from './log-collector';
|
|
15
15
|
|
|
16
16
|
const TAG = 'preprocessor';
|
|
17
17
|
|
|
18
|
-
function isLocalFileUri(value: unknown): value is string {
|
|
19
|
-
return typeof value === "string" && (
|
|
20
|
-
value.startsWith("file://") || value.startsWith("content://")
|
|
21
|
-
);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
18
|
/**
|
|
25
19
|
* Classify a network error into a user-friendly message.
|
|
26
20
|
* Technical details are preserved in Firestore logs/session subcollection.
|
|
@@ -130,7 +124,7 @@ export async function preprocessInput(
|
|
|
130
124
|
processedUrls.push(url);
|
|
131
125
|
generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: upload OK`);
|
|
132
126
|
} catch (error) {
|
|
133
|
-
const elapsed =
|
|
127
|
+
const elapsed = getElapsedTime(arrayStartTime);
|
|
134
128
|
const technicalMsg = getErrorMessage(error);
|
|
135
129
|
generationLogCollector.error(sessionId, TAG, `${arrayField}[${i}] upload FAILED after ${elapsed}ms: ${technicalMsg}`);
|
|
136
130
|
throw new Error(classifyUploadError(technicalMsg));
|
|
@@ -143,7 +137,7 @@ export async function preprocessInput(
|
|
|
143
137
|
processedUrls.push(url);
|
|
144
138
|
generationLogCollector.log(sessionId, TAG, `${arrayField}[${i}/${imageUrls.length}]: local file upload OK`);
|
|
145
139
|
} catch (error) {
|
|
146
|
-
const elapsed =
|
|
140
|
+
const elapsed = getElapsedTime(arrayStartTime);
|
|
147
141
|
const technicalMsg = getErrorMessage(error);
|
|
148
142
|
generationLogCollector.error(sessionId, TAG, `${arrayField}[${i}] local file upload FAILED after ${elapsed}ms: ${technicalMsg}`);
|
|
149
143
|
throw new Error(classifyUploadError(technicalMsg));
|
|
@@ -156,7 +150,7 @@ export async function preprocessInput(
|
|
|
156
150
|
}
|
|
157
151
|
}
|
|
158
152
|
|
|
159
|
-
const arrayElapsed =
|
|
153
|
+
const arrayElapsed = getElapsedTime(arrayStartTime);
|
|
160
154
|
generationLogCollector.log(sessionId, TAG, `${arrayField}: all ${processedUrls.length} upload(s) succeeded in ${arrayElapsed}ms`);
|
|
161
155
|
result[arrayField] = processedUrls;
|
|
162
156
|
}
|
|
@@ -179,7 +173,7 @@ export async function preprocessInput(
|
|
|
179
173
|
}
|
|
180
174
|
}
|
|
181
175
|
|
|
182
|
-
const totalElapsed =
|
|
176
|
+
const totalElapsed = getElapsedTime(startTime);
|
|
183
177
|
generationLogCollector.log(sessionId, TAG, `Preprocessing complete in ${totalElapsed}ms`);
|
|
184
178
|
return result;
|
|
185
179
|
}
|
|
@@ -1,168 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Input Validator Utility
|
|
3
|
-
*
|
|
2
|
+
* Input Validator Utility (Infrastructure Layer)
|
|
3
|
+
* Delegates to domain ValidationService for actual validation logic
|
|
4
|
+
*
|
|
5
|
+
* This file now serves as a thin adapter layer for backward compatibility.
|
|
6
|
+
* The actual validation logic has been moved to the domain layer.
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { isImageDataUri } from './validators/data-uri-validator.util';
|
|
9
|
-
import { isNonEmptyString } from './validators/string-validator.util';
|
|
9
|
+
import { ValidationService } from "../../domain/services";
|
|
10
|
+
import type { ValidationError } from "../../domain/services/ValidationService";
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/on\w+\s*=/i,
|
|
15
|
-
/<iframe/i,
|
|
16
|
-
/<embed/i,
|
|
17
|
-
/<object/i,
|
|
18
|
-
/data:(?!image\/)/i,
|
|
19
|
-
/vbscript:/i,
|
|
20
|
-
] as const;
|
|
21
|
-
|
|
22
|
-
function hasSuspiciousContent(value: string): boolean {
|
|
23
|
-
return SUSPICIOUS_PATTERNS.some(pattern => pattern.test(value));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Validate URL format and protocol
|
|
28
|
-
* Rejects malicious URLs and unsafe protocols
|
|
29
|
-
*/
|
|
30
|
-
function isValidAndSafeUrl(value: string): boolean {
|
|
31
|
-
// Allow http/https URLs
|
|
32
|
-
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
33
|
-
try {
|
|
34
|
-
const url = new URL(value);
|
|
35
|
-
// Reject URLs with @ (potential auth bypass: http://attacker.com@internal.server/)
|
|
36
|
-
if (url.href.includes('@') && url.username) {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
// Ensure domain exists
|
|
40
|
-
if (!url.hostname || url.hostname.length === 0) {
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Allow local file URIs (file://, content://) — preprocessInput uploads them to FAL storage
|
|
50
|
-
if (value.startsWith('file://') || value.startsWith('content://')) {
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Allow base64 image data URIs only
|
|
55
|
-
if (isImageDataUri(value)) {
|
|
56
|
-
// Check for suspicious content in data URI
|
|
57
|
-
const dataContent = value.substring(0, 200); // Check first 200 chars
|
|
58
|
-
if (hasSuspiciousContent(dataContent)) {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface ValidationError {
|
|
68
|
-
field: string;
|
|
69
|
-
message: string;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export class InputValidationError extends Error {
|
|
73
|
-
public readonly errors: readonly ValidationError[];
|
|
74
|
-
|
|
75
|
-
constructor(errors: ValidationError[]) {
|
|
76
|
-
const message = errors.map((e) => `${e.field}: ${e.message}`).join("; ");
|
|
77
|
-
super(`Input validation failed: ${message}`);
|
|
78
|
-
this.name = "InputValidationError";
|
|
79
|
-
this.errors = errors;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
12
|
+
// Re-export for backward compatibility
|
|
13
|
+
export { InputValidationError } from "../../domain/services/ValidationService";
|
|
14
|
+
export type { ValidationError };
|
|
82
15
|
|
|
83
16
|
/**
|
|
84
17
|
* Validate model and input parameters
|
|
18
|
+
* Delegates to domain ValidationService
|
|
19
|
+
*
|
|
20
|
+
* @param model - Model ID
|
|
21
|
+
* @param input - Input parameters to validate
|
|
22
|
+
* @throws InputValidationError if validation fails
|
|
85
23
|
*/
|
|
86
24
|
export function validateInput(
|
|
87
|
-
|
|
25
|
+
model: string,
|
|
88
26
|
input: Record<string, unknown>
|
|
89
27
|
): void {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// Validate input is not empty
|
|
93
|
-
if (!input || typeof input !== "object" || Object.keys(input).length === 0) {
|
|
94
|
-
errors.push({ field: "input", message: "Input must be a non-empty object" });
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// BLOCK sync_mode:true — it causes FAL to return base64 data URIs instead of CDN URLs
|
|
98
|
-
if (input.sync_mode === true) {
|
|
99
|
-
errors.push({
|
|
100
|
-
field: "sync_mode",
|
|
101
|
-
message: "sync_mode:true is forbidden. It returns base64 data URIs instead of HTTPS CDN URLs, which breaks Firestore persistence (2048 char limit). Use falProvider.subscribe() for CDN URLs.",
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Validate and check prompt for malicious content
|
|
106
|
-
if (input.prompt !== undefined) {
|
|
107
|
-
if (!isValidPrompt(input.prompt)) {
|
|
108
|
-
errors.push({
|
|
109
|
-
field: "prompt",
|
|
110
|
-
message: "Prompt must be a non-empty string (max 5000 characters)",
|
|
111
|
-
});
|
|
112
|
-
} else if (typeof input.prompt === "string") {
|
|
113
|
-
// Check for suspicious content (defense in depth)
|
|
114
|
-
if (hasSuspiciousContent(input.prompt)) {
|
|
115
|
-
errors.push({
|
|
116
|
-
field: "prompt",
|
|
117
|
-
message: "Prompt contains potentially unsafe content (script tags, event handlers, or suspicious protocols)",
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Validate and check negative_prompt for malicious content
|
|
124
|
-
if (input.negative_prompt !== undefined) {
|
|
125
|
-
if (!isValidPrompt(input.negative_prompt)) {
|
|
126
|
-
errors.push({
|
|
127
|
-
field: "negative_prompt",
|
|
128
|
-
message: "Negative prompt must be a non-empty string (max 5000 characters)",
|
|
129
|
-
});
|
|
130
|
-
} else if (typeof input.negative_prompt === "string") {
|
|
131
|
-
// Check for suspicious content (defense in depth)
|
|
132
|
-
if (hasSuspiciousContent(input.negative_prompt)) {
|
|
133
|
-
errors.push({
|
|
134
|
-
field: "negative_prompt",
|
|
135
|
-
message: "Negative prompt contains potentially unsafe content (script tags, event handlers, or suspicious protocols)",
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Validate all image_url fields
|
|
142
|
-
for (const field of IMAGE_URL_FIELDS) {
|
|
143
|
-
const value = input[field];
|
|
144
|
-
if (value !== undefined) {
|
|
145
|
-
if (typeof value !== "string") {
|
|
146
|
-
errors.push({
|
|
147
|
-
field,
|
|
148
|
-
message: `${field} must be a string`,
|
|
149
|
-
});
|
|
150
|
-
} else if (!isNonEmptyString(value)) {
|
|
151
|
-
// Explicitly check for empty/whitespace-only strings
|
|
152
|
-
errors.push({
|
|
153
|
-
field,
|
|
154
|
-
message: `${field} cannot be empty`,
|
|
155
|
-
});
|
|
156
|
-
} else if (!isValidAndSafeUrl(value)) {
|
|
157
|
-
errors.push({
|
|
158
|
-
field,
|
|
159
|
-
message: `${field} must be a valid and safe URL (http/https) or image data URI. Suspicious content or unsafe protocols detected.`,
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (errors.length > 0) {
|
|
166
|
-
throw new InputValidationError(errors);
|
|
167
|
-
}
|
|
28
|
+
ValidationService.validateInput(model, input);
|
|
168
29
|
}
|
|
@@ -1,71 +1,44 @@
|
|
|
1
|
-
import type { VideoModelConfig } from "@umituz/react-native-ai-generation-content";
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* FAL AI Pricing Utilities
|
|
5
|
-
*
|
|
2
|
+
* FAL AI Pricing Utilities (Infrastructure Layer)
|
|
3
|
+
* Delegates to domain PricingService for credit calculation logic
|
|
6
4
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - 720p video: $0.07/sec
|
|
10
|
-
* - Image with input: +$0.002
|
|
11
|
-
* - Image generation: $0.03
|
|
12
|
-
* Markup: 3.5x, Credit price: $0.10
|
|
5
|
+
* This file now serves as a thin adapter layer for backward compatibility.
|
|
6
|
+
* The actual pricing logic has been moved to the domain layer.
|
|
13
7
|
*/
|
|
14
8
|
|
|
15
|
-
|
|
16
|
-
VIDEO_480P_PER_SECOND: 0.05,
|
|
17
|
-
VIDEO_720P_PER_SECOND: 0.07,
|
|
18
|
-
IMAGE_INPUT: 0.002,
|
|
19
|
-
IMAGE: 0.03,
|
|
20
|
-
} as const;
|
|
21
|
-
|
|
22
|
-
const MARKUP = 3.5;
|
|
23
|
-
const CREDIT_PRICE = 0.1;
|
|
9
|
+
import { PricingService } from "../../../domain/services/PricingService";
|
|
24
10
|
|
|
25
|
-
export
|
|
11
|
+
// Re-export types for backward compatibility
|
|
12
|
+
export type { GenerationResolution } from "../../../domain/services/PricingService";
|
|
26
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Calculate credits for video generation
|
|
16
|
+
* Delegates to domain PricingService
|
|
17
|
+
*/
|
|
27
18
|
export function calculateVideoCredits(
|
|
28
19
|
duration: number,
|
|
29
|
-
resolution:
|
|
30
|
-
hasImageInput
|
|
20
|
+
resolution: "480p" | "720p",
|
|
21
|
+
hasImageInput?: boolean
|
|
31
22
|
): number {
|
|
32
|
-
|
|
33
|
-
resolution === "480p"
|
|
34
|
-
? COSTS.VIDEO_480P_PER_SECOND
|
|
35
|
-
: COSTS.VIDEO_720P_PER_SECOND;
|
|
36
|
-
let cost = costPerSec * duration;
|
|
37
|
-
if (hasImageInput) cost += COSTS.IMAGE_INPUT;
|
|
38
|
-
return Math.max(1, Math.ceil((cost * MARKUP) / CREDIT_PRICE));
|
|
23
|
+
return PricingService.calculateVideoCredits(duration, resolution, hasImageInput);
|
|
39
24
|
}
|
|
40
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Calculate credits for image generation
|
|
28
|
+
* Delegates to domain PricingService
|
|
29
|
+
*/
|
|
41
30
|
export function calculateImageCredits(): number {
|
|
42
|
-
return
|
|
31
|
+
return PricingService.calculateImageCredits();
|
|
43
32
|
}
|
|
44
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Calculate credits from video model config
|
|
36
|
+
* Delegates to domain PricingService
|
|
37
|
+
*/
|
|
45
38
|
export function calculateCreditsFromConfig(
|
|
46
|
-
config: VideoModelConfig,
|
|
39
|
+
config: import("@umituz/react-native-ai-generation-content").VideoModelConfig,
|
|
47
40
|
duration: number,
|
|
48
|
-
resolution: string
|
|
41
|
+
resolution: string
|
|
49
42
|
): number {
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
!config ||
|
|
53
|
-
typeof config !== "object" ||
|
|
54
|
-
!config.pricing ||
|
|
55
|
-
typeof config.pricing !== "object" ||
|
|
56
|
-
!config.pricing.costPerSecond ||
|
|
57
|
-
typeof config.pricing.costPerSecond !== "object"
|
|
58
|
-
) {
|
|
59
|
-
throw new Error("Invalid VideoModelConfig: pricing structure is missing or invalid");
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const costPerSecondMap = config.pricing.costPerSecond;
|
|
63
|
-
const costPerSec = costPerSecondMap[resolution] ?? 0;
|
|
64
|
-
|
|
65
|
-
if (typeof costPerSec !== "number" || costPerSec < 0) {
|
|
66
|
-
throw new Error(`Invalid cost per second for resolution "${resolution}": must be a non-negative number`);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const cost = costPerSec * duration;
|
|
70
|
-
return Math.max(1, Math.ceil((cost * MARKUP) / CREDIT_PRICE));
|
|
43
|
+
return PricingService.calculateCreditsFromConfig(config, duration, resolution);
|
|
71
44
|
}
|