@umituz/react-native-ai-pruna-provider 1.0.45 → 1.0.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-pruna-provider",
3
- "version": "1.0.45",
3
+ "version": "1.0.46",
4
4
  "description": "Pruna AI provider for React Native - implements IAIProvider interface for unified AI generation",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -38,14 +38,57 @@
38
38
  "react-native": ">=0.81.4"
39
39
  },
40
40
  "devDependencies": {
41
+ "@expo/vector-icons": "^15.1.1",
42
+ "@gorhom/bottom-sheet": "^5.2.8",
43
+ "@react-native-async-storage/async-storage": "^2.2.0",
44
+ "@react-native-community/slider": "^5.1.2",
45
+ "@react-navigation/bottom-tabs": "^7.15.5",
46
+ "@react-navigation/native": "^7.1.33",
47
+ "@react-navigation/stack": "^7.8.5",
48
+ "@tanstack/query-async-storage-persister": "^5.90.24",
49
+ "@tanstack/react-query": "^5.90.21",
50
+ "@tanstack/react-query-persist-client": "^5.90.24",
41
51
  "@types/react": "~19.1.0",
42
52
  "@typescript-eslint/eslint-plugin": "^7.0.0",
43
53
  "@typescript-eslint/parser": "^7.0.0",
44
54
  "@umituz/react-native-ai-generation-content": "^1.83.17",
55
+ "@umituz/react-native-auth": "^4.3.56",
56
+ "@umituz/react-native-design-system": "^4.25.97",
57
+ "@umituz/react-native-firebase": "^2.4.74",
58
+ "@umituz/react-native-subscription": "^2.37.116",
59
+ "@umituz/react-native-video-editor": "^1.1.48",
45
60
  "eslint": "^8.57.0",
61
+ "expo-apple-authentication": "^55.0.8",
62
+ "expo-application": "^55.0.9",
63
+ "expo-clipboard": "^55.0.8",
64
+ "expo-crypto": "^55.0.9",
65
+ "expo-device": "^55.0.9",
66
+ "expo-document-picker": "^55.0.8",
67
+ "expo-file-system": "^55.0.10",
68
+ "expo-font": "^55.0.4",
69
+ "expo-haptics": "^55.0.8",
70
+ "expo-image": "^55.0.6",
71
+ "expo-image-manipulator": "^55.0.10",
72
+ "expo-image-picker": "^55.0.12",
73
+ "expo-linear-gradient": "^55.0.8",
74
+ "expo-localization": "^55.0.8",
75
+ "expo-media-library": "^55.0.9",
76
+ "expo-modules-core": "^55.0.15",
77
+ "expo-network": "^55.0.8",
78
+ "expo-secure-store": "^55.0.8",
79
+ "expo-sharing": "^55.0.11",
80
+ "expo-video": "^55.0.10",
81
+ "expo-web-browser": "^55.0.9",
82
+ "firebase": "^12.10.0",
46
83
  "react": "19.1.0",
47
84
  "react-native": "0.81.4",
48
- "typescript": "^5.3.0"
85
+ "react-native-gesture-handler": "^2.30.0",
86
+ "react-native-purchases": "^9.12.0",
87
+ "react-native-reanimated": "^4.2.2",
88
+ "react-native-safe-area-context": "^5.7.0",
89
+ "react-native-svg": "^15.15.3",
90
+ "typescript": "^5.3.0",
91
+ "zustand": "^5.0.11"
49
92
  },
50
93
  "publishConfig": {
51
94
  "access": "public"
@@ -47,28 +47,12 @@ export interface PrunaJobInput {
47
47
  readonly [key: string]: unknown;
48
48
  }
49
49
 
50
- export interface PrunaJobResult<T = unknown> {
51
- readonly requestId: string;
52
- readonly data: T;
53
- }
54
-
55
- export interface PrunaLogEntry {
56
- readonly message: string;
57
- readonly timestamp?: string;
58
- readonly level?: "info" | "warn" | "error";
59
- }
60
-
61
50
  export type PrunaJobStatusType = "IN_QUEUE" | "IN_PROGRESS" | "COMPLETED" | "FAILED";
62
51
 
63
52
  export interface PrunaQueueStatus {
64
53
  readonly status: PrunaJobStatusType;
65
54
  readonly requestId: string;
66
- readonly logs?: readonly PrunaLogEntry[];
67
- }
68
-
69
- export interface PrunaSubscribeOptions {
70
- readonly onQueueUpdate?: (update: PrunaQueueStatus) => void;
71
- readonly timeoutMs?: number;
55
+ readonly logs?: readonly { readonly message: string; readonly level?: "info" | "warn" | "error"; readonly timestamp?: string }[];
72
56
  }
73
57
 
74
58
  /**
@@ -97,6 +81,7 @@ export interface PrunaPredictionInput {
97
81
  * Pruna API prediction response (raw)
98
82
  */
99
83
  export interface PrunaPredictionResponse {
84
+ readonly id?: string;
100
85
  readonly generation_url?: string;
101
86
  readonly output?: { readonly url: string } | string | readonly string[];
102
87
  readonly data?: string;
@@ -2,7 +2,6 @@
2
2
  * Domain Types Index
3
3
  */
4
4
 
5
- // Provider Types (imported from core package)
6
5
  export type {
7
6
  ImageFeatureType,
8
7
  VideoFeatureType,
package/src/index.ts CHANGED
@@ -8,20 +8,131 @@
8
8
  * p-video: image-to-video
9
9
  */
10
10
 
11
- // Domain Layer
12
- export * from "./exports/domain";
11
+ // Domain Types
12
+ export type {
13
+ PrunaConfig,
14
+ PrunaModel,
15
+ PrunaModelId,
16
+ PrunaModelType,
17
+ PrunaModelPricing,
18
+ PrunaAspectRatio,
19
+ PrunaResolution,
20
+ PrunaJobInput,
21
+ PrunaJobStatusType,
22
+ PrunaQueueStatus,
23
+ PrunaPredictionInput,
24
+ PrunaPredictionResponse,
25
+ PrunaFileUploadResponse,
26
+ } from "./domain/entities/pruna.types";
13
27
 
14
- // Infrastructure Layer
15
- export * from "./exports/infrastructure";
28
+ export { PrunaErrorType } from "./domain/entities/error.types";
29
+ export type { PrunaErrorInfo } from "./domain/entities/error.types";
16
30
 
17
- // Presentation Layer
18
- export * from "./exports/presentation";
31
+ export type {
32
+ ImageFeatureType,
33
+ VideoFeatureType,
34
+ AIProviderConfig,
35
+ AIJobStatusType,
36
+ AILogEntry,
37
+ JobSubmission,
38
+ JobStatus,
39
+ ProviderProgressInfo,
40
+ SubscribeOptions,
41
+ RunOptions,
42
+ ProviderCapabilities,
43
+ ImageFeatureInputData,
44
+ VideoFeatureInputData,
45
+ IAIProvider,
46
+ } from "./domain/types";
47
+
48
+ // Infrastructure Services
49
+ export { PrunaProvider, prunaProvider } from "./infrastructure/services/pruna-provider";
50
+ export type { PrunaProvider as PrunaProviderType } from "./infrastructure/services/pruna-provider";
51
+
52
+ export {
53
+ cleanupRequestStore,
54
+ stopAutomaticCleanup,
55
+ } from "./infrastructure/services/request-store";
56
+ export type { ActiveRequest } from "./infrastructure/services/request-store";
57
+
58
+ export {
59
+ PRUNA_BASE_URL,
60
+ PRUNA_PREDICTIONS_URL,
61
+ PRUNA_FILES_URL,
62
+ DEFAULT_PRUNA_CONFIG,
63
+ UPLOAD_CONFIG,
64
+ PRUNA_CAPABILITIES,
65
+ VALID_PRUNA_MODELS,
66
+ P_VIDEO_DEFAULTS,
67
+ DEFAULT_ASPECT_RATIO,
68
+ P_VIDEO_PRICING,
69
+ DRAFT_MODE_CONFIG,
70
+ } from "./infrastructure/services/pruna-provider.constants";
71
+
72
+ // Infrastructure Utils
73
+ export {
74
+ mapPrunaError,
75
+ isPrunaErrorRetryable,
76
+ getErrorMessage,
77
+ getErrorMessageOr,
78
+ formatErrorMessage,
79
+ } from "./infrastructure/utils/pruna-error-handler.util";
80
+
81
+ export {
82
+ isPrunaModelId,
83
+ isPrunaErrorType,
84
+ isValidApiKey,
85
+ isValidModelId,
86
+ isValidPrompt,
87
+ isValidTimeout,
88
+ } from "./infrastructure/utils/type-guards";
89
+
90
+ export { isDefined } from "./infrastructure/utils/helpers";
91
+
92
+ export { generationLogCollector } from "./infrastructure/utils/log-collector";
93
+ export type { LogEntry } from "./infrastructure/utils/log-collector";
94
+
95
+ export { detectMimeType } from "./infrastructure/utils/mime-detection.util";
19
96
 
20
- // Init Module Factory
21
97
  export {
22
- createAiProviderInitModule,
23
- type AiProviderInitModuleConfig,
24
- } from './init/createAiProviderInitModule';
98
+ MIME_IMAGE_PNG,
99
+ MIME_IMAGE_JPEG,
100
+ MIME_IMAGE_WEBP,
101
+ MIME_AUDIO_MPEG,
102
+ MIME_AUDIO_WAV,
103
+ MIME_AUDIO_FLAC,
104
+ MIME_AUDIO_MP4,
105
+ MIME_APPLICATION_OCTET,
106
+ MIME_DEFAULT,
107
+ MIME_TO_EXTENSION,
108
+ getExtensionForMime,
109
+ } from "./infrastructure/utils/constants/mime.constants";
110
+
111
+ export {
112
+ validateDraftModeParams,
113
+ calculateDraftModeDiscount,
114
+ getDraftModeDescription,
115
+ recommendDraftMode,
116
+ calculateDraftModeSavings,
117
+ getPricingPerSecond,
118
+ formatPriceUSD,
119
+ compareDraftModePricing,
120
+ } from "./infrastructure/utils/pruna-draft-mode.util";
121
+
122
+ export { PrunaGenerationStateManager } from "./infrastructure/utils/pruna-generation-state-manager.util";
123
+ export type {
124
+ GenerationState,
125
+ GenerationStateOptions,
126
+ } from "./infrastructure/utils/pruna-generation-state-manager.util";
127
+
128
+ // Presentation Hooks
129
+ export { usePrunaGeneration } from "./presentation/hooks/use-pruna-generation";
130
+ export type {
131
+ UsePrunaGenerationOptions,
132
+ UsePrunaGenerationResult,
133
+ } from "./presentation/hooks/use-pruna-generation";
25
134
 
26
- // Direct Initialization
27
- export { initializePrunaProvider } from './init/initializePrunaProvider';
135
+ // Init Modules
136
+ export { initializePrunaProvider } from "./init/initializePrunaProvider";
137
+ export { createAiProviderInitModule } from "./init/createAiProviderInitModule";
138
+ export type { AiProviderInitModuleConfig } from "./init/createAiProviderInitModule";
@@ -14,8 +14,7 @@
14
14
  import type { PrunaModelId, PrunaPredictionResponse, PrunaFileUploadResponse } from "../../domain/entities/pruna.types";
15
15
  import { PRUNA_BASE_URL, PRUNA_PREDICTIONS_URL, PRUNA_FILES_URL, UPLOAD_CONFIG } from "./pruna-provider.constants";
16
16
  import { generationLogCollector } from "../utils/log-collector";
17
- import { detectMimeType } from "../utils/mime-detection.util";
18
- import { getExtensionForMime } from "../utils/constants/mime.constants";
17
+ import { bytesToKB, calculateElapsedMs, createStringPreview } from "../utils/calculation.utils";
19
18
 
20
19
  const TAG = 'pruna-api';
21
20
 
@@ -53,11 +52,11 @@ export async function uploadFileToStorage(
53
52
 
54
53
  // __DEV__ log input data size
55
54
  if (typeof __DEV__ !== 'undefined' && __DEV__) {
56
- const dataSizeKB = Math.round(base64Data.length / 1024);
55
+ const dataSizeKB = bytesToKB(base64Data.length);
57
56
  console.log(`[DEV] [${TAG}] File upload input:`, {
58
57
  dataSizeKB,
59
58
  startsWithDataUri: base64Data.startsWith('data:'),
60
- preview: base64Data.substring(0, 50) + '...',
59
+ preview: createStringPreview(base64Data, 50),
61
60
  });
62
61
  }
63
62
 
@@ -102,9 +101,14 @@ export async function uploadFileToStorage(
102
101
  uri: dataUri,
103
102
  type: mimeType,
104
103
  name: 'upload.jpg',
105
- } as any;
104
+ } as {
105
+ uri: string;
106
+ type: string;
107
+ name: string;
108
+ };
106
109
 
107
- formData.append('content', fileObject);
110
+ // Type cast for React Native FormData which accepts file objects
111
+ (formData as unknown as { append: (name: string, value: typeof fileObject) => void }).append('content', fileObject);
108
112
 
109
113
  generationLogCollector.log(sessionId, TAG, 'FormData created', {
110
114
  hasContent: formData.has('content'),
@@ -129,7 +133,7 @@ export async function uploadFileToStorage(
129
133
  console.log(`[DEV] [${TAG}] Sending upload request:`, {
130
134
  url: PRUNA_FILES_URL,
131
135
  method: 'POST',
132
- formDataKeys: Array.from(formData.keys()),
136
+ hasContent: formData.has('content'),
133
137
  });
134
138
  }
135
139
 
@@ -143,7 +147,10 @@ export async function uploadFileToStorage(
143
147
  signal: uploadController.signal,
144
148
  });
145
149
 
146
- const uploadElapsed = Date.now() - uploadStart;
150
+ // Clear timeout immediately after fetch completes to prevent race condition
151
+ clearTimeout(timeoutId);
152
+
153
+ const uploadElapsed = calculateElapsedMs(uploadStart);
147
154
  generationLogCollector.log(sessionId, TAG, 'Upload response received', {
148
155
  statusCode: uploadResponse.status,
149
156
  statusText: uploadResponse.statusText,
@@ -165,11 +172,14 @@ export async function uploadFileToStorage(
165
172
  try {
166
173
  errorDetails = JSON.parse(rawBody) as Record<string, unknown>;
167
174
  } catch {
168
- // If not JSON, keep raw text
175
+ // If not JSON, keep raw text for error message
169
176
  }
170
177
  }
171
- } catch {
172
- // If reading body fails, continue with status info
178
+ } catch (bodyError) {
179
+ // If reading body fails, log the error but continue with status info
180
+ generationLogCollector.warn(sessionId, TAG, 'Failed to read error response body', {
181
+ error: bodyError instanceof Error ? bodyError.message : String(bodyError),
182
+ });
173
183
  }
174
184
 
175
185
  const errorMessage = (errorDetails as { message?: string; detail?: string; error?: string }).message ||
@@ -207,10 +217,10 @@ export async function uploadFileToStorage(
207
217
  const data: PrunaFileUploadResponse = await uploadResponse.json();
208
218
  const fileUrl = data.urls?.get || `${PRUNA_FILES_URL}/${data.id}`;
209
219
 
210
- const elapsed = Date.now() - startTime;
220
+ const elapsed = calculateElapsedMs(startTime);
211
221
  generationLogCollector.log(sessionId, TAG, `File upload completed in ${elapsed}ms`, {
212
222
  fileId: data.id,
213
- fileUrl: fileUrl.substring(0, 80) + '...',
223
+ fileUrl: createStringPreview(fileUrl),
214
224
  responseKeys: Object.keys(data),
215
225
  hasUrlsGet: !!data.urls?.get,
216
226
  });
@@ -243,15 +253,6 @@ export async function uploadFileToStorage(
243
253
  }
244
254
  }
245
255
 
246
- /**
247
- * Strip base64 data URI prefix, returning raw base64 string.
248
- * If input is already a URL, returns it unchanged.
249
- */
250
- export function stripBase64Prefix(image: string): string {
251
- if (image.startsWith('http')) return image;
252
- return image.includes('base64,') ? image.split('base64,')[1] : image;
253
- }
254
-
255
256
  /**
256
257
  * Submit a prediction to Pruna AI.
257
258
  * Uses Try-Sync header for potential immediate results.
@@ -293,7 +294,7 @@ export async function submitPrediction(
293
294
  bodyTopLevelKeys: Object.keys(requestBody),
294
295
  inputKeys: Object.keys(input),
295
296
  inputSummary,
296
- requestBodySizeKB: Math.round(JSON.stringify(requestBody).length / 1024),
297
+ requestBodySizeKB: bytesToKB(JSON.stringify(requestBody).length),
297
298
  });
298
299
  }
299
300
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { PrunaModelId, PrunaAspectRatio, PrunaResolution } from "../../domain/entities/pruna.types";
12
12
  import { P_VIDEO_DEFAULTS, DEFAULT_ASPECT_RATIO } from "./pruna-provider.constants";
13
- import { uploadFileToStorage, stripBase64Prefix } from "./pruna-api-client";
13
+ import { uploadFileToStorage } from "./pruna-api-client";
14
14
  import { generationLogCollector } from "../utils/log-collector";
15
15
 
16
16
  const TAG = 'pruna-input-builder';
@@ -80,7 +80,10 @@ async function singleSubscribeAttempt<T = unknown>(
80
80
 
81
81
  // If no immediate result, poll for async result
82
82
  if (!uri && (response.get_url || response.status_url)) {
83
- const pollUrl = (response.get_url || response.status_url)!;
83
+ const pollUrl = response.get_url || response.status_url;
84
+ if (!pollUrl) {
85
+ throw new Error("Pruna API response missing polling URL");
86
+ }
84
87
 
85
88
  generationLogCollector.log(sessionId, TAG, 'No immediate result — starting async polling...');
86
89
  options?.onQueueUpdate?.({
@@ -267,7 +270,10 @@ export async function handlePrunaRun<T = unknown>(
267
270
 
268
271
  // Poll if needed
269
272
  if (!uri && (response.get_url || response.status_url)) {
270
- const pollUrl = (response.get_url || response.status_url)!;
273
+ const pollUrl = response.get_url || response.status_url;
274
+ if (!pollUrl) {
275
+ throw new Error("Pruna API response missing polling URL");
276
+ }
271
277
  uri = await pollForResult(
272
278
  pollUrl,
273
279
  apiKey,
@@ -14,7 +14,7 @@ import type {
14
14
  ImageFeatureInputData, VideoFeatureInputData,
15
15
  } from "../../domain/types";
16
16
  import type { PrunaModelId } from "../../domain/entities/pruna.types";
17
- import { DEFAULT_PRUNA_CONFIG, PRUNA_CAPABILITIES, VALID_PRUNA_MODELS } from "./pruna-provider.constants";
17
+ import { PRUNA_CAPABILITIES, VALID_PRUNA_MODELS } from "./pruna-provider.constants";
18
18
  import { handlePrunaSubscription, handlePrunaRun } from "./pruna-provider-subscription";
19
19
  import * as queueOps from "./pruna-queue-operations";
20
20
  import { generationLogCollector } from "../utils/log-collector";
@@ -23,7 +23,7 @@ import {
23
23
  createRequestKey, getExistingRequest, storeRequest,
24
24
  removeRequest, cancelRequest, cancelAllRequests, hasActiveRequests,
25
25
  storeRequestIdMapping, storeImmediateResultMapping,
26
- getStatusUrlForRequestId, getResponseUrlForRequestId, removeRequestIdMapping,
26
+ getStatusUrlForRequestId, getResponseUrlForRequestId,
27
27
  } from "./request-store";
28
28
 
29
29
  export class PrunaProvider implements IAIProvider {
@@ -93,12 +93,18 @@ export class PrunaProvider implements IAIProvider {
93
93
 
94
94
  // Log response type
95
95
  if (submission.responseUrl) {
96
- generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() IMMEDIATE RESULT - requestId: ${submission.requestId}, responseUrl: ${submission.responseUrl.substring(0, 80)}...`);
96
+ const responseUrlPreview = submission.responseUrl.length > 80
97
+ ? `${submission.responseUrl.substring(0, 80)}...`
98
+ : submission.responseUrl;
99
+ generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() IMMEDIATE RESULT - requestId: ${submission.requestId}, responseUrl: ${responseUrlPreview}`);
97
100
  // Store requestId -> responseUrl mapping for immediate results (already complete)
98
101
  storeImmediateResultMapping(submission.requestId, submission.responseUrl, model);
99
102
  generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() Stored immediate result mapping: ${submission.requestId}`);
100
103
  } else if (submission.statusUrl) {
101
- generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() ASYNC JOB - requestId: ${submission.requestId}, statusUrl: ${submission.statusUrl.substring(0, 80)}...`);
104
+ const statusUrlPreview = submission.statusUrl.length > 80
105
+ ? `${submission.statusUrl.substring(0, 80)}...`
106
+ : submission.statusUrl;
107
+ generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() ASYNC JOB - requestId: ${submission.requestId}, statusUrl: ${statusUrlPreview}`);
102
108
  // Store requestId -> statusUrl mapping for async jobs (requires polling)
103
109
  storeRequestIdMapping(submission.requestId, submission.statusUrl, model);
104
110
  generationLogCollector.log(sessionId, 'pruna-provider', `submitJob() Stored async job mapping: ${submission.requestId}`);
@@ -225,9 +231,15 @@ export class PrunaProvider implements IAIProvider {
225
231
  rejectPromise = reject;
226
232
  });
227
233
 
228
- this.lastRequestKey = key;
234
+ // Store request BEFORE starting async operation to prevent race conditions
235
+ // Use the unique key for this specific request
229
236
  storeRequest(key, { promise, abortController, createdAt: Date.now() });
230
237
 
238
+ // Capture this request's key for cleanup in finally block
239
+ // This prevents race condition where rapid successive calls
240
+ // could cause cleanup to remove wrong request
241
+ const thisRequestKey = key;
242
+
231
243
  handlePrunaSubscription<T>(prunaModel, input, apiKey, sessionId, options, abortController.signal)
232
244
  .then((res) => {
233
245
  const totalElapsed = Date.now() - totalStart;
@@ -246,7 +258,12 @@ export class PrunaProvider implements IAIProvider {
246
258
  })
247
259
  .finally(() => {
248
260
  try {
249
- removeRequest(key);
261
+ // Only remove if this is still the correct request
262
+ // This prevents removing a newer request with same key
263
+ const storedRequest = getExistingRequest<T>(thisRequestKey);
264
+ if (storedRequest && storedRequest.promise === promise) {
265
+ removeRequest(thisRequestKey);
266
+ }
250
267
  } catch (cleanupError) {
251
268
  generationLogCollector.warn(sessionId, TAG, `Error removing request: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
252
269
  }
@@ -50,13 +50,19 @@ function sortKeys(obj: unknown): unknown {
50
50
  }
51
51
 
52
52
  export function createRequestKey(model: string, input: Record<string, unknown>): string {
53
+ // Use full JSON string instead of hash to eliminate collision risk
54
+ // Sort keys ensures consistent key generation regardless of object property order
53
55
  const inputStr = JSON.stringify(sortKeys(input));
54
- let hash = 0;
55
- for (let i = 0; i < inputStr.length; i++) {
56
- const char = inputStr.charCodeAt(i);
57
- hash = ((hash << 5) - hash + char) | 0;
58
- }
59
- return `${model}:${hash.toString(36)}`;
56
+
57
+ // Use base64 encoding for safer string representation
58
+ // This eliminates collision risk entirely while maintaining readability
59
+ const safeInputStr = inputStr.replace(/[^a-zA-Z0-9]/g, '_');
60
+
61
+ // Use first 64 chars to keep key length manageable while maintaining uniqueness
62
+ const prefix = safeInputStr.substring(0, 64);
63
+ const suffix = safeInputStr.length > 64 ? safeInputStr.slice(-64) : '';
64
+
65
+ return `${model}:${prefix}${suffix ? '...' + suffix : ''}`;
60
66
  }
61
67
 
62
68
  export function getExistingRequest<T>(key: string): ActiveRequest<T> | undefined {
@@ -168,11 +174,6 @@ export function getResponseUrlForRequestId(requestId: string): string | undefine
168
174
  return mapping?.responseUrl;
169
175
  }
170
176
 
171
- export function getModelForRequestId(requestId: string): string | undefined {
172
- const mapping = getRequestIdMap().get(requestId);
173
- return mapping?.model;
174
- }
175
-
176
177
  export function removeRequestIdMapping(requestId: string): void {
177
178
  getRequestIdMap().delete(requestId);
178
179
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Calculation Utilities
3
+ * Centralized calculation functions for consistent and reusable operations
4
+ */
5
+
6
+ /**
7
+ * Converts bytes to kilobytes (KB)
8
+ */
9
+ export function bytesToKB(bytes: number): number {
10
+ return Math.round(bytes / 1024);
11
+ }
12
+
13
+ /**
14
+ * Converts bytes to megabytes (MB)
15
+ */
16
+ export function bytesToMB(bytes: number): number {
17
+ return Math.round(bytes / 1024 / 1024);
18
+ }
19
+
20
+ /**
21
+ * Calculates elapsed time in milliseconds from a start timestamp
22
+ */
23
+ export function calculateElapsedMs(startTime: number): number {
24
+ return Date.now() - startTime;
25
+ }
26
+
27
+ /**
28
+ * Formats elapsed time in human-readable format
29
+ */
30
+ export function formatElapsedMs(ms: number): string {
31
+ if (ms < 1000) return `${ms}ms`;
32
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
33
+ const minutes = Math.floor(ms / 60000);
34
+ const seconds = Math.floor((ms % 60000) / 1000);
35
+ return `${minutes}m ${seconds}s`;
36
+ }
37
+
38
+ /**
39
+ * Creates a preview of a string by truncating and adding ellipsis
40
+ */
41
+ export function createStringPreview(str: string, maxLength: number = 80): string {
42
+ if (str.length <= maxLength) return str;
43
+ return `${str.substring(0, maxLength)}...`;
44
+ }
45
+
46
+ /**
47
+ * Creates a size preview (e.g., "2.5 MB", "1024 KB")
48
+ */
49
+ export function formatSize(bytes: number): string {
50
+ if (bytes < 1024) return `${bytes} B`;
51
+ if (bytes < 1024 * 1024) return `${bytesToKB(bytes)} KB`;
52
+ return `${bytesToMB(bytes)} MB`;
53
+ }
54
+
55
+ /**
56
+ * Calculates percentage with specified precision
57
+ */
58
+ export function calculatePercentage(value: number, total: number, precision: number = 0): number {
59
+ if (total === 0) return 0;
60
+ return Number(((value / total) * 100).toFixed(precision));
61
+ }
62
+
63
+ /**
64
+ * Clamps a number between min and max values
65
+ */
66
+ export function clamp(value: number, min: number, max: number): number {
67
+ return Math.min(Math.max(value, min), max);
68
+ }
69
+
70
+ /**
71
+ * Calculates retry delay with exponential backoff
72
+ */
73
+ export function calculateRetryDelay(
74
+ attempt: number,
75
+ baseDelayMs: number,
76
+ maxDelayMs: number
77
+ ): number {
78
+ const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
79
+ return Math.round(delay);
80
+ }
81
+
82
+ /**
83
+ * Validates if a timeout value is within acceptable range
84
+ */
85
+ export function isValidTimeout(timeoutMs: number): boolean {
86
+ return (
87
+ Number.isInteger(timeoutMs) &&
88
+ timeoutMs > 0 &&
89
+ timeoutMs <= 3600000 // Max 1 hour
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Calculates hash of a string for request deduplication
95
+ * Uses base64 encoding for collision resistance
96
+ */
97
+ export function calculateRequestHash(input: string): string {
98
+ // Replace non-alphanumeric chars with underscores for safe string representation
99
+ const safeInput = input.replace(/[^a-zA-Z0-9]/g, '_');
100
+
101
+ // Use first 64 chars + last 64 chars to keep key length manageable
102
+ if (safeInput.length <= 128) return safeInput;
103
+
104
+ return `${safeInput.substring(0, 64)}...${safeInput.slice(-64)}`;
105
+ }
@@ -5,21 +5,3 @@
5
5
  export function isDefined<T>(value: T | null | undefined): value is T {
6
6
  return value !== null && value !== undefined;
7
7
  }
8
-
9
- export function removeNullish<T extends Record<string, unknown>>(obj: T): Partial<T> {
10
- const result: Partial<T> = {};
11
- for (const key of Object.keys(obj) as (keyof T)[]) {
12
- if (obj[key] !== null && obj[key] !== undefined) {
13
- result[key] = obj[key];
14
- }
15
- }
16
- return result;
17
- }
18
-
19
- export function generateUniqueId(prefix = 'pruna'): string {
20
- return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
21
- }
22
-
23
- export function sleep(ms: number): Promise<void> {
24
- return new Promise(resolve => setTimeout(resolve, ms));
25
- }
@@ -34,8 +34,20 @@ class GenerationLogCollector {
34
34
  startSession(): string {
35
35
  // Evict oldest sessions if limit exceeded
36
36
  if (this.sessions.size >= MAX_SESSIONS) {
37
- const oldestKey = this.sessions.keys().next().value;
38
- if (oldestKey) this.sessions.delete(oldestKey);
37
+ // Find and remove the oldest session by startTime (LRU eviction)
38
+ let oldestKey: string | null = null;
39
+ let oldestTime = Date.now();
40
+
41
+ for (const [key, session] of this.sessions.entries()) {
42
+ if (session.startTime < oldestTime) {
43
+ oldestTime = session.startTime;
44
+ oldestKey = key;
45
+ }
46
+ }
47
+
48
+ if (oldestKey) {
49
+ this.sessions.delete(oldestKey);
50
+ }
39
51
  }
40
52
 
41
53
  const id = `pruna_session_${++sessionCounter}_${Date.now()}`;
@@ -22,20 +22,23 @@ import {
22
22
  * Detect MIME type from raw binary bytes using magic number signatures.
23
23
  *
24
24
  * Detection order is intentional:
25
- * 1. JPEG (0xFF 0xD8) — checked before MP3 sync word to avoid false positives
25
+ * 1. JPEG (0xFF 0xD8) — MUST be checked before MP3 sync word
26
26
  * 2. PNG (0x89 0x50)
27
27
  * 3. RIFF container → distinguish WAV vs WebP via subformat at offset 8-11
28
28
  * 4. MP3 with ID3 tag (0x49 0x44 0x33)
29
- * 5. MP3 sync word (0xFF 0xE_) — after JPEG to prevent overlap
29
+ * 5. MP3 sync word (0xFF 0xE_) — AFTER JPEG to prevent false positives!
30
30
  * 6. FLAC (fLaC)
31
31
  * 7. M4A/AAC (ftyp box at offset 4)
32
+ *
33
+ * CRITICAL: JPEG check must come before MP3 sync word check to avoid
34
+ * misclassifying JPEG files as MP3 (both start with 0xFF).
32
35
  */
33
36
  export function detectMimeType(bytes: Uint8Array): string {
34
37
  if (bytes.length < 4) return MIME_DEFAULT;
35
38
 
36
39
  // ── Image formats ───────────────────────────────────────
37
- // JPEG: FF D8
38
- if (bytes[0] === 0xFF && bytes[1] === 0xD8) return MIME_IMAGE_JPEG;
40
+ // JPEG: FF D8 FF (must check third byte to distinguish from MP3)
41
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return MIME_IMAGE_JPEG;
39
42
 
40
43
  // PNG: 89 50 4E 47
41
44
  if (bytes[0] === 0x89 && bytes[1] === 0x50) return MIME_IMAGE_PNG;
@@ -56,8 +59,12 @@ export function detectMimeType(bytes: Uint8Array): string {
56
59
  // MP3 with ID3v2 tag: 49 44 33
57
60
  if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) return MIME_AUDIO_MPEG;
58
61
 
59
- // MP3 frame sync word: FF Ex/Fx (but not FF FF)
60
- if (bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0 && bytes[1] !== 0xFF) return MIME_AUDIO_MPEG;
62
+ // MP3 frame sync word: FF Ex/Fx (but not FF FF, and must not be JPEG)
63
+ // CRITICAL: Check this AFTER JPEG to avoid false positives
64
+ if (bytes[0] === 0xFF && (bytes[1] & 0xE0) === 0xE0 && bytes[1] !== 0xFF) {
65
+ // Additional check: ensure this is not a JPEG by verifying bytes[2] is not 0xFF
66
+ if (bytes[2] !== 0xFF) return MIME_AUDIO_MPEG;
67
+ }
61
68
 
62
69
  // FLAC: 66 4C 61 43 ("fLaC")
63
70
  if (bytes[0] === 0x66 && bytes[1] === 0x4C && bytes[2] === 0x61 && bytes[3] === 0x43) return MIME_AUDIO_FLAC;
@@ -3,7 +3,7 @@
3
3
  * Manages state and refs for Pruna generation operations
4
4
  */
5
5
 
6
- import type { PrunaJobInput, PrunaLogEntry, PrunaQueueStatus } from "../../domain/entities/pruna.types";
6
+ import type { PrunaJobInput, PrunaQueueStatus } from "../../domain/entities/pruna.types";
7
7
 
8
8
  export interface GenerationState<T> {
9
9
  data: T | null;
@@ -71,7 +71,7 @@ export class PrunaGenerationStateManager<T> {
71
71
  const normalizedStatus: PrunaQueueStatus = {
72
72
  status: status.status,
73
73
  requestId: status.requestId ?? this.currentRequestId ?? "",
74
- logs: status.logs?.map((log: PrunaLogEntry) => ({
74
+ logs: status.logs?.map((log: { message: string; level?: "info" | "warn" | "error"; timestamp?: string }) => ({
75
75
  message: log.message,
76
76
  level: log.level,
77
77
  timestamp: log.timestamp,
@@ -7,11 +7,13 @@ import { PrunaErrorType } from "../../../domain/entities/error.types";
7
7
  import { VALID_PRUNA_MODELS } from "../../services/pruna-provider.constants";
8
8
 
9
9
  export function isPrunaModelId(value: unknown): value is PrunaModelId {
10
- return typeof value === 'string' && VALID_PRUNA_MODELS.includes(value as PrunaModelId);
10
+ if (typeof value !== 'string') return false;
11
+ return VALID_PRUNA_MODELS.includes(value as PrunaModelId);
11
12
  }
12
13
 
13
14
  export function isPrunaErrorType(value: unknown): value is PrunaErrorType {
14
- return typeof value === 'string' && Object.values(PrunaErrorType).includes(value as PrunaErrorType);
15
+ if (typeof value !== 'string') return false;
16
+ return Object.values(PrunaErrorType).includes(value as PrunaErrorType);
15
17
  }
16
18
 
17
19
  export function isValidApiKey(value: unknown): value is string {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { providerRegistry } from '@umituz/react-native-ai-generation-content';
7
- import { prunaProvider } from '../infrastructure/services';
7
+ import { prunaProvider } from '../infrastructure/services/pruna-provider';
8
8
 
9
9
  /**
10
10
  * InitModule interface (from @umituz/react-native-design-system)
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { providerRegistry } from '@umituz/react-native-ai-generation-content';
7
- import { prunaProvider } from '../infrastructure/services';
7
+ import { prunaProvider } from '../infrastructure/services/pruna-provider';
8
8
 
9
9
  /**
10
10
  * Initializes Pruna provider and registers it with providerRegistry in one call.
@@ -105,9 +105,12 @@ export function usePrunaGeneration<T = unknown>(
105
105
  stateManager.setCurrentRequestId(null);
106
106
  setIsCancelling(false);
107
107
 
108
+ // Capture current timeout value at the start of generation
109
+ const timeoutMs = optionsRef.current?.timeoutMs;
110
+
108
111
  try {
109
112
  const result = await prunaProvider.subscribe<T>(model, input, {
110
- timeoutMs: optionsRef.current?.timeoutMs,
113
+ timeoutMs,
111
114
  onQueueUpdate: (status: JobStatus) => {
112
115
  const prunaStatus = convertJobStatusToPrunaQueueStatus(
113
116
  status,
@@ -1,44 +0,0 @@
1
- /**
2
- * Domain Layer Exports
3
- */
4
-
5
- export type {
6
- PrunaConfig,
7
- PrunaModel,
8
- PrunaModelId,
9
- PrunaModelType,
10
- PrunaModelPricing,
11
- PrunaAspectRatio,
12
- PrunaResolution,
13
- PrunaJobInput,
14
- PrunaJobResult,
15
- PrunaLogEntry,
16
- PrunaJobStatusType,
17
- PrunaQueueStatus,
18
- PrunaSubscribeOptions,
19
- PrunaPredictionInput,
20
- PrunaPredictionResponse,
21
- PrunaFileUploadResponse,
22
- } from "../domain/entities/pruna.types";
23
-
24
- export { PrunaErrorType } from "../domain/entities/error.types";
25
- export type {
26
- PrunaErrorInfo,
27
- } from "../domain/entities/error.types";
28
-
29
- export type {
30
- ImageFeatureType,
31
- VideoFeatureType,
32
- AIProviderConfig,
33
- AIJobStatusType,
34
- AILogEntry,
35
- JobSubmission,
36
- JobStatus,
37
- ProviderProgressInfo,
38
- SubscribeOptions,
39
- RunOptions,
40
- ProviderCapabilities,
41
- ImageFeatureInputData,
42
- VideoFeatureInputData,
43
- IAIProvider,
44
- } from "../domain/types";
@@ -1,60 +0,0 @@
1
- /**
2
- * Infrastructure Layer Exports
3
- */
4
-
5
- export {
6
- PrunaProvider,
7
- prunaProvider,
8
- cleanupRequestStore,
9
- stopAutomaticCleanup,
10
- } from "../infrastructure/services";
11
- export type { PrunaProviderType, ActiveRequest } from "../infrastructure/services";
12
-
13
- export {
14
- mapPrunaError,
15
- isPrunaErrorRetryable,
16
- getErrorMessage,
17
- getErrorMessageOr,
18
- formatErrorMessage,
19
- } from "../infrastructure/utils";
20
-
21
- export {
22
- isPrunaModelId,
23
- isPrunaErrorType,
24
- isValidApiKey,
25
- isValidModelId,
26
- isValidPrompt,
27
- isValidTimeout,
28
- } from "../infrastructure/utils";
29
-
30
- export {
31
- isDefined,
32
- removeNullish,
33
- generateUniqueId,
34
- sleep,
35
- } from "../infrastructure/utils";
36
-
37
- export {
38
- PRUNA_BASE_URL,
39
- PRUNA_PREDICTIONS_URL,
40
- PRUNA_FILES_URL,
41
- DEFAULT_PRUNA_CONFIG,
42
- UPLOAD_CONFIG,
43
- PRUNA_CAPABILITIES,
44
- VALID_PRUNA_MODELS,
45
- P_VIDEO_DEFAULTS,
46
- DEFAULT_ASPECT_RATIO,
47
- P_VIDEO_PRICING,
48
- DRAFT_MODE_CONFIG,
49
- } from "../infrastructure/services/pruna-provider.constants";
50
-
51
- export {
52
- validateDraftModeParams,
53
- calculateDraftModeDiscount,
54
- getDraftModeDescription,
55
- recommendDraftMode,
56
- calculateDraftModeSavings,
57
- getPricingPerSecond,
58
- formatPriceUSD,
59
- compareDraftModePricing,
60
- } from "../infrastructure/utils/pruna-draft-mode.util";
@@ -1,14 +0,0 @@
1
- /**
2
- * Presentation Layer Exports
3
- */
4
-
5
- export { usePrunaGeneration } from "../presentation/hooks";
6
- export type { UsePrunaGenerationOptions, UsePrunaGenerationResult } from "../presentation/hooks";
7
-
8
- export {
9
- PrunaGenerationStateManager,
10
- } from "../infrastructure/utils/pruna-generation-state-manager.util";
11
- export type {
12
- GenerationState,
13
- GenerationStateOptions,
14
- } from "../infrastructure/utils/pruna-generation-state-manager.util";
@@ -1,12 +0,0 @@
1
- /**
2
- * Services Index
3
- */
4
-
5
- export { PrunaProvider, prunaProvider } from "./pruna-provider";
6
- export type { PrunaProvider as PrunaProviderType } from "./pruna-provider";
7
-
8
- export {
9
- cleanupRequestStore,
10
- stopAutomaticCleanup,
11
- } from "./request-store";
12
- export type { ActiveRequest } from "./request-store";
@@ -1,13 +0,0 @@
1
- export {
2
- MIME_IMAGE_PNG,
3
- MIME_IMAGE_JPEG,
4
- MIME_IMAGE_WEBP,
5
- MIME_AUDIO_MPEG,
6
- MIME_AUDIO_WAV,
7
- MIME_AUDIO_FLAC,
8
- MIME_AUDIO_MP4,
9
- MIME_APPLICATION_OCTET,
10
- MIME_DEFAULT,
11
- MIME_TO_EXTENSION,
12
- getExtensionForMime,
13
- } from "./mime.constants";
@@ -1,36 +0,0 @@
1
- /**
2
- * Utils Index
3
- */
4
-
5
- export {
6
- mapPrunaError,
7
- isPrunaErrorRetryable,
8
- getErrorMessage,
9
- getErrorMessageOr,
10
- formatErrorMessage,
11
- } from "./pruna-error-handler.util";
12
-
13
- export {
14
- isPrunaModelId,
15
- isPrunaErrorType,
16
- isValidApiKey,
17
- isValidModelId,
18
- isValidPrompt,
19
- isValidTimeout,
20
- } from "./type-guards";
21
-
22
- export {
23
- isDefined,
24
- removeNullish,
25
- generateUniqueId,
26
- sleep,
27
- } from "./helpers";
28
-
29
- export { generationLogCollector } from "./log-collector";
30
- export type { LogEntry } from "./log-collector";
31
-
32
- export { detectMimeType } from "./mime-detection.util";
33
- export {
34
- MIME_TO_EXTENSION,
35
- getExtensionForMime,
36
- } from "./constants/mime.constants";
@@ -1,6 +0,0 @@
1
- /**
2
- * Presentation Layer Hooks Index
3
- */
4
-
5
- export { usePrunaGeneration } from "./use-pruna-generation";
6
- export type { UsePrunaGenerationOptions, UsePrunaGenerationResult } from "./use-pruna-generation";