@variax-ai/video-watermark
Hidden (forensic) watermarking for video. Embeds an identifier into the pixels of a frame so it survives export, re-encoding and rescaling, and can be recovered from the delivered video.
Built on adobe/trustmark (MIT), whose published JavaScript can only decode. The encoder here is a TypeScript implementation of their pipeline against the same ONNX models.
This package works on pixels, not documents. It has no dependency on
@variax-ai/video-schema or the renderer, so it works equally well on video that
never came from a VideoDocument.
Install
npm install @variax-ai/video-watermark onnxruntime-node
onnxruntime-node (Node) or onnxruntime-web (browser) is an optional peer
dependency — install whichever you need. The file helpers additionally require
ffmpeg and ffprobe on PATH.
Models are not bundled. They are fetched from Adobe's host on first use (~64MB)
and cached when cacheDir is set.
Usage
Frames:
import { Watermarker } from '@variax-ai/video-watermark'
const wm = await Watermarker.create({ cacheDir: '.models' })
const marked = await wm.embedFrame(frame, { templateId: 42, renderId: 7 })
const found = await wm.extract([marked])
// { valid: true, payload: { templateId: 42, renderId: 7 }, ... }
Whole files, from @variax-ai/video-watermark/node:
import { extractFile, watermarkFile } from '@variax-ai/video-watermark/node'
await watermarkFile(wm, 'in.mp4', 'out.mp4', { templateId: 42 })
const found = await extractFile(wm, 'out.mp4')
Browser support
The main entry runs in a browser: install onnxruntime-web instead of
onnxruntime-node and it works unchanged. src/browser.test.ts bundles the
entry for a browser target on every test run, so this stays true rather than
being a claim in a README.
If you already have an ort instance — a shared WASM build, WebGPU, one in a worker — adapt it rather than letting the package import its own:
import * as ort from 'onnxruntime-web'
import { Watermarker, createRuntime } from '@variax-ai/video-watermark'
ort.env.wasm.numThreads = 1
const wm = await Watermarker.create({ runtime: createRuntime(ort) })
Measured in Chrome on the WASM backend, 1280x720, single-threaded:
| Model load | ~2.5s from a local host (~64MB from Adobe's) |
| First frame (includes one inference) | ~0.8–1.3s |
| Subsequent frames in a shot | ~74ms/frame |
| Extract from one frame | ~0.3s |
The per-frame figure is low for the same reason it is in Node: sharedResidual
runs the encoder once per shot, and the rest is plain arithmetic. A 30s 30fps
clip is roughly a minute of embedding.
Three things to know:
- There is no video-in, video-out path in the browser. This package
watermarks frames. Under Node,
watermarkFilewires ffmpeg up for you; in a browser you supply decode and encode yourself —<video>+canvas orWebCodecs.VideoDecoderin,VideoEncoderplus a muxer (orMediaRecorder) out — and putembedFramesin the middle. It accepts anyImageData. cacheDiris Node-only. In a browser the models are refetched unless you passmodelsyourself or supply your ownruntime. Adobe's host sendsaccess-control-allow-origin: *, so fetching from a page works.- Re-encoding is where the mark is at risk, not the browser. Whatever encoder you pick has to preserve the residual; the measured robustness table below assumes something in the quality range of x264 at CRF 23 or better.
The payload is an identifier, not metadata
The watermark carries 100 bits: payload, error correction, and a 4-bit schema tag. Usable payload depends on the schema:
| Schema | Payload bits | Correctable flips |
|---|---|---|
BCH_SUPER |
40 | 8 |
BCH_5 (default) |
61 | 5 |
BCH_4 |
68 | 4 |
BCH_3 |
75 | 3 |
templateId is pinned to 32 bits so it means the same thing across schemas;
renderId takes the remainder. Under 10 bytes total — resolve real metadata
from templateId against your own catalogue rather than trying to embed it.
Embedding strategies
embedFrames defaults to sharedResidual: one residual is computed and reused
until the picture changes materially. Beyond being much cheaper, it keeps the
residual temporally stable, which both looks better and compresses better — a
residual that flickers frame to frame is noise the video encoder has to spend
bits on. Use strategy: 'perFrame' when frames within a shot differ enough that
a shared residual stops matching.
Measured robustness
From npm run bench, on a real 1920x1080 Variax render — flat brand background,
large type, hard-edged shapes, no photographic texture, which is the hard case
for this style of watermark:
| Condition | Raw bit accuracy | Payload recovered |
|---|---|---|
| H.264 CRF 18, native | 100% | yes |
| H.264 CRF 23, native | 100% | yes |
| H.264 CRF 28, native | 100% | yes |
| Downscaled to 720p | 100% | yes |
| Downscaled to 640px | 100% | yes |
| 10% centre crop | 100% | yes |
| Re-encoded twice | 100% | yes |
| Control: unwatermarked | 58% | no |
Mean PSNR 48.1 dB, at 31.5ms per 1080p frame with the default strategy.
The control row is the important one — it lands near the 50% chance level, which is what shows the table above is measuring a real signal.
Verification
npm test runs offline in milliseconds. It includes cross-implementation
vectors generated by Adobe's own Python datalayer.py, asserting our packets are
bit-identical to theirs, and PIL-generated vectors pinning the resampler — the
resize matters because TrustMark's reference downscales with PIL, which
area-averages, and a naive bilinear sampler silently destroys the mark.
End-to-end tests against the real models are gated behind an env var, since they download ~64MB:
VARIAX_WATERMARK_E2E=1 npm test
Browser support is covered at three levels, because each catches something the others cannot:
| Check | Catches | Automated |
|---|---|---|
browser.test.ts |
Node builtins reaching the browser entry | yes |
runtime-web.test.ts |
the ort adapter diverging from onnxruntime-web |
yes (gated) |
npm run check:browser |
anything only a real browser engine shows | no — reports PASS/FAIL for a human |
check:browser serves a page that embeds and extracts through the real
createRuntime path. It reuses the cached models, so run the gated tests once
first to populate them.
Licence
MIT. The BCH implementation and watermarking pipeline are ported from adobe/trustmark, also MIT.