expo-image-to-video 0.1.0
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/README.md +35 -0
- package/android/.gradle/8.9/checksums/checksums.lock +0 -0
- package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
- package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
- package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
- package/android/.gradle/8.9/gc.properties +0 -0
- package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/android/.gradle/vcs-1/gc.properties +0 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/imagetovideo/ExpoImageToVideoModule.kt +103 -0
- package/android/src/main/java/expo/modules/imagetovideo/ExpoImageToVideoView.kt +30 -0
- package/android/src/main/java/expo/modules/imagetovideo/VideoEncoder.kt +102 -0
- package/build/ExpoImageToVideo.types.d.ts +33 -0
- package/build/ExpoImageToVideo.types.d.ts.map +1 -0
- package/build/ExpoImageToVideo.types.js +2 -0
- package/build/ExpoImageToVideo.types.js.map +1 -0
- package/build/ExpoImageToVideoModule.d.ts +11 -0
- package/build/ExpoImageToVideoModule.d.ts.map +1 -0
- package/build/ExpoImageToVideoModule.js +6 -0
- package/build/ExpoImageToVideoModule.js.map +1 -0
- package/build/ExpoImageToVideoModule.web.d.ts +10 -0
- package/build/ExpoImageToVideoModule.web.d.ts.map +1 -0
- package/build/ExpoImageToVideoModule.web.js +12 -0
- package/build/ExpoImageToVideoModule.web.js.map +1 -0
- package/build/ExpoImageToVideoView.d.ts +4 -0
- package/build/ExpoImageToVideoView.d.ts.map +1 -0
- package/build/ExpoImageToVideoView.js +7 -0
- package/build/ExpoImageToVideoView.js.map +1 -0
- package/build/ExpoImageToVideoView.web.d.ts +4 -0
- package/build/ExpoImageToVideoView.web.d.ts.map +1 -0
- package/build/ExpoImageToVideoView.web.js +7 -0
- package/build/ExpoImageToVideoView.web.js.map +1 -0
- package/build/index.d.ts +10 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +20 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoImageToVideo.podspec +31 -0
- package/ios/ExpoImageToVideoModule.swift +108 -0
- package/ios/ExpoImageToVideoView.swift +38 -0
- package/ios/VideoEncoder.swift +85 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# expo-image-to-video
|
|
2
|
+
|
|
3
|
+
High-performance native image-to-video conversion for Expo
|
|
4
|
+
|
|
5
|
+
# API documentation
|
|
6
|
+
|
|
7
|
+
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/image-to-video/)
|
|
8
|
+
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/image-to-video/)
|
|
9
|
+
|
|
10
|
+
# Installation in managed Expo projects
|
|
11
|
+
|
|
12
|
+
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release.
|
|
13
|
+
|
|
14
|
+
# Installation in bare React Native projects
|
|
15
|
+
|
|
16
|
+
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
|
|
17
|
+
|
|
18
|
+
### Add the package to your npm dependencies
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
npm install expo-image-to-video
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Configure for Android
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Configure for iOS
|
|
30
|
+
|
|
31
|
+
Run `npx pod-install` after installing the npm package.
|
|
32
|
+
|
|
33
|
+
# Contributing
|
|
34
|
+
|
|
35
|
+
Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
File without changes
|
|
Binary file
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
group = 'expo.modules.imagetovideo'
|
|
4
|
+
version = '0.1.0'
|
|
5
|
+
|
|
6
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
7
|
+
apply from: expoModulesCorePlugin
|
|
8
|
+
applyKotlinExpoModulesCorePlugin()
|
|
9
|
+
useCoreDependencies()
|
|
10
|
+
useExpoPublishing()
|
|
11
|
+
|
|
12
|
+
// If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
|
|
13
|
+
// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
|
|
14
|
+
// Most of the time, you may like to manage the Android SDK versions yourself.
|
|
15
|
+
def useManagedAndroidSdkVersions = false
|
|
16
|
+
if (useManagedAndroidSdkVersions) {
|
|
17
|
+
useDefaultAndroidSdkVersions()
|
|
18
|
+
} else {
|
|
19
|
+
buildscript {
|
|
20
|
+
// Simple helper that allows the root project to override versions declared by this library.
|
|
21
|
+
ext.safeExtGet = { prop, fallback ->
|
|
22
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
project.android {
|
|
26
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 36)
|
|
27
|
+
defaultConfig {
|
|
28
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
29
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
android {
|
|
35
|
+
namespace "expo.modules.imagetovideo"
|
|
36
|
+
defaultConfig {
|
|
37
|
+
versionCode 1
|
|
38
|
+
versionName "0.1.0"
|
|
39
|
+
}
|
|
40
|
+
lintOptions {
|
|
41
|
+
abortOnError false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
package expo.modules.imagetovideo
|
|
2
|
+
|
|
3
|
+
import expo.modules.kotlin.modules.Module
|
|
4
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
5
|
+
import kotlinx.coroutines.CoroutineScope
|
|
6
|
+
import kotlinx.coroutines.Dispatchers
|
|
7
|
+
import kotlinx.coroutines.launch
|
|
8
|
+
import java.io.File
|
|
9
|
+
import java.net.URL
|
|
10
|
+
|
|
11
|
+
class ExpoImageToVideoModule : Module() {
|
|
12
|
+
// Each module class must implement the definition function. The definition consists of components
|
|
13
|
+
// that describes the module's functionality and behavior.
|
|
14
|
+
// See https://docs.expo.dev/modules/module-api for more details about available components.
|
|
15
|
+
override fun definition() = ModuleDefinition {
|
|
16
|
+
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
|
|
17
|
+
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
|
|
18
|
+
// The module will be accessible from `requireNativeModule('ExpoImageToVideo')` in JavaScript.
|
|
19
|
+
Name("ExpoImageToVideo")
|
|
20
|
+
|
|
21
|
+
// Defines constant property on the module.
|
|
22
|
+
Constant("PI") {
|
|
23
|
+
Math.PI
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Defines event names that the module can send to JavaScript.
|
|
27
|
+
Events("onChange")
|
|
28
|
+
|
|
29
|
+
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
|
30
|
+
Function("hello") {
|
|
31
|
+
"Hello world! 👋"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Defines a JavaScript function that always returns a Promise and whose native code
|
|
35
|
+
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
|
36
|
+
AsyncFunction("setValueAsync") { value: String ->
|
|
37
|
+
// Send an event to JavaScript.
|
|
38
|
+
sendEvent("onChange", mapOf(
|
|
39
|
+
"value" to value
|
|
40
|
+
))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
AsyncFunction("generateVideo") { options: VideoOptions, promise: expo.modules.kotlin.Promise ->
|
|
44
|
+
val context = appContext.reactContext ?: throw Exception("Context not found")
|
|
45
|
+
|
|
46
|
+
// Run in background thread to keep UI and JS thread responsive
|
|
47
|
+
CoroutineScope(Dispatchers.IO).launch {
|
|
48
|
+
try {
|
|
49
|
+
val outputFile = if (options.outputPath != null) {
|
|
50
|
+
File(options.outputPath)
|
|
51
|
+
} else {
|
|
52
|
+
File(context.cacheDir, "video_${System.currentTimeMillis()}.mp4")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
val encoder = VideoEncoder(
|
|
56
|
+
outputFile,
|
|
57
|
+
options.width,
|
|
58
|
+
options.height,
|
|
59
|
+
options.fps,
|
|
60
|
+
options.bitrate ?: 2500000
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
encoder.start()
|
|
64
|
+
|
|
65
|
+
options.images.forEach { uri ->
|
|
66
|
+
// Resolve Expo/Content URIs to Bitmaps
|
|
67
|
+
val bitmap = ImageUtils.loadBitmap(context, uri, options.width, options.height)
|
|
68
|
+
?: throw Exception("Failed to load image: $uri")
|
|
69
|
+
|
|
70
|
+
encoder.encodeFrame(bitmap)
|
|
71
|
+
bitmap.recycle() // Critical for memory management
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
encoder.stop()
|
|
75
|
+
promise.resolve(outputFile.absolutePath)
|
|
76
|
+
} catch (e: Exception) {
|
|
77
|
+
promise.reject("ERR_VIDEO_ENCODING", e.message, e)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Enables the module to be used as a native view. Definition components that are accepted as part of
|
|
83
|
+
// the view definition: Prop, Events.
|
|
84
|
+
View(ExpoImageToVideoView::class) {
|
|
85
|
+
// Defines a setter for the `url` prop.
|
|
86
|
+
Prop("url") { view: ExpoImageToVideoView, url: URL ->
|
|
87
|
+
view.webView.loadUrl(url.toString())
|
|
88
|
+
}
|
|
89
|
+
// Defines an event that the view can send to JavaScript.
|
|
90
|
+
Events("onLoad")
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Data class for Expo Module auto-serialization
|
|
96
|
+
data class VideoOptions(
|
|
97
|
+
val images: List<String>,
|
|
98
|
+
val fps: Int,
|
|
99
|
+
val width: Int,
|
|
100
|
+
val height: Int,
|
|
101
|
+
val bitrate: Int?,
|
|
102
|
+
val outputPath: String?
|
|
103
|
+
) : expo.modules.kotlin.types.Enumerable
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package expo.modules.imagetovideo
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.webkit.WebView
|
|
5
|
+
import android.webkit.WebViewClient
|
|
6
|
+
import expo.modules.kotlin.AppContext
|
|
7
|
+
import expo.modules.kotlin.viewevent.EventDispatcher
|
|
8
|
+
import expo.modules.kotlin.views.ExpoView
|
|
9
|
+
|
|
10
|
+
class ExpoImageToVideoView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
|
|
11
|
+
// Creates and initializes an event dispatcher for the `onLoad` event.
|
|
12
|
+
// The name of the event is inferred from the value and needs to match the event name defined in the module.
|
|
13
|
+
private val onLoad by EventDispatcher()
|
|
14
|
+
|
|
15
|
+
// Defines a WebView that will be used as the root subview.
|
|
16
|
+
internal val webView = WebView(context).apply {
|
|
17
|
+
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
18
|
+
webViewClient = object : WebViewClient() {
|
|
19
|
+
override fun onPageFinished(view: WebView, url: String) {
|
|
20
|
+
// Sends an event to JavaScript. Triggers a callback defined on the view component in JavaScript.
|
|
21
|
+
onLoad(mapOf("url" to url))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
init {
|
|
27
|
+
// Adds the WebView to the view hierarchy.
|
|
28
|
+
addView(webView)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
package expo.modules.imagetovideo
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.Canvas
|
|
5
|
+
import android.graphics.Rect
|
|
6
|
+
import android.media.MediaCodec
|
|
7
|
+
import android.media.MediaCodecInfo
|
|
8
|
+
import android.media.MediaFormat
|
|
9
|
+
import android.media.MediaMuxer
|
|
10
|
+
import android.view.Surface
|
|
11
|
+
import java.io.File
|
|
12
|
+
|
|
13
|
+
class VideoEncoder(
|
|
14
|
+
private val outputFile: File,
|
|
15
|
+
private val width: Int,
|
|
16
|
+
private val height: Int,
|
|
17
|
+
private val fps: Int,
|
|
18
|
+
private val bitrate: Int
|
|
19
|
+
) {
|
|
20
|
+
private var encoder: MediaCodec? = null
|
|
21
|
+
private var muxer: MediaMuxer? = null
|
|
22
|
+
private var inputSurface: Surface? = null
|
|
23
|
+
private var trackIndex = -1
|
|
24
|
+
private var frameDurationUs = 1000000L / fps
|
|
25
|
+
private var frameCount = 0
|
|
26
|
+
|
|
27
|
+
fun start() {
|
|
28
|
+
val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height).apply {
|
|
29
|
+
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
|
|
30
|
+
setInteger(MediaFormat.KEY_BIT_RATE, bitrate)
|
|
31
|
+
setInteger(MediaFormat.KEY_FRAME_RATE, fps)
|
|
32
|
+
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // One I-frame per second
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
encoder = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_VIDEO_AVC).apply {
|
|
36
|
+
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
|
37
|
+
inputSurface = createInputSurface()
|
|
38
|
+
start()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fun encodeFrame(bitmap: Bitmap) {
|
|
45
|
+
val canvas = inputSurface?.lockCanvas(null) ?: return
|
|
46
|
+
try {
|
|
47
|
+
// Draw bitmap and scale to fit the video frame
|
|
48
|
+
val destRect = Rect(0, 0, width, height)
|
|
49
|
+
canvas.drawBitmap(bitmap, null, destRect, null)
|
|
50
|
+
} finally {
|
|
51
|
+
// Setting presentation time in microseconds
|
|
52
|
+
val pts = frameCount * frameDurationUs
|
|
53
|
+
inputSurface?.unlockCanvasAndPost(canvas)
|
|
54
|
+
drainEncoder(false, pts)
|
|
55
|
+
frameCount++
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun drainEncoder(endOfStream: Boolean, pts: Long) {
|
|
60
|
+
val bufferInfo = MediaCodec.BufferInfo()
|
|
61
|
+
val encoder = encoder ?: return
|
|
62
|
+
|
|
63
|
+
if (endOfStream) {
|
|
64
|
+
encoder.signalEndOfInputStream()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
while (true) {
|
|
68
|
+
val outputBufferIndex = encoder.dequeueOutputBuffer(bufferInfo, 10000)
|
|
69
|
+
if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) {
|
|
70
|
+
if (!endOfStream) break
|
|
71
|
+
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
|
|
72
|
+
trackIndex = muxer?.addTrack(encoder.outputFormat) ?: -1
|
|
73
|
+
muxer?.start()
|
|
74
|
+
} else if (outputBufferIndex >= 0) {
|
|
75
|
+
val encodedData = encoder.getOutputBuffer(outputBufferIndex) ?: continue
|
|
76
|
+
|
|
77
|
+
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) {
|
|
78
|
+
bufferInfo.size = 0
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (bufferInfo.size != 0 && trackIndex != -1) {
|
|
82
|
+
encodedData.position(bufferInfo.offset)
|
|
83
|
+
encodedData.limit(bufferInfo.offset + bufferInfo.size)
|
|
84
|
+
bufferInfo.presentationTimeUs = pts
|
|
85
|
+
muxer?.writeSampleData(trackIndex, encodedData, bufferInfo)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
encoder.releaseOutputBuffer(outputBufferIndex, false)
|
|
89
|
+
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) break
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fun stop() {
|
|
95
|
+
drainEncoder(true, frameCount * frameDurationUs)
|
|
96
|
+
encoder?.stop()
|
|
97
|
+
encoder?.release()
|
|
98
|
+
muxer?.stop()
|
|
99
|
+
muxer?.release()
|
|
100
|
+
inputSurface?.release()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
export type OnLoadEventPayload = {
|
|
3
|
+
url: string;
|
|
4
|
+
};
|
|
5
|
+
export type ExpoImageToVideoModuleEvents = {
|
|
6
|
+
onChange: (params: ChangeEventPayload) => void;
|
|
7
|
+
};
|
|
8
|
+
export type ChangeEventPayload = {
|
|
9
|
+
value: string;
|
|
10
|
+
};
|
|
11
|
+
export type ExpoImageToVideoViewProps = {
|
|
12
|
+
url: string;
|
|
13
|
+
onLoad: (event: {
|
|
14
|
+
nativeEvent: OnLoadEventPayload;
|
|
15
|
+
}) => void;
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
};
|
|
18
|
+
export interface VideoOptions {
|
|
19
|
+
/** Array of local file URIs (e.g., from expo-image-picker or expo-file-system) */
|
|
20
|
+
images: string[];
|
|
21
|
+
/** Frames per second (e.g., 30 or 60) */
|
|
22
|
+
fps: number;
|
|
23
|
+
/** Final video width (pixels) */
|
|
24
|
+
width: number;
|
|
25
|
+
/** Final video height (pixels) */
|
|
26
|
+
height: number;
|
|
27
|
+
/** Target bitrate in bits per second. Default is 2.5Mbps (2,500,000) */
|
|
28
|
+
bitrate?: number;
|
|
29
|
+
/** Optional custom output path. If not provided, a temp file is created */
|
|
30
|
+
outputPath?: string;
|
|
31
|
+
}
|
|
32
|
+
export type VideoModuleEvents = {};
|
|
33
|
+
//# sourceMappingURL=ExpoImageToVideo.types.d.ts.map
|
|
@@ -0,0 +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,kFAAkF;IAClF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,yCAAyC;IACzC,GAAG,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,KAAK,EAAE,MAAM,CAAC;IACd,kCAAkC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,MAAM,iBAAiB,GAAG,EAE/B,CAAC"}
|
|
@@ -0,0 +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 /** Array of local file URIs (e.g., from expo-image-picker or expo-file-system) */\n images: string[];\n /** Frames per second (e.g., 30 or 60) */\n fps: number;\n /** Final video width (pixels) */\n width: number;\n /** Final video height (pixels) */\n height: number;\n /** Target bitrate in bits per second. Default is 2.5Mbps (2,500,000) */\n bitrate?: number;\n /** Optional custom output path. If not provided, a temp file is created */\n outputPath?: string;\n}\n\nexport type VideoModuleEvents = {\n // You could add onProgress here later if needed\n};"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NativeModule } from 'expo';
|
|
2
|
+
import { ExpoImageToVideoModuleEvents, VideoOptions } from './ExpoImageToVideo.types';
|
|
3
|
+
declare class ExpoImageToVideoModule extends NativeModule<ExpoImageToVideoModuleEvents> {
|
|
4
|
+
PI: number;
|
|
5
|
+
hello(): string;
|
|
6
|
+
setValueAsync(value: string): Promise<void>;
|
|
7
|
+
generateVideo(options: VideoOptions): Promise<string>;
|
|
8
|
+
}
|
|
9
|
+
declare const _default: ExpoImageToVideoModule;
|
|
10
|
+
export default _default;
|
|
11
|
+
//# sourceMappingURL=ExpoImageToVideoModule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoModule.d.ts","sourceRoot":"","sources":["../src/ExpoImageToVideoModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,EAAE,4BAA4B,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAEtF,OAAO,OAAO,sBAAuB,SAAQ,YAAY,CAAC,4BAA4B,CAAC;IACrF,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,IAAI,MAAM;IACf,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAC3C,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;CACtD;;AAGD,wBAA+E"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { requireNativeModule } from 'expo';
|
|
2
|
+
// This call loads the native module object from the JSI.
|
|
3
|
+
export default requireNativeModule('ExpoImageToVideo');
|
|
4
|
+
// // This links directly to the 'Name("ExpoImageToVideo")' defined in Swift/Kotlin
|
|
5
|
+
// export default requireNativeModule('ExpoImageToVideo');
|
|
6
|
+
//# sourceMappingURL=ExpoImageToVideoModule.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoModule.js","sourceRoot":"","sources":["../src/ExpoImageToVideoModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAWzD,yDAAyD;AACzD,eAAe,mBAAmB,CAAyB,kBAAkB,CAAC,CAAC;AAE/E,mFAAmF;AACnF,0DAA0D","sourcesContent":["import { NativeModule, requireNativeModule } from 'expo';\n\nimport { ExpoImageToVideoModuleEvents, VideoOptions } from './ExpoImageToVideo.types';\n\ndeclare class ExpoImageToVideoModule extends NativeModule<ExpoImageToVideoModuleEvents> {\n PI: number;\n hello(): string;\n setValueAsync(value: string): Promise<void>;\n generateVideo(options: VideoOptions): Promise<string>;\n}\n\n// This call loads the native module object from the JSI.\nexport default requireNativeModule<ExpoImageToVideoModule>('ExpoImageToVideo');\n\n// // This links directly to the 'Name(\"ExpoImageToVideo\")' defined in Swift/Kotlin\n// export default requireNativeModule('ExpoImageToVideo');"]}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NativeModule } from 'expo';
|
|
2
|
+
import { ExpoImageToVideoModuleEvents } from './ExpoImageToVideo.types';
|
|
3
|
+
declare class ExpoImageToVideoModule extends NativeModule<ExpoImageToVideoModuleEvents> {
|
|
4
|
+
PI: number;
|
|
5
|
+
setValueAsync(value: string): Promise<void>;
|
|
6
|
+
hello(): string;
|
|
7
|
+
}
|
|
8
|
+
declare const _default: typeof ExpoImageToVideoModule;
|
|
9
|
+
export default _default;
|
|
10
|
+
//# sourceMappingURL=ExpoImageToVideoModule.web.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoModule.web.d.ts","sourceRoot":"","sources":["../src/ExpoImageToVideoModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,YAAY,EAAE,MAAM,MAAM,CAAC;AAEvD,OAAO,EAAE,4BAA4B,EAAE,MAAM,0BAA0B,CAAC;AAExE,cAAM,sBAAuB,SAAQ,YAAY,CAAC,4BAA4B,CAAC;IAC7E,EAAE,SAAW;IACP,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAGjD,KAAK;CAGN;;AAED,wBAAmF"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { registerWebModule, NativeModule } from 'expo';
|
|
2
|
+
class ExpoImageToVideoModule extends NativeModule {
|
|
3
|
+
PI = Math.PI;
|
|
4
|
+
async setValueAsync(value) {
|
|
5
|
+
this.emit('onChange', { value });
|
|
6
|
+
}
|
|
7
|
+
hello() {
|
|
8
|
+
return 'Hello world! 👋';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export default registerWebModule(ExpoImageToVideoModule, 'ExpoImageToVideoModule');
|
|
12
|
+
//# sourceMappingURL=ExpoImageToVideoModule.web.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoModule.web.js","sourceRoot":"","sources":["../src/ExpoImageToVideoModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAIvD,MAAM,sBAAuB,SAAQ,YAA0C;IAC7E,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;IACb,KAAK,CAAC,aAAa,CAAC,KAAa;QAC/B,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC;IACnC,CAAC;IACD,KAAK;QACH,OAAO,iBAAiB,CAAC;IAC3B,CAAC;CACF;AAED,eAAe,iBAAiB,CAAC,sBAAsB,EAAE,wBAAwB,CAAC,CAAC","sourcesContent":["import { registerWebModule, NativeModule } from 'expo';\n\nimport { ExpoImageToVideoModuleEvents } from './ExpoImageToVideo.types';\n\nclass ExpoImageToVideoModule extends NativeModule<ExpoImageToVideoModuleEvents> {\n PI = Math.PI;\n async setValueAsync(value: string): Promise<void> {\n this.emit('onChange', { value });\n }\n hello() {\n return 'Hello world! 👋';\n }\n}\n\nexport default registerWebModule(ExpoImageToVideoModule, 'ExpoImageToVideoModule');\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoView.d.ts","sourceRoot":"","sources":["../src/ExpoImageToVideoView.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAKrE,MAAM,CAAC,OAAO,UAAU,oBAAoB,CAAC,KAAK,EAAE,yBAAyB,qBAE5E"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { requireNativeView } from 'expo';
|
|
2
|
+
import * as React from 'react';
|
|
3
|
+
const NativeView = requireNativeView('ExpoImageToVideo');
|
|
4
|
+
export default function ExpoImageToVideoView(props) {
|
|
5
|
+
return <NativeView {...props}/>;
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=ExpoImageToVideoView.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoView.js","sourceRoot":"","sources":["../src/ExpoImageToVideoView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AACzC,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,UAAU,GACd,iBAAiB,CAAC,kBAAkB,CAAC,CAAC;AAExC,MAAM,CAAC,OAAO,UAAU,oBAAoB,CAAC,KAAgC;IAC3E,OAAO,CAAC,UAAU,CAAC,IAAI,KAAK,CAAC,EAAG,CAAC;AACnC,CAAC","sourcesContent":["import { requireNativeView } from 'expo';\nimport * as React from 'react';\n\nimport { ExpoImageToVideoViewProps } from './ExpoImageToVideo.types';\n\nconst NativeView: React.ComponentType<ExpoImageToVideoViewProps> =\n requireNativeView('ExpoImageToVideo');\n\nexport default function ExpoImageToVideoView(props: ExpoImageToVideoViewProps) {\n return <NativeView {...props} />;\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoView.web.d.ts","sourceRoot":"","sources":["../src/ExpoImageToVideoView.web.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAE/B,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AAErE,MAAM,CAAC,OAAO,UAAU,oBAAoB,CAAC,KAAK,EAAE,yBAAyB,qBAU5E"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
export default function ExpoImageToVideoView(props) {
|
|
3
|
+
return (<div>
|
|
4
|
+
<iframe style={{ flex: 1 }} src={props.url} onLoad={() => props.onLoad({ nativeEvent: { url: props.url } })}/>
|
|
5
|
+
</div>);
|
|
6
|
+
}
|
|
7
|
+
//# sourceMappingURL=ExpoImageToVideoView.web.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoImageToVideoView.web.js","sourceRoot":"","sources":["../src/ExpoImageToVideoView.web.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,MAAM,CAAC,OAAO,UAAU,oBAAoB,CAAC,KAAgC;IAC3E,OAAO,CACL,CAAC,GAAG,CACF;MAAA,CAAC,MAAM,CACL,KAAK,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CACnB,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CACf,MAAM,CAAC,CAAC,GAAG,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,EAEpE;IAAA,EAAE,GAAG,CAAC,CACP,CAAC;AACJ,CAAC","sourcesContent":["import * as React from 'react';\n\nimport { ExpoImageToVideoViewProps } from './ExpoImageToVideo.types';\n\nexport default function ExpoImageToVideoView(props: ExpoImageToVideoViewProps) {\n return (\n <div>\n <iframe\n style={{ flex: 1 }}\n src={props.url}\n onLoad={() => props.onLoad({ nativeEvent: { url: props.url } })}\n />\n </div>\n );\n}\n"]}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as ExpoImageToVideoView } from './ExpoImageToVideoView';
|
|
2
|
+
import { VideoOptions } from './ExpoImageToVideo.types';
|
|
3
|
+
/**
|
|
4
|
+
* Converts a list of images to an MP4 video file using native hardware encoders.
|
|
5
|
+
* @param options Configuration for the video encoding process.
|
|
6
|
+
* @returns A promise that resolves to the local URI of the generated .mp4 file.
|
|
7
|
+
*/
|
|
8
|
+
export declare function generateVideo(options: VideoOptions): Promise<string>;
|
|
9
|
+
export * from './ExpoImageToVideo.types';
|
|
10
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,OAAO,IAAI,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAIzE,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAGxD;;;;GAIG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAO1E;AAED,cAAc,0BAA0B,CAAC"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
import ExpoImageToVideoModule from './ExpoImageToVideoModule';
|
|
7
|
+
/**
|
|
8
|
+
* Converts a list of images to an MP4 video file using native hardware encoders.
|
|
9
|
+
* @param options Configuration for the video encoding process.
|
|
10
|
+
* @returns A promise that resolves to the local URI of the generated .mp4 file.
|
|
11
|
+
*/
|
|
12
|
+
export async function generateVideo(options) {
|
|
13
|
+
// Validate basic requirements before hitting native code
|
|
14
|
+
if (options.images.length === 0) {
|
|
15
|
+
throw new Error("At least one image is required to generate a video.");
|
|
16
|
+
}
|
|
17
|
+
return await ExpoImageToVideoModule.generateVideo(options);
|
|
18
|
+
}
|
|
19
|
+
export * from './ExpoImageToVideo.types';
|
|
20
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,uDAAuD;AACvD,sDAAsD;AACtD,OAAO,EAAE,OAAO,IAAI,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AACzE,6CAA6C;AAE7C,OAAO,sBAAsB,MAAM,0BAA0B,CAAC;AAI9D;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,OAAqB;IACvD,yDAAyD;IACzD,IAAI,OAAO,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAChC,MAAM,IAAI,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACzE,CAAC;IAED,OAAO,MAAM,sBAAsB,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;AAC7D,CAAC;AAED,cAAc,0BAA0B,CAAC","sourcesContent":["// Reexport the native module. On web, it will be resolved to ExpoImageToVideoModule.web.ts\n// and on native platforms to ExpoImageToVideoModule.ts\n// export { default } from './ExpoImageToVideoModule';\nexport { default as ExpoImageToVideoView } from './ExpoImageToVideoView';\n// export * from './ExpoImageToVideo.types';\n\nimport ExpoImageToVideoModule from './ExpoImageToVideoModule';\nimport { VideoOptions } from './ExpoImageToVideo.types';\n\n\n/**\n * Converts a list of images to an MP4 video file using native hardware encoders.\n * @param options Configuration for the video encoding process.\n * @returns A promise that resolves to the local URI of the generated .mp4 file.\n */\nexport async function generateVideo(options: VideoOptions): Promise<string> {\n // Validate basic requirements before hitting native code\n if (options.images.length === 0) {\n throw new Error(\"At least one image is required to generate a video.\");\n }\n \n return await ExpoImageToVideoModule.generateVideo(options);\n}\n\nexport * from './ExpoImageToVideo.types';"]}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'ExpoImageToVideo'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.platforms = {
|
|
14
|
+
:ios => '15.1',
|
|
15
|
+
:tvos => '15.1'
|
|
16
|
+
}
|
|
17
|
+
s.swift_version = '5.9'
|
|
18
|
+
s.source = { git: 'https://github.com/tsnguyenducphuong/media-kit' }
|
|
19
|
+
s.static_framework = true
|
|
20
|
+
|
|
21
|
+
s.dependency 'ExpoModulesCore'
|
|
22
|
+
|
|
23
|
+
s.frameworks = 'AVFoundation', 'CoreMedia', 'CoreVideo', 'UIKit'
|
|
24
|
+
|
|
25
|
+
# Swift/Objective-C compatibility
|
|
26
|
+
s.pod_target_xcconfig = {
|
|
27
|
+
'DEFINES_MODULE' => 'YES',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
31
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
public class ExpoImageToVideoModule: Module {
|
|
5
|
+
// Each module class must implement the definition function. The definition consists of components
|
|
6
|
+
// that describes the module's functionality and behavior.
|
|
7
|
+
// See https://docs.expo.dev/modules/module-api for more details about available components.
|
|
8
|
+
public func definition() -> ModuleDefinition {
|
|
9
|
+
// Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
|
|
10
|
+
// Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
|
|
11
|
+
// The module will be accessible from `requireNativeModule('ExpoImageToVideo')` in JavaScript.
|
|
12
|
+
Name("ExpoImageToVideo")
|
|
13
|
+
|
|
14
|
+
// Defines constant property on the module.
|
|
15
|
+
Constant("PI") {
|
|
16
|
+
Double.pi
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Defines event names that the module can send to JavaScript.
|
|
20
|
+
Events("onChange")
|
|
21
|
+
|
|
22
|
+
// Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
|
|
23
|
+
Function("hello") {
|
|
24
|
+
return "Hello world! 👋"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Defines a JavaScript function that always returns a Promise and whose native code
|
|
28
|
+
// is by default dispatched on the different thread than the JavaScript runtime runs on.
|
|
29
|
+
AsyncFunction("setValueAsync") { (value: String) in
|
|
30
|
+
// Send an event to JavaScript.
|
|
31
|
+
self.sendEvent("onChange", [
|
|
32
|
+
"value": value
|
|
33
|
+
])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
AsyncFunction("generateVideo") { (options: VideoOptions, promise: Promise) in
|
|
37
|
+
// Move to background thread to avoid blocking the JS/UI threads
|
|
38
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
39
|
+
do {
|
|
40
|
+
let outputURL = options.outputPath != nil
|
|
41
|
+
? URL(fileURLWithPath: options.outputPath!)
|
|
42
|
+
: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("video_\(UUID().uuidString).mp4")
|
|
43
|
+
|
|
44
|
+
// Delete existing file if it exists (AVAssetWriter will fail otherwise)
|
|
45
|
+
if FileManager.default.fileExists(atPath: outputURL.path) {
|
|
46
|
+
try FileManager.default.removeItem(at: outputURL)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let encoder = try VideoEncoder(
|
|
50
|
+
outputURL: outputURL,
|
|
51
|
+
width: options.width,
|
|
52
|
+
height: options.height,
|
|
53
|
+
fps: options.fps,
|
|
54
|
+
bitrate: options.bitrate ?? 2_500_000
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
for (index, imageUri) in options.images.enumerated() {
|
|
58
|
+
// Use autoreleasepool to prevent memory spikes during high-res processing
|
|
59
|
+
try autoreleasepool {
|
|
60
|
+
guard let url = URL(string: imageUri),
|
|
61
|
+
let data = try? Data(contentsOf: url),
|
|
62
|
+
let image = UIImage(data: data) else {
|
|
63
|
+
throw NSError(domain: "ExpoImageToVideo", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to load image at \(imageUri)"])
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let frameTime = CMTime(value: Int64(index), timescale: Int32(options.fps))
|
|
67
|
+
try encoder.addFrame(image: image, at: frameTime)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
encoder.finish { success in
|
|
72
|
+
if success {
|
|
73
|
+
promise.resolve(outputURL.path)
|
|
74
|
+
} else {
|
|
75
|
+
promise.reject("ERR_VIDEO_FINALIZATION", "Could not finalize MP4 file")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
promise.reject("ERR_VIDEO_ENCODING", error.localizedDescription)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Enables the module to be used as a native view. Definition components that are accepted as part of the
|
|
85
|
+
// view definition: Prop, Events.
|
|
86
|
+
View(ExpoImageToVideoView.self) {
|
|
87
|
+
// Defines a setter for the `url` prop.
|
|
88
|
+
Prop("url") { (view: ExpoImageToVideoView, url: URL) in
|
|
89
|
+
if view.webView.url != url {
|
|
90
|
+
view.webView.load(URLRequest(url: url))
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
Events("onLoad")
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
// Structure to map the JS options object
|
|
101
|
+
struct VideoOptions: Record {
|
|
102
|
+
@Field var images: [String] = []
|
|
103
|
+
@Field var fps: Int = 30
|
|
104
|
+
@Field var width: Int = 1280
|
|
105
|
+
@Field var height: Int = 720
|
|
106
|
+
@Field var bitrate: Int? = nil
|
|
107
|
+
@Field var outputPath: String? = nil
|
|
108
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import WebKit
|
|
3
|
+
|
|
4
|
+
// This view will be used as a native component. Make sure to inherit from `ExpoView`
|
|
5
|
+
// to apply the proper styling (e.g. border radius and shadows).
|
|
6
|
+
class ExpoImageToVideoView: ExpoView {
|
|
7
|
+
let webView = WKWebView()
|
|
8
|
+
let onLoad = EventDispatcher()
|
|
9
|
+
var delegate: WebViewDelegate?
|
|
10
|
+
|
|
11
|
+
required init(appContext: AppContext? = nil) {
|
|
12
|
+
super.init(appContext: appContext)
|
|
13
|
+
clipsToBounds = true
|
|
14
|
+
delegate = WebViewDelegate { url in
|
|
15
|
+
self.onLoad(["url": url])
|
|
16
|
+
}
|
|
17
|
+
webView.navigationDelegate = delegate
|
|
18
|
+
addSubview(webView)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override func layoutSubviews() {
|
|
22
|
+
webView.frame = bounds
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class WebViewDelegate: NSObject, WKNavigationDelegate {
|
|
27
|
+
let onUrlChange: (String) -> Void
|
|
28
|
+
|
|
29
|
+
init(onUrlChange: @escaping (String) -> Void) {
|
|
30
|
+
self.onUrlChange = onUrlChange
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) {
|
|
34
|
+
if let url = webView.url {
|
|
35
|
+
onUrlChange(url.absoluteString)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
class VideoEncoder {
|
|
5
|
+
private let assetWriter: AVAssetWriter
|
|
6
|
+
private let input: AVAssetWriterInput
|
|
7
|
+
private let adaptor: AVAssetWriterInputPixelBufferAdaptor
|
|
8
|
+
private let width: Int
|
|
9
|
+
private let height: Int
|
|
10
|
+
|
|
11
|
+
init(outputURL: URL, width: Int, height: Int, fps: Int, bitrate: Int) throws {
|
|
12
|
+
self.width = width
|
|
13
|
+
self.height = height
|
|
14
|
+
|
|
15
|
+
assetWriter = try AVAssetWriter(outputURL: outputURL, fileType: .mp4)
|
|
16
|
+
|
|
17
|
+
let videoSettings: [String: Any] = [
|
|
18
|
+
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
19
|
+
AVVideoWidthKey: width,
|
|
20
|
+
AVVideoHeightKey: height,
|
|
21
|
+
AVVideoCompressionPropertiesKey: [
|
|
22
|
+
AVVideoAverageBitRateKey: bitrate,
|
|
23
|
+
AVVideoExpectedSourceFrameRateKey: fps,
|
|
24
|
+
AVVideoMaxKeyFrameIntervalKey: fps
|
|
25
|
+
]
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
input = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
|
29
|
+
input.expectsMediaDataInRealTime = false
|
|
30
|
+
|
|
31
|
+
let bufferAttributes: [String: Any] = [
|
|
32
|
+
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB,
|
|
33
|
+
kCVPixelBufferWidthKey as String: width,
|
|
34
|
+
kCVPixelBufferHeightKey as String: height
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: input, sourcePixelBufferAttributes: bufferAttributes)
|
|
38
|
+
|
|
39
|
+
assetWriter.add(input)
|
|
40
|
+
assetWriter.startWriting()
|
|
41
|
+
assetWriter.startSession(atSourceTime: .zero)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func addFrame(image: UIImage, at time: CMTime) throws {
|
|
45
|
+
// Wait until the hardware encoder is ready for the next frame
|
|
46
|
+
while !input.isReadyForMoreMediaData {
|
|
47
|
+
Thread.sleep(forTimeInterval: 0.01)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
guard let pixelBuffer = createPixelBuffer(from: image) else {
|
|
51
|
+
throw NSError(domain: "VideoEncoder", code: 2, userInfo: [NSLocalizedDescriptionKey: "PixelBuffer creation failed"])
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if !adaptor.append(pixelBuffer, withPresentationTime: time) {
|
|
55
|
+
throw assetWriter.error ?? NSError(domain: "VideoEncoder", code: 3, userInfo: [NSLocalizedDescriptionKey: "Failed to append pixel buffer"])
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private func createPixelBuffer(from image: UIImage) -> CVPixelBuffer? {
|
|
60
|
+
var pixelBuffer: CVPixelBuffer?
|
|
61
|
+
let status = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, adaptor.pixelBufferPool!, &pixelBuffer)
|
|
62
|
+
|
|
63
|
+
guard status == kCVReturnSuccess, let buffer = pixelBuffer else { return nil }
|
|
64
|
+
|
|
65
|
+
CVPixelBufferLockBaseAddress(buffer, [])
|
|
66
|
+
let data = CVPixelBufferGetBaseAddress(buffer)
|
|
67
|
+
|
|
68
|
+
let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
|
|
69
|
+
let context = CGContext(data: data, width: width, height: height, bitsPerComponent: 8, bytesPerRow: CVPixelBufferGetBytesPerRow(buffer), space: rgbColorSpace, bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue)
|
|
70
|
+
|
|
71
|
+
if let cgImage = image.cgImage {
|
|
72
|
+
context?.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
CVPixelBufferUnlockBaseAddress(buffer, [])
|
|
76
|
+
return buffer
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func finish(completion: @escaping (Bool) -> Void) {
|
|
80
|
+
input.markAsFinished()
|
|
81
|
+
assetWriter.finishWriting {
|
|
82
|
+
completion(self.assetWriter.status == .completed)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-image-to-video",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "High-performance native image-to-video conversion for Expo",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"build",
|
|
9
|
+
"ios",
|
|
10
|
+
"android",
|
|
11
|
+
"expo-module.config.json",
|
|
12
|
+
"package.json"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "expo-module build",
|
|
16
|
+
"clean": "expo-module clean",
|
|
17
|
+
"lint": "expo-module lint",
|
|
18
|
+
"test": "expo-module test",
|
|
19
|
+
"prepare": "expo-module prepare",
|
|
20
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
21
|
+
"expo-module": "expo-module",
|
|
22
|
+
"open:ios": "xed example/ios",
|
|
23
|
+
"open:android": "open -a \"Android Studio\" example/android"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"react-native",
|
|
27
|
+
"expo",
|
|
28
|
+
"expo-image-to-video",
|
|
29
|
+
"ExpoImageToVideo"
|
|
30
|
+
],
|
|
31
|
+
"repository": "https://github.com/tsnguyenducphuong/media-kit",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/tsnguyenducphuong/media-kit/issues"
|
|
34
|
+
},
|
|
35
|
+
"author": "Phuong Nguyen <ts.nguyenducphuong@gmail.com> (https://github.com/tsnguyenducphuong)",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"homepage": "https://github.com/tsnguyenducphuong/media-kit#readme",
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/react": "~19.1.0",
|
|
40
|
+
"expo": "^54.0.27",
|
|
41
|
+
"expo-module-scripts": "^5.0.8",
|
|
42
|
+
"react-native": "0.81.5"
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"expo": "*",
|
|
46
|
+
"react": "*",
|
|
47
|
+
"react-native": "*"
|
|
48
|
+
}
|
|
49
|
+
}
|