@umituz/react-native-ai-generation-content 1.84.12 → 1.84.14

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/domains/creations/domain/entities/Creation.ts +1 -1
  3. package/src/domains/creations/presentation/components/GalleryResultPreview.tsx +1 -1
  4. package/src/domains/creations/presentation/screens/creations-gallery.types.ts +1 -1
  5. package/src/domains/generation/index.ts +5 -0
  6. package/src/domains/generation/wizard/index.ts +5 -0
  7. package/src/domains/generation/wizard/infrastructure/strategies/audio-generation.executor.ts +113 -0
  8. package/src/domains/generation/wizard/infrastructure/strategies/audio-generation.strategy.ts +97 -0
  9. package/src/domains/generation/wizard/infrastructure/strategies/audio-generation.types.ts +26 -0
  10. package/src/domains/generation/wizard/infrastructure/strategies/image-generation.executor.ts +1 -4
  11. package/src/domains/generation/wizard/infrastructure/strategies/image-generation.strategy.ts +3 -0
  12. package/src/domains/generation/wizard/infrastructure/strategies/video-generation.strategy.ts +1 -3
  13. package/src/domains/generation/wizard/infrastructure/strategies/video-generation.types.ts +8 -0
  14. package/src/domains/generation/wizard/infrastructure/strategies/wizard-strategy.factory.ts +19 -2
  15. package/src/domains/generation/wizard/infrastructure/strategies/wizard-strategy.types.ts +1 -1
  16. package/src/domains/generation/wizard/infrastructure/utils/creation-persistence.types.ts +1 -0
  17. package/src/domains/generation/wizard/infrastructure/utils/creation-update-operations.ts +5 -2
  18. package/src/domains/generation/wizard/presentation/components/WizardStepRenderer.tsx +1 -1
  19. package/src/domains/generation/wizard/presentation/components/WizardStepRenderer.utils.ts +2 -1
  20. package/src/domains/generation/wizard/presentation/components/step-renderers/renderAudioPickerStep.tsx +1 -1
  21. package/src/domains/generation/wizard/presentation/hooks/usePhotoBlockingGeneration.ts +15 -7
  22. package/src/domains/generation/wizard/presentation/hooks/wizard-generation.types.ts +1 -1
  23. package/src/domains/generation/wizard/presentation/screens/AudioPickerScreen.tsx +5 -2
  24. package/src/domains/result-preview/presentation/components/ResultPreviewScreen.tsx +4 -4
  25. package/src/domains/result-preview/presentation/types/result-components.types.ts +1 -1
  26. package/src/domains/result-preview/presentation/types/result-screen.types.ts +2 -2
  27. package/src/domains/scenarios/domain/Scenario.ts +1 -1
  28. package/src/exports/presentation.ts +2 -1
  29. package/src/index.ts +4 -1
  30. package/src/infrastructure/providers/generation-services.provider.tsx +1 -1
  31. package/src/infrastructure/utils/couple-input.util.ts +8 -4
  32. package/src/infrastructure/utils/image-input-preprocessor.util.ts +87 -0
  33. package/src/infrastructure/utils/intensity.util.ts +5 -2
  34. package/src/presentation/hooks/generation/index.ts +8 -0
  35. package/src/presentation/hooks/generation/useAudioGenerationExecutor.ts +149 -0
  36. package/src/presentation/hooks/generation/useImageGenerationExecutor.ts +5 -2
  37. package/src/presentation/hooks/index.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-generation-content",
3
- "version": "1.84.12",
3
+ "version": "1.84.14",
4
4
  "description": "Provider-agnostic AI generation orchestration for React Native with result preview components",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -88,7 +88,7 @@ export function mapDocumentToCreation(
88
88
  ): Creation {
89
89
  const creationDate = toDate(data.createdAt) ?? new Date();
90
90
 
91
- const uri = data.output?.imageUrl || data.output?.videoUrl || data.uri || "";
91
+ const uri = data.output?.imageUrl || data.output?.videoUrl || data.output?.audioUrl || data.uri || "";
92
92
 
93
93
  return {
94
94
  id,
@@ -56,7 +56,7 @@ export function GalleryResultPreview({
56
56
  onSaveError: () => alert.show(AlertType.ERROR, AlertMode.TOAST, t("common.error"), t("result.saveError")),
57
57
  });
58
58
 
59
- const hasRating = selectedCreation.rating !== undefined && selectedCreation.rating !== null;
59
+ const hasRating = selectedCreation.rating !== undefined;
60
60
 
61
61
  return (
62
62
  <>
@@ -22,7 +22,7 @@ export interface CreationsGalleryScreenProps {
22
22
  /** Function to get dynamic title from creation metadata */
23
23
  readonly getCreationTitle?: (creation: { type: string; metadata?: Record<string, unknown> }) => string;
24
24
  /** Custom handler when a creation card is pressed. When provided, overrides the built-in preview. */
25
- readonly onCreationPress?: (creation: { id: string; uri: string; type: string; originalUri?: string; output?: { imageUrl?: string; videoUrl?: string }; metadata?: Record<string, unknown> }) => void;
25
+ readonly onCreationPress?: (creation: { id: string; uri: string; type: string; originalUri?: string; output?: { imageUrl?: string; videoUrl?: string; audioUrl?: string }; metadata?: Record<string, unknown> }) => void;
26
26
  /** Called when the user taps the Edit button in the creation detail view. Receives the image URL. Only shown for image creations. */
27
27
  readonly onEdit?: (imageUrl: string) => void;
28
28
  /** Called when the user taps the Edit button in the creation detail view. Receives the video URL. Only shown for video creations. */
@@ -54,6 +54,7 @@ export type {
54
54
  UseWizardGenerationReturn,
55
55
  WizardScenarioData,
56
56
  WizardOutputType,
57
+ WizardAudioInput,
57
58
  GenericWizardFlowProps,
58
59
  TextInputScreenTranslations,
59
60
  TextInputScreenConfig,
@@ -75,6 +76,10 @@ export {
75
76
  TEXT_TO_IMAGE_WIZARD_CONFIG,
76
77
  TEXT_TO_VIDEO_WIZARD_CONFIG,
77
78
  IMAGE_TO_VIDEO_WIZARD_CONFIG,
79
+ // Audio generation
80
+ buildAudioInput,
81
+ createAudioStrategy,
82
+ executeAudioGeneration,
78
83
  } from "./wizard";
79
84
 
80
85
  // Flow Infrastructure
@@ -80,6 +80,11 @@ export type {
80
80
  AudioPickerScreenProps,
81
81
  } from "./presentation/screens";
82
82
 
83
+ // Audio Generation Types
84
+ export type { WizardAudioInput } from "./infrastructure/strategies/audio-generation.types";
85
+ export { buildAudioInput, createAudioStrategy } from "./infrastructure/strategies/audio-generation.strategy";
86
+ export { executeAudioGeneration } from "./infrastructure/strategies/audio-generation.executor";
87
+
83
88
  // Feature Configs
84
89
  export {
85
90
  TEXT_TO_IMAGE_WIZARD_CONFIG,
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Audio Generation Executor
3
+ * Handles TTS generation via the AI provider (model-agnostic).
4
+ * The model endpoint (e.g., Chatterbox) is configured in the main app.
5
+ */
6
+
7
+ import type { WizardAudioInput } from "./audio-generation.types";
8
+ import { addGenerationLogs, addGenerationLog, startGenerationLogSession } from "../../../../../infrastructure/services/generation-log-store";
9
+
10
+ /** Audio generation timeout — TTS is typically fast (10-30s) */
11
+ const AUDIO_GENERATION_TIMEOUT_MS = 60_000;
12
+
13
+ interface AudioExecutionResult {
14
+ success: boolean;
15
+ audioUrl?: string;
16
+ error?: string;
17
+ logSessionId?: string;
18
+ }
19
+
20
+ export async function executeAudioGeneration(
21
+ input: WizardAudioInput,
22
+ model: string,
23
+ onProgress?: (progress: number) => void,
24
+ providerId?: string,
25
+ ): Promise<AudioExecutionResult> {
26
+ const TAG = "AudioExecutor";
27
+ const startTime = Date.now();
28
+ const sid = startGenerationLogSession();
29
+ const { resolveProvider } = await import("../../../../../infrastructure/services/provider-resolver");
30
+
31
+ let provider;
32
+ try {
33
+ provider = resolveProvider(providerId);
34
+ } catch {
35
+ addGenerationLog(sid, TAG, "Provider not initialized!", "error");
36
+ return { success: false, error: "AI provider not initialized", logSessionId: sid };
37
+ }
38
+
39
+ try {
40
+ addGenerationLog(sid, TAG, "TTS generation starting", "info", {
41
+ model,
42
+ textLength: input.text.length,
43
+ voice: input.voice,
44
+ language: input.language,
45
+ timeout: AUDIO_GENERATION_TIMEOUT_MS,
46
+ });
47
+
48
+ // Build model input — generic structure, model-specific params come from app config
49
+ const modelInput: Record<string, unknown> = {
50
+ text: input.text,
51
+ };
52
+
53
+ if (input.voice) {
54
+ modelInput.voice = input.voice;
55
+ }
56
+ if (input.language) {
57
+ modelInput.language = input.language;
58
+ }
59
+ if (input.exaggeration !== undefined) {
60
+ modelInput.exaggeration = input.exaggeration;
61
+ }
62
+ if (input.cfgWeight !== undefined) {
63
+ modelInput.cfg_weight = input.cfgWeight;
64
+ }
65
+
66
+ addGenerationLog(sid, TAG, "Calling provider.subscribe()...");
67
+ const result = await provider.subscribe(model, modelInput, {
68
+ timeoutMs: AUDIO_GENERATION_TIMEOUT_MS,
69
+ onQueueUpdate: (status) => {
70
+ addGenerationLog(sid, TAG, `Queue: ${status.status}`);
71
+ },
72
+ });
73
+
74
+ // Collect provider logs
75
+ const providerSessionId = (result as { __providerSessionId?: string })?.__providerSessionId;
76
+ const providerLogs = provider.endLogSession?.(providerSessionId) ?? provider.getSessionLogs?.(providerSessionId) ?? [];
77
+ addGenerationLogs(sid, providerLogs);
78
+
79
+ // Extract audio URL from response
80
+ // FAL TTS models return: { audio: { url } } or { audio_url } or { url }
81
+ const rawResult = result as Record<string, unknown>;
82
+ const data = (rawResult?.data ?? rawResult) as {
83
+ audio?: { url: string };
84
+ audio_url?: string;
85
+ url?: string;
86
+ };
87
+
88
+ const audioUrl = data?.audio?.url ?? data?.audio_url ?? data?.url;
89
+
90
+ const elapsed = Date.now() - startTime;
91
+ onProgress?.(100);
92
+
93
+ if (audioUrl) {
94
+ addGenerationLog(sid, TAG, `TTS SUCCESS in ${elapsed}ms`, "info", { audioUrl, elapsed });
95
+ return { success: true, audioUrl, logSessionId: sid };
96
+ }
97
+
98
+ addGenerationLog(sid, TAG, `No audio in response after ${elapsed}ms`, "error", {
99
+ responseKeys: Object.keys(data || {}),
100
+ elapsed,
101
+ });
102
+ return { success: false, error: "No audio generated", logSessionId: sid };
103
+ } catch (error) {
104
+ // Collect provider logs even on failure
105
+ const providerLogs = provider.endLogSession?.() ?? [];
106
+ addGenerationLogs(sid, providerLogs);
107
+
108
+ const elapsed = Date.now() - startTime;
109
+ const errorMsg = error instanceof Error ? error.message : "Audio generation failed";
110
+ addGenerationLog(sid, TAG, `TTS FAILED after ${elapsed}ms: ${errorMsg}`, "error", { elapsed });
111
+ return { success: false, error: errorMsg, logSessionId: sid };
112
+ }
113
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Audio Generation Strategy
3
+ * Handles TTS-specific generation logic (execution only)
4
+ */
5
+
6
+ import type { WizardScenarioData } from "../../presentation/hooks/useWizardGeneration";
7
+ import type { WizardStrategy } from "./wizard-strategy.types";
8
+ import type { WizardAudioInput, CreateAudioStrategyOptions } from "./audio-generation.types";
9
+ import { executeAudioGeneration } from "./audio-generation.executor";
10
+
11
+ // ============================================================================
12
+ // Input Builder
13
+ // ============================================================================
14
+
15
+ export function buildAudioInput(
16
+ wizardData: Record<string, unknown>,
17
+ scenario: WizardScenarioData,
18
+ ): WizardAudioInput {
19
+ // Extract text from wizard data (TEXT_INPUT step stores as "text" or "prompt")
20
+ const text =
21
+ typeof wizardData.text === "string"
22
+ ? wizardData.text
23
+ : typeof wizardData.prompt === "string"
24
+ ? wizardData.prompt
25
+ : "";
26
+
27
+ if (!text.trim()) {
28
+ throw new Error("Text is required for audio generation");
29
+ }
30
+
31
+ // Extract voice selection from wizard data
32
+ const voice =
33
+ typeof wizardData.voice === "string"
34
+ ? wizardData.voice
35
+ : typeof wizardData.selectedVoice === "string"
36
+ ? wizardData.selectedVoice
37
+ : typeof scenario.voice === "string"
38
+ ? scenario.voice
39
+ : undefined;
40
+
41
+ // Extract optional parameters
42
+ const language =
43
+ typeof wizardData.language === "string"
44
+ ? wizardData.language
45
+ : typeof scenario.language === "string"
46
+ ? scenario.language
47
+ : undefined;
48
+
49
+ const exaggeration =
50
+ typeof wizardData.exaggeration === "number"
51
+ ? wizardData.exaggeration
52
+ : typeof scenario.exaggeration === "number"
53
+ ? scenario.exaggeration
54
+ : undefined;
55
+
56
+ const cfgWeight =
57
+ typeof wizardData.cfgWeight === "number"
58
+ ? wizardData.cfgWeight
59
+ : typeof scenario.cfgWeight === "number"
60
+ ? scenario.cfgWeight
61
+ : undefined;
62
+
63
+ return { text, voice, language, exaggeration, cfgWeight };
64
+ }
65
+
66
+ // ============================================================================
67
+ // Strategy Factory
68
+ // ============================================================================
69
+
70
+ export function createAudioStrategy(options: CreateAudioStrategyOptions): WizardStrategy {
71
+ const { scenario } = options;
72
+
73
+ if (!scenario.model) {
74
+ throw new Error("Model is required for audio generation");
75
+ }
76
+
77
+ const model = scenario.model;
78
+ const providerId = scenario.providerId;
79
+
80
+ return {
81
+ execute: async (input: unknown) => {
82
+ if (!input || typeof input !== "object") {
83
+ throw new Error("Invalid input: expected WizardAudioInput object");
84
+ }
85
+ const audioInput = input as WizardAudioInput;
86
+ const result = await executeAudioGeneration(audioInput, model, undefined, providerId);
87
+
88
+ if (!result.success || !result.audioUrl) {
89
+ const error = new Error(result.error || "Audio generation failed");
90
+ (error as Error & { logSessionId?: string }).logSessionId = result.logSessionId;
91
+ throw error;
92
+ }
93
+
94
+ return { audioUrl: result.audioUrl, logSessionId: result.logSessionId };
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Wizard Audio Generation Types
3
+ * Type definitions for wizard audio generation strategy (TTS)
4
+ */
5
+
6
+ import type { WizardScenarioData } from "../../presentation/hooks/useWizardGeneration";
7
+
8
+ export interface WizardAudioInput {
9
+ /** Text content to convert to speech */
10
+ readonly text: string;
11
+ /** Voice preset name (model-specific) */
12
+ readonly voice?: string;
13
+ /** Language code (e.g., "en", "es") */
14
+ readonly language?: string;
15
+ /** Exaggeration factor for voice expressiveness (0.0 - 1.0) */
16
+ readonly exaggeration?: number;
17
+ /** CFG/pace control weight */
18
+ readonly cfgWeight?: number;
19
+ }
20
+
21
+ export interface CreateAudioStrategyOptions {
22
+ readonly scenario: WizardScenarioData;
23
+ readonly collectionName?: string;
24
+ /** Credit cost for this generation - REQUIRED, determined by the app */
25
+ readonly creditCost: number;
26
+ }
@@ -80,14 +80,11 @@ export async function executeImageGeneration(
80
80
  modelInput.image_urls = imageUrls;
81
81
  }
82
82
 
83
- let lastStatus = "";
84
83
  addGenerationLog(sid, TAG, 'Calling provider.subscribe()...');
85
84
  const result = await provider.subscribe(model, modelInput, {
86
85
  timeoutMs: GENERATION_TIMEOUT_MS,
87
86
  onQueueUpdate: (status) => {
88
- if (status.status !== lastStatus) {
89
- lastStatus = status.status;
90
- }
87
+ addGenerationLog(sid, TAG, `Queue: ${status.status}`);
91
88
  },
92
89
  });
93
90
 
@@ -88,6 +88,9 @@ export function createImageStrategy(options: CreateImageStrategyOptions): Wizard
88
88
 
89
89
  return {
90
90
  execute: async (input: unknown) => {
91
+ if (!input || typeof input !== "object") {
92
+ throw new Error("Invalid input: expected WizardImageInput object");
93
+ }
91
94
  const imageInput = input as WizardImageInput;
92
95
  const result = await executeImageGeneration(imageInput, model, undefined, providerId);
93
96
 
@@ -32,9 +32,7 @@ async function extractAudioAsBase64(wizardData: Record<string, unknown>): Promis
32
32
  }
33
33
  return base64;
34
34
  } catch (error) {
35
- if (typeof __DEV__ !== "undefined" && __DEV__) {
36
- console.warn("[VideoStrategy] Failed to read audio file:", error);
37
- }
35
+ console.warn("[VideoStrategy] Failed to read audio file:", error);
38
36
  return undefined;
39
37
  }
40
38
  }
@@ -56,6 +56,14 @@ export function validatePhotoCount(
56
56
  break;
57
57
  case "text":
58
58
  break;
59
+ default: {
60
+ // Exhaustive check: log unexpected input types in development
61
+ const _exhaustive: never = effectiveInputType;
62
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
63
+ console.warn(`[validatePhotoCount] Unknown inputType: ${_exhaustive}`);
64
+ }
65
+ break;
66
+ }
59
67
  }
60
68
 
61
69
  return { isValid: true };
@@ -9,6 +9,7 @@ import type { WizardStrategy } from "./wizard-strategy.types";
9
9
  import type { VideoModelConfig } from "../../../../../domain/interfaces/video-model-config.types";
10
10
  import { createImageStrategy, buildImageInput } from "./image-generation.strategy";
11
11
  import { createVideoStrategy, buildVideoInput } from "./video-generation.strategy";
12
+ import { createAudioStrategy, buildAudioInput } from "./audio-generation.strategy";
12
13
 
13
14
  // ============================================================================
14
15
  // Types
@@ -34,6 +35,10 @@ export function createWizardStrategy(options: CreateWizardStrategyOptions): Wiza
34
35
  return createImageStrategy({ scenario, collectionName, creditCost });
35
36
  }
36
37
 
38
+ if (scenario.outputType === "audio") {
39
+ return createAudioStrategy({ scenario, collectionName, creditCost });
40
+ }
41
+
37
42
  // Default to video strategy for video outputType or undefined
38
43
  return createVideoStrategy({ scenario, modelConfig, collectionName, creditCost });
39
44
  }
@@ -47,9 +52,21 @@ export async function buildWizardInput(
47
52
  scenario: WizardScenarioData,
48
53
  ): Promise<unknown> {
49
54
  if (scenario.outputType === "image") {
50
- return buildImageInput(wizardData, scenario);
55
+ const input = await buildImageInput(wizardData, scenario);
56
+ if (!input) {
57
+ throw new Error("Failed to build image input");
58
+ }
59
+ return input;
60
+ }
61
+
62
+ if (scenario.outputType === "audio") {
63
+ return buildAudioInput(wizardData, scenario);
51
64
  }
52
65
 
53
66
  // Default to video input for video outputType or undefined
54
- return buildVideoInput(wizardData, scenario);
67
+ const input = await buildVideoInput(wizardData, scenario);
68
+ if (!input) {
69
+ throw new Error("Failed to build video input");
70
+ }
71
+ return input;
55
72
  }
@@ -12,7 +12,7 @@ export interface QueueSubmissionResult {
12
12
 
13
13
  export interface WizardStrategy {
14
14
  /** Execute the generation - returns result with URLs (blocking) */
15
- execute: (input: unknown) => Promise<{ imageUrl?: string; videoUrl?: string }>;
15
+ execute: (input: unknown) => Promise<{ imageUrl?: string; videoUrl?: string; audioUrl?: string; logSessionId?: string }>;
16
16
  /** Submit to queue for background processing - returns immediately with requestId */
17
17
  submitToQueue?: (input: unknown) => Promise<QueueSubmissionResult>;
18
18
  }
@@ -21,6 +21,7 @@ export interface CompletedCreationData {
21
21
  readonly uri: string;
22
22
  readonly imageUrl?: string;
23
23
  readonly videoUrl?: string;
24
+ readonly audioUrl?: string;
24
25
  readonly thumbnailUrl?: string;
25
26
  /** Unix timestamp (ms) when generation was submitted; used to compute durationMs */
26
27
  readonly generationStartedAt?: number;
@@ -53,19 +53,22 @@ export async function updateToCompleted(
53
53
  const output: CreationOutput = {
54
54
  ...(data.imageUrl && { imageUrl: data.imageUrl }),
55
55
  ...(data.videoUrl && { videoUrl: data.videoUrl }),
56
+ ...(data.audioUrl && { audioUrl: data.audioUrl }),
56
57
  ...(data.thumbnailUrl && { thumbnailUrl: data.thumbnailUrl }),
57
58
  };
58
59
 
59
60
  const completedAt = new Date();
60
- const durationMs =
61
+ const rawDuration =
61
62
  data.generationStartedAt !== undefined
62
63
  ? completedAt.getTime() - data.generationStartedAt
63
64
  : undefined;
65
+ const durationMs = rawDuration !== undefined && rawDuration >= 0 ? rawDuration : undefined;
66
+ const hasOutput = Object.keys(output).length > 0;
64
67
 
65
68
  await repository.update(userId, creationId, {
66
69
  uri: data.uri,
67
70
  status: "completed" as const,
68
- output,
71
+ ...(hasOutput && { output }),
69
72
  completedAt,
70
73
  ...(durationMs !== undefined && { durationMs }),
71
74
  } as Partial<Creation>);
@@ -61,7 +61,7 @@ export const WizardStepRenderer: React.FC<WizardStepRendererProps> = ({
61
61
  const media = extractMediaUrl(generationResult);
62
62
  if (!media) return null;
63
63
  const isVideo = media.isVideo || getMediaTypeFromUrl(media.url) === "video";
64
- const handleTryAgain = onTryAgain ?? onBack;
64
+ const handleTryAgain = onTryAgain ?? onBack ?? (() => {});
65
65
  return (
66
66
  <ResultPreviewScreen
67
67
  imageUrl={isVideo ? undefined : media.url}
@@ -11,7 +11,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
11
11
  }
12
12
 
13
13
  function isWizardStepConfig(value: unknown): value is WizardStepConfig {
14
- return isRecord(value);
14
+ if (!isRecord(value)) return false;
15
+ return typeof value.id === "string" && typeof value.type === "string";
15
16
  }
16
17
 
17
18
  function isUploadedImage(value: unknown): value is UploadedImage {
@@ -26,7 +26,7 @@ export function renderAudioPickerStep({
26
26
  }: AudioPickerStepProps): React.ReactElement {
27
27
  const audioConfig = getAudioPickerConfig(step.config);
28
28
  const titleKey = audioConfig?.titleKey ?? `wizard.steps.${step.id}.title`;
29
- const subtitleKey = audioConfig?.subtitleKey ?? `wizard.steps.${step.id}.subtitle`;
29
+ const subtitleKey = audioConfig?.subtitleKey;
30
30
  const isOptional = !(step.required ?? true);
31
31
 
32
32
  return (
@@ -45,21 +45,24 @@ export function usePhotoBlockingGeneration(
45
45
  alertMessages,
46
46
  onSuccess,
47
47
  onError,
48
+ onCreditsExhausted,
48
49
  } = props;
49
50
 
50
51
  const creationIdRef = useRef<string | null>(null);
51
52
 
52
53
  const handleSuccess = useCallback(
53
54
  async (result: unknown) => {
54
- const typedResult = result as { imageUrl?: string; videoUrl?: string; logSessionId?: string };
55
+ const typedResult = result as { imageUrl?: string; videoUrl?: string; audioUrl?: string; logSessionId?: string };
55
56
  const creationId = creationIdRef.current;
57
+ const resultUri = typedResult.imageUrl || typedResult.videoUrl || typedResult.audioUrl;
56
58
 
57
- if (creationId && userId) {
59
+ if (creationId && userId && resultUri) {
58
60
  try {
59
61
  await persistence.updateToCompleted(userId, creationId, {
60
- uri: typedResult.imageUrl || typedResult.videoUrl || "",
62
+ uri: resultUri,
61
63
  imageUrl: typedResult.imageUrl,
62
64
  videoUrl: typedResult.videoUrl,
65
+ audioUrl: typedResult.audioUrl,
63
66
  logSessionId: typedResult.logSessionId,
64
67
  });
65
68
  } catch (err) {
@@ -73,16 +76,21 @@ export function usePhotoBlockingGeneration(
73
76
 
74
77
  // Deduct credits after successful generation
75
78
  if (deductCredits && creditCost) {
76
- await deductCredits(creditCost).catch((err) => {
79
+ try {
80
+ const deducted = await deductCredits(creditCost);
81
+ if (!deducted) {
82
+ onCreditsExhausted?.();
83
+ }
84
+ } catch (err) {
77
85
  if (typeof __DEV__ !== "undefined" && __DEV__) {
78
86
  console.error("[PhotoBlockingGeneration] deductCredits error:", err);
79
87
  }
80
- });
88
+ }
81
89
  }
82
90
 
83
91
  onSuccess?.(result);
84
92
  },
85
- [userId, persistence, deductCredits, creditCost, onSuccess],
93
+ [userId, persistence, deductCredits, creditCost, onSuccess, onCreditsExhausted],
86
94
  );
87
95
 
88
96
  const handleError = useCallback(
@@ -133,7 +141,7 @@ export function usePhotoBlockingGeneration(
133
141
  resolution,
134
142
  creditCost,
135
143
  aspectRatio,
136
- provider: "fal",
144
+ provider: scenario.providerId ?? "fal",
137
145
  outputType: scenario.outputType,
138
146
  });
139
147
  creationIdRef.current = result.creationId;
@@ -7,7 +7,7 @@ import type { AlertMessages } from "../../../../../presentation/hooks/generation
7
7
  import type { ScenarioInputType } from "../../../../scenarios/domain/Scenario";
8
8
  import type { VideoModelConfig } from "../../../../../domain/interfaces/video-model-config.types";
9
9
 
10
- export type WizardOutputType = "image" | "video";
10
+ export type WizardOutputType = "image" | "video" | "audio";
11
11
 
12
12
  export interface WizardScenarioData {
13
13
  readonly id: string;
@@ -91,7 +91,7 @@ export const AudioPickerScreen: React.FC<AudioPickerScreenProps> = ({
91
91
  }, [onContinue]);
92
92
 
93
93
  const formatFileSize = useCallback((bytes?: number) => {
94
- if (!bytes) return "";
94
+ if (bytes === undefined || bytes === null) return "";
95
95
  if (bytes < 1024) return `${bytes} B`;
96
96
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
97
97
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
@@ -101,7 +101,7 @@ export const AudioPickerScreen: React.FC<AudioPickerScreenProps> = ({
101
101
  const styles = useMemo(() => createStyles(tokens), [tokens]);
102
102
 
103
103
  return (
104
- <View style={{ flex: 1, backgroundColor: tokens.colors.backgroundPrimary }}>
104
+ <View style={[styles.root, { backgroundColor: tokens.colors.backgroundPrimary }]}>
105
105
  <NavigationHeader
106
106
  title=""
107
107
  onBackPress={onBack}
@@ -183,6 +183,9 @@ export const AudioPickerScreen: React.FC<AudioPickerScreenProps> = ({
183
183
 
184
184
  const createStyles = (tokens: DesignTokens) =>
185
185
  StyleSheet.create({
186
+ root: {
187
+ flex: 1,
188
+ },
186
189
  scrollContent: {
187
190
  paddingHorizontal: tokens.spacing.lg,
188
191
  paddingBottom: 40,
@@ -85,13 +85,13 @@ export const ResultPreviewScreen: React.FC<ResultPreviewScreenProps> = ({
85
85
  showRating={showRating}
86
86
  />
87
87
  </View>
88
- {showRecent && (
88
+ {showRecent && recentCreations && translations.recentCreations && translations.viewAll && (
89
89
  <RecentCreationsSection
90
- recentCreations={recentCreations!}
90
+ recentCreations={recentCreations}
91
91
  onViewAll={onViewAll}
92
92
  onCreationPress={onCreationPress}
93
- title={translations.recentCreations!}
94
- viewAllLabel={translations.viewAll!}
93
+ title={translations.recentCreations}
94
+ viewAllLabel={translations.viewAll}
95
95
  />
96
96
  )}
97
97
  </View>
@@ -29,7 +29,7 @@ export interface ResultActionBarProps {
29
29
  /** Share callback */
30
30
  onShare: () => void;
31
31
  /** Try again callback */
32
- onTryAgain: () => void;
32
+ onTryAgain?: () => void;
33
33
  /** Rate callback */
34
34
  onRate?: () => void;
35
35
  /** Save button text */
@@ -19,8 +19,8 @@ export interface ResultPreviewScreenProps {
19
19
  /** Action callbacks */
20
20
  onDownload: () => void;
21
21
  onShare: () => void;
22
- onTryAgain: () => void;
23
- onNavigateBack: () => void;
22
+ onTryAgain?: () => void;
23
+ onNavigateBack?: () => void;
24
24
  onRate?: () => void;
25
25
  /** Edit callback — opens photo editor for the result image */
26
26
  onEdit?: () => void;
@@ -4,7 +4,7 @@
4
4
  * Note: ScenarioId and ScenarioCategory should be defined in the app, not here
5
5
  */
6
6
 
7
- export type ScenarioOutputType = "image" | "video";
7
+ export type ScenarioOutputType = "image" | "video" | "audio";
8
8
 
9
9
  export type ScenarioInputType = "single" | "dual" | "text";
10
10
 
@@ -9,7 +9,7 @@ export {
9
9
  useGenerationFlow, useAIFeatureCallbacks,
10
10
  useAIGenerateState, AIGenerateStep,
11
11
  useGenerationOrchestrator, useImageGeneration, useVideoGeneration, useDualImageGeneration,
12
- useImagePicker, useImageGenerationExecutor,
12
+ useImagePicker, useImageGenerationExecutor, useAudioGenerationExecutor,
13
13
  createGenerationError, getAlertMessage, parseError,
14
14
  } from "../presentation/hooks";
15
15
  export type {
@@ -26,6 +26,7 @@ export type {
26
26
  UploadedImage,
27
27
  GenerationTarget, GenerationInput, AIImageResult,
28
28
  ImageGenerationExecutorConfig, ImageGenerationExecutorReturn,
29
+ AudioGenerationInput, AudioGenerationExecutorConfig, AudioGenerationExecutorReturn,
29
30
  } from "../presentation/hooks";
30
31
 
31
32
  // Components
package/src/index.ts CHANGED
@@ -31,7 +31,10 @@ export {
31
31
  IMAGE_TO_VIDEO_WIZARD_CONFIG,
32
32
  SOLO_VIDEO_WIZARD_CONFIG,
33
33
  } from "./domains/generation/wizard";
34
- export type { WizardScenarioData } from "./domains/generation/wizard";
34
+ export type { WizardScenarioData, WizardAudioInput } from "./domains/generation/wizard";
35
+
36
+ // Audio Generation (TTS)
37
+ export { executeAudioGeneration, buildAudioInput, createAudioStrategy } from "./domains/generation/wizard";
35
38
 
36
39
  // Wizard Validators and Credit Utilities
37
40
  export {
@@ -12,7 +12,7 @@ import React, {
12
12
  } from "react";
13
13
 
14
14
  export interface GenerationServicesValue {
15
- /** Current authenticated user ID (null = not authenticated) */
15
+ /** Current authenticated user ID (undefined = not authenticated) */
16
16
  readonly userId: string | undefined;
17
17
  /** Deduct credits. Returns true if successful, false if insufficient. */
18
18
  readonly deductCredits: (cost: number) => Promise<boolean>;
@@ -1,7 +1,11 @@
1
- import type { GenerationTarget } from "../../presentation/hooks/generation/useImageGenerationExecutor";
1
+ /** Target for generation: which model on which provider */
2
+ interface GenerationTargetLike {
3
+ readonly model: string;
4
+ readonly providerId: string;
5
+ }
2
6
 
3
7
  interface CoupleInputResult {
4
- readonly target: GenerationTarget;
8
+ readonly target: GenerationTargetLike;
5
9
  readonly imageUrls: string[];
6
10
  }
7
11
 
@@ -13,8 +17,8 @@ export function resolveCoupleInput(
13
17
  partner1PhotoUri: string,
14
18
  partner2PhotoUri: string | null,
15
19
  isCoupleMode: boolean,
16
- singleTarget: GenerationTarget,
17
- coupleTarget: GenerationTarget,
20
+ singleTarget: GenerationTargetLike,
21
+ coupleTarget: GenerationTargetLike,
18
22
  ): CoupleInputResult {
19
23
  if (isCoupleMode && partner2PhotoUri) {
20
24
  return {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Image Input Preprocessor
3
+ *
4
+ * Converts local file URIs (file://, ph://, content://) to base64 strings
5
+ * before passing params to any AI provider. This ensures providers only
6
+ * receive base64 or HTTPS URLs — never platform-specific file URIs.
7
+ *
8
+ * Uses @umituz/react-native-design-system/filesystem for file I/O.
9
+ * Used by useImageGenerationExecutor to preprocess params automatically.
10
+ */
11
+
12
+ import { readFileAsBase64 } from "@umituz/react-native-design-system/filesystem";
13
+
14
+ const LOCAL_URI_PREFIXES = ["file://", "ph://", "content://"];
15
+
16
+ const isLocalFileUri = (value: unknown): value is string =>
17
+ typeof value === "string" &&
18
+ LOCAL_URI_PREFIXES.some((p) => value.startsWith(p));
19
+
20
+ /** Known field names that may contain image URIs */
21
+ const IMAGE_FIELDS = [
22
+ "image",
23
+ "image_url",
24
+ "image_urls",
25
+ "images",
26
+ "reference_image",
27
+ "source_image",
28
+ "target_image",
29
+ "driver_image_url",
30
+ "face_image",
31
+ "mask_image",
32
+ ] as const;
33
+
34
+ /**
35
+ * Read a local file URI as base64.
36
+ */
37
+ async function readLocalUri(uri: string): Promise<string> {
38
+ const result = await readFileAsBase64(uri);
39
+ if (!result) throw new Error(`Failed to read file: ${uri}`);
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * Resolve a single value: convert local file URI to base64, pass others through.
45
+ */
46
+ async function resolveValue(value: string): Promise<string> {
47
+ return isLocalFileUri(value) ? readLocalUri(value) : value;
48
+ }
49
+
50
+ /**
51
+ * Preprocess generation params: scan known image fields and convert
52
+ * local file URIs to base64. Returns a new object (never mutates input).
53
+ */
54
+ export async function preprocessImageInputs(
55
+ params: Record<string, unknown>,
56
+ ): Promise<Record<string, unknown>> {
57
+ const result = { ...params };
58
+ let converted = 0;
59
+
60
+ for (const field of IMAGE_FIELDS) {
61
+ const value = result[field];
62
+ if (value === undefined || value === null) continue;
63
+
64
+ if (typeof value === "string" && isLocalFileUri(value)) {
65
+ result[field] = await resolveValue(value);
66
+ converted++;
67
+ } else if (Array.isArray(value)) {
68
+ const hasLocalUri = value.some(isLocalFileUri);
69
+ if (hasLocalUri) {
70
+ result[field] = await Promise.all(
71
+ value.map((v) =>
72
+ typeof v === "string" ? resolveValue(v) : v,
73
+ ),
74
+ );
75
+ converted++;
76
+ }
77
+ }
78
+ }
79
+
80
+ if (converted > 0 && typeof __DEV__ !== "undefined" && __DEV__) {
81
+ console.log(
82
+ `[ImagePreprocessor] Converted ${converted} field(s) from file URI to base64`,
83
+ );
84
+ }
85
+
86
+ return result;
87
+ }
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * Maps a 0-100 intensity slider value to AI model strength parameter.
3
3
  * Range: 0→0.30, 50→0.61, 100→0.92
4
+ * Input is clamped to [0, 100].
4
5
  */
5
- export const intensityToStrength = (intensity: number): number =>
6
- 0.3 + (intensity / 100) * 0.62;
6
+ export const intensityToStrength = (intensity: number): number => {
7
+ const clamped = Math.max(0, Math.min(100, intensity));
8
+ return 0.3 + (clamped / 100) * 0.62;
9
+ };
@@ -57,6 +57,14 @@ export type {
57
57
  ImageGenerationExecutorReturn,
58
58
  } from "./useImageGenerationExecutor";
59
59
 
60
+ // Standalone audio generation executor (Template Method Pattern)
61
+ export { useAudioGenerationExecutor } from "./useAudioGenerationExecutor";
62
+ export type {
63
+ AudioGenerationInput,
64
+ AudioGenerationExecutorConfig,
65
+ AudioGenerationExecutorReturn,
66
+ } from "./useAudioGenerationExecutor";
67
+
60
68
  // Error utilities
61
69
  export {
62
70
  createGenerationError,
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Audio Generation Executor Hook (Template Method Pattern)
3
+ *
4
+ * Mirrors useImageGenerationExecutor but for TTS/audio generation.
5
+ * Encapsulates: auth → credit deduction → provider call → creation save → error/refund
6
+ *
7
+ * Consumers provide domain-specific logic via config:
8
+ * - buildInput: text, voice, target (model + provider)
9
+ * - buildMetadata: creation metadata
10
+ */
11
+
12
+ import { useState, useCallback, useMemo } from "react";
13
+ import { useGenerationServices } from "../../../infrastructure/providers/generation-services.provider";
14
+ import { resolveProvider } from "../../../infrastructure/services/provider-resolver";
15
+ import { createCreationsRepository } from "../../../domains/creations/infrastructure/adapters";
16
+ import type { GenerationTarget } from "./useImageGenerationExecutor";
17
+
18
+ /** Domain-specific audio generation input returned by buildInput */
19
+ export interface AudioGenerationInput {
20
+ /** Which model + provider to use */
21
+ readonly target: GenerationTarget;
22
+ /** The text being converted to speech (stored in creation record) */
23
+ readonly prompt: string;
24
+ /** Full params passed to provider.subscribe() */
25
+ readonly params: Record<string, unknown>;
26
+ }
27
+
28
+ /** Provider-agnostic AI audio result */
29
+ interface AIAudioResult {
30
+ readonly audio?: { readonly url: string };
31
+ readonly audio_url?: string;
32
+ readonly url?: string;
33
+ }
34
+
35
+ /** Default credit cost for audio generation */
36
+ const DEFAULT_AUDIO_CREDIT_COST = 1;
37
+
38
+ export interface AudioGenerationExecutorConfig<P> {
39
+ /** Creation type stored in Firestore (e.g. "voice-generator") */
40
+ readonly type: string;
41
+ /** Credit cost per generation. Defaults to 1. */
42
+ readonly creditCost?: number;
43
+ /** Whether to call onGenerationSuccess after completion */
44
+ readonly trackRating?: boolean;
45
+ /** Build AI generation input from domain-specific params. May be async. */
46
+ readonly buildInput: (params: P) => Promise<AudioGenerationInput> | AudioGenerationInput;
47
+ /** Build domain-specific metadata for the creation record */
48
+ readonly buildMetadata?: (params: P) => Record<string, unknown>;
49
+ }
50
+
51
+ export interface AudioGenerationExecutorReturn<P> {
52
+ readonly execute: (params: P) => Promise<string | null>;
53
+ readonly isLoading: boolean;
54
+ readonly error: string | null;
55
+ }
56
+
57
+ export function useAudioGenerationExecutor<P>(
58
+ config: AudioGenerationExecutorConfig<P>,
59
+ ): AudioGenerationExecutorReturn<P> {
60
+ const { userId, deductCredits, refundCredits, onGenerationSuccess } =
61
+ useGenerationServices();
62
+ const repository = useMemo(
63
+ () => createCreationsRepository("creations"),
64
+ [],
65
+ );
66
+ const [error, setError] = useState<string | null>(null);
67
+ const [isLoading, setIsLoading] = useState(false);
68
+
69
+ const execute = useCallback(
70
+ async (params: P): Promise<string | null> => {
71
+ if (!userId || isLoading) return null;
72
+
73
+ setError(null);
74
+ setIsLoading(true);
75
+ let deducted = false;
76
+ const cost = config.creditCost ?? DEFAULT_AUDIO_CREDIT_COST;
77
+
78
+ try {
79
+ const creditSuccess = await deductCredits(cost);
80
+ if (!creditSuccess) {
81
+ setError("Insufficient credits");
82
+ return null;
83
+ }
84
+ deducted = true;
85
+
86
+ const input = await config.buildInput(params);
87
+ const provider = resolveProvider(input.target.providerId);
88
+ const result = (await provider.subscribe(
89
+ input.target.model,
90
+ input.params,
91
+ )) as AIAudioResult;
92
+
93
+ // Extract audio URL — FAL returns { audio: { url } }, some models { audio_url } or { url }
94
+ const audioUrl = result?.audio?.url ?? result?.audio_url ?? result?.url;
95
+ if (!audioUrl) throw new Error("No audio returned");
96
+
97
+ await repository.create(userId, {
98
+ id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
99
+ type: config.type,
100
+ uri: audioUrl,
101
+ createdAt: new Date(),
102
+ isShared: false,
103
+ isFavorite: false,
104
+ status: "completed",
105
+ output: { audioUrl },
106
+ model: input.target.model,
107
+ prompt: input.prompt,
108
+ metadata: config.buildMetadata?.(params) ?? {},
109
+ });
110
+
111
+ if (config.trackRating && onGenerationSuccess) {
112
+ void onGenerationSuccess();
113
+ }
114
+
115
+ return audioUrl;
116
+ } catch (err) {
117
+ const message =
118
+ err instanceof Error ? err.message : "Generation failed";
119
+ setError(message);
120
+ if (deducted) {
121
+ try {
122
+ await refundCredits(cost);
123
+ } catch {
124
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
125
+ console.error(`[${config.type}] Refund failed`);
126
+ }
127
+ }
128
+ }
129
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
130
+ console.error(`[${config.type}]`, err);
131
+ }
132
+ return null;
133
+ } finally {
134
+ setIsLoading(false);
135
+ }
136
+ },
137
+ [
138
+ userId,
139
+ isLoading,
140
+ config,
141
+ deductCredits,
142
+ refundCredits,
143
+ repository,
144
+ onGenerationSuccess,
145
+ ],
146
+ );
147
+
148
+ return { execute, isLoading, error };
149
+ }
@@ -15,6 +15,7 @@ import { useState, useCallback, useMemo } from "react";
15
15
  import { useGenerationServices } from "../../../infrastructure/providers/generation-services.provider";
16
16
  import { resolveProvider } from "../../../infrastructure/services/provider-resolver";
17
17
  import { createCreationsRepository } from "../../../domains/creations/infrastructure/adapters";
18
+ import { preprocessImageInputs } from "../../../infrastructure/utils/image-input-preprocessor.util";
18
19
 
19
20
  /** Target for generation: which model on which provider */
20
21
  export interface GenerationTarget {
@@ -73,7 +74,7 @@ export function useImageGenerationExecutor<P>(
73
74
 
74
75
  const execute = useCallback(
75
76
  async (params: P): Promise<string | null> => {
76
- if (!userId) return null;
77
+ if (!userId || isLoading) return null;
77
78
 
78
79
  setError(null);
79
80
  setIsLoading(true);
@@ -90,9 +91,10 @@ export function useImageGenerationExecutor<P>(
90
91
 
91
92
  const input = await config.buildInput(params);
92
93
  const provider = resolveProvider(input.target.providerId);
94
+ const processedParams = await preprocessImageInputs(input.params);
93
95
  const result = (await provider.subscribe(
94
96
  input.target.model,
95
- input.params,
97
+ processedParams,
96
98
  )) as AIImageResult;
97
99
 
98
100
  const imageUrl = result?.images?.[0]?.url;
@@ -140,6 +142,7 @@ export function useImageGenerationExecutor<P>(
140
142
  },
141
143
  [
142
144
  userId,
145
+ isLoading,
143
146
  config,
144
147
  deductCredits,
145
148
  refundCredits,
@@ -10,6 +10,7 @@ export {
10
10
  useDualImageGeneration,
11
11
  useImagePicker,
12
12
  useImageGenerationExecutor,
13
+ useAudioGenerationExecutor,
13
14
  createGenerationError,
14
15
  getAlertMessage,
15
16
  parseError,
@@ -39,6 +40,9 @@ export type {
39
40
  AIImageResult,
40
41
  ImageGenerationExecutorConfig,
41
42
  ImageGenerationExecutorReturn,
43
+ AudioGenerationInput,
44
+ AudioGenerationExecutorConfig,
45
+ AudioGenerationExecutorReturn,
42
46
  } from "./generation";
43
47
 
44
48
  export { useGeneration } from "./use-generation";