@stefanmartin/expo-video-watermark 0.2.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/.eslintrc.js +5 -0
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/android/build.gradle +58 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/videowatermark/ExpoVideoWatermarkModule.kt +167 -0
- package/build/ExpoVideoWatermarkModule.d.ts +14 -0
- package/build/ExpoVideoWatermarkModule.d.ts.map +1 -0
- package/build/ExpoVideoWatermarkModule.js +4 -0
- package/build/ExpoVideoWatermarkModule.js.map +1 -0
- package/build/ExpoVideoWatermarkModule.web.d.ts +7 -0
- package/build/ExpoVideoWatermarkModule.web.d.ts.map +1 -0
- package/build/ExpoVideoWatermarkModule.web.js +8 -0
- package/build/ExpoVideoWatermarkModule.web.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoVideoWatermark.podspec +29 -0
- package/ios/ExpoVideoWatermarkModule.swift +205 -0
- package/package.json +43 -0
- package/src/ExpoVideoWatermarkModule.ts +15 -0
- package/src/ExpoVideoWatermarkModule.web.ts +9 -0
- package/src/index.ts +3 -0
- package/tsconfig.json +9 -0
package/.eslintrc.js
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Stefan Martin
|
|
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,52 @@
|
|
|
1
|
+
# expo-video-watermark
|
|
2
|
+
|
|
3
|
+
An Expo native module for adding watermark images to videos on iOS and Android.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx expo install @stefanmartin/expo-video-watermark
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import ExpoVideoWatermark from '@stefanmartin/expo-video-watermark';
|
|
15
|
+
|
|
16
|
+
// Add a watermark to a video
|
|
17
|
+
const outputPath = await ExpoVideoWatermark.watermarkVideo(
|
|
18
|
+
'/path/to/source/video.mp4',
|
|
19
|
+
'/path/to/watermark.png',
|
|
20
|
+
'/path/to/output/video.mp4'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
console.log('Watermarked video saved to:', outputPath);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
### `watermarkVideo(videoPath, imagePath, outputPath)`
|
|
29
|
+
|
|
30
|
+
Adds a watermark image onto a video and writes the result to the specified output path.
|
|
31
|
+
|
|
32
|
+
**Parameters:**
|
|
33
|
+
|
|
34
|
+
| Name | Type | Description |
|
|
35
|
+
|------|------|-------------|
|
|
36
|
+
| `videoPath` | `string` | Local filesystem path to the source MP4 video |
|
|
37
|
+
| `imagePath` | `string` | Local filesystem path to the PNG watermark image |
|
|
38
|
+
| `outputPath` | `string` | Local filesystem path where the output MP4 should be written |
|
|
39
|
+
|
|
40
|
+
**Returns:** `Promise<string>` - Resolves with the output path on success.
|
|
41
|
+
|
|
42
|
+
## Platform Support
|
|
43
|
+
|
|
44
|
+
| Platform | Supported |
|
|
45
|
+
|----------|-----------|
|
|
46
|
+
| iOS | Yes |
|
|
47
|
+
| Android | Yes |
|
|
48
|
+
| Web | No |
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
|
|
3
|
+
group = 'expo.modules.videowatermark'
|
|
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", 33)
|
|
29
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 36)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
android {
|
|
35
|
+
namespace "expo.modules.videowatermark"
|
|
36
|
+
defaultConfig {
|
|
37
|
+
versionCode 1
|
|
38
|
+
versionName "0.1.0"
|
|
39
|
+
}
|
|
40
|
+
lintOptions {
|
|
41
|
+
abortOnError false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Media3 Transformer + effect dependencies for watermarking (replacing FFmpeg)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
dependencies {
|
|
48
|
+
def media3_version = "1.9.1"
|
|
49
|
+
|
|
50
|
+
// Core Editing & Transformation (What you have, updated)
|
|
51
|
+
implementation "androidx.media3:media3-transformer:$media3_version"
|
|
52
|
+
implementation "androidx.media3:media3-effect:$media3_version"
|
|
53
|
+
implementation "androidx.media3:media3-common-ktx:$media3_version"
|
|
54
|
+
|
|
55
|
+
// Recommended for API 33+ Workflows:
|
|
56
|
+
implementation "androidx.media3:media3-exoplayer:$media3_version" // For real-time preview
|
|
57
|
+
implementation "androidx.media3:media3-muxer:$media3_version" // High-performance MP4/fragmented MP4 output
|
|
58
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
package expo.modules.videowatermark
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.graphics.BitmapFactory
|
|
6
|
+
import android.media.MediaMetadataRetriever
|
|
7
|
+
import android.os.Handler
|
|
8
|
+
import android.os.Looper
|
|
9
|
+
import androidx.annotation.OptIn
|
|
10
|
+
import androidx.media3.common.MediaItem
|
|
11
|
+
import androidx.media3.common.util.UnstableApi
|
|
12
|
+
import androidx.media3.effect.BitmapOverlay
|
|
13
|
+
import androidx.media3.effect.OverlayEffect
|
|
14
|
+
import androidx.media3.effect.OverlaySettings
|
|
15
|
+
import androidx.media3.transformer.Composition
|
|
16
|
+
import androidx.media3.transformer.EditedMediaItem
|
|
17
|
+
import androidx.media3.transformer.Effects
|
|
18
|
+
import androidx.media3.transformer.ExportException
|
|
19
|
+
import androidx.media3.transformer.ExportResult
|
|
20
|
+
import androidx.media3.transformer.Transformer
|
|
21
|
+
import com.google.common.collect.ImmutableList
|
|
22
|
+
import expo.modules.kotlin.Promise
|
|
23
|
+
import expo.modules.kotlin.exception.Exceptions
|
|
24
|
+
import expo.modules.kotlin.modules.Module
|
|
25
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
26
|
+
import java.io.File
|
|
27
|
+
|
|
28
|
+
class ExpoVideoWatermarkModule : Module() {
|
|
29
|
+
private val context: Context
|
|
30
|
+
get() = appContext.reactContext ?: throw Exceptions.AppContextLost()
|
|
31
|
+
|
|
32
|
+
@OptIn(UnstableApi::class)
|
|
33
|
+
override fun definition() = ModuleDefinition {
|
|
34
|
+
Name("ExpoVideoWatermark")
|
|
35
|
+
|
|
36
|
+
AsyncFunction("watermarkVideo") { videoPath: String, imagePath: String, outputPath: String, promise: Promise ->
|
|
37
|
+
processWatermark(videoPath, imagePath, outputPath, promise)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@OptIn(UnstableApi::class)
|
|
42
|
+
private fun processWatermark(
|
|
43
|
+
videoPath: String,
|
|
44
|
+
imagePath: String,
|
|
45
|
+
outputPath: String,
|
|
46
|
+
promise: Promise
|
|
47
|
+
) {
|
|
48
|
+
// Strip file:// prefix if present
|
|
49
|
+
val cleanVideoPath = videoPath.removePrefix("file://")
|
|
50
|
+
val cleanImagePath = imagePath.removePrefix("file://")
|
|
51
|
+
val cleanOutputPath = outputPath.removePrefix("file://")
|
|
52
|
+
|
|
53
|
+
// Validate video file exists
|
|
54
|
+
val videoFile = File(cleanVideoPath)
|
|
55
|
+
if (!videoFile.exists()) {
|
|
56
|
+
promise.reject("VIDEO_NOT_FOUND", "Video file not found at path: $cleanVideoPath", null)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Validate image file exists
|
|
61
|
+
val imageFile = File(cleanImagePath)
|
|
62
|
+
if (!imageFile.exists()) {
|
|
63
|
+
promise.reject("IMAGE_NOT_FOUND", "Watermark image not found at path: $cleanImagePath", null)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Load the watermark bitmap
|
|
68
|
+
val watermarkBitmap: Bitmap? = BitmapFactory.decodeFile(cleanImagePath)
|
|
69
|
+
if (watermarkBitmap == null) {
|
|
70
|
+
promise.reject("IMAGE_DECODE_ERROR", "Failed to decode image at: $cleanImagePath", null)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Ensure output directory exists
|
|
75
|
+
val outputFile = File(cleanOutputPath)
|
|
76
|
+
outputFile.parentFile?.mkdirs()
|
|
77
|
+
|
|
78
|
+
// Remove existing output file if present
|
|
79
|
+
if (outputFile.exists()) {
|
|
80
|
+
outputFile.delete()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get video dimensions to calculate scale
|
|
84
|
+
val retriever = MediaMetadataRetriever()
|
|
85
|
+
try {
|
|
86
|
+
retriever.setDataSource(cleanVideoPath)
|
|
87
|
+
} catch (e: Exception) {
|
|
88
|
+
promise.reject("VIDEO_METADATA_ERROR", "Failed to read video metadata: ${e.message}", e)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
val videoWidth = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toFloatOrNull() ?: 0f
|
|
93
|
+
val videoHeight = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toFloatOrNull() ?: 0f
|
|
94
|
+
retriever.release()
|
|
95
|
+
|
|
96
|
+
if (videoWidth <= 0 || videoHeight <= 0) {
|
|
97
|
+
promise.reject("VIDEO_METADATA_ERROR", "Failed to get video dimensions", null)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Calculate scale to make watermark span full video width, maintaining aspect ratio
|
|
102
|
+
val watermarkWidth = watermarkBitmap.width.toFloat()
|
|
103
|
+
val scale = videoWidth / watermarkWidth
|
|
104
|
+
|
|
105
|
+
// Create overlay settings for full-width bottom positioning
|
|
106
|
+
// In Media3, coordinates are normalized: (0,0) is center
|
|
107
|
+
// x range [-1, 1] (left to right), y range [-1, 1] (bottom to top)
|
|
108
|
+
val overlaySettings = OverlaySettings.Builder()
|
|
109
|
+
.setScale(scale, scale) // Scale uniformly to match video width
|
|
110
|
+
.setOverlayFrameAnchor(0f, -1f) // Anchor at bottom-center of watermark
|
|
111
|
+
.setBackgroundFrameAnchor(0f, -1f) // Position at very bottom of video
|
|
112
|
+
.build()
|
|
113
|
+
|
|
114
|
+
// Create the bitmap overlay
|
|
115
|
+
val bitmapOverlay = BitmapOverlay.createStaticBitmapOverlay(
|
|
116
|
+
watermarkBitmap,
|
|
117
|
+
overlaySettings
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Create overlay effect
|
|
121
|
+
val overlayEffect = OverlayEffect(ImmutableList.of(bitmapOverlay))
|
|
122
|
+
|
|
123
|
+
// Create effects with video overlay
|
|
124
|
+
val effects = Effects(
|
|
125
|
+
/* audioProcessors= */ listOf(),
|
|
126
|
+
/* videoEffects= */ listOf(overlayEffect)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
// Create media item from video
|
|
130
|
+
val mediaItem = MediaItem.fromUri("file://$cleanVideoPath")
|
|
131
|
+
|
|
132
|
+
// Create edited media item with effects
|
|
133
|
+
val editedMediaItem = EditedMediaItem.Builder(mediaItem)
|
|
134
|
+
.setEffects(effects)
|
|
135
|
+
.build()
|
|
136
|
+
|
|
137
|
+
// Handler for main thread callbacks
|
|
138
|
+
val mainHandler = Handler(Looper.getMainLooper())
|
|
139
|
+
|
|
140
|
+
// Build and start transformer
|
|
141
|
+
mainHandler.post {
|
|
142
|
+
val transformer = Transformer.Builder(context)
|
|
143
|
+
.addListener(object : Transformer.Listener {
|
|
144
|
+
override fun onCompleted(composition: Composition, exportResult: ExportResult) {
|
|
145
|
+
watermarkBitmap.recycle()
|
|
146
|
+
promise.resolve(cleanOutputPath)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
override fun onError(
|
|
150
|
+
composition: Composition,
|
|
151
|
+
exportResult: ExportResult,
|
|
152
|
+
exportException: ExportException
|
|
153
|
+
) {
|
|
154
|
+
watermarkBitmap.recycle()
|
|
155
|
+
promise.reject(
|
|
156
|
+
"EXPORT_FAILED",
|
|
157
|
+
"Video export failed: ${exportException.message ?: "Unknown error"}",
|
|
158
|
+
exportException
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
.build()
|
|
163
|
+
|
|
164
|
+
transformer.start(editedMediaItem, cleanOutputPath)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NativeModule } from 'expo';
|
|
2
|
+
declare class ExpoVideoWatermarkModule extends NativeModule {
|
|
3
|
+
/**
|
|
4
|
+
* Adds a watermark image onto a video and writes the result to `outputPath`.
|
|
5
|
+
* @param videoPath Local filesystem path to the source MP4 video
|
|
6
|
+
* @param imagePath Local filesystem path to the PNG watermark image
|
|
7
|
+
* @param outputPath Local filesystem path where the output MP4 should be written
|
|
8
|
+
* @returns A promise that resolves with the output path on success.
|
|
9
|
+
*/
|
|
10
|
+
watermarkVideo(videoPath: string, imagePath: string, outputPath: string): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
declare const _default: ExpoVideoWatermarkModule;
|
|
13
|
+
export default _default;
|
|
14
|
+
//# sourceMappingURL=ExpoVideoWatermarkModule.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoVideoWatermarkModule.d.ts","sourceRoot":"","sources":["../src/ExpoVideoWatermarkModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAuB,MAAM,MAAM,CAAC;AAEzD,OAAO,OAAO,wBAAyB,SAAQ,YAAY;IACzD;;;;;;OAMG;IACH,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAC1F;;AAGD,wBAAmF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoVideoWatermarkModule.js","sourceRoot":"","sources":["../src/ExpoVideoWatermarkModule.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,mBAAmB,EAAE,MAAM,MAAM,CAAC;AAazD,yDAAyD;AACzD,eAAe,mBAAmB,CAA2B,oBAAoB,CAAC,CAAC","sourcesContent":["import { NativeModule, requireNativeModule } from 'expo';\n\ndeclare class ExpoVideoWatermarkModule extends NativeModule {\n /**\n * Adds a watermark image onto a video and writes the result to `outputPath`.\n * @param videoPath Local filesystem path to the source MP4 video\n * @param imagePath Local filesystem path to the PNG watermark image\n * @param outputPath Local filesystem path where the output MP4 should be written\n * @returns A promise that resolves with the output path on success.\n */\n watermarkVideo(videoPath: string, imagePath: string, outputPath: string): Promise<string>;\n}\n\n// This call loads the native module object from the JSI.\nexport default requireNativeModule<ExpoVideoWatermarkModule>('ExpoVideoWatermark');\n"]}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { NativeModule } from 'expo';
|
|
2
|
+
declare class ExpoVideoWatermarkModule extends NativeModule {
|
|
3
|
+
watermarkVideo(_videoPath: string, _imagePath: string, _outputPath: string): Promise<string>;
|
|
4
|
+
}
|
|
5
|
+
declare const _default: typeof ExpoVideoWatermarkModule;
|
|
6
|
+
export default _default;
|
|
7
|
+
//# sourceMappingURL=ExpoVideoWatermarkModule.web.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoVideoWatermarkModule.web.d.ts","sourceRoot":"","sources":["../src/ExpoVideoWatermarkModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,YAAY,EAAE,MAAM,MAAM,CAAC;AAEvD,cAAM,wBAAyB,SAAQ,YAAY;IAC3C,cAAc,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;CAGnG;;AAED,wBAAuF"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { registerWebModule, NativeModule } from 'expo';
|
|
2
|
+
class ExpoVideoWatermarkModule extends NativeModule {
|
|
3
|
+
async watermarkVideo(_videoPath, _imagePath, _outputPath) {
|
|
4
|
+
throw new Error('watermarkVideo is not supported on web');
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export default registerWebModule(ExpoVideoWatermarkModule, 'ExpoVideoWatermarkModule');
|
|
8
|
+
//# sourceMappingURL=ExpoVideoWatermarkModule.web.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ExpoVideoWatermarkModule.web.js","sourceRoot":"","sources":["../src/ExpoVideoWatermarkModule.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAEvD,MAAM,wBAAyB,SAAQ,YAAY;IACjD,KAAK,CAAC,cAAc,CAAC,UAAkB,EAAE,UAAkB,EAAE,WAAmB;QAC9E,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;IAC5D,CAAC;CACF;AAED,eAAe,iBAAiB,CAAC,wBAAwB,EAAE,0BAA0B,CAAC,CAAC","sourcesContent":["import { registerWebModule, NativeModule } from 'expo';\n\nclass ExpoVideoWatermarkModule extends NativeModule {\n async watermarkVideo(_videoPath: string, _imagePath: string, _outputPath: string): Promise<string> {\n throw new Error('watermarkVideo is not supported on web');\n }\n}\n\nexport default registerWebModule(ExpoVideoWatermarkModule, 'ExpoVideoWatermarkModule');\n"]}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,4BAA4B,CAAC"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,yDAAyD;AACzD,OAAO,EAAE,OAAO,EAAE,MAAM,4BAA4B,CAAC","sourcesContent":["// Reexport the native module. On web, it will be resolved to ExpoVideoWatermarkModule.web.ts\n// and on native platforms to ExpoVideoWatermarkModule.ts\nexport { default } from './ExpoVideoWatermarkModule';\n"]}
|
|
@@ -0,0 +1,29 @@
|
|
|
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 = 'ExpoVideoWatermark'
|
|
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/ste00martin/expo-video-watermark' }
|
|
19
|
+
s.static_framework = true
|
|
20
|
+
|
|
21
|
+
s.dependency 'ExpoModulesCore'
|
|
22
|
+
|
|
23
|
+
# Swift/Objective-C compatibility
|
|
24
|
+
s.pod_target_xcconfig = {
|
|
25
|
+
'DEFINES_MODULE' => 'YES',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
29
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
public class ExpoVideoWatermarkModule: Module {
|
|
6
|
+
public func definition() -> ModuleDefinition {
|
|
7
|
+
Name("ExpoVideoWatermark")
|
|
8
|
+
|
|
9
|
+
AsyncFunction("watermarkVideo") { (videoPath: String, imagePath: String, outputPath: String, promise: Promise) in
|
|
10
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
11
|
+
self.processWatermark(videoPath: videoPath, imagePath: imagePath, outputPath: outputPath, promise: promise)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
private func processWatermark(videoPath: String, imagePath: String, outputPath: String, promise: Promise) {
|
|
17
|
+
// Strip file:// prefix if present
|
|
18
|
+
let cleanVideoPath = videoPath.hasPrefix("file://") ? String(videoPath.dropFirst(7)) : videoPath
|
|
19
|
+
let cleanImagePath = imagePath.hasPrefix("file://") ? String(imagePath.dropFirst(7)) : imagePath
|
|
20
|
+
let cleanOutputPath = outputPath.hasPrefix("file://") ? String(outputPath.dropFirst(7)) : outputPath
|
|
21
|
+
|
|
22
|
+
// Validate video file exists
|
|
23
|
+
let videoURL = URL(fileURLWithPath: cleanVideoPath)
|
|
24
|
+
guard FileManager.default.fileExists(atPath: cleanVideoPath) else {
|
|
25
|
+
promise.reject("VIDEO_NOT_FOUND", "Video file not found at path: \(cleanVideoPath)")
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Validate image file exists and load it
|
|
30
|
+
guard FileManager.default.fileExists(atPath: cleanImagePath),
|
|
31
|
+
let watermarkImage = UIImage(contentsOfFile: cleanImagePath) else {
|
|
32
|
+
promise.reject("IMAGE_NOT_FOUND", "Watermark image not found at path: \(cleanImagePath)")
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Load video asset
|
|
37
|
+
let asset = AVURLAsset(url: videoURL)
|
|
38
|
+
|
|
39
|
+
// Get video track
|
|
40
|
+
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
|
|
41
|
+
promise.reject("INVALID_VIDEO", "No video track found in file: \(videoPath)")
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Create composition
|
|
46
|
+
let composition = AVMutableComposition()
|
|
47
|
+
|
|
48
|
+
guard let compositionVideoTrack = composition.addMutableTrack(
|
|
49
|
+
withMediaType: .video,
|
|
50
|
+
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
51
|
+
) else {
|
|
52
|
+
promise.reject("COMPOSITION_ERROR", "Failed to create video composition track")
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let duration = asset.duration
|
|
57
|
+
|
|
58
|
+
do {
|
|
59
|
+
// Insert video track
|
|
60
|
+
try compositionVideoTrack.insertTimeRange(
|
|
61
|
+
CMTimeRange(start: .zero, duration: duration),
|
|
62
|
+
of: videoTrack,
|
|
63
|
+
at: .zero
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
// Handle audio track if present
|
|
67
|
+
if let audioTrack = asset.tracks(withMediaType: .audio).first,
|
|
68
|
+
let compositionAudioTrack = composition.addMutableTrack(
|
|
69
|
+
withMediaType: .audio,
|
|
70
|
+
preferredTrackID: kCMPersistentTrackID_Invalid
|
|
71
|
+
) {
|
|
72
|
+
try compositionAudioTrack.insertTimeRange(
|
|
73
|
+
CMTimeRange(start: .zero, duration: duration),
|
|
74
|
+
of: audioTrack,
|
|
75
|
+
at: .zero
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
promise.reject("COMPOSITION_ERROR", "Failed to insert tracks: \(error.localizedDescription)")
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Get video size accounting for transform
|
|
84
|
+
let videoSize = self.naturalSizeForTrack(videoTrack)
|
|
85
|
+
|
|
86
|
+
// Create layer hierarchy for watermark overlay
|
|
87
|
+
let parentLayer = CALayer()
|
|
88
|
+
let videoLayer = CALayer()
|
|
89
|
+
|
|
90
|
+
parentLayer.frame = CGRect(origin: .zero, size: videoSize)
|
|
91
|
+
videoLayer.frame = CGRect(origin: .zero, size: videoSize)
|
|
92
|
+
parentLayer.addSublayer(videoLayer)
|
|
93
|
+
|
|
94
|
+
// Create watermark layer spanning full width at bottom
|
|
95
|
+
let watermarkLayer = CALayer()
|
|
96
|
+
watermarkLayer.contents = watermarkImage.cgImage
|
|
97
|
+
|
|
98
|
+
// Scale watermark to match video width, maintaining aspect ratio
|
|
99
|
+
let watermarkWidth = videoSize.width
|
|
100
|
+
let aspectRatio = watermarkImage.size.height / watermarkImage.size.width
|
|
101
|
+
let watermarkHeight = watermarkWidth * aspectRatio
|
|
102
|
+
|
|
103
|
+
// Position at very bottom (Core Animation y=0 is bottom)
|
|
104
|
+
watermarkLayer.frame = CGRect(
|
|
105
|
+
x: 0,
|
|
106
|
+
y: 0,
|
|
107
|
+
width: watermarkWidth,
|
|
108
|
+
height: watermarkHeight
|
|
109
|
+
)
|
|
110
|
+
watermarkLayer.opacity = 1.0
|
|
111
|
+
parentLayer.addSublayer(watermarkLayer)
|
|
112
|
+
|
|
113
|
+
// Create video composition
|
|
114
|
+
let videoComposition = AVMutableVideoComposition()
|
|
115
|
+
videoComposition.renderSize = videoSize
|
|
116
|
+
videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
|
|
117
|
+
videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(
|
|
118
|
+
postProcessingAsVideoLayer: videoLayer,
|
|
119
|
+
in: parentLayer
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Create instruction
|
|
123
|
+
let instruction = AVMutableVideoCompositionInstruction()
|
|
124
|
+
instruction.timeRange = CMTimeRange(start: .zero, duration: duration)
|
|
125
|
+
|
|
126
|
+
let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
|
|
127
|
+
|
|
128
|
+
// Apply transform to handle rotated videos
|
|
129
|
+
let transform = self.transformForTrack(videoTrack)
|
|
130
|
+
layerInstruction.setTransform(transform, at: .zero)
|
|
131
|
+
|
|
132
|
+
instruction.layerInstructions = [layerInstruction]
|
|
133
|
+
videoComposition.instructions = [instruction]
|
|
134
|
+
|
|
135
|
+
// Setup export
|
|
136
|
+
let outputURL = URL(fileURLWithPath: cleanOutputPath)
|
|
137
|
+
|
|
138
|
+
// Remove existing file if present
|
|
139
|
+
try? FileManager.default.removeItem(at: outputURL)
|
|
140
|
+
|
|
141
|
+
// Create parent directory if needed
|
|
142
|
+
let parentDir = outputURL.deletingLastPathComponent()
|
|
143
|
+
try? FileManager.default.createDirectory(at: parentDir, withIntermediateDirectories: true)
|
|
144
|
+
|
|
145
|
+
guard let exportSession = AVAssetExportSession(
|
|
146
|
+
asset: composition,
|
|
147
|
+
presetName: AVAssetExportPresetHighestQuality
|
|
148
|
+
) else {
|
|
149
|
+
promise.reject("EXPORT_ERROR", "Could not create export session")
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
exportSession.outputURL = outputURL
|
|
154
|
+
exportSession.outputFileType = .mp4
|
|
155
|
+
exportSession.videoComposition = videoComposition
|
|
156
|
+
|
|
157
|
+
exportSession.exportAsynchronously {
|
|
158
|
+
switch exportSession.status {
|
|
159
|
+
case .completed:
|
|
160
|
+
promise.resolve(outputPath)
|
|
161
|
+
case .failed:
|
|
162
|
+
let errorMessage = exportSession.error?.localizedDescription ?? "Unknown error"
|
|
163
|
+
promise.reject("EXPORT_FAILED", "Video export failed: \(errorMessage)")
|
|
164
|
+
case .cancelled:
|
|
165
|
+
promise.reject("EXPORT_CANCELLED", "Video export was cancelled")
|
|
166
|
+
default:
|
|
167
|
+
promise.reject("EXPORT_ERROR", "Export ended with status: \(exportSession.status.rawValue)")
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Calculate the natural size accounting for video transform/rotation
|
|
173
|
+
private func naturalSizeForTrack(_ track: AVAssetTrack) -> CGSize {
|
|
174
|
+
let size = track.naturalSize
|
|
175
|
+
let transform = track.preferredTransform
|
|
176
|
+
|
|
177
|
+
// Check if video is rotated 90 or 270 degrees
|
|
178
|
+
if abs(transform.a) == 0 && abs(transform.d) == 0 {
|
|
179
|
+
return CGSize(width: size.height, height: size.width)
|
|
180
|
+
}
|
|
181
|
+
return size
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Get the transform needed to render the video correctly
|
|
185
|
+
private func transformForTrack(_ track: AVAssetTrack) -> CGAffineTransform {
|
|
186
|
+
let size = track.naturalSize
|
|
187
|
+
let transform = track.preferredTransform
|
|
188
|
+
|
|
189
|
+
// Handle different rotation cases
|
|
190
|
+
if transform.a == 0 && transform.d == 0 {
|
|
191
|
+
if transform.b == 1.0 && transform.c == -1.0 {
|
|
192
|
+
// 90 degrees rotation
|
|
193
|
+
return CGAffineTransform(translationX: size.height, y: 0).rotated(by: .pi / 2)
|
|
194
|
+
} else if transform.b == -1.0 && transform.c == 1.0 {
|
|
195
|
+
// 270 degrees rotation
|
|
196
|
+
return CGAffineTransform(translationX: 0, y: size.width).rotated(by: -.pi / 2)
|
|
197
|
+
}
|
|
198
|
+
} else if transform.a == -1.0 && transform.d == -1.0 {
|
|
199
|
+
// 180 degrees rotation
|
|
200
|
+
return CGAffineTransform(translationX: size.width, y: size.height).rotated(by: .pi)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return .identity
|
|
204
|
+
}
|
|
205
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stefanmartin/expo-video-watermark",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Creating video watermarks on locally stored videos",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "expo-module build",
|
|
9
|
+
"clean": "expo-module clean",
|
|
10
|
+
"lint": "expo-module lint",
|
|
11
|
+
"test": "expo-module test",
|
|
12
|
+
"prepare": "expo-module prepare",
|
|
13
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
14
|
+
"expo-module": "expo-module",
|
|
15
|
+
"open:ios": "xed example/ios",
|
|
16
|
+
"open:android": "open -a \"Android Studio\" example/android"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"react-native",
|
|
20
|
+
"expo",
|
|
21
|
+
"expo-video-watermark",
|
|
22
|
+
"ExpoVideoWatermark"
|
|
23
|
+
],
|
|
24
|
+
"repository": "https://github.com/ste00martin/expo-video-watermark",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/ste00martin/expo-video-watermark/issues"
|
|
27
|
+
},
|
|
28
|
+
"author": "Stefan Martin <ste00martin@gmail.com> (https://github.com/ste00martin)",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"homepage": "https://github.com/ste00martin/expo-video-watermark#readme",
|
|
31
|
+
"dependencies": {},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/react": "~19.1.0",
|
|
34
|
+
"expo": "^54.0.27",
|
|
35
|
+
"expo-module-scripts": "^5.0.8",
|
|
36
|
+
"react-native": "0.81.5"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"expo": "*",
|
|
40
|
+
"react": "*",
|
|
41
|
+
"react-native": "*"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NativeModule, requireNativeModule } from 'expo';
|
|
2
|
+
|
|
3
|
+
declare class ExpoVideoWatermarkModule extends NativeModule {
|
|
4
|
+
/**
|
|
5
|
+
* Adds a watermark image onto a video and writes the result to `outputPath`.
|
|
6
|
+
* @param videoPath Local filesystem path to the source MP4 video
|
|
7
|
+
* @param imagePath Local filesystem path to the PNG watermark image
|
|
8
|
+
* @param outputPath Local filesystem path where the output MP4 should be written
|
|
9
|
+
* @returns A promise that resolves with the output path on success.
|
|
10
|
+
*/
|
|
11
|
+
watermarkVideo(videoPath: string, imagePath: string, outputPath: string): Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// This call loads the native module object from the JSI.
|
|
15
|
+
export default requireNativeModule<ExpoVideoWatermarkModule>('ExpoVideoWatermark');
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { registerWebModule, NativeModule } from 'expo';
|
|
2
|
+
|
|
3
|
+
class ExpoVideoWatermarkModule extends NativeModule {
|
|
4
|
+
async watermarkVideo(_videoPath: string, _imagePath: string, _outputPath: string): Promise<string> {
|
|
5
|
+
throw new Error('watermarkVideo is not supported on web');
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default registerWebModule(ExpoVideoWatermarkModule, 'ExpoVideoWatermarkModule');
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED