react-native-expo-watermark
v0.1.1
Published
Burns text/image watermarks directly into photo and video pixels using native AVFoundation (iOS) and Media3 (Android) media APIs.
Downloads
349
Maintainers
Readme
react-native-expo-watermark
react-native-expo-watermark burns a watermark — text, images, or both — directly into the pixels of a photo or video, using each platform's own first-party media APIs. There's no screen overlay and no third-party video-processing library involved: the watermark becomes part of the exported file itself, so it survives sharing, uploading, or opening the file outside your app.
- iOS —
AVFoundation(AVMutableVideoComposition+AVVideoCompositionCoreAnimationToolfor video,CoreGraphics/UIGraphicsImageRendererfor photos). - Android —
Media3(Transformer+OverlayEffectfor video,Canvas/Bitmapfor photos).
Features
- Text watermarks — any number of text elements, each with its own position, font size, color, and opacity.
- Image watermarks — any number of image elements (e.g. a logo), each with its own position, size, and opacity.
- Two layout modes:
tile— repeats the watermark across the whole frame (the classic "CONFIDENTIAL" diagonal pattern).fixed— you decide exactly how many watermark instances to draw and where, each with its own position, rotation, and scale.
- Multiple layers per watermark — combine a repeating background (e.g. your company name tiled everywhere) with one or more fixed-position layers (e.g. a case number stamped once in the center) in a single call.
- Photos and videos, same
WatermarkConfigshape for both. - Orientation-safe — the video's own recorded orientation (landscape or portrait) is preserved automatically; the watermark is always drawn upright relative to the corrected frame, never sideways.
- Configurable output quality (
config.quality: 'low' | 'medium' | 'high'). - Export progress and cancellation for video, via
exportVideoWithWatermark'soptions.onProgress/cancelExport. - Fails loud, not silent — a bad watermark image, an unrecognized
layout.mode, or any other malformed input throws a specific, coded error instead of being skipped or guessed at. See Validation and error handling.
Example output
Real, unedited outputs of composeImageWatermark:
| layout: { mode: 'tile' } | layout: { mode: 'fixed' } (logo + rotated text) | Two layers: tile + fixed combined |
|---|---|---|
|
|
|
|
| { texts: [{ text: 'TEST' }, { text: 'SAMPLE WATERMARK' }], layout: { mode: 'tile' } } | { images: [logo], texts: [{ text: 'TEST' }], layout: { mode: 'fixed', positions: [...] } } | { layers: [{ texts: [{ text: 'TEST', opacity: 0.35 }], layout: { mode: 'tile' } }, { texts: [{ text: 'SAMPLE' }], layout: { mode: 'fixed', positions: [{ x: 0.5, y: 0.5 }] } }] } |
The exact configs behind these three images are TILE_WATERMARK, buildFixedWatermark(), and MULTI_LAYER_WATERMARK in example/App.tsx — run the example app to regenerate them against your own images.
Requirements and device compatibility
| Platform | Minimum version | Why |
|---|---|---|
| iOS | 15.1 (configurable) | Not a hard technical floor — AVFoundation/CoreGraphics/CALayer APIs used here have existed since iOS 10-12. The podspec pins 15.1; lower or raise it in ios/ReactNativeExpoWatermark.podspec to match your own app's target. |
| Android | API 21 (Android 5.0) | Required by androidx.media3. |
In practice this rarely restricts anything: most apps' own minSdkVersion / iOS deployment target is already at or above these floors (Android apps commonly target API 24+; iOS apps commonly target iOS 16+ already). If your app already runs on a device, that device already supports this package.
Android Media3 version pinning: this package pins androidx.media3:media3-transformer, media3-effect, and media3-common to an exact version (1.8.0) in android/build.gradle. If your app also uses expo-video (or anything else that depends on Media3), all Media3 artifacts must resolve to the same version — mixing versions compiles fine but crashes at runtime with an AbstractMethodError. If you see that crash, check what Media3 version your other Media3-dependent packages use (e.g. node_modules/expo-video/android/build.gradle) and update this package's pins to match.
Recommended integration pattern — never let watermarking block a capture. Watermarking is inherently a second, best-effort step on top of a photo/video the user already captured. Wrap every call in a try/catch that falls back to the original, un-watermarked file on failure, so a watermarking error costs you a missing watermark, never a lost capture:
async function applyWatermark(uri: string, config: WatermarkConfig): Promise<string> {
try {
return await ReactNativeExpoWatermark.composeImageWatermark(uri, config);
} catch (error) {
console.warn('Watermark failed, using the original file instead:', error);
return uri;
}
}Installation
npx expo install react-native-expo-watermarkAfter installing, rebuild your native project (npx expo prebuild if needed, then npx expo run:ios / npx expo run:android) — this package includes native code and won't work in Expo Go.
Quick start
import ReactNativeExpoWatermark from 'react-native-expo-watermark';
const config = {
layers: [
{ texts: [{ text: 'CONFIDENTIAL', fontSize: 24, color: '#FFFFFF', opacity: 0.5 }], layout: { mode: 'tile' } },
],
};
// Photo
const watermarkedPhotoUri = await ReactNativeExpoWatermark.composeImageWatermark(photoUri, config);
// Video
const watermarkedVideoUri = await ReactNativeExpoWatermark.exportVideoWithWatermark(videoUri, config);Both functions return a Promise<string> — the file URI of a new file. The original photo/video is never modified.
How you must supply files
Every uri this package touches — the source imageUri/videoUri you pass in, and every WatermarkImageElement.uri inside your config — must be a local file the OS can open directly:
| Scheme | iOS | Android |
|---|---|---|
| file://... | ✅ | ✅ |
| content://... | ❌ not applicable | ✅ |
| ph://, assets-library:// (iOS photo library) | ❌ | — |
| http://, https:// (remote URL) | ❌ | ❌ |
| A bundled asset module id (require('./foo.png') passed directly, not resolved) | ❌ | ❌ |
Resolve everything to a local URI before calling this package. Typical sources and how to resolve them:
- A photo/video just captured with
expo-camera/expo-image-picker— already a localfile://URI, use it as-is. - A bundled asset (
require('./logo.png')) — resolve it first withexpo-asset:import { Asset } from 'expo-asset'; const asset = Asset.fromModule(require('./logo.png')); await asset.downloadAsync(); const uri = asset.localUri ?? asset.uri; // now a real file:// URI - A remote image/video (
https://...) — download it first (e.g.expo-file-system'sdownloadAsync) and pass the resulting local URI. - An image picked from the Android photo library as a
content://URI — passed through as-is; supported directly (see the table above).
Supported image formats: whatever the OS's own image decoder accepts — at minimum JPEG and PNG on both platforms; HEIC additionally on iOS; WebP additionally on Android. This applies both to the source image in composeImageWatermark and to every WatermarkImageElement.uri.
Supported video formats: whatever AVFoundation (iOS) / Media3 (Android) can decode — in practice, anything the OS's own Camera/Photos app can play (H.264/HEVC in an MP4/MOV container covers the overwhelming majority of real-world cases, including everything a phone's own camera records). The source must have at least one video track — an audio-only file, or a corrupted file with none, throws ERR_NO_VIDEO_TRACK.
Output format is fixed: photos always come back as JPEG; videos always come back as MP4. There's no option to preserve the source's original container/codec.
Validation and error handling
Every input is checked at one of two points, and nothing fails silently:
1. Config shape — checked synchronously, in JS, before any native call. composeImageWatermark and exportVideoWithWatermark both call validateWatermarkConfig(config) first. A malformed config throws a WatermarkValidationError (a plain Error subclass, error.name === 'WatermarkValidationError') with a message naming the exact field that's wrong — nothing crosses the JS-to-native bridge until the shape is valid. Checked here:
| Rule | Example message |
|---|---|
| config.layers has at least one entry | config.layers must contain at least one layer. |
| config.quality (if set) is 'low', 'medium', or 'high' | config.quality must be 'low', 'medium', or 'high', got "ultra". |
| Each layer has at least one text or image element | layers[0] has no texts and no images — it would draw nothing. |
| Each text's text is non-empty | layers[0].texts[0].text must be a non-empty string. |
| Each text's opacity (if set) is 0–1 | layers[0].texts[0].opacity must be between 0 and 1, got 2. |
| Each text's color (if set) matches #RRGGBB | layers[0].texts[0].color must be a '#RRGGBB' hex string, got "red". |
| Each image has a non-empty uri | layers[0].images[0].uri is required. |
| Each image's width/height are > 0 | layers[0].images[0] must have width/height > 0, got 0x40. |
| Each image's opacity (if set) is 0–1 | same shape as text opacity |
| layout.mode is 'tile' or 'fixed' | layers[0].layout.mode must be 'tile' or 'fixed', got "diagonal". |
| layout.mode: 'fixed' has a non-empty positions | layers[0].layout is 'fixed' but 'positions' is empty — nothing would be drawn. |
| Each fixed position.x/position.y is 0–1 | layers[0].layout.positions[0].x must be between 0 and 1, got 1.5. |
This only checks shape — it can't tell you whether an image uri actually points at a readable file, since that requires a native file-system read. validateWatermarkConfig is also exported directly, so you can validate a config early — e.g. while building a settings UI — without triggering an export.
2. File I/O and native decoding/export — checked natively, throws a coded error. Once native work starts, every image element (source and watermark) is decoded and every layout.mode re-checked before any drawing or export begins — so a video export never gets partway through and then silently drops a bad watermark image; it fails immediately, up front. Error objects thrown from native code carry a .code you can switch on, identical on both platforms:
| Code | Thrown when |
|---|---|
| ERR_INVALID_IMAGE | The source imageUri passed to composeImageWatermark couldn't be read or decoded. |
| ERR_INVALID_WATERMARK_IMAGE | A WatermarkImageElement.uri couldn't be read or decoded. The message names the exact URI. |
| ERR_INVALID_LAYOUT_MODE | (Native-side re-check; normally caught earlier by validateWatermarkConfig.) |
| ERR_ENCODING_FAILED | (iOS only) The final watermarked image couldn't be JPEG-encoded. |
| ERR_INVALID_URI | videoUri (or, on iOS, a file uri passed to deleteWatermarkFile) isn't a parseable URL. |
| ERR_NO_VIDEO_TRACK | The source file has no video track (e.g. audio-only, or corrupted). |
| ERR_COMPOSITION_FAILED | (iOS only) Internal AVMutableComposition setup failed. |
| ERR_EXPORT_SESSION_FAILED | (iOS only) AVAssetExportSession could not be created for the given preset/asset combination. |
| ERR_VIDEO_EXPORT | The export ran but failed (codec issue, disk full, OS-level export failure, etc.) — the message/cause carries the underlying OS error. |
| ERR_EXPORT_CANCELLED | The export was stopped via cancelExport(requestId). |
| ERR_DELETE_FAILED | deleteWatermarkFile couldn't delete an existing file (e.g. a permissions issue). Deleting a file that's already gone is not an error — see below. |
| ERR_NO_CONTEXT | (Android only) The React context was unavailable when the call ran (app in a very early/late lifecycle state). |
What is not validated, on purpose:
fontSize,spacingX/spacingY,rotationDegrees,scaleaccept any number, including negative or huge values — you get whatever CoreGraphics/Canvas does with them (e.g. a negative scale mirrors the stamp).- An out-of-range
WatermarkPosition.rotationDegrees/scale(as opposed tox/y, which are range-checked) is not rejected — rotation wraps naturally and an extreme scale just draws very small/large. - A malformed
coloron a config that skippedvalidateWatermarkConfigfalls back to white rather than throwing. Always go through the public API (ReactNativeExpoWatermark.composeImageWatermark/exportVideoWithWatermark), which validates first, and this won't come up.
API
composeImageWatermark(imageUri: string, config: WatermarkConfig): Promise<string>
Draws config's watermark on top of the image at imageUri and returns the URI of a new JPEG file. Throws WatermarkValidationError for a malformed config, or a coded native error (see above) if imageUri/a watermark image can't be read.
exportVideoWithWatermark(videoUri: string, config: WatermarkConfig, options?: ExportVideoOptions): Promise<string>
Re-encodes the video at videoUri with config's watermark burned into every frame, and returns the URI of a new MP4 file. This re-encodes the whole video, so it takes real time proportional to the video's length — show a loading state while it runs (or use options.onProgress, below).
type ExportVideoOptions = {
/** Called roughly every 250ms with progress from 0 to 1. */
onProgress?: (progress: number) => void;
/** An id you choose, to later cancel this export with `cancelExport`. Auto-generated if omitted. */
requestId?: string;
};const requestId = 'my-export-1';
const promise = ReactNativeExpoWatermark.exportVideoWithWatermark(videoUri, config, {
requestId,
onProgress: (p) => setProgressPercent(Math.round(p * 100)),
});
// Later, e.g. if the user backs out of the screen:
ReactNativeExpoWatermark.cancelExport(requestId);
// `promise` above rejects with an ERR_EXPORT_CANCELLED error.cancelExport(requestId: string): void
Cancels an in-flight exportVideoWithWatermark call started with this requestId. No-op if nothing is running under that id (already finished, or never started).
deleteWatermarkFile(uri: string): Promise<void>
Deletes a file previously returned by composeImageWatermark, exportVideoWithWatermark, or exportVideoPassthrough. These functions write to the OS's ephemeral temp/cache directory (which the OS may reclaim under storage pressure, but won't necessarily do promptly) — nothing deletes them proactively, so call this once you're done with a file (e.g. right after uploading it) to free space sooner. Succeeds silently if the file no longer exists — safe to call defensively without checking first.
getPlatformInfo(): string
Returns a string like "iOS 18.1" or "Android 14". Diagnostic/logging only — don't parse or branch on it.
validateWatermarkConfig(config: WatermarkConfig): void
Validates a WatermarkConfig's shape, throwing WatermarkValidationError on the same rules listed in Validation and error handling. composeImageWatermark and exportVideoWithWatermark already call this for you — call it directly only if you want to validate a config before the user triggers an export, e.g. while building a settings form.
WatermarkConfig
type WatermarkConfig = {
layers: WatermarkLayer[];
/** Output quality/size tradeoff. Defaults to 'high'. */
quality?: 'low' | 'medium' | 'high';
};
type WatermarkLayer = {
texts?: WatermarkTextElement[];
images?: WatermarkImageElement[];
layout: WatermarkLayout;
};A watermark is one or more layers, drawn in order (the first layer ends up at the back). Each layer is one or more text/image elements composed together into a single stamp, plus a layout that decides whether that stamp repeats across the whole canvas or is placed at specific positions you choose.
Most watermarks only need one layer — wrap it in a one-element array (layers: [{ ... }]). Reach for multiple layers when you need independent arrangements in the same pass, e.g. a repeating company name tiled in the background plus a one-off case number stamped once in the center (see the examples below) — one native export pass draws both, instead of running the export twice.
quality controls the output size/quality tradeoff:
- Photos: JPEG compression quality (
high≈ 92%,medium≈ 80%,low≈ 60%). - Video (iOS): maps to
AVAssetExportPresetHighestQuality/MediumQuality/LowQuality. - Video (Android):
highkeeps the source resolution;medium/lowdownscale so the shortest side is at most 720px/480px respectively.
WatermarkTextElement
type WatermarkTextElement = {
text: string; // required, non-empty
offsetX?: number; // default 0 — offset from the stamp's origin, in points (iOS) / dp (Android)
offsetY?: number; // default 0
fontSize?: number; // default 16
color?: string; // default '#FFFFFF' — '#RRGGBB' hex color (no alpha channel in the hex — use `opacity`)
opacity?: number; // default 1 — 0 (invisible) to 1 (opaque)
bold?: boolean; // default true
};WatermarkImageElement
type WatermarkImageElement = {
uri: string; // required — local file URI; see "How you must supply files" above
offsetX?: number; // default 0
offsetY?: number; // default 0
width: number; // required, > 0 — draw size, in points/dp
height: number; // required, > 0
opacity?: number; // default 1
};The source image is scaled (up or down) to exactly width×height — it's never cropped. A 10px source image drawn at width: 30, height: 30 is upscaled to fill that box.
WatermarkLayout
Two shapes, picked by mode:
// Repeats the stamp across the whole canvas.
type TileLayout = {
mode: 'tile';
rotationDegrees?: number; // default -18 — rotation of the whole repeating pattern
spacingX?: number; // default 220 — horizontal distance between repeats, in points/dp
spacingY?: number; // default 170 — vertical distance between repeats
};
// Draws the stamp exactly once per entry in `positions`.
type FixedLayout = {
mode: 'fixed';
positions: WatermarkPosition[]; // required, non-empty
};WatermarkPosition (for mode: 'fixed')
type WatermarkPosition = {
x: number; // 0 to 1, relative to canvas width (0.5 = horizontal center)
y: number; // 0 to 1, relative to canvas height (0.5 = vertical center)
rotationDegrees?: number; // default 0 — rotation of the stamp at this position only
scale?: number; // default 1 — uniform scale of the stamp at this position only
};Using relative (0–1) coordinates instead of absolute pixels means the same config produces sensibly-placed watermarks regardless of the photo/video's actual resolution or orientation.
Examples
Repeating "CONFIDENTIAL" pattern, multiple text lines per tile:
await ReactNativeExpoWatermark.composeImageWatermark(photoUri, {
layers: [
{
texts: [
{ text: 'ACME CORP', fontSize: 20 },
{ text: 'CONFIDENTIAL', offsetY: 22, fontSize: 12 },
],
layout: { mode: 'tile', rotationDegrees: -18, spacingX: 220, spacingY: 170 },
},
],
});A single logo + timestamp in the bottom-right corner, at reduced output quality:
await ReactNativeExpoWatermark.exportVideoWithWatermark(videoUri, {
quality: 'medium',
layers: [
{
images: [{ uri: logoUri, offsetX: 0, offsetY: 0, width: 48, height: 48, opacity: 0.9 }],
texts: [{ text: '2026-08-21 13:45', offsetX: 56, offsetY: 30, fontSize: 16 }],
layout: { mode: 'fixed', positions: [{ x: 0.75, y: 0.9 }] },
},
],
});Two independent stamps, different rotation and scale each:
await ReactNativeExpoWatermark.composeImageWatermark(photoUri, {
layers: [
{
images: [{ uri: logoUri, width: 40, height: 40 }],
texts: [{ text: 'ACME', offsetX: 48, offsetY: 24, fontSize: 18 }],
layout: {
mode: 'fixed',
positions: [
{ x: 0.05, y: 0.08 },
{ x: 0.55, y: 0.85, rotationDegrees: -12, scale: 1.3 },
],
},
},
],
});Repeating company name in the background, plus a case number stamped once in the center (two layers, one pass):
await ReactNativeExpoWatermark.exportVideoWithWatermark(videoUri, {
layers: [
// Back layer: "ACME" tiled across the whole frame.
{ texts: [{ text: 'ACME', fontSize: 18, opacity: 0.35 }], layout: { mode: 'tile' } },
// Front layer: the case number, once, dead center.
{
texts: [{ text: '1234', fontSize: 40, color: '#FFFFFF' }],
layout: { mode: 'fixed', positions: [{ x: 0.5, y: 0.5 }] },
},
],
});More runnable examples, including progress/cancellation, are in example/App.tsx.
Orientation
The output always matches the source photo/video's own recorded orientation — this package never forces landscape or portrait. Internally:
- iOS reads the video track's
naturalSizeandpreferredTransformand computes the display-corrected render size from them before drawing anything. - Android reads the video's rotation metadata via
MediaMetadataRetrieverand swaps width/height when the rotation is 90°/270°.
Because the watermark is drawn using that already-corrected size as its canvas, text and images always come out upright, whether the original was shot landscape or portrait.
Known limitations
- Web is not supported. All functions throw on web; there's no browser-based watermarking implementation.
- iOS Simulator video export. Letting a video export run to completion on the iOS Simulator can crash the app inside Apple's own
CoreAnimation/IOSurfacestack — a limitation of the Simulator's virtualized GPU withAVVideoCompositionCoreAnimationTool-based video compositing, not of this package. Always test video watermarking on a physical iOS device. Photo watermarking and Android are unaffected. exportVideoPassthrough(videoUri: string): Promise<string>re-exports a video through the native composition pipeline with no watermark and no progress/cancellation support. It's a diagnostic tool for isolating whether an issue is in the base video pipeline or in the watermark drawing itself — not meant for production use.- Video export re-encodes the entire file, so it's not instant — budget real processing time (roughly proportional to video length and resolution) and show a loading indicator, or use
options.onProgress. - Output format is fixed (JPEG for photos, MP4 for video) — there's no option to preserve the source container/codec.
Development
npm install
npm test # runs the Jest suite for the pure-JS validation logic
npm run open:ios # or open:android — launches the example app for manual/visual testingThe example app (example/App.tsx) exercises every function, both layout modes, validation errors, and progress/cancellation against real assets — it's the fastest way to see a change in action, since the native drawing/export code runs against real OS media pipelines that don't have a meaningful mock.
License
MIT
