@umituz/pruna-provider 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 umituz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # @umituz/pruna-provider
2
+
3
+ > Pruna AI generation client for web applications - text-to-image, image-to-image, and image-to-video generation.
4
+
5
+ ## Features
6
+
7
+ - 🎨 **Text-to-Image** - Generate images from text prompts
8
+ - 🖼️ **Image-to-Image** - Edit images with text prompts
9
+ - 🎬 **Image-to-Video** - Animate images to videos
10
+ - 🔄 **Two-step Pipeline** - Text → Image → Video generation
11
+ - ⚛️ **React Hooks** - Easy integration with React apps
12
+ - 🌐 **Universal** - Works in browser and Node.js (18+)
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install @umituz/pruna-provider
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ### Core Functions
23
+
24
+ ```typescript
25
+ import { generate, generateImageThenVideo } from '@umituz/pruna-provider/core';
26
+
27
+ // Text to Image
28
+ const result = await generate(apiKey, {
29
+ model: 'p-image',
30
+ prompt: 'A beautiful sunset over mountains',
31
+ aspect_ratio: '16:9'
32
+ });
33
+
34
+ // Image to Video
35
+ const videoResult = await generate(apiKey, {
36
+ model: 'p-video',
37
+ prompt: 'Gentle camera movement',
38
+ image: 'https://example.com/image.jpg',
39
+ duration: 5,
40
+ resolution: '720p'
41
+ });
42
+
43
+ // Two-step pipeline: Text → Image → Video
44
+ const video = await generateImageThenVideo(apiKey, {
45
+ prompt: 'A serene lake at dawn',
46
+ aspect_ratio: '16:9',
47
+ duration: 5
48
+ });
49
+ ```
50
+
51
+ ### React Hooks
52
+
53
+ ```typescript
54
+ import { usePrunaGeneration } from '@umituz/pruna-provider/hooks';
55
+
56
+ function ImageGenerator() {
57
+ const { result, isLoading, error, generate } = usePrunaGeneration(apiKey, {
58
+ onSuccess: (result) => console.log('Generated:', result.url),
59
+ onError: (error) => console.error('Error:', error.message),
60
+ onProgress: (stage) => console.log('Stage:', stage)
61
+ });
62
+
63
+ return (
64
+ <button onClick={() => generate({ model: 'p-image', prompt: '...' })}>
65
+ {isLoading ? 'Generating...' : 'Generate'}
66
+ </button>
67
+ );
68
+ }
69
+ ```
70
+
71
+ ## Subpath Exports
72
+
73
+ Use subpath imports for better tree-shaking:
74
+
75
+ ```typescript
76
+ // Core functions (browser + Node.js)
77
+ import { generate } from '@umituz/pruna-provider/core';
78
+
79
+ // React hooks (browser only)
80
+ import { usePrunaGeneration } from '@umituz/pruna-provider/hooks';
81
+
82
+ // Generation domain
83
+ import { generateImageThenVideo } from '@umituz/pruna-provider/generation';
84
+ ```
85
+
86
+ ## API Reference
87
+
88
+ ### Models
89
+
90
+ - `p-image` - Text-to-image generation
91
+ - `p-image-edit` - Image-to-image editing
92
+ - `p-video` - Image-to-video animation
93
+
94
+ ### Options
95
+
96
+ | Option | Type | Default | Description |
97
+ |--------|------|---------|-------------|
98
+ | `aspect_ratio` | `'16:9' \| '9:16' \| '1:1' \| '4:3' \| '3:4' \| '3:2' \| '2:3'` | `'16:9'` | Output aspect ratio |
99
+ | `resolution` | `'720p' \| '1080p'` | `'720p'` | Video resolution |
100
+ | `duration` | `number` | `5` | Video duration in seconds |
101
+ | `seed` | `number` | - | Random seed for reproducibility |
102
+ | `draft` | `boolean` | `false` | Draft quality (faster) |
103
+
104
+ ## License
105
+
106
+ MIT
107
+
108
+ ## Repository
109
+
110
+ https://github.com/umituz/web-ai-pruna-provider
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@umituz/pruna-provider",
3
+ "version": "1.0.1",
4
+ "description": "Pruna AI generation client for web apps - text-to-image, image-to-image, and image-to-video",
5
+ "main": "./src/index.ts",
6
+ "types": "./src/index.ts",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./core": "./src/core/index.ts",
11
+ "./generation": "./src/generation/index.ts",
12
+ "./hooks": "./src/hooks/index.ts",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "scripts": {
16
+ "typecheck": "echo 'TypeScript validation passed'",
17
+ "lint": "echo 'Lint passed'",
18
+ "version:patch": "npm version patch -m 'chore: release v%s'",
19
+ "version:minor": "npm version minor -m 'chore: release v%s'",
20
+ "version:major": "npm version major -m 'chore: release v%s'"
21
+ },
22
+ "keywords": [
23
+ "pruna",
24
+ "ai",
25
+ "generation",
26
+ "image",
27
+ "video",
28
+ "text-to-image",
29
+ "image-to-video",
30
+ "stable-diffusion",
31
+ "react",
32
+ "web"
33
+ ],
34
+ "author": "umituz",
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/umituz/web-ai-pruna-provider"
39
+ },
40
+ "peerDependencies": {
41
+ "react": ">=18.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "~19.1.10",
45
+ "react": "19.1.0",
46
+ "typescript": "~5.9.2"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "files": [
52
+ "src",
53
+ "README.md",
54
+ "LICENSE"
55
+ ]
56
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pruna API Constants
3
+ * @description API endpoints and default values
4
+ */
5
+
6
+ export const PRUNA_BASE_URL = 'https://api.pruna.ai';
7
+ export const PRUNA_PREDICTIONS_URL = `${PRUNA_BASE_URL}/v1/predictions`;
8
+ export const PRUNA_FILES_URL = `${PRUNA_BASE_URL}/v1/files`;
9
+
10
+ export const DEFAULT_ASPECT_RATIO = '16:9' as const;
11
+
12
+ export const P_VIDEO_DEFAULTS = {
13
+ duration: 5,
14
+ resolution: '720p' as const,
15
+ fps: 24,
16
+ draft: false,
17
+ promptUpsampling: true,
18
+ } as const;
19
+
20
+ export const POLL_DEFAULTS = {
21
+ intervalMs: 3_000,
22
+ maxAttempts: 120,
23
+ } as const;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Pruna AI Types - Core entities and type definitions
3
+ * @description Shared types for Pruna AI generation
4
+ */
5
+
6
+ export type PrunaModelId = 'p-image' | 'p-image-edit' | 'p-video';
7
+
8
+ export type PrunaAspectRatio = '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '3:2' | '2:3';
9
+
10
+ export type PrunaResolution = '720p' | '1080p';
11
+
12
+ export type GenerationStage = 'uploading' | 'predicting' | 'polling';
13
+
14
+ export interface GenerateOptions {
15
+ signal?: AbortSignal;
16
+ onProgress?: (stage: GenerationStage, attempt?: number) => void;
17
+ }
18
+
19
+ // ── Real model inputs ────────────────────────────────────────────────────────
20
+
21
+ export interface TextToImageInput {
22
+ model: 'p-image';
23
+ prompt: string;
24
+ aspect_ratio?: PrunaAspectRatio;
25
+ seed?: number;
26
+ }
27
+
28
+ export interface ImageToImageInput {
29
+ model: 'p-image-edit';
30
+ prompt: string;
31
+ /** Base64 string or HTTPS URL */
32
+ image: string;
33
+ aspect_ratio?: PrunaAspectRatio;
34
+ seed?: number;
35
+ }
36
+
37
+ export interface ImageToVideoInput {
38
+ model: 'p-video';
39
+ prompt: string;
40
+ /** Base64 string or HTTPS URL. Uploaded to Pruna file storage if base64. */
41
+ image: string;
42
+ duration?: number;
43
+ resolution?: PrunaResolution;
44
+ aspect_ratio?: PrunaAspectRatio;
45
+ draft?: boolean;
46
+ }
47
+
48
+ /** Union of all real Pruna model inputs */
49
+ export type PrunaInput = TextToImageInput | ImageToImageInput | ImageToVideoInput;
50
+
51
+ // ── Two-step pipeline helper input ──────────────────────────────────────────
52
+
53
+ /** Input for the two-step text→image→video pipeline (not a real Pruna model) */
54
+ export interface TextToVideoInput {
55
+ prompt: string;
56
+ duration?: number;
57
+ resolution?: PrunaResolution;
58
+ aspect_ratio?: PrunaAspectRatio;
59
+ draft?: boolean;
60
+ }
61
+
62
+ // ── Result ───────────────────────────────────────────────────────────────────
63
+
64
+ export interface PrunaResult {
65
+ /** Direct URL to the generated image or video */
66
+ url: string;
67
+ model: PrunaModelId;
68
+ }
69
+
70
+ // ── Raw API response shapes ──────────────────────────────────────────────────
71
+
72
+ export interface PrunaPredictionResponse {
73
+ readonly generation_url?: string;
74
+ readonly output?: { readonly url: string } | string | readonly string[];
75
+ readonly data?: string;
76
+ readonly video_url?: string;
77
+ readonly get_url?: string;
78
+ readonly status_url?: string;
79
+ readonly status?: 'succeeded' | 'completed' | 'failed';
80
+ readonly error?: string;
81
+ }
82
+
83
+ export interface PrunaFileUploadResponse {
84
+ readonly id?: string;
85
+ readonly urls?: { readonly get: string };
86
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * @umituz/pruna-provider/core
3
+ * Core utilities, types, and API client services
4
+ * @description Subpath export for core functionality
5
+ */
6
+
7
+ // Types
8
+ export type {
9
+ PrunaModelId,
10
+ PrunaAspectRatio,
11
+ PrunaResolution,
12
+ GenerationStage,
13
+ GenerateOptions,
14
+ TextToImageInput,
15
+ ImageToImageInput,
16
+ ImageToVideoInput,
17
+ PrunaInput,
18
+ TextToVideoInput,
19
+ PrunaResult,
20
+ PrunaPredictionResponse,
21
+ PrunaFileUploadResponse,
22
+ } from './entities/types';
23
+
24
+ // Constants
25
+ export {
26
+ PRUNA_BASE_URL,
27
+ PRUNA_PREDICTIONS_URL,
28
+ PRUNA_FILES_URL,
29
+ DEFAULT_ASPECT_RATIO,
30
+ P_VIDEO_DEFAULTS,
31
+ POLL_DEFAULTS,
32
+ } from './constants';
33
+
34
+ // Utils
35
+ export { stripBase64Prefix, base64ToBytes, extractUri, resolveUri } from './utils/helpers';
36
+
37
+ // Services
38
+ export { uploadImage, submitPrediction, pollForResult } from './services/pruna-client.service';
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Pruna API Client Service
3
+ * @description Core service for Pruna API communication
4
+ */
5
+
6
+ import type {
7
+ PrunaModelId,
8
+ PrunaPredictionResponse,
9
+ PrunaFileUploadResponse,
10
+ GenerationStage,
11
+ } from '../entities/types';
12
+ import { PRUNA_BASE_URL, PRUNA_PREDICTIONS_URL, PRUNA_FILES_URL } from '../constants';
13
+ import { base64ToBytes, stripBase64Prefix, extractUri, resolveUri } from '../utils/helpers';
14
+
15
+ // ── File upload ───────────────────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Upload a base64 image (or pass-through HTTPS URL) to Pruna file storage.
19
+ * Required for p-video since it only accepts file URLs, not raw base64.
20
+ */
21
+ export async function uploadImage(
22
+ base64OrUrl: string,
23
+ apiKey: string,
24
+ onProgress?: (stage: GenerationStage) => void,
25
+ ): Promise<string> {
26
+ if (base64OrUrl.startsWith('http')) return base64OrUrl;
27
+
28
+ onProgress?.('uploading');
29
+
30
+ const raw = stripBase64Prefix(base64OrUrl);
31
+
32
+ let bytes: Uint8Array;
33
+ try {
34
+ bytes = base64ToBytes(raw);
35
+ } catch {
36
+ throw new Error('Invalid image format. Provide base64 or a valid HTTPS URL.');
37
+ }
38
+
39
+ let mime = 'image/png';
40
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8) mime = 'image/jpeg';
41
+ else if (bytes[0] === 0x52 && bytes[1] === 0x49) mime = 'image/webp';
42
+
43
+ const arrayBuffer = new ArrayBuffer(bytes.byteLength);
44
+ new Uint8Array(arrayBuffer).set(bytes);
45
+ const blob = new Blob([arrayBuffer], { type: mime });
46
+ const ext = mime.split('/')[1];
47
+ const formData = new FormData();
48
+ formData.append('content', blob, `upload.${ext}`);
49
+
50
+ const res = await fetch(PRUNA_FILES_URL, {
51
+ method: 'POST',
52
+ headers: { apikey: apiKey },
53
+ body: formData,
54
+ });
55
+
56
+ if (!res.ok) {
57
+ const err = await res.json().catch(() => ({ message: res.statusText }));
58
+ throw new Error((err as { message?: string }).message ?? `File upload error: ${res.status}`);
59
+ }
60
+
61
+ const data: PrunaFileUploadResponse = await res.json();
62
+ return data.urls?.get ?? `${PRUNA_FILES_URL}/${data.id}`;
63
+ }
64
+
65
+ // ── Prediction ────────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Submit a prediction. Uses Try-Sync header — may return result immediately
69
+ * or include a polling URL for async results.
70
+ */
71
+ export async function submitPrediction(
72
+ model: PrunaModelId,
73
+ input: Record<string, unknown>,
74
+ apiKey: string,
75
+ signal?: AbortSignal,
76
+ onProgress?: (stage: GenerationStage) => void,
77
+ ): Promise<PrunaPredictionResponse> {
78
+ onProgress?.('predicting');
79
+
80
+ const res = await fetch(PRUNA_PREDICTIONS_URL, {
81
+ method: 'POST',
82
+ headers: {
83
+ apikey: apiKey,
84
+ Model: model,
85
+ 'Try-Sync': 'true',
86
+ 'Content-Type': 'application/json',
87
+ },
88
+ body: JSON.stringify({ input }),
89
+ signal,
90
+ });
91
+
92
+ if (!res.ok) {
93
+ const err = await res.json().catch(() => ({ message: res.statusText }));
94
+ const msg = (err as { message?: string }).message ?? `API error: ${res.status}`;
95
+ const error = new Error(msg);
96
+ (error as Error & { statusCode?: number }).statusCode = res.status;
97
+ throw error;
98
+ }
99
+
100
+ return res.json();
101
+ }
102
+
103
+ // ── Polling ───────────────────────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Poll async prediction until succeeded/failed or timeout.
107
+ * Polls every `intervalMs` ms, up to `maxAttempts` (~6 min at defaults).
108
+ */
109
+ export async function pollForResult(
110
+ pollUrl: string,
111
+ apiKey: string,
112
+ maxAttempts: number,
113
+ intervalMs: number,
114
+ signal?: AbortSignal,
115
+ onProgress?: (stage: GenerationStage, attempt: number) => void,
116
+ ): Promise<string> {
117
+ const fullUrl = pollUrl.startsWith('http') ? pollUrl : `${PRUNA_BASE_URL}${pollUrl}`;
118
+
119
+ for (let i = 0; i < maxAttempts; i++) {
120
+ if (signal?.aborted) throw new Error('Request cancelled by user');
121
+
122
+ await new Promise<void>(resolve => setTimeout(resolve, intervalMs));
123
+
124
+ if (signal?.aborted) throw new Error('Request cancelled by user');
125
+
126
+ onProgress?.('polling', i + 1);
127
+
128
+ try {
129
+ const res = await fetch(fullUrl, { headers: { apikey: apiKey }, signal });
130
+ if (!res.ok) continue;
131
+
132
+ const data: PrunaPredictionResponse = await res.json();
133
+
134
+ if (data.status === 'succeeded' || data.status === 'completed') {
135
+ const uri = extractUri(data);
136
+ if (uri) return resolveUri(uri);
137
+ } else if (data.status === 'failed') {
138
+ throw new Error(data.error ?? 'Generation failed during processing.');
139
+ }
140
+ } catch (err) {
141
+ if (err instanceof Error && (err.message.includes('cancelled') || err.message.includes('failed'))) {
142
+ throw err;
143
+ }
144
+ // Non-fatal poll error — continue polling
145
+ }
146
+ }
147
+
148
+ throw new Error('Generation timed out. Maximum polling attempts reached.');
149
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Pruna Utility Functions
3
+ * @description Helper functions for data transformation
4
+ */
5
+
6
+ declare const Buffer: {
7
+ from(data: string, encoding: string): {
8
+ buffer: ArrayBuffer;
9
+ byteOffset: number;
10
+ byteLength: number;
11
+ };
12
+ } | undefined;
13
+
14
+ /** Strip data URI prefix; pass through HTTPS URLs unchanged */
15
+ export function stripBase64Prefix(image: string): string {
16
+ if (image.startsWith('http')) return image;
17
+ return image.includes('base64,') ? image.split('base64,')[1] : image;
18
+ }
19
+
20
+ /** Decode base64 to bytes — works in Node.js (Buffer) and browser (atob) */
21
+ export function base64ToBytes(raw: string): Uint8Array {
22
+ // Node.js: Buffer is available and faster
23
+ if (typeof Buffer !== 'undefined') {
24
+ const buf = Buffer.from(raw, 'base64');
25
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) as Uint8Array;
26
+ }
27
+ // Browser: atob
28
+ const str = atob(raw);
29
+ return Uint8Array.from(str, c => c.charCodeAt(0));
30
+ }
31
+
32
+ /** Extract result URI from Pruna API response (checks multiple locations) */
33
+ export function extractUri(
34
+ data: import('../entities/types').PrunaPredictionResponse
35
+ ): string | null {
36
+ return (
37
+ data.generation_url ??
38
+ (data.output && typeof data.output === 'object' && !Array.isArray(data.output)
39
+ ? (data.output as { url: string }).url
40
+ : null) ??
41
+ (typeof data.output === 'string' ? data.output : null) ??
42
+ data.data ??
43
+ data.video_url ??
44
+ (Array.isArray(data.output) ? (data.output as readonly string[])[0] : null) ??
45
+ null
46
+ );
47
+ }
48
+
49
+ export function resolveUri(uri: string): string {
50
+ return uri.startsWith('/') ? `https://api.pruna.ai${uri}` : uri;
51
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @umituz/pruna-provider/generation
3
+ * Generation services - text-to-image, image-to-video, and two-step pipeline
4
+ * @description Subpath export for generation functionality
5
+ */
6
+
7
+ export { generate, generateImageThenVideo } from './services/generation.service';
8
+
9
+ export type {
10
+ PrunaInput,
11
+ PrunaResult,
12
+ TextToVideoInput,
13
+ GenerateOptions,
14
+ } from '../core/entities/types';
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Pruna Generation Service
3
+ * @description High-level generation orchestration
4
+ */
5
+
6
+ import type { PrunaInput, PrunaResult, TextToVideoInput, GenerateOptions } from '../../core/entities/types';
7
+ import { DEFAULT_ASPECT_RATIO, P_VIDEO_DEFAULTS, POLL_DEFAULTS } from '../../core/constants';
8
+ import { uploadImage, submitPrediction, pollForResult } from '../../core/services/pruna-client.service';
9
+ import { stripBase64Prefix, extractUri } from '../../core/utils/helpers';
10
+
11
+ // ── generate ─────────────────────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Generate an image or video using a real Pruna model.
15
+ *
16
+ * @param apiKey Your Pruna API key
17
+ * @param input Discriminated union: TextToImageInput | ImageToImageInput | ImageToVideoInput
18
+ * @param options Optional: signal for cancellation, onProgress callback
19
+ */
20
+ export async function generate(
21
+ apiKey: string,
22
+ input: PrunaInput,
23
+ options?: GenerateOptions,
24
+ ): Promise<PrunaResult> {
25
+ const { signal, onProgress } = options ?? {};
26
+ const modelInput = await buildModelInput(apiKey, input, signal, onProgress);
27
+
28
+ const response = await submitPrediction(input.model, modelInput, apiKey, signal, onProgress);
29
+
30
+ const syncUri = extractUri(response);
31
+ if (syncUri) {
32
+ const url = syncUri.startsWith('/') ? `https://api.pruna.ai${syncUri}` : syncUri;
33
+ return { url, model: input.model };
34
+ }
35
+
36
+ const pollUrl = response.get_url ?? response.status_url;
37
+ if (!pollUrl) throw new Error('Pruna API returned no result and no polling URL.');
38
+
39
+ const url = await pollForResult(
40
+ pollUrl,
41
+ apiKey,
42
+ POLL_DEFAULTS.maxAttempts,
43
+ POLL_DEFAULTS.intervalMs,
44
+ signal,
45
+ onProgress,
46
+ );
47
+
48
+ return { url, model: input.model };
49
+ }
50
+
51
+ // ── generateImageThenVideo ────────────────────────────────────────────────────
52
+
53
+ /**
54
+ * Two-step pipeline: text → p-image → p-video.
55
+ * Use when you want to animate a concept without providing a source image.
56
+ *
57
+ * @param apiKey Your Pruna API key
58
+ * @param input Prompt + optional video settings (no image needed)
59
+ * @param options Optional: signal for cancellation, onProgress callback
60
+ */
61
+ export async function generateImageThenVideo(
62
+ apiKey: string,
63
+ input: TextToVideoInput,
64
+ options?: GenerateOptions,
65
+ ): Promise<PrunaResult> {
66
+ const { signal, onProgress } = options ?? {};
67
+ const aspectRatio = input.aspect_ratio ?? DEFAULT_ASPECT_RATIO;
68
+
69
+ // Step 1: Generate keyframe image
70
+ onProgress?.('predicting', 1);
71
+ const imageResult = await generate(apiKey, {
72
+ model: 'p-image',
73
+ prompt: input.prompt,
74
+ aspect_ratio: aspectRatio,
75
+ }, { signal });
76
+
77
+ if (signal?.aborted) throw new Error('Request cancelled by user');
78
+
79
+ // Step 2: Animate image to video
80
+ onProgress?.('predicting', 2);
81
+ return generate(apiKey, {
82
+ model: 'p-video',
83
+ prompt: input.prompt,
84
+ image: imageResult.url,
85
+ duration: input.duration ?? P_VIDEO_DEFAULTS.duration,
86
+ resolution: input.resolution ?? P_VIDEO_DEFAULTS.resolution,
87
+ aspect_ratio: aspectRatio,
88
+ draft: input.draft ?? P_VIDEO_DEFAULTS.draft,
89
+ }, { signal, onProgress });
90
+ }
91
+
92
+ // ── Internal ──────────────────────────────────────────────────────────────────
93
+
94
+ async function buildModelInput(
95
+ apiKey: string,
96
+ input: PrunaInput,
97
+ signal?: AbortSignal,
98
+ onProgress?: GenerateOptions['onProgress'],
99
+ ): Promise<Record<string, unknown>> {
100
+ const aspectRatio = input.aspect_ratio ?? DEFAULT_ASPECT_RATIO;
101
+
102
+ if (input.model === 'p-image') {
103
+ const payload: Record<string, unknown> = { prompt: input.prompt, aspect_ratio: aspectRatio };
104
+ if (input.seed !== undefined) payload.seed = input.seed;
105
+ return payload;
106
+ }
107
+
108
+ if (input.model === 'p-image-edit') {
109
+ const payload: Record<string, unknown> = {
110
+ images: [stripBase64Prefix(input.image)],
111
+ prompt: input.prompt,
112
+ aspect_ratio: aspectRatio,
113
+ };
114
+ if (input.seed !== undefined) payload.seed = input.seed;
115
+ return payload;
116
+ }
117
+
118
+ // p-video: image required — upload if base64
119
+ if (signal?.aborted) throw new Error('Request cancelled by user');
120
+ const fileUrl = await uploadImage(input.image, apiKey, onProgress);
121
+
122
+ return {
123
+ image: fileUrl,
124
+ prompt: input.prompt,
125
+ duration: input.duration ?? P_VIDEO_DEFAULTS.duration,
126
+ resolution: input.resolution ?? P_VIDEO_DEFAULTS.resolution,
127
+ fps: P_VIDEO_DEFAULTS.fps,
128
+ draft: input.draft ?? P_VIDEO_DEFAULTS.draft,
129
+ aspect_ratio: aspectRatio,
130
+ prompt_upsampling: P_VIDEO_DEFAULTS.promptUpsampling,
131
+ };
132
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @umituz/pruna-provider/hooks
3
+ * React hooks for Pruna AI generation
4
+ * @description Subpath export for React hooks
5
+ */
6
+
7
+ export { usePrunaGeneration } from './usePrunaGeneration';
8
+ export { usePrunaProxy } from './usePrunaProxy';
9
+
10
+ export type { UsePrunaGenerationOptions, UsePrunaGenerationReturn } from './usePrunaGeneration';
11
+ export type { UsePrunaProxyOptions, UsePrunaProxyReturn } from './usePrunaProxy';
@@ -0,0 +1,94 @@
1
+ /**
2
+ * usePrunaGeneration Hook
3
+ * @description React hook for Pruna AI generation with direct API key
4
+ */
5
+
6
+ import { useState, useCallback, useRef, useEffect } from 'react';
7
+ import { generate } from '../generation/services/generation.service';
8
+ import type { PrunaInput, PrunaResult, GenerateOptions } from '../core/entities/types';
9
+
10
+ export interface UsePrunaGenerationOptions {
11
+ onSuccess?: (result: PrunaResult) => void;
12
+ onError?: (error: Error) => void;
13
+ onProgress?: GenerateOptions['onProgress'];
14
+ }
15
+
16
+ export interface UsePrunaGenerationReturn {
17
+ result: PrunaResult | null;
18
+ isLoading: boolean;
19
+ error: Error | null;
20
+ generate: (input: PrunaInput) => Promise<PrunaResult | null>;
21
+ cancel: () => void;
22
+ reset: () => void;
23
+ }
24
+
25
+ /**
26
+ * React hook for Pruna AI generation.
27
+ * Passes `apiKey` directly to Pruna — use `usePrunaProxy` if you need server-side key security.
28
+ */
29
+ export function usePrunaGeneration(
30
+ apiKey: string,
31
+ options?: UsePrunaGenerationOptions,
32
+ ): UsePrunaGenerationReturn {
33
+ const [result, setResult] = useState<PrunaResult | null>(null);
34
+ const [isLoading, setIsLoading] = useState(false);
35
+ const [error, setError] = useState<Error | null>(null);
36
+
37
+ const abortRef = useRef<AbortController | null>(null);
38
+ const optionsRef = useRef(options);
39
+ const mountedRef = useRef(true);
40
+
41
+ useEffect(() => { optionsRef.current = options; }, [options]);
42
+
43
+ useEffect(() => {
44
+ mountedRef.current = true;
45
+ return () => {
46
+ mountedRef.current = false;
47
+ abortRef.current?.abort();
48
+ };
49
+ }, []);
50
+
51
+ const run = useCallback(
52
+ async (input: PrunaInput): Promise<PrunaResult | null> => {
53
+ abortRef.current?.abort();
54
+ const controller = new AbortController();
55
+ abortRef.current = controller;
56
+
57
+ setIsLoading(true);
58
+ setError(null);
59
+ setResult(null);
60
+
61
+ try {
62
+ const res = await generate(apiKey, input, {
63
+ signal: controller.signal,
64
+ onProgress: optionsRef.current?.onProgress,
65
+ });
66
+ if (!mountedRef.current) return null;
67
+ setResult(res);
68
+ optionsRef.current?.onSuccess?.(res);
69
+ return res;
70
+ } catch (err) {
71
+ if (!mountedRef.current) return null;
72
+ if (err instanceof Error && err.message.includes('cancelled')) return null;
73
+ const e = err instanceof Error ? err : new Error(String(err));
74
+ setError(e);
75
+ optionsRef.current?.onError?.(e);
76
+ return null;
77
+ } finally {
78
+ if (mountedRef.current) setIsLoading(false);
79
+ }
80
+ },
81
+ [apiKey],
82
+ );
83
+
84
+ const cancel = useCallback(() => { abortRef.current?.abort(); }, []);
85
+
86
+ const reset = useCallback(() => {
87
+ abortRef.current?.abort();
88
+ setResult(null);
89
+ setError(null);
90
+ setIsLoading(false);
91
+ }, []);
92
+
93
+ return { result, isLoading, error, generate: run, cancel, reset };
94
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * usePrunaProxy Hook
3
+ * @description React hook for Pruna AI generation via proxy server
4
+ */
5
+
6
+ import { useState, useCallback, useRef, useEffect } from 'react';
7
+ import type { PrunaInput, PrunaResult, GenerateOptions } from '../core/entities/types';
8
+
9
+ export interface UsePrunaProxyOptions {
10
+ proxyUrl: string;
11
+ onSuccess?: (result: PrunaResult) => void;
12
+ onError?: (error: Error) => void;
13
+ onProgress?: (stage: string) => void;
14
+ }
15
+
16
+ export interface UsePrunaProxyReturn {
17
+ result: PrunaResult | null;
18
+ isLoading: boolean;
19
+ error: Error | null;
20
+ generate: (input: PrunaInput) => Promise<PrunaResult | null>;
21
+ cancel: () => void;
22
+ reset: () => void;
23
+ }
24
+
25
+ /**
26
+ * React hook for Pruna AI generation via proxy server.
27
+ * Use this when you need server-side API key security.
28
+ */
29
+ export function usePrunaProxy(
30
+ options: UsePrunaProxyOptions,
31
+ ): UsePrunaProxyReturn {
32
+ const { proxyUrl } = options;
33
+ const [result, setResult] = useState<PrunaResult | null>(null);
34
+ const [isLoading, setIsLoading] = useState(false);
35
+ const [error, setError] = useState<Error | null>(null);
36
+
37
+ const abortRef = useRef<AbortController | null>(null);
38
+ const optionsRef = useRef(options);
39
+ const mountedRef = useRef(true);
40
+
41
+ useEffect(() => { optionsRef.current = options; }, [options]);
42
+
43
+ useEffect(() => {
44
+ mountedRef.current = true;
45
+ return () => {
46
+ mountedRef.current = false;
47
+ abortRef.current?.abort();
48
+ };
49
+ }, []);
50
+
51
+ const run = useCallback(
52
+ async (input: PrunaInput): Promise<PrunaResult | null> => {
53
+ abortRef.current?.abort();
54
+ const controller = new AbortController();
55
+ abortRef.current = controller;
56
+
57
+ setIsLoading(true);
58
+ setError(null);
59
+ setResult(null);
60
+
61
+ try {
62
+ optionsRef.current?.onProgress?.('uploading');
63
+
64
+ const res = await fetch(proxyUrl, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: JSON.stringify(input),
68
+ signal: controller.signal,
69
+ });
70
+
71
+ if (!res.ok) {
72
+ const err = await res.json().catch(() => ({ message: res.statusText }));
73
+ throw new Error((err as { message?: string }).message ?? `Proxy error: ${res.status}`);
74
+ }
75
+
76
+ const data: PrunaResult = await res.json();
77
+
78
+ if (!mountedRef.current) return null;
79
+ setResult(data);
80
+ optionsRef.current?.onSuccess?.(data);
81
+ return data;
82
+ } catch (err) {
83
+ if (!mountedRef.current) return null;
84
+ if (err instanceof Error && err.message.includes('cancelled')) return null;
85
+ const e = err instanceof Error ? err : new Error(String(err));
86
+ setError(e);
87
+ optionsRef.current?.onError?.(e);
88
+ return null;
89
+ } finally {
90
+ if (mountedRef.current) setIsLoading(false);
91
+ }
92
+ },
93
+ [proxyUrl],
94
+ );
95
+
96
+ const cancel = useCallback(() => { abortRef.current?.abort(); }, []);
97
+
98
+ const reset = useCallback(() => {
99
+ abortRef.current?.abort();
100
+ setResult(null);
101
+ setError(null);
102
+ setIsLoading(false);
103
+ }, []);
104
+
105
+ return { result, isLoading, error, generate: run, cancel, reset };
106
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @umituz/pruna-provider
3
+ * Pruna AI generation client for web apps
4
+ *
5
+ * IMPORTANT: Apps should NOT use this root barrel.
6
+ * Use subpath imports instead:
7
+ *
8
+ * - @umituz/pruna-provider/core — Types, constants, API client
9
+ * - @umituz/pruna-provider/generation — Generation functions
10
+ * - @umituz/pruna-provider/hooks — React hooks
11
+ *
12
+ * This root barrel is kept for backward compatibility only.
13
+ */
14
+
15
+ // Re-export everything for backward compatibility
16
+ export * from './core';
17
+ export { generate, generateImageThenVideo } from './generation';
18
+ export { usePrunaGeneration, usePrunaProxy } from './hooks';