ts-forensic-watermark
v3.0.0
Published
Forensic watermarking library in TypeScript with Node.js helpers
Downloads
253
Maintainers
Readme
ts-forensic-watermark
A secure, isomorphic (environment-agnostic) forensic watermarking library in TypeScript. Embed robust and tamper-resistant watermarks into images, videos, and audio.
Table of Contents
- Installation
- Quick Start (Copy-Paste Ready)
- Where does inputBuffer come from?
- High-Level API Guide
- generateWatermarkPayloads — signed payload generation
- embedImageWatermarks — pixel-level embedding
- finalizeImageBuffer — EOF metadata append
- analyzeTextWatermarks — automatic text watermark scan
- analyzeAudioWatermarks — FSK audio watermark extraction
- analyzeImageWatermarks — forensic image analysis
- verifyWatermarks — batch cryptographic verification
- embedLlmImageWatermark — LLM DCT frame watermark embedding
- analyzeLlmImageWatermarks — LLM DCT frame watermark analysis
- analyzeAllImageWatermarks — analyze image with all methods at once
- Node.js-Only Helper API
- embedForensicImage — direct image embedding
- extractForensicImage — direct image extraction
- embedLlmVideoFile — LLM DCT embedding for a single image/frame
- extractLlmVideoFile — LLM DCT extraction from a single image/frame
- embedLlmVideoFrames — LLM DCT embedding across all video frames
- extractLlmVideoFrames — LLM DCT extraction from a video file
- analyzeVideoLlmWatermarks — video analysis ready for verifyWatermarks
- embedVideoWatermark — video watermark injection
- Low-Level API Reference
- Parameter Reference
- Technical Background
- 1. Forensic Watermark: DWT + DCT + SVD + QIM
- 2. Reed-Solomon Error Correction (ECC)
- 3. FSK Acoustic Watermark
- 4. Tamper Detection with HMAC-SHA256
- 5. Arnold Transform (Spatial Scrambling)
- 6. Sync Marker (16 bits)
- 7. EOF Metadata Append
- 8. MP4 UUID Box
- 9. H.264 SEI User Data
- 10. LLM DCT Frame Watermark
- 11. Soft-decision (erasure) decoding & structured bit interleaving (v3.0.0+)
- Operational Notes & Trade-offs
- Architecture
- Web UI Guide
- Watermark Method Selection Guide
💡 Architecture Philosophy: Library-First
All business logic (generation, signing, extraction, verification) is encapsulated within the library. The Web UI is merely an interface; the core logic works identically across browsers, Node.js servers, and CLI tools using a pure isomorphic design.
🚀 Key Features
| Category | Technology | Robustness | | :--- | :--- | :--- | | [Image] Frequency-based forensic watermark | DWT + DCT + SVD + QIM | High (JPEG / resize resistant) | | [Image/Video] LLM DCT frame watermark | Loeffler–Ligtenberg–Moschytz 8-point DCT + QIM | High (H.264/H.265 compression resistant) | | [Audio/Video] FSK acoustic watermark | Frequency-Shift Keying (14–16 kHz) + Goertzel | High (analog recording + AAC compression resistant) | | [Shared] Metadata signing | HMAC-SHA256 (Web Crypto API) | Tamper detection | | [Shared] Error correction | Reed-Solomon ECC (GF64) + erasure decoding (v3.0.0+) | Self-healing, up to 2× correction capacity | | [Shared] Spatial scrambling | Arnold transform + structured bit interleaving (v3.0.0+) | Analysis + burst-error resistance |
v3.0.0 — robustness update (breaking change)
- Soft-decision (erasure) decoding: extraction declares low-confidence symbols as erasures based on
bitSums, then decodes within the2·errors + erasures ≤ eccLenbound. Up to 2× correction capacity with the same ECC, so longer payloads (30–32 chars) become practical even under JPEG Q≈70. - Structured bit interleaving: a symbol's 6 bits are now placed at
b * 63 + s(bit-plane major), so any two bits of the same symbol are at least 63 positions apart in the bit matrix. Combined with Arnold scrambling, this dramatically reduces the probability of a single JPEG block wiping out all bits of one symbol. - Compatibility: watermarks created with v2.x cannot be extracted with v3.x (bit layout changed). Pin v2.x if you need to read legacy watermarks.
- Zero perceptual change: all of the above are encoder/decoder-layer changes; embedding strength (
delta), targeted blocks, and SVD operation are identical to v2.x.
Installation
npm install ts-forensic-watermarkjimp, ffmpeg-static, and fluent-ffmpeg are listed in dependencies and are installed automatically with the command above.
Automatic entry point selection
The package selects the correct entry point for each environment automatically.
| Environment | File used | Included features |
| :--- | :--- | :--- |
| Node.js (require / import) | dist/index.js | Everything, including Node.js helpers |
| Browser bundlers (Vite, webpack, etc.) | dist/browser.js | Browser-safe functions only |
Bundlers like Vite, webpack, and Rollup automatically read the "browser" field in package.json and use dist/browser.js, which has no dependency on fs, jimp, or FFmpeg. You do not need to change your import statements for browser code.
// Works identically in both browser and Node.js
import { embedImageWatermarks, generateWatermarkPayloads } from 'ts-forensic-watermark';
// Importing Node.js-only helpers in a browser build will produce a bundler warning
// import { embedForensicImage } from 'ts-forensic-watermark'; // ← browser: unavailableQuick Start (Copy-Paste Ready)
Node.js — embed a watermark from a file path (shortest example)
import * as fs from 'fs';
import { embedForensicImage, extractForensicImage, generateWatermarkPayloads } from 'ts-forensic-watermark';
async function main() {
const secretKey = "my-secret-key-2024";
// 1. Generate payload (data + signature to embed)
const payloads = await generateWatermarkPayloads(
{ userId: "user_001", sessionId: "TX1234" },
secretKey
);
console.log("Payload to embed:", payloads.securePayload); // e.g. "TX1234a3f9b2..."
// 2. Read input file as Buffer
const inputBuffer = fs.readFileSync('./input.jpg');
// 3. Embed watermark and save (uses Jimp internally)
const outputBuffer = await embedForensicImage(inputBuffer, payloads.securePayload);
fs.writeFileSync('./output.png', outputBuffer);
console.log("Done: output.png");
// 4. Verify: extract the embedded watermark
const result = await extractForensicImage(outputBuffer);
console.log("Extracted:", result?.payload); // "TX1234a3f9b2..."
console.log("Confidence:", result?.confidence); // 0–100
}
main().catch(console.error);Node.js — fetch image from URL and embed watermark
import * as fs from 'fs';
import { embedForensicImage, generateWatermarkPayloads } from 'ts-forensic-watermark';
async function main() {
const secretKey = "my-secret-key-2024";
const imageUrl = "https://example.com/photo.jpg"; // ← change this
const payloads = await generateWatermarkPayloads(
{ userId: "user_002", sessionId: "TX5678" },
secretKey
);
// Pass the URL string directly — the library fetches it for you
const outputBuffer = await embedForensicImage(imageUrl, payloads.securePayload, undefined, './watermarked.png');
console.log("Done: watermarked.png");
}
main();Browser — embed watermark from a file input
<input type="file" id="fileInput" accept="image/*">
<canvas id="canvas"></canvas>
<script type="module">
import { embedImageWatermarks, generateWatermarkPayloads } from 'ts-forensic-watermark';
document.getElementById('fileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
const secretKey = "my-secret-key-2024";
// 1. Generate payload
const payloads = await generateWatermarkPayloads(
{ userId: "user_003", sessionId: "TX9999" },
secretKey
);
// 2. Draw file onto Canvas to obtain ImageData
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// ImageData = raw pixel buffer (the library's input)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 3. Embed watermark (mutates imageData in-place)
embedImageWatermarks(imageData, payloads.securePayload);
// 4. Write modified pixels back to Canvas
ctx.putImageData(imageData, 0, 0);
URL.revokeObjectURL(objectUrl);
console.log("Watermark embedded — Canvas updated");
};
img.src = objectUrl;
});
</script>Where does inputBuffer come from?
Most APIs accept a Buffer (Node.js) or Uint8Array (browser). Here are the common ways to obtain one.
1. File path (Node.js)
The simplest approach — use fs.readFileSync or fs.promises.readFile.
import * as fs from 'fs';
// Synchronous
const inputBuffer: Buffer = fs.readFileSync('./path/to/image.jpg');
// Asynchronous (recommended)
const inputBuffer: Buffer = await fs.promises.readFile('./path/to/image.jpg');
// Pass directly to the library
const outputBuffer = await embedForensicImage(inputBuffer, payload);
fs.writeFileSync('./output.png', outputBuffer);2. URL (Node.js)
Node.js 18+ has built-in fetch:
// Node.js 18+ (built-in fetch)
async function loadFromUrl(url: string): Promise<Buffer> {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
const inputBuffer = await loadFromUrl("https://example.com/photo.jpg");
const outputBuffer = await embedForensicImage(inputBuffer, payload);For Node.js 17 and below use the https module or node-fetch:
// npm install node-fetch
import fetch from 'node-fetch';
const res = await fetch("https://example.com/photo.jpg");
const inputBuffer = Buffer.from(await res.arrayBuffer());3. URL in the browser
async function loadImageDataFromUrl(url: string): Promise<ImageData> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous"; // required for cross-origin images
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, img.width, img.height));
};
img.onerror = reject;
img.src = url;
});
}
const imageData = await loadImageDataFromUrl("https://example.com/photo.jpg");
embedImageWatermarks(imageData, payload);Note: Cross-origin images require the server to send an
Access-Control-Allow-Originheader.
4. Local file picker (browser)
// Get ArrayBuffer / Uint8Array from a <input type="file"> selection
async function loadFromFileInput(file: File): Promise<Uint8Array> {
return new Uint8Array(await file.arrayBuffer());
}
// Get ImageData + raw buffer at the same time
async function loadImageDataFromFile(file: File): Promise<{ imageData: ImageData, buffer: Uint8Array }> {
const buffer = await loadFromFileInput(file);
const url = URL.createObjectURL(file);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
URL.revokeObjectURL(url);
resolve({ imageData, buffer });
};
img.onerror = reject;
img.src = url;
});
}High-Level API Guide
1. generateWatermarkPayloads — signed payload generation
Purpose: Generates the two payload variants used throughout the rest of the pipeline.
import { generateWatermarkPayloads } from 'ts-forensic-watermark';
const payloads = await generateWatermarkPayloads(
{
userId: "user_8822", // required
sessionId: "TX9901", // required (used for forensic / FSK payload)
orderId: "ORDER-001", // any extra fields are included in EOF metadata
planType: "premium"
},
"your-secret-key", // HMAC signing key
6 // secureIdLength: chars of sessionId to use (default: 6)
);
// Outputs:
console.log(payloads.jsonString);
// '{"userId":"user_8822","sessionId":"TX9901","orderId":"ORDER-001",
// "planType":"premium","timestamp":"2024-01-01T00:00:00.000Z","signature":"a3f9b2..."}'
// → used for EOF / video SEI embedding
console.log(payloads.securePayload);
// 'TX9901a3f9b2c841d5ef97' (exactly 22 bytes)
// → used for image forensic and FSK audio embedding
console.log(payloads.json);
// Parsed object (same as jsonString parsed)securePayload structure:
[ first 6 chars of sessionId ][ first 16 chars of HMAC ]
TX9901 a3f9b2c841d5ef97
|←── 6 bytes ──→|←────────── 16 bytes ──────────→|
|←─────────────── 22 bytes total ─────────────────→|2. embedImageWatermarks — pixel-level embedding
Purpose: Writes an invisible frequency-domain watermark into ImageData pixels. Mutates the imageData object in-place.
import { embedImageWatermarks } from 'ts-forensic-watermark';
// imageData obtained from Canvas API or Jimp
embedImageWatermarks(
imageData, // ImageData / ImageDataLike (mutated in-place)
payloads.securePayload, // 22-byte payload string
{
delta: 120, // embedding strength
varianceThreshold: 25, // block selection threshold
arnoldIterations: 7 // scramble depth (must match extraction)
}
);
// Returns void. imageData.data is modified directly.Note: After calling this function you must write the modified pixels back — via ctx.putImageData() in the browser or image.bitmap.data = Buffer.from(imageData.data) with Jimp.
Full browser example (embed + PNG download)
async function embedAndDownload(file: File, userId: string, sessionId: string) {
const secretKey = "my-secret-key";
const payloads = await generateWatermarkPayloads({ userId, sessionId }, secretKey);
const img = new Image();
img.src = URL.createObjectURL(file);
await new Promise(r => img.onload = r);
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, img.width, img.height);
embedImageWatermarks(imageData, payloads.securePayload);
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob!);
a.download = 'watermarked.png';
a.click();
}, 'image/png');
}3. finalizeImageBuffer — EOF metadata append
Purpose: Appends signed JSON metadata as plain text to the end of a file buffer. JPEG/PNG decoders ignore trailing bytes, so the image still displays normally. Low robustness but human-readable.
import { finalizeImageBuffer } from 'ts-forensic-watermark';
import * as fs from 'fs';
const originalBuffer = new Uint8Array(fs.readFileSync('./input.jpg'));
const finalBuffer = finalizeImageBuffer(
originalBuffer, // Uint8Array of the original file
payloads.jsonString // JSON string or object (auto-stringified)
);
fs.writeFileSync('./output_with_eof.jpg', finalBuffer);
// The appended bytes look like this (human-readable):
// ---WATERMARK_START---
// {"userId":"user_8822","sessionId":"TX9901",...,"signature":"a3f9b2..."}
// ---WATERMARK_END---Recommended dual-layer pattern:
// Layer 1: invisible pixel watermark (high robustness)
embedImageWatermarks(imageData, payloads.securePayload);
// Export modified canvas to a new buffer, then:
// Layer 2: EOF metadata (low robustness, but easy to read)
const finalBuffer = finalizeImageBuffer(new Uint8Array(newBuffer), payloads.jsonString);4. analyzeTextWatermarks — automatic text watermark scan
Purpose: Scans a binary file buffer for three types of text-based watermarks: EOF, MP4 UUID Box, and H.264 SEI. Works in both Node.js and the browser.
import { analyzeTextWatermarks } from 'ts-forensic-watermark';
import * as fs from 'fs';
const fileBuffer = new Uint8Array(fs.readFileSync('./suspicious_video.mp4'));
const watermarks = analyzeTextWatermarks(fileBuffer);
// Example output:
// [
// { type: 'EOF', name: '...', robustness: 'Low (脆弱)', data: { userId: "...", signature: "..." } },
// { type: 'H264_SEI', name: '...', robustness: 'High (堅牢)', data: { userId: "...", signature: "..." } }
// ]
watermarks.forEach(wm => {
console.log(`Detected: ${wm.name}`);
console.log(` type: ${wm.type}, robustness: ${wm.robustness}`);
console.log(` data:`, wm.data);
});Detectable watermark types:
| type | Description | Robustness |
| :--- | :--- | :--- |
| EOF | ---WATERMARK_START--- marker at end of file | Low |
| UUID_BOX | UUID Box inside MP4 container | Low |
| H264_SEI | SEI user_data_unregistered in H.264 stream | High |
5. analyzeAudioWatermarks — FSK audio watermark extraction
Purpose: Extracts an FSK watermark from a Float32Array of audio samples. Use with the Web Audio API or an audio decoding library.
import { analyzeAudioWatermarks } from 'ts-forensic-watermark';
// Browser: decode audio file via AudioContext
async function analyzeAudioFile(file: File) {
const audioContext = new AudioContext();
const arrayBuffer = await file.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
const channelData: Float32Array = audioBuffer.getChannelData(0);
const watermarks = analyzeAudioWatermarks(channelData, {
sampleRate: audioBuffer.sampleRate
});
watermarks.forEach(wm => {
console.log(`FSK detected: ${wm.data.payload}`);
});
}// Node.js: read WAV with the `wav` package
// npm install wav
import * as fs from 'fs';
import * as wav from 'wav';
function readWavFile(path: string): Promise<{ channelData: Float32Array, sampleRate: number }> {
return new Promise((resolve, reject) => {
const reader = new wav.Reader();
const chunks: Buffer[] = [];
reader.on('format', (format) => {
reader.on('data', (chunk: Buffer) => chunks.push(chunk));
reader.on('end', () => {
const raw = Buffer.concat(chunks);
const samples = new Float32Array(raw.length / 2);
for (let i = 0; i < samples.length; i++) {
samples[i] = raw.readInt16LE(i * 2) / 32768;
}
resolve({ channelData: samples, sampleRate: format.sampleRate });
});
});
reader.on('error', reject);
fs.createReadStream(path).pipe(reader);
});
}
const { channelData, sampleRate } = await readWavFile('./audio_with_watermark.wav');
const watermarks = analyzeAudioWatermarks(channelData, { sampleRate });6. analyzeImageWatermarks — forensic image analysis
Purpose: Extracts the DWT+DCT+SVD forensic watermark from ImageData. Supports multi-angle extraction for rotation robustness.
import { analyzeImageWatermarks } from 'ts-forensic-watermark';
const watermarks = analyzeImageWatermarks(imageData, {
delta: 120,
arnoldIterations: 7,
robustAngles: [0, 90, 180, 270, 0.5, -0.5, 1, -1, 2, -2, 3, -3]
});
watermarks.forEach(wm => {
console.log(`Forensic detected: ${wm.data.payload}`);
console.log(`Confidence: ${wm.data.confidence.toFixed(1)}%`);
console.log(`Angle: ${wm.data.angle}°`);
});robustAngles guide:
[0]— default, no rotation (fastest)[0, 90, 180, 270]— handles right-angle rotations[0, 90, 180, 270, 0.5, -0.5, 1, -1]— handles slight tilts (scanner-level)[0, 90, 180, 270, 0.5, -0.5, 1, -1, 2, -2, 3, -3]— recommended; covers photos of screens taken with a smartphone (tilts up to ±3°)
Note: Each angle runs one full extraction pass. Since the loop exits immediately on success, putting
0first (no rotation) is the most efficient ordering. Arbitrary angles beyond ±5° disrupt DCT block boundaries enough to exceed Reed-Solomon's correction capacity.
7. verifyWatermarks — batch cryptographic verification
Purpose: Cryptographically verifies watermarks detected by the analyze* functions using HMAC.
import { analyzeTextWatermarks, analyzeImageWatermarks, verifyWatermarks } from 'ts-forensic-watermark';
import * as fs from 'fs';
const secretKey = "my-secret-key-2024";
const fileBuffer = new Uint8Array(fs.readFileSync('./output.mp4'));
const textWatermarks = analyzeTextWatermarks(fileBuffer);
const imageWatermarks = analyzeImageWatermarks(imageData, { delta: 120 });
const results = await verifyWatermarks(
[...textWatermarks, ...imageWatermarks],
secretKey,
6 // secureIdLength — must match what was used during embedding
);
results.forEach(wm => {
const ok = wm.verification?.valid;
console.log(`[${wm.type}] ${ok ? '✅ Authentic' : '❌ Tampered / Invalid'}`);
console.log(` ${wm.verification?.message}`);
if (wm.data.userId) console.log(` userId: ${wm.data.userId}`);
});Verification logic by watermark type:
| Type | Verification method | Signed fields |
| :--- | :--- | :--- |
| EOF, UUID_BOX, H264_SEI | JSON signature (HMAC-SHA256 over userId:sessionId) | userId, sessionId |
| AUDIO_FSK, FORENSIC, LLM_VIDEO | Secure payload (HMAC) | Entire payload |
8. embedLlmImageWatermark — LLM DCT frame watermark embedding
Purpose: Embeds an LLM DCT watermark directly into an ImageData object (from the browser Canvas API or a Node.js canvas polyfill). This is the LLM DCT equivalent of embedImageWatermarks (DWT+DCT+SVD).
import { embedLlmImageWatermark, generateWatermarkPayloads } from 'ts-forensic-watermark';
const payloads = await generateWatermarkPayloads(
{ userId: "user_001", sessionId: "TX9901" },
"my-secret-key-2024"
);
// Browser example: get ImageData from Canvas and embed
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(videoFrame, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
embedLlmImageWatermark(imageData, payloads.securePayload, {
quantStep: 300, // quantization step (recommended: 300)
coeffRow: 2, // DCT coefficient position — row
coeffCol: 1, // DCT coefficient position — column
arnoldIterations: 7, // scramble depth
payloadSymbols: 22, // number of data symbols
});
ctx.putImageData(imageData, 0, 0); // write modified pixels back9. analyzeLlmImageWatermarks — LLM DCT frame watermark analysis
Purpose: Analyzes and extracts an LLM DCT watermark from ImageData, returning results in DetectedWatermark[] format. This is the LLM DCT equivalent of analyzeImageWatermarks (DWT+DCT+SVD). Supports multi-angle extraction via robustAngles.
import { analyzeLlmImageWatermarks, verifyWatermarks } from 'ts-forensic-watermark';
// Browser example: get ImageData from Canvas and analyze
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const watermarks = analyzeLlmImageWatermarks(imageData, {
quantStep: 300,
arnoldIterations: 7,
payloadSymbols: 22,
robustAngles: [0, 90, 180, 270, 0.5, -0.5, 1, -1, 2, -2, 3, -3],
});
// Verify
const verified = await verifyWatermarks(watermarks, "my-secret-key-2024", 6, 22);
verified.forEach(wm => {
console.log(`[${wm.type}] payload: ${wm.data.payload}`);
console.log(` Confidence: ${wm.data.confidence.toFixed(1)}%`);
console.log(` Signature: ${wm.verification?.valid ? 'authentic' : 'failed'}`);
});10. analyzeAllImageWatermarks — analyze image with all methods at once
Purpose: Tries both DWT+DCT+SVD forensic and LLM DCT extraction on the same ImageData in one call, returning all detected watermarks. Useful when the embedding method is unknown or both methods may be present.
import { analyzeAllImageWatermarks, verifyWatermarks } from 'ts-forensic-watermark';
// Get ImageData from Canvas
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
// Try both DWT+DCT+SVD and LLM DCT
const allWatermarks = analyzeAllImageWatermarks(
imageData,
// forensicOptions (optional — defaults to delta:120)
{ delta: 120, arnoldIterations: 7, payloadSymbols: 22 },
// llmOptions (optional — defaults to quantStep:300)
{ quantStep: 300, arnoldIterations: 7, payloadSymbols: 22 }
);
// Verify all at once
const verified = await verifyWatermarks(allWatermarks, "my-secret-key-2024", 6, 22);
verified.forEach(wm => {
const method = wm.type === 'FORENSIC' ? 'DWT+DCT+SVD' : 'LLM DCT';
console.log(`[${method}] payload: ${wm.data.payload}`);
console.log(` Confidence: ${wm.data.confidence?.toFixed(1)}%`);
console.log(` Signature: ${wm.verification?.valid ? '✅ authentic' : '❌ failed'}`);
});Comparison with analyzeImageWatermarks:
| | analyzeImageWatermarks | analyzeAllImageWatermarks |
|:---|:---|:---|
| Methods tried | DWT+DCT+SVD only | DWT+DCT+SVD + LLM DCT |
| Arguments | (imageData, forensicOptions) | (imageData, forensicOptions?, llmOptions?) |
| Use case | Method is known | Method unknown / detect both |
| Speed | Faster | Slightly slower (runs both) |
Node.js-Only Helper API
These functions use Jimp and FFmpeg internally. Node.js environment only — do not import in browser builds.
The embedForensicImage and extractForensicImage functions accept ImageInput:
type ImageInput = Buffer | string;
// Buffer — raw bytes (backward compatible)
// string starting with 'http://' or 'https://' → fetched via fetch()
// any other string → treated as a local file path via fs.promises.readFileembedForensicImage — direct image embedding
import { embedForensicImage, generateWatermarkPayloads } from 'ts-forensic-watermark';
const payloads = await generateWatermarkPayloads(
{ userId: "user_001", sessionId: "TX1234" },
"my-secret-key-2024"
);
// ── Pattern 1: file path (simplest) ──
const outputBuffer = await embedForensicImage(
'./input.jpg', // local file path
payloads.securePayload, // 22-byte payload
{ delta: 150 }, // ForensicOptions (optional)
'./output.png' // outputPath: optional — also writes to disk when provided
);
// ── Pattern 2: URL ──
const fromUrl = await embedForensicImage(
'https://example.com/photo.jpg',
payloads.securePayload,
undefined,
'./watermarked_from_url.png'
);
// ── Pattern 3: Buffer (backward compatible) ──
import * as fs from 'fs';
const buf = await embedForensicImage(fs.readFileSync('./input.jpg'), payloads.securePayload);
fs.writeFileSync('./output.png', buf);Output format: Always PNG. This prevents watermark degradation from JPEG re-compression.
outputPath: When the 4th argument is provided, the buffer is also written to disk viafs.promises.writeFile. The same buffer is returned regardless.
extractForensicImage — direct image extraction
import { extractForensicImage, verifySecurePayload } from 'ts-forensic-watermark';
// ── Pattern 1: file path ──
const result = await extractForensicImage('./output.png');
// ── Pattern 2: URL ──
const resultFromUrl = await extractForensicImage('https://example.com/watermarked.png');
// ── Pattern 3: Buffer (backward compatible) ──
import * as fs from 'fs';
const result2 = await extractForensicImage(fs.readFileSync('./output.png'));
if (result && result.payload !== 'RECOVERY_FAILED') {
console.log("Extracted:", result.payload); // "TX1234a3f9b2..."
console.log("Confidence:", result.confidence); // 0–100
const isValid = await verifySecurePayload(result.payload, "my-secret-key-2024", 6);
console.log("Signature:", isValid ? "authentic" : "tampered");
} else {
console.log("Extraction failed or data corrupted");
}Automatic fallback: Internally tries delta: 120 first, then falls back to delta: 60 if extraction fails, covering watermarks embedded with different strength settings.
embedLlmVideoFile — LLM DCT embedding for a single image/frame
Reads a single image file (PNG/JPG) with Jimp, embeds an LLM DCT watermark, and returns the result as a PNG buffer. Intended for still images or individual frames.
import { embedLlmVideoFile, generateWatermarkPayloads } from 'ts-forensic-watermark';
const payloads = await generateWatermarkPayloads(
{ userId: "user_001", sessionId: "TX9901" },
"my-secret-key-2024"
);
const outputBuffer = await embedLlmVideoFile(
'./frame.png', // ImageInput: Buffer, file path, or URL
payloads.securePayload, // payload to embed (Base64url)
{ quantStep: 300, coeffRow: 2, coeffCol: 1, arnoldIterations: 7, payloadSymbols: 22 },
'./output_frame.png' // optional — also writes to disk when provided
);extractLlmVideoFile — LLM DCT extraction from a single image/frame
import { extractLlmVideoFile, verifySecurePayload } from 'ts-forensic-watermark';
const result = await extractLlmVideoFile('./output_frame.png', {
quantStep: 300, arnoldIterations: 7, payloadSymbols: 22,
});
if (result) {
console.log("Payload:", result.payload);
console.log("Confidence:", result.confidence); // 0–100 (sync marker match rate)
}embedLlmVideoFrames — LLM DCT embedding across all video frames
Embeds a watermark into every frame of a video file. Internal flow:
- FFmpeg extracts all frames as numbered PNG files (temp directory)
- Jimp reads each frame →
embedLlmVideoFrameembeds → overwrites PNG - FFmpeg re-encodes PNG sequence + original audio → H.264 / yuv420p output
- Temp directory is deleted
import { embedLlmVideoFrames, generateWatermarkPayloads } from 'ts-forensic-watermark';
const payloads = await generateWatermarkPayloads(
{ userId: "user_001", sessionId: "TX9901" },
"my-secret-key-2024"
);
await embedLlmVideoFrames(
'./input.mp4', // input video path
payloads.securePayload, // payload to embed
'./output_wm.mp4', // output video path
{ quantStep: 300, arnoldIterations: 7, payloadSymbols: 22 }
);
console.log("All frames watermarked.");Note: Processing time scales linearly with frame count. A 1-minute 30 fps video requires ~1,800 frames to be processed.
extractLlmVideoFrames — LLM DCT extraction from a video file
Samples frames at even intervals and returns the highest-confidence extraction result.
import { extractLlmVideoFrames, verifySecurePayload } from 'ts-forensic-watermark';
const result = await extractLlmVideoFrames('./output_wm.mp4', {
quantStep: 300,
arnoldIterations: 7,
payloadSymbols: 22,
sampleFrames: 10, // sample up to 10 frames at 1-second intervals (default: 10)
});
if (result) {
console.log("Payload:", result.payload);
console.log("Confidence:", result.confidence);
const isValid = await verifySecurePayload(result.payload, "my-secret-key-2024", 6, 22);
console.log("Signature:", isValid ? "authentic" : "tampered");
} else {
console.log("Extraction failed (Reed-Solomon uncorrectable)");
}| Parameter | Description | Default |
|:---|:---|:---|
| sampleFrames | Number of frames to sample (1 per second) | 10 |
| quantStep / arnoldIterations / payloadSymbols | Must match embedding-time settings | 300 / 7 / 22 |
analyzeVideoLlmWatermarks — video analysis ready for verifyWatermarks
Wraps extractLlmVideoFrames output into DetectedWatermark[] format so it can be passed directly to verifyWatermarks — watermark extraction and signature verification in a single pipeline.
import { analyzeVideoLlmWatermarks, verifyWatermarks } from 'ts-forensic-watermark';
const secretKey = "my-secret-key-2024";
// ① Sample frames from video → DetectedWatermark[]
const watermarks = await analyzeVideoLlmWatermarks('./output_wm.mp4', {
quantStep: 300,
arnoldIterations: 7,
payloadSymbols: 22,
sampleFrames: 10,
});
// ② Pass directly to verifyWatermarks
const verified = await verifyWatermarks(watermarks, secretKey, 6, 22);
verified.forEach(wm => {
console.log(`[${wm.type}] payload: ${wm.data.payload}`);
console.log(` Confidence: ${wm.data.confidence.toFixed(1)}%`);
console.log(` Signature: ${wm.verification?.valid ? '✅ authentic' : '❌ tampered'}`);
});Comparison of extraction functions:
| Function | Environment | Return type | Directly usable with verifyWatermarks |
|:---|:---:|:---:|:---:|
| extractLlmVideoFrames | Node.js | {payload, confidence}\|null | ❌ |
| analyzeVideoLlmWatermarks | Node.js | DetectedWatermark[] | ✅ |
| analyzeLlmImageWatermarks | Browser / Node.js | DetectedWatermark[] | ✅ (single image/frame) |
embedVideoWatermark — video watermark injection
Purpose: Injects watermarks into an H.264 video via SEI units and an MP4 UUID Box. No re-encoding — video quality and bitrate are unchanged.
import { embedVideoWatermark, generateWatermarkPayloads, analyzeTextWatermarks } from 'ts-forensic-watermark';
import * as fs from 'fs';
const secretKey = "my-secret-key-2024";
const payloads = await generateWatermarkPayloads(
{ userId: "user_8822", sessionId: "TX9901" },
secretKey
);
await embedVideoWatermark(
'./input.mp4', // input video path
'./output.mp4', // output path
payloads.jsonString, // JSON metadata to embed
"d41d8cd98f00b204e9800998ecf8427e" // UUID (optional, has default)
);
// Verify
const outputBuffer = new Uint8Array(fs.readFileSync('./output.mp4'));
const found = analyzeTextWatermarks(outputBuffer);
console.log("Detected types:", found.map(w => w.type)); // ['H264_SEI', 'UUID_BOX']Manual FFmpeg equivalent:
ffmpeg -i input.mp4 \
-c:v copy \
-bsf:v "h264_metadata=sei_user_data='086f3693b7b34f2c965321492feee5b8+eyJ1c2VySWQi...'" \
-c:a copy \
output.mp4Low-Level API Reference
Image core (forensic.ts)
embedForensic(imageData, payload, options?)
import { embedForensic } from 'ts-forensic-watermark';
const imageDataLike = {
data: new Uint8ClampedArray(width * height * 4), // RGBA
width: 800,
height: 600
};
embedForensic(imageDataLike, "TX9901a3f9b2c841d5ef7", {
delta: 200,
varianceThreshold: 10,
arnoldIterations: 5,
force: true // skip variance / SVD threshold checks
});extractForensic(imageData, options?)
import { extractForensic } from 'ts-forensic-watermark';
const result = extractForensic(imageDataLike, { delta: 200, arnoldIterations: 5 });
// { payload: "TX9901...", confidence: 87.3, debug: {...} }
// null — image too small
// { payload: "RECOVERY_FAILED", confidence: 30 } — ECC failedLLM DCT frame core (llm-dct.ts)
embedLlmVideoFrame(imageData, payload, options?)
Embeds an LLM DCT + QIM watermark into every 8×8 block of an ImageData (mutates in-place).
import { embedLlmVideoFrame } from 'ts-forensic-watermark';
const imageDataLike = {
data: new Uint8ClampedArray(width * height * 4), // RGBA
width: 1920,
height: 1080
};
embedLlmVideoFrame(imageDataLike, "TX9901SGVsbG8hV29ybGQ-", {
quantStep: 300, // QIM quantization step (default: 300)
coeffRow: 2, // DCT coefficient row (default: 2)
coeffCol: 1, // DCT coefficient column (default: 1)
arnoldIterations: 7, // Arnold scramble iterations (default: 7)
payloadSymbols: 22, // data symbols (default: 22, ECC=41)
});
// imageDataLike.data is mutated in-place — no return valueCoefficient position guide:
| Position (row, col) | Frequency band | Compression resistance | Visibility | |:---:|:---|:---:|:---:| | (0, 0) | DC component | Highest | Noticeable | | (1–2, 0–2) | Low frequency (recommended) | High | Slightly visible | | (3–4, 3–4) | Mid frequency | Medium | Nearly invisible | | (5–7, 5–7) | High frequency | Low | Invisible |
extractLlmVideoFrame(imageData, options?)
Extracts an LLM DCT watermark via soft majority voting.
import { extractLlmVideoFrame } from 'ts-forensic-watermark';
const result = extractLlmVideoFrame(imageDataLike, {
quantStep: 300, // must match embedding settings
coeffRow: 2,
coeffCol: 1,
arnoldIterations: 7,
payloadSymbols: 22,
});
if (result) {
console.log("payload:", result.payload);
// confidence: 16-bit sync marker match rate (0–100)
// values above 50 suggest successful extraction
console.log("confidence:", result.confidence.toFixed(1) + "%");
} else {
// Reed-Solomon uncorrectable (error count exceeded ECC capacity)
console.log("Extraction failed");
}Return value:
| Field | Type | Description |
|:---|:---|:---|
| payload | string | Recovered Base64url payload |
| confidence | number | Sync marker match rate 0–100. Values below 50 are suspect |
| null | — | Reed-Solomon correction failed |
Audio core (fsk.ts)
generateFskBuffer(payload, options?)
import { generateFskBuffer } from 'ts-forensic-watermark';
import * as fs from 'fs';
const fskWav: Uint8Array = generateFskBuffer("TX9901a3f9b2c841d5ef7", {
sampleRate: 44100,
bitDuration: 0.025,
syncDuration: 0.05,
marginDuration: 0.1,
amplitude: 50, // default; raise only if detection is unreliable
// freqZero/freqOne/freqSync omitted → uses defaults (15k/16k/14kHz)
});
// fskWav is a WAV-format binary (Uint8Array)
fs.writeFileSync('./watermark_signal.wav', fskWav);
// Mix with original audio via FFmpeg:
// ffmpeg -i original.mp3 -i watermark_signal.wav \
// -filter_complex "amix=inputs=2:duration=first" output.mp3extractFskBuffer(channelData, options?)
import { extractFskBuffer } from 'ts-forensic-watermark';
// channelData: Float32Array of normalized audio samples (-1.0 to 1.0)
// Omitting freqZero/freqOne/freqSync uses the defaults (15k/16k/14kHz)
const payload: string | null = extractFskBuffer(channelData, { sampleRate: 44100 });
if (payload) console.log("FSK extracted:", payload);Utilities (utils.ts)
generateSecurePayload(id, secret, idLength?)
import { generateSecurePayload } from 'ts-forensic-watermark';
const payload = await generateSecurePayload("TX9901", "my-secret-key", 6);
// "TX9901a3f9b2c841d5ef97" (22 bytes fixed)verifySecurePayload(payload, secret, idLength?)
import { verifySecurePayload } from 'ts-forensic-watermark';
const isValid = await verifySecurePayload("TX9901a3f9b2c841d5ef97", "my-secret-key", 6);
// true / falserotateImageData(imageData, angle)
import { rotateImageData } from 'ts-forensic-watermark';
const rotated = rotateImageData(imageData, 45); // bilinear interpolation
// 90 / 180 / 270 use a fast integer pathappendEofWatermark / extractEofWatermark
import { appendEofWatermark, extractEofWatermark } from 'ts-forensic-watermark';
const withWatermark = appendEofWatermark(originalBuffer, '{"userId":"user_001",...}');
const extracted: string | null = extractEofWatermark(withWatermark);
// Scans only the last 4096 bytes — fast even for large files
if (extracted) console.log(JSON.parse(extracted).userId);Parameter Reference
ForensicOptions (image watermark settings)
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| delta | number | 120 | Embedding strength. Higher values survive heavier JPEG compression but introduce more visible noise. Use 120 for normal use, 200–300 for very aggressive compression (quality < 50). |
| varianceThreshold | number | 25 | Block selection threshold. 8×8 blocks with luminance variance below this value (flat areas like sky/walls) are skipped. Setting to 0 embeds everywhere but increases noise visibility. |
| arnoldIterations | number | 7 | Arnold transform iterations. Controls the spatial scrambling depth. Must be identical at embedding and extraction — any mismatch causes complete extraction failure. |
| force | boolean | false | When true, skips variance and SVD threshold checks to force-embed in every block. Used for tests and video pattern generation. |
| robustAngles | number[] | [0] | Angles to try during extraction via analyzeImageWatermarks. More angles = higher rotation robustness but slower. |
| payloadSymbols | number | 22 | Number of data symbols. Controls the payload / ECC split (ECC = 63 − payloadSymbols). Must match between embedding and extraction. |
| softDecoding | boolean | true | Soft-decision (erasure) decoding (v3.0.0+). Low-confidence symbol positions are declared erasures, expanding RS correction capacity to 2·errors + erasures ≤ eccLen — up to 2× the hard-decision limit. Greatly improves robustness under JPEG and other lossy paths. Set false to fall back to pure hard-decision decoding. |
| erasureThreshold | number | 0.35 | Relative erasure threshold (only when softDecoding: true). A symbol is erased when its minimum bit confidence drops below (median over all symbols) × threshold. Higher values erase more symbols (rescue weaker bits at the cost of ECC budget); lower values approach hard decoding. |
| regions | number | 1 | Spatial diversity (multi-region embedding). Divides the image into N regions and embeds the same watermark into each. Extraction soft-combines (Maximal-Ratio Combining) the per-region soft values before RS decoding. Improves robustness against partial crops and locally-uneven degradation. Each region requires at least 160×160 px; if the image is too small, automatically falls back to the largest N that fits (down to 1). Must match between embedding and extraction. See Spatial diversity for details. |
LlmVideoOptions (LLM DCT frame watermark settings)
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| quantStep | number | 300 | QIM quantization step. Higher values survive H.264/H.265 compression better but introduce more visible brightness change. Recommended range: 200–500. |
| coeffRow | number | 2 | DCT coefficient row to embed into. Lower values (1–2) target low-frequency coefficients and are more compression-resistant. Must match between embedding and extraction. |
| coeffCol | number | 1 | DCT coefficient column to embed into. Same guidance as coeffRow. |
| arnoldIterations | number | 7 | Arnold transform iterations. Controls scrambling depth. Must be identical at embedding and extraction — any mismatch causes complete extraction failure. |
| payloadSymbols | number | 22 | Number of data symbols. Controls the payload / ECC split. Must match between embedding and extraction. |
| robustAngles | number[] | [0] | Angles to try during extraction via analyzeLlmImageWatermarks. More angles = higher rotation robustness but slower. |
FskOptions (FSK audio watermark settings)
| Parameter | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| sampleRate | number | 44100 | Sample rate (Hz). Must match between generation and extraction. |
| bitDuration | number | 0.025 | Seconds per bit. Shorter = less total signal time but lower reliability on poor recordings. 25 ms is the recommended balance. |
| syncDuration | number | 0.05 | Sync tone duration (seconds). The marker tone the extractor uses to locate the start of data. |
| marginDuration | number | 0.1 | Guard interval after sync (seconds). Silent gap between sync tone and data bits; provides timing margin. |
| amplitude | number | 50 | Signal volume (raw 16-bit PCM value). Practical range: 50–5000. Max: 32767. Higher values are easier to detect but may be audible. |
| freqZero | number | 15000 | Frequency for bit 0 (Hz). Default 15 kHz — inaudible to most people (especially 30+) and faithfully preserved by AAC at 192 kb/s+. Recommend at least 500 Hz gap from freqOne. |
| freqOne | number | 16000 | Frequency for bit 1 (Hz). Default 16 kHz. Same rationale as freqZero. |
| freqSync | number | 14000 | Sync tone frequency (Hz). Default 14 kHz. Kept separate from data frequencies to avoid false sync detection. |
📚 Technical Background
1. Forensic Watermark: DWT + DCT + SVD + QIM
Processing pipeline
Input image (RGBA)
↓ RGB → YCbCr (use Y luminance channel only)
↓ Split into 8×8 blocks
↓ [per block]
↓ DWT → 4 sub-bands (LL / HL / LH / HH)
↓ 4×4 DCT applied to HL and LH bands
↓ Jacobi SVD on DCT coefficient matrix
↓ QIM embedding on largest singular value σ₁
↓ Inverse SVD → Inverse DCT → Inverse DWT
↓ YCbCr → RGB
↓ Watermarked image (RGBA)Role of each algorithm
YCbCr color space Separates the luminance (Y) component — where human vision is most sensitive — from chrominance (Cb/Cr). Embedding only in the Y channel minimises visible colour shifts.
DWT (Discrete Wavelet Transform) Used in JPEG 2000. Decomposes each 8×8 block into four frequency bands:
LL— low-frequency (overall brightness / large shapes)HL— horizontal high-frequency edgesLH— vertical high-frequency edgesHH— diagonal high-frequency
This library uses Dual-Band QIM — embedding simultaneously in both HL and LH — so that if one band is destroyed the other can still carry the bit.
DCT (Discrete Cosine Transform) The core of JPEG compression. Because JPEG quantises DCT coefficients, embedding in the DCT domain makes the watermark much more likely to survive JPEG compression.
SVD (Singular Value Decomposition) + QIM (Quantization Index Modulation) SVD factors a matrix A = UΣVᵀ. The largest singular value σ₁ represents the "energy" of the block and is stable under geometric transforms (rotation, scaling).
QIM encodes each bit by quantising σ₁ to one of two lattices separated by delta:
bit=1: σ₁ = round((σ₁ - delta*0.75) / delta) * delta + delta*0.75
bit=0: σ₁ = round((σ₁ - delta*0.25) / delta) * delta + delta*0.25After compression, the bit can be recovered from σ₁ mod delta.
Arnold transform (torus automorphism) Spatially shuffles the 400-bit (20×20) payload matrix:
(x', y') = ((x + 2y) mod N, (x + y) mod N)The transform is periodic (period 30 for N=20) and invertible. Scrambling converts burst errors into scattered errors that Reed-Solomon can correct.
Strengths and weaknesses
Strengths:
- JPEG compression (quality ≥ 60 recovers reliably)
- Resize / scale (aspect-ratio preserved)
- Mild colour grading / contrast adjustments
- Screenshots (when
deltais sufficient)
Weaknesses:
- Heavy JPEG compression (quality < 30)
- Cropping (insufficient blocks remain)
- Very small images (fewer than 400 blocks of 8×8 → embedding impossible)
- GIF format (colour palette quantisation destroys the signal)
- Large rotations without
robustAnglesconfigured
2. Reed-Solomon Error Correction (ECC)
Overview
The algebraic error-correcting code used in barcodes, QR codes, and CDs. This library uses Reed-Solomon over GF(2⁶) = GF(64) (ReedSolomonGF64).
Why GF(64)? Watermark payloads are Base64url strings (A-Z, a-z, 0-9, -, _, 64 characters). GF(64) makes 1 symbol exactly equal to 1 Base64url character (6 bits), so symbol boundaries align perfectly with payload boundaries — no padding loss as you would get with GF(2⁸) = 8-bit symbols.
Forensic watermark configuration (default):
- Data symbols: 22 (= 22 chars Base64url)
- ECC symbols: 41
- Codeword length: 63 symbols (the GF(64) maximum, = 378 bits)
- Hard-decision correction: up to 20 symbol errors
- Soft-decision correction (v3.0.0+): up to 41 symbol erasures (
2·errors + erasures ≤ 41)
FSK watermark configuration (default):
- Data symbols: 22
- ECC symbols: 18
- Codeword length: 40 symbols (FSK uses a fixed 40-symbol codeword)
- Hard-decision correction: up to 9 symbol errors
How it works
The Berlekamp-Massey algorithm computes the error-locator polynomial from syndrome polynomials; Chien search finds error positions; Forney's formula recovers the error values. The entire implementation (watermark-lib/src/rs-gf64.ts) is dependency-free and runs in both the browser and Node.js.
Errors-and-erasures decoder (v3.0.0+): the soft bitSums values from the extraction stage identify low-confidence symbol positions, which are declared as erasures before decoding. By the RS theoretical bound 2 × errors + erasures ≤ eccLen, pure-erasure decoding gives twice the correction capacity of hard-decision decoding.
Strengths:
- Handles burst errors (consecutive corrupted symbols) — further improved by structured interleaving (v3.0.0+)
- Handles random scattered errors
- Soft-decision (erasure) decoding (v3.0.0+) — directly corrects the bit-confidence loss caused by JPEG and other lossy paths
Weaknesses:
- Returns
nullwhen errors exceed the capacity bound (uncorrectable) - Cannot help when degradation destroys the 16-bit synchronization marker entirely
3. FSK Acoustic Watermark
Overview
Frequency-Shift Keying (FSK) is a digital modulation technique that maps bit values to different carrier frequencies. This library uses the 14–16 kHz range — inaudible to most people yet faithfully preserved by AAC at 192 kb/s and above.
Design rationale: Earlier versions used 17/18/19 kHz, but FFmpeg's default AAC encoder (~69 kb/s) heavily attenuates that range via its psychoacoustic model, destroying the FSK signal beyond Reed-Solomon's repair capacity. The 14/15/16 kHz defaults survive AAC compression reliably while remaining inaudible at normal playback volumes.
Signal structure (timeline):
[14 kHz sync tone: 50 ms][silent guard interval: 100 ms][data bits: ~6 s]
↑ detection marker ↑ 15/16 kHz × 240 bitsTotal duration: ~6.15 s (default settings)
Goertzel algorithm for frequency detection
Instead of a full FFT, Goertzel computes the power at a single frequency in O(N) time:
k = round(N × f / sampleRate)
w = 2πk / N
Q₀ = 2cos(w)·Q₁ − Q₂ + sample[i]
magnitude = √(Q₁² + Q₂² − Q₁·Q₂·2cos(w))Robust scanning decoder
At extraction time, the decoder tolerates timing errors via four strategies:
- Time-shift sweep: tries 6 offsets (±15 ms) around the detected sync position
- Bit-shift sweep: tries 0–7 bit offsets to handle byte-boundary drift
- Bit-inversion attempt: also tries RS decoding on bit-flipped data
- RS correction: repairs up to 4 corrupted bytes automatically
Strengths and weaknesses
Strengths:
- Survives analog-hole attacks (speaker → microphone recording)
- Survives AAC/MP3 compression (14–16 kHz is faithfully preserved at 192 kb/s+)
- Independent of video content — survives video editing if audio is untouched
- Can be mixed into any audio track with FFmpeg
- Inaudible to most people at the default
amplitudeof 50
Weaknesses:
- Destroyed by aggressive low-pass filters below ~13 kHz
- Time-stretching or pitch-shifting breaks sync detection
- Very low SNR (heavy noise overlay) can exceed RS capacity
- Audio track replacement defeats it completely
- May be faintly audible to young listeners at high
amplitudevalues
4. Tamper Detection with HMAC-SHA256
Overview
Hash-based Message Authentication Code (HMAC) proves data authenticity (i.e. that it has not been tampered with) using a secret key combined with SHA-256.
Signing flow:
message = userId + ":" + sessionId (e.g. "user_8822:TX9901")
signature = HMAC-SHA256(message, secretKey) → 64-char hex string
JSON gets: { ..., signature: "a3f9b2..." }Verification flow:
expected = HMAC-SHA256(userId + ":" + sessionId, secretKey)
valid = expected === metadata.signatureWeb Crypto API: Uses globalThis.crypto.subtle, available natively in both Node.js (18+) and browsers. Zero external dependencies.
Secure payload (22-byte variant)
Within the 22-byte constraint for forensic/FSK:
secureIdLength=6:
ID part: "TX9901" (6 bytes)
HMAC part: "SGVsbG8hV29ybGQ-" (16 bytes — Base64url-encoded HMAC bytes)HMAC encoding: Base64url (96 bits)
The raw HMAC-SHA256 bytes are Base64url-encoded (A-Za-z0-9-_), then the first 16 characters are used. Each Base64url character carries 6 bits of entropy, giving 96 bits of strength for 16 characters.
| Encoding | Bits per char | 16-char strength | Combinations | | :--- | :---: | :---: | :--- | | Hex (legacy) | 4 bits | 64 bits | ~1.8 × 10¹⁹ | | Base64url (current) | 6 bits | 96 bits | ~7.9 × 10²⁸ |
Backward compatibility: verifySecurePayload automatically detects and accepts both Base64url (new) and hex (legacy) formats, so watermarks embedded with older versions of the library continue to verify correctly.
Strengths:
- Detects any modification (even a single bit change breaks the signature)
- Prevents replay attacks when
timestampis included in the signed fields - Forgery impossible without the secret key
Weaknesses:
- Key leakage completely defeats tamper detection
- HMAC provides authenticity, not secrecy (payload is not encrypted)
- Longer IDs with the 22-byte constraint leave less room for HMAC, reducing security
5. Arnold Transform (Spatial Scrambling)
Periodicity: For a 20×20 matrix the period is 30 (30 applications return to the original).
Security note: arnoldIterations is not a secret key. Even if an attacker knows this value, they cannot forge a watermark without the HMAC secret key. The purpose of scrambling is burst-error dispersal, not secrecy.
6. Sync Marker (16 bits)
A fixed 16-bit pattern 1010101001010101 is prepended to the bit stream before Arnold scrambling. At extraction, the fraction of these bits that match the expected pattern is the primary component of the confidence score. A perfect marker match indicates the correct arnoldIterations and delta settings were used.
7. EOF Metadata Append
Overview
Appends arbitrary text (JSON) as plain bytes after the end of a media file. Most decoders stop reading at the formal end-of-file marker and ignore any trailing bytes.
Appended format:
[original JPEG/PNG/MP4 bytes...][0x0A]---WATERMARK_START---[0x0A]
{"userId":"user_8822","sessionId":"TX9901","timestamp":"...","signature":"a3f9b2..."}
[0x0A]---WATERMARK_END---[0x0A]Extraction: Only the last 4096 bytes of the file are decoded, making it fast regardless of file size. The extractor searches backwards for the ---WATERMARK_START--- / ---WATERMARK_END--- pair.
Why each format ignores trailing bytes
| Format | Why trailing bytes are ignored |
| :--- | :--- |
| JPEG | Decoder stops at the FFD9 (EOI) marker; everything after is discarded |
| PNG | Decoder stops after the IEND chunk; trailing data is ignored |
| MP4 | Data outside the box structure is skipped by most players |
Strengths and weaknesses
Strengths:
- Simple and reliable (no third-party dependencies)
- Human-readable — easy to inspect during forensic investigation
- Applicable to any file format
- Fast extraction (scans only last 4 KB)
Weaknesses:
- Low tamper resistance — deletable with a text editor or hex editor
- Lost on re-encoding / format conversion (JPEG → PNG, etc.)
- Lost on video transcode (FFmpeg
-c:v libx264) - Presence of the watermark is immediately visible in a binary viewer
- Slightly increases file size (~100–200 extra bytes)
8. MP4 UUID Box
Overview
MP4 (ISO Base Media File Format) is a nested "box" container. The uuid box type is a standards-defined extension point for application-specific data; most players simply skip unrecognised UUID boxes and continue playback.
UUID Box binary structure:
[4 bytes: box size (big-endian uint32)]
[4 bytes: 'uuid' ASCII]
[16 bytes: UUID byte sequence = WATERMARK_UUID_HEX]
[N bytes: payload (JSON bytes)]Example hex dump:
00 00 00 39 ← size (57 bytes)
75 75 69 64 ← 'uuid'
d4 1d 8c d9 8f 00 b2 04 e9 80 09 98 ec f8 42 7e ← UUID (16 bytes)
7b 22 75 73 65 72 49 64 22 3a 22 ... ← JSON payloadDetection in analyzeTextWatermarks
Two complementary methods are used:
- Regex scan (fast): Decodes the first 5 MB as text and searches for
{"userId":"...",...,"signature":"..."}pattern - Binary search (precise): Linear scan for the UUID byte sequence (up to first 50 MB)
Strengths and weaknesses
Strengths:
- Standards-compliant — most tools skip unknown UUID boxes gracefully
- Survives stream-copy operations (
ffmpeg -c copy) - Minimal file-size overhead
Weaknesses:
- Destroyed by video re-encoding (transcode)
- Easily detected and deleted with
mp4box,mp4info, or a hex editor - No inherent protection beyond the HMAC signature
10. LLM DCT Frame Watermark
Overview
LLM (Loeffler–Ligtenberg–Moschytz, 1989) is a butterfly-network algorithm that computes the 8-point 1D DCT using only 5 multiplications and 29 additions. It achieves the same transform as standard JPEG DCT without calling Math.cos or Math.sqrt at runtime — all constants are pre-computed.
Pre-computed constants used (6 values):
| Constant | Value | Meaning |
|:---|:---|:---|
| LLM_C4 | 0.7071… | cos(4π/16) = 1/√2 |
| LLM_C6 | 0.3826… | cos(6π/16) |
| LLM_C2mC6 | 0.5411… | cos(2π/16) − cos(6π/16) |
| LLM_C2pC6 | 1.3065… | cos(2π/16) + cos(6π/16) |
| LLM_2C2 | 1.8477… | 2·cos(2π/16) |
| LLM_SQRT2 | 1.4142… | √2 |
Embedding algorithm
1. RS encode: payload → GF(64) Reed-Solomon codeword (data + ECC = 63 symbols)
2. Arnold scramble: shuffle 400 bits with Arnold transform
3. Scan all 8×8 blocks across the frame
├── Load Y luminance channel into Float32Array(64)
├── fdct2d(): 2D LLM DCT (rows then columns, 8 passes each)
├── QIM embed at coefficient [coeffRow, coeffCol]:
│ bit=1 → q*Q + 0.75*Q
│ bit=0 → q*Q + 0.25*Q
├── idct2d(): 2D LLM inverse DCT (columns then rows)
└── ÷64 scale correction + add delta to RGB, clamp
4. Bits are cyclically embedded across all blocks (redundancy)Extraction algorithm
1. Scan all blocks
├── fdct2d() → read target coefficient
└── Soft QIM: accumulate (val % Q + Q) % Q - Q/2 (majority vote)
2. Hard decision: sign of accumulated value → bit
3. Inverse Arnold scramble
4. RS decode: error-correct to recover payload
5. Confidence: 16-bit sync marker match rate (0–100%)Strengths and weaknesses
Strengths:
- Stable embedding in video frames, uniform images (gradients, screenshots, CG)
- Survives H.264/H.265 compression (increase
quantStepfor higher resistance) - Works on images as small as 8×8 pixels (lower minimum size than DWT+DCT+SVD)
- Fast processing (5 multiplications + 29 additions per butterfly)
- Well-suited for embedding across all frames of a video
- Rotation-robust extraction supported via
robustAnglesinanalyzeLlmImageWatermarks
Weaknesses:
- Less effective at hiding in natural photographs (cannot exploit complex texture)
- Heavy JPEG compression (quality < 30) or large resizes reduce reliability (mitigate by raising
quantStep) - Cropping (most of the image removed) destroys redundancy
- Applying DWT+DCT+SVD and LLM DCT to the same image simultaneously causes interference — use only one method per image
Comparison with DWT+DCT+SVD
| Property | LLM DCT | DWT+DCT+SVD |
|:---|:---|:---|
| Best target | Video frames, uniform images | Natural photos, high-texture images |
| Speed | Fast (minimal arithmetic) | Moderate (DWT subband processing) |
| Compression resistance | High (tunable via quantStep) | High (SVD singular value modulation) |
| Luminance change | ±few levels (depends on quantStep) | ±delta value (depends on texture) |
| Minimum image size | 8×8 pixels | 32×32 pixels (DWT requirement) |
| Rotation robustness | ✅ via robustAngles | ✅ via robustAngles |
| ECC | GF(64) RS (63 symbols) | GF(64) RS (63 symbols) |
9. H.264 SEI User Data
Overview
The H.264 (AVC) standard defines SEI (Supplemental Enhancement Information) NAL units. SEI type 5 — user_data_unregistered — allows applications to embed arbitrary data identified by a 16-byte UUID. This is a fully standards-conformant extension point.
SEI NAL unit binary structure:
[3–4 bytes: H.264 start code (00 00 01 or 00 00 00 01)]
[1 byte: nal_unit_type = 0x06 (SEI)]
[1 byte: payloadType = 0x05 (user_data_unregistered)]
[1 byte: payloadSize]
[16 bytes: UUID (086f3693b7b34f2c965321492feee5b8)]
[N bytes: Base64url-encoded JSON payload]FFmpeg injection (generateH264SeiPayload → h264_metadata filter)
generateH264SeiPayload produces the string format expected by FFmpeg's h264_metadata bitstream filter:
086f3693b7b34f2c965321492feee5b8+eyJ1c2VySWQiOiJ1c2VyXzg4MjIiLC4uLn0=
└── UUID (32-char hex) ──────────┘└── Base64url-encoded JSON ───────────┘Why Base64url?
FFmpeg receives the sei_user_data argument via a shell command line. Base64url avoids shell-unsafe characters (", {, }, :) that would break argument parsing.
Detection in analyzeTextWatermarks
A linear binary search for the 16-byte UUID 086f3693b7b34f2c965321492feee5b8 scans the first 50 MB of the file. On a match, the following Base64url bytes are decoded to recover the JSON.
Strengths and weaknesses
Strengths:
- Codec-standard injection method — widely supported
- Survives stream-copy (no re-encoding needed)
- Zero impact on video quality or bitrate
- Most players skip SEI NAL units transparently during playback
- Trusted by forensic investigators as a standard-conformant channel
Weaknesses:
- H.264 (AVC) only — H.265 (HEVC), VP9, and AV1 need different mechanisms
- Destroyed by video transcoding
- Detectable and deletable with
ffprobe -show_packetsor bitstream filter tools - Requires FFmpeg 4.0+ for the
h264_metadatafilter
11. Soft-decision (erasure) decoding & structured bit interleaving (v3.0.0+)
Two encoding-layer improvements introduced in v3.0.0. The image embedding (delta, target blocks, SVD operation) is identical to v2.x — zero perceptual change — yet JPEG robustness is significantly improved.
11.1 Soft-decision (erasure) decoding
Overview
In v2.x, extraction collapsed each bitSums[i] value to a hard 0/1 (> 0 ? 1 : 0) before handing it to the RS decoder. This discarded the precious confidence information carried by |bitSums[i]|.
In v3.0.0, low-confidence symbol positions are declared as erasures before invoking an errors-and-erasures decoder. Erasures are far cheaper than errors in the RS bound:
| Mode | Correctable bound | At ECC=41 |
| :--- | :--- | :--- |
| Hard-decision (v2.x) | 2 × errors ≤ eccLen → errors ≤ ⌊eccLen/2⌋ | 20 symbols |
| Erasures only | erasures ≤ eccLen | 41 symbols |
| Mixed (v3.0.0+) | `2 × errors
