npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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.

  • iOSAVFoundation (AVMutableVideoComposition + AVVideoCompositionCoreAnimationTool for video, CoreGraphics/UIGraphicsImageRenderer for photos).
  • AndroidMedia3 (Transformer + OverlayEffect for video, Canvas/Bitmap for 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 WatermarkConfig shape 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's options.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 | |---|---|---| | Tiled "TEST" text watermark repeating diagonally across a photo | A logo and "TEST" text stamped twice at fixed positions, one rotated and scaled up | A dim "TEST" text tiled in the background plus "SAMPLE" stamped once, dead center | | { 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-watermark

After 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 local file:// URI, use it as-is.
  • A bundled asset (require('./logo.png')) — resolve it first with expo-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's downloadAsync) 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, scale accept 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 to x/y, which are range-checked) is not rejected — rotation wraps naturally and an extreme scale just draws very small/large.
  • A malformed color on a config that skipped validateWatermarkConfig falls 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): high keeps the source resolution; medium/low downscale 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 naturalSize and preferredTransform and computes the display-corrected render size from them before drawing anything.
  • Android reads the video's rotation metadata via MediaMetadataRetriever and 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/IOSurface stack — a limitation of the Simulator's virtualized GPU with AVVideoCompositionCoreAnimationTool-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 testing

The 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