@umituz/react-native-ai-fal-provider 2.0.18 → 2.0.20
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/constants/default-models.constants.ts +8 -2
- package/src/domain/types/provider.types.ts +0 -2
- package/src/exports/infrastructure.ts +0 -9
- package/src/infrastructure/services/fal-provider-subscription.ts +6 -1
- package/src/infrastructure/services/fal-provider.constants.ts +2 -11
- package/src/infrastructure/services/fal-provider.ts +21 -27
- package/src/infrastructure/services/request-store.ts +5 -9
- package/src/infrastructure/utils/cost-tracker.ts +7 -6
- package/src/infrastructure/utils/fal-storage.util.ts +4 -2
- package/src/infrastructure/utils/image-helpers.util.ts +11 -1
- package/src/infrastructure/utils/index.ts +0 -9
- package/src/infrastructure/utils/input-builders.util.ts +0 -2
- package/src/infrastructure/utils/input-preprocessor.util.ts +13 -1
- package/src/infrastructure/utils/job-metadata/job-metadata-queries.util.ts +16 -2
- package/src/infrastructure/utils/prompt-helpers.util.ts +8 -2
- package/src/infrastructure/utils/type-guards.util.ts +5 -6
- package/src/init/createAiProviderInitModule.ts +0 -20
- package/src/presentation/hooks/use-fal-generation.ts +22 -3
- package/src/presentation/hooks/use-models.ts +3 -0
- package/src/infrastructure/builders/image-feature-builder.ts +0 -64
- package/src/infrastructure/builders/video-feature-builder.ts +0 -55
- package/src/infrastructure/services/fal-feature-models.ts +0 -48
- package/src/infrastructure/utils/image-feature-builders.util.ts +0 -104
- package/src/infrastructure/utils/video-feature-builders.util.ts +0 -58
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.20",
|
|
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",
|
|
@@ -80,10 +80,16 @@ export function getDefaultModelsByType(type: FalModelType): FalModelConfig[] {
|
|
|
80
80
|
return DEFAULT_IMAGE_TO_VIDEO_MODELS;
|
|
81
81
|
case "text-to-text":
|
|
82
82
|
return DEFAULT_TEXT_TO_TEXT_MODELS;
|
|
83
|
-
case "image-to-image":
|
|
83
|
+
case "image-to-image": {
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.warn('Model type "image-to-image" not supported yet');
|
|
84
86
|
return [];
|
|
85
|
-
|
|
87
|
+
}
|
|
88
|
+
default: {
|
|
89
|
+
// eslint-disable-next-line no-console
|
|
90
|
+
console.warn('Unknown model type:', type);
|
|
86
91
|
return [];
|
|
92
|
+
}
|
|
87
93
|
}
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -39,8 +39,6 @@ export interface AIProviderConfig {
|
|
|
39
39
|
textToImageModel?: string;
|
|
40
40
|
imageEditModel?: string;
|
|
41
41
|
videoGenerationModel?: string;
|
|
42
|
-
videoFeatureModels?: Partial<Record<VideoFeatureType, string>>;
|
|
43
|
-
imageFeatureModels?: Partial<Record<ImageFeatureType, string>>;
|
|
44
42
|
}
|
|
45
43
|
|
|
46
44
|
// =============================================================================
|
|
@@ -18,15 +18,6 @@ export {
|
|
|
18
18
|
isFalErrorRetryable,
|
|
19
19
|
buildSingleImageInput,
|
|
20
20
|
buildDualImageInput,
|
|
21
|
-
buildUpscaleInput,
|
|
22
|
-
buildPhotoRestoreInput,
|
|
23
|
-
buildVideoFromImageInput,
|
|
24
|
-
buildFaceSwapInput,
|
|
25
|
-
buildImageToImageInput,
|
|
26
|
-
buildRemoveBackgroundInput,
|
|
27
|
-
buildRemoveObjectInput,
|
|
28
|
-
buildReplaceBackgroundInput,
|
|
29
|
-
buildHDTouchUpInput,
|
|
30
21
|
} from "../infrastructure/utils";
|
|
31
22
|
|
|
32
23
|
export { CostTracker } from "../infrastructure/utils/cost-tracker";
|
|
@@ -123,9 +123,14 @@ export async function handleFalSubscription<T = unknown>(
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const userMessage = parseFalError(error);
|
|
126
|
+
if (!userMessage || userMessage.trim().length === 0) {
|
|
127
|
+
throw new Error("An unknown error occurred. Please try again.");
|
|
128
|
+
}
|
|
126
129
|
throw new Error(userMessage);
|
|
127
130
|
} finally {
|
|
128
|
-
if (timeoutId)
|
|
131
|
+
if (timeoutId) {
|
|
132
|
+
clearTimeout(timeoutId);
|
|
133
|
+
}
|
|
129
134
|
if (listenerAdded && abortHandler && signal) {
|
|
130
135
|
signal.removeEventListener("abort", abortHandler);
|
|
131
136
|
}
|
|
@@ -14,17 +14,8 @@ export const DEFAULT_FAL_CONFIG = {
|
|
|
14
14
|
} as const;
|
|
15
15
|
|
|
16
16
|
export const FAL_CAPABILITIES: ProviderCapabilities = {
|
|
17
|
-
imageFeatures: [
|
|
18
|
-
|
|
19
|
-
"photo-restore",
|
|
20
|
-
"face-swap",
|
|
21
|
-
"anime-selfie",
|
|
22
|
-
"remove-background",
|
|
23
|
-
"remove-object",
|
|
24
|
-
"hd-touch-up",
|
|
25
|
-
"replace-background",
|
|
26
|
-
] as const,
|
|
27
|
-
videoFeatures: ["image-to-video", "text-to-video"] as const,
|
|
17
|
+
imageFeatures: [] as const,
|
|
18
|
+
videoFeatures: [] as const,
|
|
28
19
|
textToImage: true,
|
|
29
20
|
textToVideo: true,
|
|
30
21
|
imageToVideo: true,
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
import { fal } from "@fal-ai/client";
|
|
7
7
|
import type {
|
|
8
8
|
IAIProvider, AIProviderConfig, JobSubmission, JobStatus, SubscribeOptions,
|
|
9
|
-
RunOptions, ImageFeatureType, VideoFeatureType,
|
|
10
|
-
|
|
9
|
+
RunOptions, ProviderCapabilities, ImageFeatureType, VideoFeatureType,
|
|
10
|
+
ImageFeatureInputData, VideoFeatureInputData,
|
|
11
11
|
} from "../../domain/types";
|
|
12
12
|
import type { CostTrackerConfig } from "../../domain/entities/cost-tracking.types";
|
|
13
13
|
import { DEFAULT_FAL_CONFIG, FAL_CAPABILITIES } from "./fal-provider.constants";
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
removeRequest, cancelAllRequests, hasActiveRequests,
|
|
19
19
|
} from "./request-store";
|
|
20
20
|
import * as queueOps from "./fal-queue-operations";
|
|
21
|
-
import * as featureModels from "./fal-feature-models";
|
|
22
21
|
import { validateInput } from "../utils/input-validator.util";
|
|
23
22
|
|
|
24
23
|
export class FalProvider implements IAIProvider {
|
|
@@ -28,13 +27,9 @@ export class FalProvider implements IAIProvider {
|
|
|
28
27
|
private apiKey: string | null = null;
|
|
29
28
|
private initialized = false;
|
|
30
29
|
private costTracker: CostTracker | null = null;
|
|
31
|
-
private videoFeatureModels: Record<string, string> = {};
|
|
32
|
-
private imageFeatureModels: Record<string, string> = {};
|
|
33
30
|
|
|
34
31
|
initialize(config: AIProviderConfig): void {
|
|
35
32
|
this.apiKey = config.apiKey;
|
|
36
|
-
this.videoFeatureModels = config.videoFeatureModels ?? {};
|
|
37
|
-
this.imageFeatureModels = config.imageFeatureModels ?? {};
|
|
38
33
|
fal.config({
|
|
39
34
|
credentials: config.apiKey,
|
|
40
35
|
retry: {
|
|
@@ -70,10 +65,24 @@ export class FalProvider implements IAIProvider {
|
|
|
70
65
|
return FAL_CAPABILITIES;
|
|
71
66
|
}
|
|
72
67
|
|
|
73
|
-
isFeatureSupported(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
isFeatureSupported(_feature: ImageFeatureType | VideoFeatureType): boolean {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getImageFeatureModel(_feature: ImageFeatureType): string {
|
|
73
|
+
throw new Error("Feature-specific models are not supported in this provider. Use the main app's feature implementations.");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
buildImageFeatureInput(_feature: ImageFeatureType, _data: ImageFeatureInputData): Record<string, unknown> {
|
|
77
|
+
throw new Error("Feature-specific input building is not supported in this provider. Use the main app's feature implementations.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getVideoFeatureModel(_feature: VideoFeatureType): string {
|
|
81
|
+
throw new Error("Feature-specific models are not supported in this provider. Use the main app's feature implementations.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
buildVideoFeatureInput(_feature: VideoFeatureType, _data: VideoFeatureInputData): Record<string, unknown> {
|
|
85
|
+
throw new Error("Feature-specific input building is not supported in this provider. Use the main app's feature implementations.");
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
private validateInit(): void {
|
|
@@ -125,6 +134,7 @@ export class FalProvider implements IAIProvider {
|
|
|
125
134
|
getRequestId: (res) => res.requestId ?? undefined,
|
|
126
135
|
}).then((res) => res.result).finally(() => removeRequest(key));
|
|
127
136
|
|
|
137
|
+
// Store promise immediately to prevent race condition
|
|
128
138
|
storeRequest(key, { promise, abortController });
|
|
129
139
|
return promise;
|
|
130
140
|
}
|
|
@@ -160,22 +170,6 @@ export class FalProvider implements IAIProvider {
|
|
|
160
170
|
hasRunningRequest(): boolean {
|
|
161
171
|
return hasActiveRequests();
|
|
162
172
|
}
|
|
163
|
-
|
|
164
|
-
getImageFeatureModel(feature: ImageFeatureType): string {
|
|
165
|
-
return featureModels.getImageFeatureModel(this.imageFeatureModels, feature);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
buildImageFeatureInput(feature: ImageFeatureType, data: ImageFeatureInputData): Record<string, unknown> {
|
|
169
|
-
return featureModels.buildImageFeatureInput(feature, data);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
getVideoFeatureModel(feature: VideoFeatureType): string {
|
|
173
|
-
return featureModels.getVideoFeatureModel(this.videoFeatureModels, feature);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
buildVideoFeatureInput(feature: VideoFeatureType, data: VideoFeatureInputData): Record<string, unknown> {
|
|
177
|
-
return featureModels.buildVideoFeatureInput(feature, data);
|
|
178
|
-
}
|
|
179
173
|
}
|
|
180
174
|
|
|
181
175
|
export const falProvider = new FalProvider();
|
|
@@ -19,10 +19,8 @@ export function getRequestStore(): RequestStore {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Create a
|
|
23
|
-
*
|
|
24
|
-
* - Input hash (for quick comparison)
|
|
25
|
-
* - Unique ID (guarantees uniqueness)
|
|
22
|
+
* Create a deterministic request key using model and input hash
|
|
23
|
+
* Same model + input will always produce the same key for deduplication
|
|
26
24
|
*/
|
|
27
25
|
export function createRequestKey(model: string, input: Record<string, unknown>): string {
|
|
28
26
|
const inputStr = JSON.stringify(input, Object.keys(input).sort());
|
|
@@ -32,11 +30,9 @@ export function createRequestKey(model: string, input: Record<string, unknown>):
|
|
|
32
30
|
const char = inputStr.charCodeAt(i);
|
|
33
31
|
hash = ((hash << 5) - hash + char) | 0;
|
|
34
32
|
}
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
39
|
-
return `${model}:${hash.toString(36)}:${uniqueId}`;
|
|
33
|
+
// Return deterministic key without unique ID
|
|
34
|
+
// This allows proper deduplication: same model + input = same key
|
|
35
|
+
return `${model}:${hash.toString(36)}`;
|
|
40
36
|
}
|
|
41
37
|
|
|
42
38
|
export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
|
|
@@ -45,7 +45,6 @@ export class CostTracker {
|
|
|
45
45
|
private config: Required<CostTrackerConfig>;
|
|
46
46
|
private costHistory: GenerationCost[] = [];
|
|
47
47
|
private currentOperationCosts: Map<string, number> = new Map();
|
|
48
|
-
private operationCounter = 0;
|
|
49
48
|
|
|
50
49
|
constructor(config?: CostTrackerConfig) {
|
|
51
50
|
this.config = {
|
|
@@ -84,11 +83,14 @@ export class CostTracker {
|
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
startOperation(modelId: string, operation: string): string {
|
|
87
|
-
// Use
|
|
88
|
-
const
|
|
86
|
+
// Use crypto.randomUUID() for guaranteed uniqueness without overflow
|
|
87
|
+
const uniqueId = typeof crypto !== 'undefined' && crypto.randomUUID
|
|
88
|
+
? crypto.randomUUID()
|
|
89
|
+
: `${Date.now()}-${Math.random().toString(36).slice(2)}-${operation}`;
|
|
90
|
+
|
|
89
91
|
const estimatedCost = this.calculateEstimatedCost(modelId);
|
|
90
92
|
|
|
91
|
-
this.currentOperationCosts.set(
|
|
93
|
+
this.currentOperationCosts.set(uniqueId, estimatedCost);
|
|
92
94
|
|
|
93
95
|
if (this.config.trackEstimatedCost) {
|
|
94
96
|
const cost: GenerationCost = {
|
|
@@ -104,7 +106,7 @@ export class CostTracker {
|
|
|
104
106
|
this.config.onCostUpdate(cost);
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
return
|
|
109
|
+
return uniqueId;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
112
|
completeOperation(
|
|
@@ -156,7 +158,6 @@ export class CostTracker {
|
|
|
156
158
|
clearHistory(): void {
|
|
157
159
|
this.costHistory = [];
|
|
158
160
|
this.currentOperationCosts.clear();
|
|
159
|
-
this.operationCounter = 0;
|
|
160
161
|
}
|
|
161
162
|
|
|
162
163
|
getCostsByModel(modelId: string): GenerationCost[] {
|
|
@@ -27,8 +27,10 @@ export async function uploadToFalStorage(base64: string): Promise<string> {
|
|
|
27
27
|
try {
|
|
28
28
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
29
29
|
await deleteTempFile(tempUri);
|
|
30
|
-
} catch {
|
|
31
|
-
//
|
|
30
|
+
} catch (cleanupError) {
|
|
31
|
+
// Log cleanup failure but don't throw
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.warn(`Failed to cleanup temp file ${tempUri}:`, cleanupError);
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
}
|
|
@@ -20,8 +20,18 @@ export function extractBase64(dataUri: string): string {
|
|
|
20
20
|
if (!dataUri.startsWith("data:")) {
|
|
21
21
|
return dataUri;
|
|
22
22
|
}
|
|
23
|
+
|
|
23
24
|
const parts = dataUri.split(",");
|
|
24
|
-
|
|
25
|
+
if (parts.length < 2) {
|
|
26
|
+
throw new Error(`Invalid data URI format: ${dataUri}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const base64Part = parts[1];
|
|
30
|
+
if (!base64Part || base64Part.length === 0) {
|
|
31
|
+
throw new Error(`Empty base64 data in URI: ${dataUri}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return base64Part;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
/**
|
|
@@ -11,15 +11,6 @@ export {
|
|
|
11
11
|
export {
|
|
12
12
|
buildSingleImageInput,
|
|
13
13
|
buildDualImageInput,
|
|
14
|
-
buildUpscaleInput,
|
|
15
|
-
buildPhotoRestoreInput,
|
|
16
|
-
buildVideoFromImageInput,
|
|
17
|
-
buildFaceSwapInput,
|
|
18
|
-
buildImageToImageInput,
|
|
19
|
-
buildRemoveBackgroundInput,
|
|
20
|
-
buildRemoveObjectInput,
|
|
21
|
-
buildReplaceBackgroundInput,
|
|
22
|
-
buildHDTouchUpInput,
|
|
23
14
|
} from "./input-builders.util";
|
|
24
15
|
|
|
25
16
|
export {
|
|
@@ -51,9 +51,16 @@ export async function preprocessInput(
|
|
|
51
51
|
if (Array.isArray(result.image_urls) && result.image_urls.length > 0) {
|
|
52
52
|
const imageUrls = result.image_urls as unknown[];
|
|
53
53
|
const processedUrls: string[] = [];
|
|
54
|
+
const errors: string[] = [];
|
|
54
55
|
|
|
55
56
|
for (let i = 0; i < imageUrls.length; i++) {
|
|
56
57
|
const imageUrl = imageUrls[i];
|
|
58
|
+
|
|
59
|
+
if (!imageUrl) {
|
|
60
|
+
errors.push(`image_urls[${i}] is null or undefined`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
57
64
|
if (isBase64DataUri(imageUrl)) {
|
|
58
65
|
const index = i;
|
|
59
66
|
const uploadPromise = uploadToFalStorage(imageUrl)
|
|
@@ -61,6 +68,7 @@ export async function preprocessInput(
|
|
|
61
68
|
processedUrls[index] = url;
|
|
62
69
|
})
|
|
63
70
|
.catch((error) => {
|
|
71
|
+
errors.push(`Failed to upload image_urls[${index}]: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
64
72
|
throw new Error(`Failed to upload image_urls[${index}]: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
65
73
|
});
|
|
66
74
|
|
|
@@ -68,10 +76,14 @@ export async function preprocessInput(
|
|
|
68
76
|
} else if (typeof imageUrl === "string") {
|
|
69
77
|
processedUrls[i] = imageUrl;
|
|
70
78
|
} else {
|
|
71
|
-
|
|
79
|
+
errors.push(`image_urls[${i}] has invalid type: ${typeof imageUrl}`);
|
|
72
80
|
}
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
if (errors.length > 0) {
|
|
84
|
+
throw new Error(`Image URL validation failed:\n${errors.join('\n')}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
75
87
|
result.image_urls = processedUrls;
|
|
76
88
|
}
|
|
77
89
|
|
|
@@ -18,8 +18,22 @@ export function serializeJobMetadata(metadata: FalJobMetadata): string {
|
|
|
18
18
|
*/
|
|
19
19
|
export function deserializeJobMetadata(data: string): FalJobMetadata | null {
|
|
20
20
|
try {
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
const parsed = JSON.parse(data) as Record<string, unknown>;
|
|
22
|
+
// Validate structure
|
|
23
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.warn('Invalid job metadata: not an object', data);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
if (!parsed.requestId || !parsed.model || !parsed.status) {
|
|
29
|
+
// eslint-disable-next-line no-console
|
|
30
|
+
console.warn('Invalid job metadata: missing required fields', data);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return parsed as unknown as FalJobMetadata;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.error('Failed to deserialize job metadata:', error, 'Data:', data);
|
|
23
37
|
return null;
|
|
24
38
|
}
|
|
25
39
|
}
|
|
@@ -14,8 +14,14 @@ export function truncatePrompt(prompt: string, maxLength: number = 5000): string
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Sanitize prompt by removing excessive whitespace
|
|
17
|
+
* Sanitize prompt by removing excessive whitespace and control characters
|
|
18
18
|
*/
|
|
19
19
|
export function sanitizePrompt(prompt: string): string {
|
|
20
|
-
return prompt
|
|
20
|
+
return prompt
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
// Remove control characters except tab, newline, carriage return
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, '')
|
|
26
|
+
.slice(0, 5000);
|
|
21
27
|
}
|
|
@@ -87,15 +87,14 @@ export function isValidApiKey(value: unknown): boolean {
|
|
|
87
87
|
/**
|
|
88
88
|
* Validate model ID format
|
|
89
89
|
*/
|
|
90
|
+
const MODEL_ID_PATTERN = /^[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_.]+(\/[a-zA-Z0-9-_.]+)?$/;
|
|
91
|
+
|
|
90
92
|
export function isValidModelId(value: unknown): boolean {
|
|
91
93
|
if (typeof value !== "string") {
|
|
92
94
|
return false;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
|
|
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;
|
|
97
|
+
return MODEL_ID_PATTERN.test(value) && value.length >= 3;
|
|
99
98
|
}
|
|
100
99
|
|
|
101
100
|
/**
|
|
@@ -109,12 +108,12 @@ export function isValidPrompt(value: unknown): boolean {
|
|
|
109
108
|
* Validate timeout value
|
|
110
109
|
*/
|
|
111
110
|
export function isValidTimeout(value: unknown): boolean {
|
|
112
|
-
return typeof value === "number" && value > 0 && value <= 600000; // Max 10 minutes
|
|
111
|
+
return typeof value === "number" && !isNaN(value) && isFinite(value) && value > 0 && value <= 600000; // Max 10 minutes
|
|
113
112
|
}
|
|
114
113
|
|
|
115
114
|
/**
|
|
116
115
|
* Validate retry count
|
|
117
116
|
*/
|
|
118
117
|
export function isValidRetryCount(value: unknown): boolean {
|
|
119
|
-
return typeof value === "number" && value >= 0 && value <= 10;
|
|
118
|
+
return typeof value === "number" && !isNaN(value) && isFinite(value) && Number.isInteger(value) && value >= 0 && value <= 10;
|
|
120
119
|
}
|
|
@@ -22,18 +22,6 @@ export interface AiProviderInitModuleConfig {
|
|
|
22
22
|
*/
|
|
23
23
|
getApiKey: () => string | undefined;
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
* Video feature models mapping
|
|
27
|
-
* Maps feature types to FAL model IDs
|
|
28
|
-
*/
|
|
29
|
-
videoFeatureModels?: Record<string, string>;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Image feature models mapping
|
|
33
|
-
* Maps feature types to FAL model IDs
|
|
34
|
-
*/
|
|
35
|
-
imageFeatureModels?: Record<string, string>;
|
|
36
|
-
|
|
37
25
|
/**
|
|
38
26
|
* Whether this module is critical for app startup
|
|
39
27
|
* @default false
|
|
@@ -61,10 +49,6 @@ export interface AiProviderInitModuleConfig {
|
|
|
61
49
|
* createFirebaseInitModule(),
|
|
62
50
|
* createAiProviderInitModule({
|
|
63
51
|
* getApiKey: () => getFalApiKey(),
|
|
64
|
-
* videoFeatureModels: {
|
|
65
|
-
* "image-to-video": "fal-ai/wan-25-preview/image-to-video",
|
|
66
|
-
* "text-to-video": "fal-ai/wan-25-preview/text-to-video",
|
|
67
|
-
* },
|
|
68
52
|
* }),
|
|
69
53
|
* ],
|
|
70
54
|
* });
|
|
@@ -75,8 +59,6 @@ export function createAiProviderInitModule(
|
|
|
75
59
|
): InitModule {
|
|
76
60
|
const {
|
|
77
61
|
getApiKey,
|
|
78
|
-
videoFeatureModels,
|
|
79
|
-
imageFeatureModels,
|
|
80
62
|
critical = false,
|
|
81
63
|
dependsOn = ['firebase'],
|
|
82
64
|
} = config;
|
|
@@ -95,8 +77,6 @@ export function createAiProviderInitModule(
|
|
|
95
77
|
|
|
96
78
|
falProvider.initialize({
|
|
97
79
|
apiKey,
|
|
98
|
-
videoFeatureModels,
|
|
99
|
-
imageFeatureModels,
|
|
100
80
|
});
|
|
101
81
|
|
|
102
82
|
return Promise.resolve(true);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* React hook for FAL AI generation operations
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useState, useCallback, useRef } from "react";
|
|
6
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
7
7
|
import { falProvider } from "../../infrastructure/services/fal-provider";
|
|
8
8
|
import { mapFalError } from "../../infrastructure/utils/error-mapper";
|
|
9
9
|
import type { FalJobInput, FalQueueStatus, FalLogEntry } from "../../domain/entities/fal.types";
|
|
@@ -38,9 +38,23 @@ export function useFalGeneration<T = unknown>(
|
|
|
38
38
|
|
|
39
39
|
const lastRequestRef = useRef<{ endpoint: string; input: FalJobInput } | null>(null);
|
|
40
40
|
const currentRequestIdRef = useRef<string | null>(null);
|
|
41
|
+
const isMountedRef = useRef(true);
|
|
42
|
+
|
|
43
|
+
// Cleanup on unmount
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
isMountedRef.current = true;
|
|
46
|
+
return () => {
|
|
47
|
+
isMountedRef.current = false;
|
|
48
|
+
if (falProvider.hasRunningRequest()) {
|
|
49
|
+
falProvider.cancelCurrentRequest();
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}, []);
|
|
41
53
|
|
|
42
54
|
const generate = useCallback(
|
|
43
55
|
async (modelEndpoint: string, input: FalJobInput): Promise<T | null> => {
|
|
56
|
+
if (!isMountedRef.current) return null;
|
|
57
|
+
|
|
44
58
|
lastRequestRef.current = { endpoint: modelEndpoint, input };
|
|
45
59
|
setIsLoading(true);
|
|
46
60
|
setError(null);
|
|
@@ -52,6 +66,7 @@ export function useFalGeneration<T = unknown>(
|
|
|
52
66
|
const result = await falProvider.subscribe<T>(modelEndpoint, input, {
|
|
53
67
|
timeoutMs: options?.timeoutMs,
|
|
54
68
|
onQueueUpdate: (status) => {
|
|
69
|
+
if (!isMountedRef.current) return;
|
|
55
70
|
if (status.requestId) {
|
|
56
71
|
currentRequestIdRef.current = status.requestId;
|
|
57
72
|
}
|
|
@@ -68,16 +83,20 @@ export function useFalGeneration<T = unknown>(
|
|
|
68
83
|
},
|
|
69
84
|
});
|
|
70
85
|
|
|
86
|
+
if (!isMountedRef.current) return null;
|
|
71
87
|
setData(result);
|
|
72
88
|
return result;
|
|
73
89
|
} catch (err) {
|
|
90
|
+
if (!isMountedRef.current) return null;
|
|
74
91
|
const errorInfo = mapFalError(err);
|
|
75
92
|
setError(errorInfo);
|
|
76
93
|
options?.onError?.(errorInfo);
|
|
77
94
|
return null;
|
|
78
95
|
} finally {
|
|
79
|
-
|
|
80
|
-
|
|
96
|
+
if (isMountedRef.current) {
|
|
97
|
+
setIsLoading(false);
|
|
98
|
+
setIsCancelling(false);
|
|
99
|
+
}
|
|
81
100
|
}
|
|
82
101
|
},
|
|
83
102
|
[options]
|
|
@@ -68,6 +68,9 @@ export function useModels(props: UseModelsProps): UseModelsReturn {
|
|
|
68
68
|
const model = models.find((m) => m.id === modelId);
|
|
69
69
|
if (model) {
|
|
70
70
|
setSelectedModel(model);
|
|
71
|
+
} else {
|
|
72
|
+
// eslint-disable-next-line no-console
|
|
73
|
+
console.warn(`Model not found: ${modelId}. Available models:`, models.map(m => m.id));
|
|
71
74
|
}
|
|
72
75
|
},
|
|
73
76
|
[models],
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image Feature Input Builder
|
|
3
|
-
* Builds inputs for image-based AI features
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
ImageFeatureType,
|
|
8
|
-
ImageFeatureInputData,
|
|
9
|
-
} from "../../domain/types";
|
|
10
|
-
import { buildSingleImageInput } from "../utils/base-builders.util";
|
|
11
|
-
import {
|
|
12
|
-
buildUpscaleInput,
|
|
13
|
-
buildPhotoRestoreInput,
|
|
14
|
-
buildFaceSwapInput,
|
|
15
|
-
buildRemoveBackgroundInput,
|
|
16
|
-
buildRemoveObjectInput,
|
|
17
|
-
buildReplaceBackgroundInput,
|
|
18
|
-
buildKontextStyleTransferInput,
|
|
19
|
-
} from "../utils/image-feature-builders.util";
|
|
20
|
-
|
|
21
|
-
const DEFAULT_ANIME_SELFIE_PROMPT = "Transform this person into anime style illustration. Keep the same gender, face structure, hair color, eye color, and expression. Make it look like a high-quality anime character portrait with vibrant colors and clean lineart.";
|
|
22
|
-
|
|
23
|
-
export function buildImageFeatureInput(
|
|
24
|
-
feature: ImageFeatureType,
|
|
25
|
-
data: ImageFeatureInputData,
|
|
26
|
-
): Record<string, unknown> {
|
|
27
|
-
const { imageBase64, targetImageBase64, prompt, options } = data;
|
|
28
|
-
|
|
29
|
-
switch (feature) {
|
|
30
|
-
case "upscale":
|
|
31
|
-
case "hd-touch-up":
|
|
32
|
-
return buildUpscaleInput(imageBase64, options);
|
|
33
|
-
|
|
34
|
-
case "photo-restore":
|
|
35
|
-
return buildPhotoRestoreInput(imageBase64, options);
|
|
36
|
-
|
|
37
|
-
case "face-swap":
|
|
38
|
-
if (!targetImageBase64) {
|
|
39
|
-
throw new Error("Face swap requires target image");
|
|
40
|
-
}
|
|
41
|
-
return buildFaceSwapInput(imageBase64, targetImageBase64, options);
|
|
42
|
-
|
|
43
|
-
case "remove-background":
|
|
44
|
-
return buildRemoveBackgroundInput(imageBase64, options);
|
|
45
|
-
|
|
46
|
-
case "remove-object":
|
|
47
|
-
return buildRemoveObjectInput(imageBase64, { prompt, ...options });
|
|
48
|
-
|
|
49
|
-
case "replace-background":
|
|
50
|
-
if (!prompt) {
|
|
51
|
-
throw new Error("Replace background requires prompt");
|
|
52
|
-
}
|
|
53
|
-
return buildReplaceBackgroundInput(imageBase64, { prompt, ...options });
|
|
54
|
-
|
|
55
|
-
case "anime-selfie":
|
|
56
|
-
return buildKontextStyleTransferInput(imageBase64, {
|
|
57
|
-
prompt: prompt || (options?.prompt as string) || DEFAULT_ANIME_SELFIE_PROMPT,
|
|
58
|
-
guidance_scale: options?.guidance_scale as number | undefined,
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
default:
|
|
62
|
-
return buildSingleImageInput(imageBase64, options);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Video Feature Input Builder
|
|
3
|
-
* Builds inputs for video-based AI features
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
VideoFeatureType,
|
|
8
|
-
VideoFeatureInputData,
|
|
9
|
-
} from "../../domain/types";
|
|
10
|
-
import {
|
|
11
|
-
buildVideoFromImageInput,
|
|
12
|
-
buildTextToVideoInput,
|
|
13
|
-
} from "../utils/video-feature-builders.util";
|
|
14
|
-
|
|
15
|
-
const DEFAULT_VIDEO_PROMPTS: Partial<Record<VideoFeatureType, string>> = {
|
|
16
|
-
"image-to-video": "Animate this image with natural, smooth motion while preserving all details",
|
|
17
|
-
"text-to-video": "Generate a high-quality video based on the description, smooth motion",
|
|
18
|
-
} as const;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Features that require image input
|
|
22
|
-
*/
|
|
23
|
-
const IMAGE_REQUIRED_FEATURES: readonly VideoFeatureType[] = [
|
|
24
|
-
"image-to-video",
|
|
25
|
-
] as const;
|
|
26
|
-
|
|
27
|
-
function isImageRequiredFeature(feature: VideoFeatureType): boolean {
|
|
28
|
-
return IMAGE_REQUIRED_FEATURES.includes(feature);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function buildVideoFeatureInput(
|
|
32
|
-
feature: VideoFeatureType,
|
|
33
|
-
data: VideoFeatureInputData,
|
|
34
|
-
): Record<string, unknown> {
|
|
35
|
-
const { sourceImageBase64, prompt, options } = data;
|
|
36
|
-
const effectivePrompt = prompt || DEFAULT_VIDEO_PROMPTS[feature] || "Generate video";
|
|
37
|
-
|
|
38
|
-
if (isImageRequiredFeature(feature)) {
|
|
39
|
-
if (!sourceImageBase64 || sourceImageBase64.trim().length === 0) {
|
|
40
|
-
throw new Error(`${feature} requires a source image`);
|
|
41
|
-
}
|
|
42
|
-
return buildVideoFromImageInput(sourceImageBase64, {
|
|
43
|
-
prompt: effectivePrompt,
|
|
44
|
-
duration: options?.duration as number | undefined,
|
|
45
|
-
resolution: options?.resolution as string | undefined,
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return buildTextToVideoInput({
|
|
50
|
-
prompt: effectivePrompt,
|
|
51
|
-
duration: options?.duration as number | undefined,
|
|
52
|
-
aspectRatio: options?.aspect_ratio as string | undefined,
|
|
53
|
-
resolution: options?.resolution as string | undefined,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FAL Feature Models - Model resolution and input building
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type {
|
|
6
|
-
ImageFeatureType,
|
|
7
|
-
VideoFeatureType,
|
|
8
|
-
ImageFeatureInputData,
|
|
9
|
-
VideoFeatureInputData,
|
|
10
|
-
} from "../../domain/types";
|
|
11
|
-
import {
|
|
12
|
-
buildImageFeatureInput as buildImageFeatureInputImpl,
|
|
13
|
-
} from "../builders/image-feature-builder";
|
|
14
|
-
import {
|
|
15
|
-
buildVideoFeatureInput as buildVideoFeatureInputImpl,
|
|
16
|
-
} from "../builders/video-feature-builder";
|
|
17
|
-
|
|
18
|
-
export function getImageFeatureModel(
|
|
19
|
-
imageFeatureModels: Record<string, string>,
|
|
20
|
-
feature: ImageFeatureType,
|
|
21
|
-
): string {
|
|
22
|
-
const model = imageFeatureModels[feature];
|
|
23
|
-
if (!model) throw new Error(`No model for image feature: ${feature}`);
|
|
24
|
-
return model;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function getVideoFeatureModel(
|
|
28
|
-
videoFeatureModels: Record<string, string>,
|
|
29
|
-
feature: VideoFeatureType,
|
|
30
|
-
): string {
|
|
31
|
-
const model = videoFeatureModels[feature];
|
|
32
|
-
if (!model) throw new Error(`No model for video feature: ${feature}`);
|
|
33
|
-
return model;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function buildImageFeatureInput(
|
|
37
|
-
feature: ImageFeatureType,
|
|
38
|
-
data: ImageFeatureInputData,
|
|
39
|
-
): Record<string, unknown> {
|
|
40
|
-
return buildImageFeatureInputImpl(feature, data);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function buildVideoFeatureInput(
|
|
44
|
-
feature: VideoFeatureType,
|
|
45
|
-
data: VideoFeatureInputData,
|
|
46
|
-
): Record<string, unknown> {
|
|
47
|
-
return buildVideoFeatureInputImpl(feature, data);
|
|
48
|
-
}
|
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Image Feature Input Builders
|
|
3
|
-
* Builder functions for specific image features
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
UpscaleOptions,
|
|
8
|
-
PhotoRestoreOptions,
|
|
9
|
-
RemoveBackgroundOptions,
|
|
10
|
-
RemoveObjectOptions,
|
|
11
|
-
ReplaceBackgroundOptions,
|
|
12
|
-
FaceSwapOptions,
|
|
13
|
-
} from "../../domain/types";
|
|
14
|
-
import { buildSingleImageInput } from "./base-builders.util";
|
|
15
|
-
import { formatImageDataUri } from "./image-helpers.util";
|
|
16
|
-
|
|
17
|
-
export function buildUpscaleInput(
|
|
18
|
-
base64: string,
|
|
19
|
-
options?: UpscaleOptions,
|
|
20
|
-
): Record<string, unknown> {
|
|
21
|
-
return buildSingleImageInput(base64, {
|
|
22
|
-
scale: options?.scaleFactor ?? 2,
|
|
23
|
-
face_enhance: options?.enhanceFaces ?? false,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function buildPhotoRestoreInput(
|
|
28
|
-
base64: string,
|
|
29
|
-
options?: PhotoRestoreOptions,
|
|
30
|
-
): Record<string, unknown> {
|
|
31
|
-
return buildSingleImageInput(base64, {
|
|
32
|
-
face_enhance: options?.enhanceFaces ?? true,
|
|
33
|
-
});
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function buildFaceSwapInput(
|
|
37
|
-
sourceBase64: string,
|
|
38
|
-
targetBase64: string,
|
|
39
|
-
options?: FaceSwapOptions,
|
|
40
|
-
): Record<string, unknown> {
|
|
41
|
-
return {
|
|
42
|
-
base_image_url: formatImageDataUri(sourceBase64),
|
|
43
|
-
swap_image_url: formatImageDataUri(targetBase64),
|
|
44
|
-
...(options?.enhanceFaces !== undefined && { enhance_faces: options.enhanceFaces }),
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function buildRemoveBackgroundInput(
|
|
49
|
-
base64: string,
|
|
50
|
-
options?: RemoveBackgroundOptions & {
|
|
51
|
-
model?: string;
|
|
52
|
-
operating_resolution?: string;
|
|
53
|
-
output_format?: string;
|
|
54
|
-
refine_foreground?: boolean;
|
|
55
|
-
},
|
|
56
|
-
): Record<string, unknown> {
|
|
57
|
-
return buildSingleImageInput(base64, {
|
|
58
|
-
model: options?.model ?? "General Use (Light)",
|
|
59
|
-
operating_resolution: options?.operating_resolution ?? "1024x1024",
|
|
60
|
-
output_format: options?.output_format ?? "png",
|
|
61
|
-
refine_foreground: options?.refine_foreground ?? true,
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function buildRemoveObjectInput(
|
|
66
|
-
base64: string,
|
|
67
|
-
options?: RemoveObjectOptions,
|
|
68
|
-
): Record<string, unknown> {
|
|
69
|
-
return buildSingleImageInput(base64, {
|
|
70
|
-
mask_url: options?.mask,
|
|
71
|
-
prompt: options?.prompt || "Remove the object and fill with background",
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function buildReplaceBackgroundInput(
|
|
76
|
-
base64: string,
|
|
77
|
-
options: ReplaceBackgroundOptions,
|
|
78
|
-
): Record<string, unknown> {
|
|
79
|
-
return buildSingleImageInput(base64, {
|
|
80
|
-
prompt: options.prompt,
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function buildHDTouchUpInput(
|
|
85
|
-
base64: string,
|
|
86
|
-
options?: UpscaleOptions,
|
|
87
|
-
): Record<string, unknown> {
|
|
88
|
-
return buildUpscaleInput(base64, options);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface KontextStyleTransferOptions {
|
|
92
|
-
prompt: string;
|
|
93
|
-
guidance_scale?: number;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
export function buildKontextStyleTransferInput(
|
|
97
|
-
base64: string,
|
|
98
|
-
options: KontextStyleTransferOptions,
|
|
99
|
-
): Record<string, unknown> {
|
|
100
|
-
return buildSingleImageInput(base64, {
|
|
101
|
-
prompt: options.prompt,
|
|
102
|
-
guidance_scale: options.guidance_scale ?? 3.5,
|
|
103
|
-
});
|
|
104
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Video Feature Input Builders
|
|
3
|
-
* Builder functions for video features
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
ImageToImagePromptConfig,
|
|
8
|
-
VideoFromImageOptions,
|
|
9
|
-
TextToVideoOptions,
|
|
10
|
-
} from "../../domain/types";
|
|
11
|
-
import { buildSingleImageInput } from "./base-builders.util";
|
|
12
|
-
import { formatImageDataUri } from "./image-helpers.util";
|
|
13
|
-
|
|
14
|
-
export function buildImageToImageInput(
|
|
15
|
-
base64: string,
|
|
16
|
-
promptConfig: ImageToImagePromptConfig,
|
|
17
|
-
): Record<string, unknown> {
|
|
18
|
-
return buildSingleImageInput(base64, {
|
|
19
|
-
prompt: promptConfig.prompt,
|
|
20
|
-
negative_prompt: promptConfig.negativePrompt,
|
|
21
|
-
strength: promptConfig.strength ?? 0.85,
|
|
22
|
-
num_inference_steps: promptConfig.num_inference_steps ?? 50,
|
|
23
|
-
guidance_scale: promptConfig.guidance_scale ?? 7.5,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function buildVideoFromImageInput(
|
|
28
|
-
base64: string,
|
|
29
|
-
options?: VideoFromImageOptions & {
|
|
30
|
-
enable_safety_checker?: boolean;
|
|
31
|
-
default_prompt?: string;
|
|
32
|
-
},
|
|
33
|
-
): Record<string, unknown> {
|
|
34
|
-
return {
|
|
35
|
-
prompt: options?.prompt || options?.default_prompt || "Generate natural motion video",
|
|
36
|
-
image_url: formatImageDataUri(base64),
|
|
37
|
-
enable_safety_checker: options?.enable_safety_checker ?? false,
|
|
38
|
-
...(options?.duration && { duration: options.duration }),
|
|
39
|
-
...(options?.resolution && { resolution: options.resolution }),
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Build input for text-to-video generation (no image required)
|
|
45
|
-
*/
|
|
46
|
-
export function buildTextToVideoInput(
|
|
47
|
-
options: TextToVideoOptions,
|
|
48
|
-
): Record<string, unknown> {
|
|
49
|
-
const { prompt, duration, aspectRatio, resolution } = options;
|
|
50
|
-
|
|
51
|
-
return {
|
|
52
|
-
prompt,
|
|
53
|
-
enable_safety_checker: false,
|
|
54
|
-
...(duration && { duration }),
|
|
55
|
-
...(aspectRatio && { aspect_ratio: aspectRatio }),
|
|
56
|
-
...(resolution && { resolution }),
|
|
57
|
-
};
|
|
58
|
-
}
|