expo-image-to-video 0.1.2 → 0.1.4
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/android/build.gradle +20 -2
- package/android/src/main/java/expo/modules/imagetovideo/ExpoImageToVideoModule.kt +39 -16
- package/android/src/main/java/expo/modules/imagetovideo/ImageUtils.kt +80 -0
- package/build/ExpoImageToVideo.types.d.ts +25 -6
- package/build/ExpoImageToVideo.types.d.ts.map +1 -1
- package/build/ExpoImageToVideo.types.js.map +1 -1
- package/build/index.d.ts +4 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -12
- package/build/index.js.map +1 -1
- package/ios/ExpoImageToVideo.podspec +2 -1
- package/ios/ExpoImageToVideoModule.swift +52 -13
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
apply plugin: 'com.android.library'
|
|
2
2
|
|
|
3
3
|
group = 'expo.modules.imagetovideo'
|
|
4
|
-
version = '0.1.
|
|
4
|
+
version = '0.1.4'
|
|
5
5
|
|
|
6
6
|
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
7
|
apply from: expoModulesCorePlugin
|
|
@@ -35,9 +35,27 @@ android {
|
|
|
35
35
|
namespace "expo.modules.imagetovideo"
|
|
36
36
|
defaultConfig {
|
|
37
37
|
versionCode 1
|
|
38
|
-
versionName "0.1.
|
|
38
|
+
versionName "0.1.4"
|
|
39
39
|
}
|
|
40
40
|
lintOptions {
|
|
41
41
|
abortOnError false
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
dependencies {
|
|
46
|
+
// 2. Kotlin Standard Library (SDK 54 uses Kotlin 1.9+)
|
|
47
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
|
48
|
+
|
|
49
|
+
// 3. Android KTX for cleaner Kotlin code
|
|
50
|
+
implementation "androidx.core:core-ktx:1.12.0"
|
|
51
|
+
|
|
52
|
+
// 4. Coroutines for background processing (Critical for VideoEncoder)
|
|
53
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3"
|
|
54
|
+
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
|
55
|
+
|
|
56
|
+
// 5. EXIF support for image rotation handling
|
|
57
|
+
implementation "androidx.exifinterface:exifinterface:1.3.6"
|
|
58
|
+
|
|
59
|
+
// 6. Lifecycle KTX (Useful if you want to scope encoding to the app lifecycle)
|
|
60
|
+
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.7.0"
|
|
61
|
+
}
|
|
@@ -2,6 +2,8 @@ package expo.modules.imagetovideo
|
|
|
2
2
|
|
|
3
3
|
import expo.modules.kotlin.modules.Module
|
|
4
4
|
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
import expo.modules.kotlin.records.Field
|
|
6
|
+
import expo.modules.kotlin.records.Record
|
|
5
7
|
import kotlinx.coroutines.CoroutineScope
|
|
6
8
|
import kotlinx.coroutines.Dispatchers
|
|
7
9
|
import kotlinx.coroutines.launch
|
|
@@ -40,35 +42,42 @@ class ExpoImageToVideoModule : Module() {
|
|
|
40
42
|
))
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
// The 'options' argument will now automatically map to the VideoOptions class below
|
|
46
|
+
AsyncFunction("generateVideo") { options: VideoOptions, promise: expo.modules.kotlin.Promise ->
|
|
44
47
|
val context = appContext.reactContext ?: throw Exception("Context not found")
|
|
45
48
|
|
|
46
|
-
// Run in background thread to keep UI and JS thread responsive
|
|
47
49
|
CoroutineScope(Dispatchers.IO).launch {
|
|
48
50
|
try {
|
|
49
51
|
val outputFile = if (options.outputPath != null) {
|
|
50
|
-
File(options.outputPath)
|
|
52
|
+
File(options.outputPath!!)
|
|
51
53
|
} else {
|
|
52
54
|
File(context.cacheDir, "video_${System.currentTimeMillis()}.mp4")
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// Ensure defaults are handled if 0 or null
|
|
58
|
+
val finalBitrate = options.bitrate ?: 2500000
|
|
59
|
+
|
|
55
60
|
val encoder = VideoEncoder(
|
|
56
61
|
outputFile,
|
|
57
62
|
options.width,
|
|
58
63
|
options.height,
|
|
59
64
|
options.fps,
|
|
60
|
-
|
|
65
|
+
finalBitrate
|
|
61
66
|
)
|
|
62
67
|
|
|
63
68
|
encoder.start()
|
|
64
69
|
|
|
65
70
|
options.images.forEach { uri ->
|
|
66
|
-
//
|
|
71
|
+
// Load bitmap with the fixed width/height
|
|
67
72
|
val bitmap = ImageUtils.loadBitmap(context, uri, options.width, options.height)
|
|
68
|
-
?: throw Exception("Failed to load image: $uri")
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
if (bitmap != null) {
|
|
75
|
+
encoder.encodeFrame(bitmap)
|
|
76
|
+
bitmap.recycle()
|
|
77
|
+
} else {
|
|
78
|
+
// Optional: Log warning for failed image load
|
|
79
|
+
println("ExpoImageToVideo: Failed to load $uri")
|
|
80
|
+
}
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
encoder.stop()
|
|
@@ -93,11 +102,25 @@ class ExpoImageToVideoModule : Module() {
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
// Data class for Expo Module auto-serialization
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
// 1. Must implement 'Record'
|
|
106
|
+
// 2. Must use 'var' (mutable) properties
|
|
107
|
+
// 3. Must use '@Field' annotation
|
|
108
|
+
class VideoOptions : Record {
|
|
109
|
+
@Field
|
|
110
|
+
var images: List<String> = emptyList()
|
|
111
|
+
|
|
112
|
+
@Field
|
|
113
|
+
var fps: Int = 30
|
|
114
|
+
|
|
115
|
+
@Field
|
|
116
|
+
var width: Int = 1280
|
|
117
|
+
|
|
118
|
+
@Field
|
|
119
|
+
var height: Int = 720
|
|
120
|
+
|
|
121
|
+
@Field
|
|
122
|
+
var bitrate: Int? = null
|
|
123
|
+
|
|
124
|
+
@Field
|
|
125
|
+
var outputPath: String? = null
|
|
126
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
package expo.modules.imagetovideo
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.graphics.Matrix
|
|
7
|
+
import android.net.Uri
|
|
8
|
+
import androidx.exifinterface.media.ExifInterface
|
|
9
|
+
import java.io.InputStream
|
|
10
|
+
|
|
11
|
+
object ImageUtils {
|
|
12
|
+
/**
|
|
13
|
+
* Loads a bitmap from a URI, scales it to fit the target dimensions,
|
|
14
|
+
* and corrects the orientation based on EXIF data.
|
|
15
|
+
*/
|
|
16
|
+
fun loadBitmap(context: Context, uriString: String, targetWidth: Int, targetHeight: Int): Bitmap? {
|
|
17
|
+
val uri = Uri.parse(uriString)
|
|
18
|
+
var inputStream: InputStream? = null
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
// 1. Get dimensions without loading into memory
|
|
22
|
+
val options = BitmapFactory.Options().apply {
|
|
23
|
+
inJustDecodeBounds = true
|
|
24
|
+
}
|
|
25
|
+
context.contentResolver.openInputStream(uri).use {
|
|
26
|
+
BitmapFactory.decodeStream(it, null, options)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Calculate scaling to save memory (OOM Prevention)
|
|
30
|
+
options.inSampleSize = calculateInSampleSize(options, targetWidth, targetHeight)
|
|
31
|
+
options.inJustDecodeBounds = false
|
|
32
|
+
|
|
33
|
+
// 3. Decode the actual bitmap
|
|
34
|
+
val decodedBitmap = context.contentResolver.openInputStream(uri).use {
|
|
35
|
+
BitmapFactory.decodeStream(it, null, options)
|
|
36
|
+
} ?: return null
|
|
37
|
+
|
|
38
|
+
// 4. Correct rotation if needed (common with phone photos)
|
|
39
|
+
return rotateImageIfRequired(context, decodedBitmap, uri)
|
|
40
|
+
} catch (e: Exception) {
|
|
41
|
+
e.printStackTrace()
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
|
|
47
|
+
val (height: Int, width: Int) = options.outHeight to options.outWidth
|
|
48
|
+
var inSampleSize = 1
|
|
49
|
+
|
|
50
|
+
if (height > reqHeight || width > reqWidth) {
|
|
51
|
+
val halfHeight: Int = height / 2
|
|
52
|
+
val halfWidth: Int = width / 2
|
|
53
|
+
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
|
|
54
|
+
inSampleSize *= 2
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return inSampleSize
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private fun rotateImageIfRequired(context: Context, img: Bitmap, selectedImage: Uri): Bitmap {
|
|
61
|
+
val input = context.contentResolver.openInputStream(selectedImage) ?: return img
|
|
62
|
+
val ei = ExifInterface(input)
|
|
63
|
+
val orientation = ei.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
|
64
|
+
|
|
65
|
+
return when (orientation) {
|
|
66
|
+
ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(img, 90f)
|
|
67
|
+
ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(img, 180f)
|
|
68
|
+
ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(img, 270f)
|
|
69
|
+
else -> img
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private fun rotateImage(img: Bitmap, degree: Float): Bitmap {
|
|
74
|
+
val matrix = Matrix()
|
|
75
|
+
matrix.postRotate(degree)
|
|
76
|
+
val rotatedImg = Bitmap.createBitmap(img, 0, 0, img.width, img.height, matrix, true)
|
|
77
|
+
img.recycle()
|
|
78
|
+
return rotatedImg
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -16,17 +16,36 @@ export type ExpoImageToVideoViewProps = {
|
|
|
16
16
|
style?: StyleProp<ViewStyle>;
|
|
17
17
|
};
|
|
18
18
|
export interface VideoOptions {
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* An array of local file URIs (file://...) to images.
|
|
21
|
+
* At least one image is required.
|
|
22
|
+
*/
|
|
20
23
|
images: string[];
|
|
21
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* Frames per second for the output video.
|
|
26
|
+
* Recommended: 30 or 60.
|
|
27
|
+
*/
|
|
22
28
|
fps: number;
|
|
23
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Width of the output video in pixels.
|
|
31
|
+
* Example: 1920 (for 1080p), 1280 (for 720p).
|
|
32
|
+
*/
|
|
24
33
|
width: number;
|
|
25
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Height of the output video in pixels.
|
|
36
|
+
* Example: 1080 (for 1080p), 720 (for 720p).
|
|
37
|
+
*/
|
|
26
38
|
height: number;
|
|
27
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Optional bitrate in bits per second.
|
|
41
|
+
* Higher values = better quality but larger file size.
|
|
42
|
+
* Default: 2,500,000 (2.5 Mbps).
|
|
43
|
+
*/
|
|
28
44
|
bitrate?: number;
|
|
29
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* Optional full path to the output file.
|
|
47
|
+
* If not provided, a temporary file is created in the cache directory.
|
|
48
|
+
*/
|
|
30
49
|
outputPath?: string;
|
|
31
50
|
}
|
|
32
51
|
export type VideoModuleEvents = {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoImageToVideo.types.d.ts","sourceRoot":"","sources":["../src/ExpoImageToVideo.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,QAAQ,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;CAChD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,kBAAkB,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7D,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideo.types.d.ts","sourceRoot":"","sources":["../src/ExpoImageToVideo.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEzD,MAAM,MAAM,kBAAkB,GAAG;IAC/B,GAAG,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG;IACzC,QAAQ,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;CAChD,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,MAAM,MAAM,yBAAyB,GAAG;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,CAAC,KAAK,EAAE;QAAE,WAAW,EAAE,kBAAkB,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7D,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B;;;OAGG;IACH,MAAM,EAAE,MAAM,EAAE,CAAC;IAEjB;;;OAGG;IACH,GAAG,EAAE,MAAM,CAAC;IAEZ;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,iBAAiB,GAAG,EAE/B,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ExpoImageToVideo.types.js","sourceRoot":"","sources":["../src/ExpoImageToVideo.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { StyleProp, ViewStyle } from 'react-native';\n\nexport type OnLoadEventPayload = {\n url: string;\n};\n\nexport type ExpoImageToVideoModuleEvents = {\n onChange: (params: ChangeEventPayload) => void;\n};\n\nexport type ChangeEventPayload = {\n value: string;\n};\n\nexport type ExpoImageToVideoViewProps = {\n url: string;\n onLoad: (event: { nativeEvent: OnLoadEventPayload }) => void;\n style?: StyleProp<ViewStyle>;\n};\n\nexport interface VideoOptions {\n
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideo.types.js","sourceRoot":"","sources":["../src/ExpoImageToVideo.types.ts"],"names":[],"mappings":"","sourcesContent":["import type { StyleProp, ViewStyle } from 'react-native';\n\nexport type OnLoadEventPayload = {\n url: string;\n};\n\nexport type ExpoImageToVideoModuleEvents = {\n onChange: (params: ChangeEventPayload) => void;\n};\n\nexport type ChangeEventPayload = {\n value: string;\n};\n\nexport type ExpoImageToVideoViewProps = {\n url: string;\n onLoad: (event: { nativeEvent: OnLoadEventPayload }) => void;\n style?: StyleProp<ViewStyle>;\n};\n\nexport interface VideoOptions {\n /**\n * An array of local file URIs (file://...) to images.\n * At least one image is required.\n */\n images: string[];\n\n /**\n * Frames per second for the output video.\n * Recommended: 30 or 60.\n */\n fps: number;\n\n /**\n * Width of the output video in pixels.\n * Example: 1920 (for 1080p), 1280 (for 720p).\n */\n width: number;\n\n /**\n * Height of the output video in pixels.\n * Example: 1080 (for 1080p), 720 (for 720p).\n */\n height: number;\n\n /**\n * Optional bitrate in bits per second.\n * Higher values = better quality but larger file size.\n * Default: 2,500,000 (2.5 Mbps).\n */\n bitrate?: number;\n\n /**\n * Optional full path to the output file.\n * If not provided, a temporary file is created in the cache directory.\n */\n outputPath?: string;\n}\n\nexport type VideoModuleEvents = {\n // You could add onProgress here later if needed\n};"]}
|
package/build/index.d.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
export { default as ExpoImageToVideoView } from './ExpoImageToVideoView';
|
|
2
1
|
import { VideoOptions } from './ExpoImageToVideo.types';
|
|
3
2
|
/**
|
|
4
|
-
* Converts a
|
|
5
|
-
* @param options Configuration for
|
|
6
|
-
* @returns A
|
|
3
|
+
* Converts a sequence of images into an MP4 video.
|
|
4
|
+
* * @param options Configuration object for video generation.
|
|
5
|
+
* @returns A Promise that resolves to the file URI of the generated video.
|
|
7
6
|
*/
|
|
8
7
|
export declare function generateVideo(options: VideoOptions): Promise<string>;
|
|
9
|
-
export
|
|
8
|
+
export { VideoOptions };
|
|
10
9
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAExD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAwB1E;AAED,OAAO,EAAE,YAAY,EAAE,CAAC"}
|
package/build/index.js
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
|
-
// Reexport the native module. On web, it will be resolved to ExpoImageToVideoModule.web.ts
|
|
2
|
-
// and on native platforms to ExpoImageToVideoModule.ts
|
|
3
|
-
// export { default } from './ExpoImageToVideoModule';
|
|
4
|
-
export { default as ExpoImageToVideoView } from './ExpoImageToVideoView';
|
|
5
|
-
// export * from './ExpoImageToVideo.types';
|
|
6
1
|
import ExpoImageToVideoModule from './ExpoImageToVideoModule';
|
|
7
2
|
/**
|
|
8
|
-
* Converts a
|
|
9
|
-
* @param options Configuration for
|
|
10
|
-
* @returns A
|
|
3
|
+
* Converts a sequence of images into an MP4 video.
|
|
4
|
+
* * @param options Configuration object for video generation.
|
|
5
|
+
* @returns A Promise that resolves to the file URI of the generated video.
|
|
11
6
|
*/
|
|
12
7
|
export async function generateVideo(options) {
|
|
13
|
-
// Validate
|
|
14
|
-
if (options.images.length === 0) {
|
|
15
|
-
throw new Error("
|
|
8
|
+
// 1. Validate Image List
|
|
9
|
+
if (!options.images || options.images.length === 0) {
|
|
10
|
+
throw new Error("[ExpoImageToVideo] The 'images' array cannot be empty. Please provide at least one image URI.");
|
|
16
11
|
}
|
|
12
|
+
// 2. Validate Dimensions
|
|
13
|
+
if (options.width <= 0 || options.height <= 0) {
|
|
14
|
+
throw new Error(`[ExpoImageToVideo] Invalid dimensions: ${options.width}x${options.height}. Width and height must be positive integers.`);
|
|
15
|
+
}
|
|
16
|
+
// 3. Validate FPS
|
|
17
|
+
if (options.fps <= 0) {
|
|
18
|
+
throw new Error(`[ExpoImageToVideo] Invalid FPS: ${options.fps}. Frames per second must be greater than 0.`);
|
|
19
|
+
}
|
|
20
|
+
// 4. Call Native Module
|
|
17
21
|
return await ExpoImageToVideoModule.generateVideo(options);
|
|
18
22
|
}
|
|
19
|
-
export * from './ExpoImageToVideo.types';
|
|
20
23
|
//# sourceMappingURL=index.js.map
|
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,sBAAsB,MAAM,0BAA0B,CAAC;AAG9D;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAqB;IACvD,yBAAyB;IACzB,IAAI,CAAC,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACnD,MAAM,IAAI,KAAK,CACb,+FAA+F,CAChG,CAAC;IACJ,CAAC;IAED,yBAAyB;IACzB,IAAI,OAAO,CAAC,KAAK,IAAI,CAAC,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,KAAK,CACb,0CAA0C,OAAO,CAAC,KAAK,IAAI,OAAO,CAAC,MAAM,+CAA+C,CACzH,CAAC;IACJ,CAAC;IAED,kBAAkB;IAClB,IAAI,OAAO,CAAC,GAAG,IAAI,CAAC,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CACb,mCAAmC,OAAO,CAAC,GAAG,6CAA6C,CAC5F,CAAC;IACJ,CAAC;IAED,wBAAwB;IACxB,OAAO,MAAM,sBAAsB,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;AAC7D,CAAC","sourcesContent":["import ExpoImageToVideoModule from './ExpoImageToVideoModule';\nimport { VideoOptions } from './ExpoImageToVideo.types';\n\n/**\n * Converts a sequence of images into an MP4 video.\n * * @param options Configuration object for video generation.\n * @returns A Promise that resolves to the file URI of the generated video.\n */\nexport async function generateVideo(options: VideoOptions): Promise<string> {\n // 1. Validate Image List\n if (!options.images || options.images.length === 0) {\n throw new Error(\n \"[ExpoImageToVideo] The 'images' array cannot be empty. Please provide at least one image URI.\"\n );\n }\n\n // 2. Validate Dimensions\n if (options.width <= 0 || options.height <= 0) {\n throw new Error(\n `[ExpoImageToVideo] Invalid dimensions: ${options.width}x${options.height}. Width and height must be positive integers.`\n );\n }\n\n // 3. Validate FPS\n if (options.fps <= 0) {\n throw new Error(\n `[ExpoImageToVideo] Invalid FPS: ${options.fps}. Frames per second must be greater than 0.`\n );\n }\n\n // 4. Call Native Module\n return await ExpoImageToVideoModule.generateVideo(options);\n}\n\nexport { VideoOptions };"]}
|
|
@@ -20,7 +20,8 @@ Pod::Spec.new do |s|
|
|
|
20
20
|
|
|
21
21
|
s.dependency 'ExpoModulesCore'
|
|
22
22
|
|
|
23
|
-
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'UIKit'
|
|
23
|
+
#s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'UIKit'
|
|
24
|
+
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'UIKit', 'CoreGraphics'
|
|
24
25
|
|
|
25
26
|
# Swift/Objective-C compatibility
|
|
26
27
|
s.pod_target_xcconfig = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ExpoModulesCore
|
|
2
2
|
import AVFoundation
|
|
3
|
+
import UIKit
|
|
3
4
|
|
|
4
5
|
public class ExpoImageToVideoModule: Module {
|
|
5
6
|
// Each module class must implement the definition function. The definition consists of components
|
|
@@ -33,19 +34,22 @@ public class ExpoImageToVideoModule: Module {
|
|
|
33
34
|
])
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
// The 'options' argument automatically maps to the VideoOptions struct below
|
|
36
38
|
AsyncFunction("generateVideo") { (options: VideoOptions, promise: Promise) in
|
|
37
|
-
|
|
39
|
+
|
|
40
|
+
// Run on global background queue to avoid blocking UI
|
|
38
41
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
39
42
|
do {
|
|
43
|
+
// 1. Output Path Setup
|
|
40
44
|
let outputURL = options.outputPath != nil
|
|
41
45
|
? URL(fileURLWithPath: options.outputPath!)
|
|
42
46
|
: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("video_\(UUID().uuidString).mp4")
|
|
43
47
|
|
|
44
|
-
// Delete existing file if it exists (AVAssetWriter will fail otherwise)
|
|
45
48
|
if FileManager.default.fileExists(atPath: outputURL.path) {
|
|
46
49
|
try FileManager.default.removeItem(at: outputURL)
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
// 2. Initialize Encoder
|
|
49
53
|
let encoder = try VideoEncoder(
|
|
50
54
|
outputURL: outputURL,
|
|
51
55
|
width: options.width,
|
|
@@ -54,13 +58,18 @@ public class ExpoImageToVideoModule: Module {
|
|
|
54
58
|
bitrate: options.bitrate ?? 2_500_000
|
|
55
59
|
)
|
|
56
60
|
|
|
61
|
+
// 3. Loop through images
|
|
57
62
|
for (index, imageUri) in options.images.enumerated() {
|
|
58
|
-
//
|
|
63
|
+
// Autoreleasepool is critical for memory dumping between frames
|
|
59
64
|
try autoreleasepool {
|
|
60
|
-
guard let url = URL(string: imageUri)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
65
|
+
guard let url = URL(string: imageUri) else { return }
|
|
66
|
+
|
|
67
|
+
// IMPROVEMENT: Downsample image on load to save RAM (like Android's inSampleSize)
|
|
68
|
+
let downsampledImage = self.loadDownsampledImage(at: url, for: CGSize(width: options.width, height: options.height))
|
|
69
|
+
|
|
70
|
+
guard let image = downsampledImage else {
|
|
71
|
+
print("ExpoImageToVideo: Failed to load \(imageUri)")
|
|
72
|
+
return
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
let frameTime = CMTime(value: Int64(index), timescale: Int32(options.fps))
|
|
@@ -68,6 +77,7 @@ public class ExpoImageToVideoModule: Module {
|
|
|
68
77
|
}
|
|
69
78
|
}
|
|
70
79
|
|
|
80
|
+
// 4. Finish
|
|
71
81
|
encoder.finish { success in
|
|
72
82
|
if success {
|
|
73
83
|
promise.resolve(outputURL.path)
|
|
@@ -81,6 +91,23 @@ public class ExpoImageToVideoModule: Module {
|
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
// Helper: Efficiently loads and downsamples image without decoding full resolution first
|
|
95
|
+
private func loadDownsampledImage(at url: URL, for size: CGSize) -> UIImage? {
|
|
96
|
+
let options: [CFString: Any] = [
|
|
97
|
+
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
98
|
+
kCGImageSourceCreateThumbnailWithTransform: true, // Respects EXIF orientation
|
|
99
|
+
kCGImageSourceShouldCacheImmediately: true,
|
|
100
|
+
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
|
104
|
+
let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
|
105
|
+
return nil
|
|
106
|
+
}
|
|
107
|
+
return UIImage(cgImage: cgImage)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
84
111
|
// Enables the module to be used as a native view. Definition components that are accepted as part of the
|
|
85
112
|
// view definition: Prop, Events.
|
|
86
113
|
View(ExpoImageToVideoView.self) {
|
|
@@ -98,11 +125,23 @@ public class ExpoImageToVideoModule: Module {
|
|
|
98
125
|
|
|
99
126
|
|
|
100
127
|
// Structure to map the JS options object
|
|
128
|
+
// Must inherit from 'Record' and use '@Field'
|
|
101
129
|
struct VideoOptions: Record {
|
|
102
|
-
@Field
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
@Field
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
@Field
|
|
131
|
+
var images: [String] = []
|
|
132
|
+
|
|
133
|
+
@Field
|
|
134
|
+
var fps: Int = 30
|
|
135
|
+
|
|
136
|
+
@Field
|
|
137
|
+
var width: Int = 1280
|
|
138
|
+
|
|
139
|
+
@Field
|
|
140
|
+
var height: Int = 720
|
|
141
|
+
|
|
142
|
+
@Field
|
|
143
|
+
var bitrate: Int? = nil
|
|
144
|
+
|
|
145
|
+
@Field
|
|
146
|
+
var outputPath: String? = nil
|
|
108
147
|
}
|