@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 ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ['universe/native', 'universe/web'],
4
+ ignorePatterns: ['build'],
5
+ };
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,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -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,4 @@
1
+ import { requireNativeModule } from 'expo';
2
+ // This call loads the native module object from the JSI.
3
+ export default requireNativeModule('ExpoVideoWatermark');
4
+ //# sourceMappingURL=ExpoVideoWatermarkModule.js.map
@@ -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"]}
@@ -0,0 +1,2 @@
1
+ export { default } from './ExpoVideoWatermarkModule';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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,4 @@
1
+ // Reexport the native module. On web, it will be resolved to ExpoVideoWatermarkModule.web.ts
2
+ // and on native platforms to ExpoVideoWatermarkModule.ts
3
+ export { default } from './ExpoVideoWatermarkModule';
4
+ //# sourceMappingURL=index.js.map
@@ -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,9 @@
1
+ {
2
+ "platforms": ["apple", "android", "web"],
3
+ "apple": {
4
+ "modules": ["ExpoVideoWatermarkModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.videowatermark.ExpoVideoWatermarkModule"]
8
+ }
9
+ }
@@ -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
@@ -0,0 +1,3 @@
1
+ // Reexport the native module. On web, it will be resolved to ExpoVideoWatermarkModule.web.ts
2
+ // and on native platforms to ExpoVideoWatermarkModule.ts
3
+ export { default } from './ExpoVideoWatermarkModule';
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ // @generated by expo-module-scripts
2
+ {
3
+ "extends": "expo-module-scripts/tsconfig.base",
4
+ "compilerOptions": {
5
+ "outDir": "./build"
6
+ },
7
+ "include": ["./src"],
8
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9
+ }