@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.
- package/package.json +1 -1
- package/src/domains/creations/domain/entities/Creation.ts +1 -1
- package/src/domains/creations/presentation/components/GalleryResultPreview.tsx +1 -1
- package/src/domains/creations/presentation/screens/creations-gallery.types.ts +1 -1
- package/src/domains/generation/index.ts +5 -0
- package/src/domains/generation/wizard/index.ts +5 -0
- package/src/domains/generation/wizard/infrastructure/strategies/audio-generation.executor.ts +113 -0
- package/src/domains/generation/wizard/infrastructure/strategies/audio-generation.strategy.ts +97 -0
- package/src/domains/generation/wizard/infrastructure/strategies/audio-generation.types.ts +26 -0
- package/src/domains/generation/wizard/infrastructure/strategies/image-generation.executor.ts +1 -4
- package/src/domains/generation/wizard/infrastructure/strategies/image-generation.strategy.ts +3 -0
- package/src/domains/generation/wizard/infrastructure/strategies/video-generation.strategy.ts +1 -3
- package/src/domains/generation/wizard/infrastructure/strategies/video-generation.types.ts +8 -0
- package/src/domains/generation/wizard/infrastructure/strategies/wizard-strategy.factory.ts +19 -2
- package/src/domains/generation/wizard/infrastructure/strategies/wizard-strategy.types.ts +1 -1
- package/src/domains/generation/wizard/infrastructure/utils/creation-persistence.types.ts +1 -0
- package/src/domains/generation/wizard/infrastructure/utils/creation-update-operations.ts +5 -2
- package/src/domains/generation/wizard/presentation/components/WizardStepRenderer.tsx +1 -1
- package/src/domains/generation/wizard/presentation/components/WizardStepRenderer.utils.ts +2 -1
- package/src/domains/generation/wizard/presentation/components/step-renderers/renderAudioPickerStep.tsx +1 -1
- package/src/domains/generation/wizard/presentation/hooks/usePhotoBlockingGeneration.ts +15 -7
- package/src/domains/generation/wizard/presentation/hooks/wizard-generation.types.ts +1 -1
- package/src/domains/generation/wizard/presentation/screens/AudioPickerScreen.tsx +5 -2
- package/src/domains/result-preview/presentation/components/ResultPreviewScreen.tsx +4 -4
- package/src/domains/result-preview/presentation/types/result-components.types.ts +1 -1
- package/src/domains/result-preview/presentation/types/result-screen.types.ts +2 -2
- package/src/domains/scenarios/domain/Scenario.ts +1 -1
- package/src/exports/presentation.ts +2 -1
- package/src/index.ts +4 -1
- package/src/infrastructure/providers/generation-services.provider.tsx +1 -1
- package/src/infrastructure/utils/couple-input.util.ts +8 -4
- package/src/infrastructure/utils/image-input-preprocessor.util.ts +87 -0
- package/src/infrastructure/utils/intensity.util.ts +5 -2
- package/src/presentation/hooks/generation/index.ts +8 -0
- package/src/presentation/hooks/generation/useAudioGenerationExecutor.ts +149 -0
- package/src/presentation/hooks/generation/useImageGenerationExecutor.ts +5 -2
- 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.
|
|
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
|
|
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
|
+
}
|
package/src/domains/generation/wizard/infrastructure/strategies/image-generation.executor.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
lastStatus = status.status;
|
|
90
|
-
}
|
|
87
|
+
addGenerationLog(sid, TAG, `Queue: ${status.status}`);
|
|
91
88
|
},
|
|
92
89
|
});
|
|
93
90
|
|
package/src/domains/generation/wizard/infrastructure/strategies/image-generation.strategy.ts
CHANGED
|
@@ -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
|
|
package/src/domains/generation/wizard/infrastructure/strategies/video-generation.strategy.ts
CHANGED
|
@@ -32,9 +32,7 @@ async function extractAudioAsBase64(wizardData: Record<string, unknown>): Promis
|
|
|
32
32
|
}
|
|
33
33
|
return base64;
|
|
34
34
|
} catch (error) {
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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={{
|
|
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>
|
|
@@ -19,8 +19,8 @@ export interface ResultPreviewScreenProps {
|
|
|
19
19
|
/** Action callbacks */
|
|
20
20
|
onDownload: () => void;
|
|
21
21
|
onShare: () => void;
|
|
22
|
-
onTryAgain
|
|
23
|
-
onNavigateBack
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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:
|
|
17
|
-
coupleTarget:
|
|
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
|
-
|
|
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
|
-
|
|
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";
|