demo · v141

Video Watermarker

Capture webcam video, feed it through a RTCRtpScriptTransform worker pipeline, and inject a frame counter into each encoded frame's additional data. Shows the full sender → transform → network → transform → decoder flow with live frame metadata.

Webcam + Chrome 141 required The watermark transform requires RTCRtpScriptTransform (Chrome 141+). The frame metadata inspector and pipeline visualiser work in all modern browsers. Camera access is requested when you click Start.
probing RTCRtpScriptTransform…
sender (raw / encoded)
receiver (decoded output)

transform pipeline

Camera MediaStream
Encoder RTCRtpSender
Transform worker: inject
···Network··· loopback
Transform worker: strip
Decoder RTCRtpReceiver

frame metadata inspector

Frames inspected: 0 Watermark counter:

most recent encoded frame

type
timestamp (μs)
duration
data size
spatial layer
temporal layer

V1 vs V2 API shape

V1 — Insertable Streams

RTCRtpSender.createEncodedStreams()

const { readable, writable } =
  sender.createEncodedStreams();
readable.pipeThrough(
  new TransformStream({ transform })
).pipeTo(writable);

// Non-standard, Chrome-only
// deprecated in Chrome 141
V2 — RTCRtpScriptTransform

RTCRtpSender.transform (Chrome 141)

const worker = new Worker("xf.js");
sender.transform =
  new RTCRtpScriptTransform(worker, {
    role: "sender",
    watermarkId: crypto.randomUUID(),
  });

// Standardised across Chrome/FF/Safari
// Options object sent to worker via
// transformer.options

the worker (xf.js)

// xf.js — sender side
self.onrtctransform = (event) => {
  const { readable, writable, options } = event.transformer;
  const { role, watermarkId } = options;
  let counter = 0;

  readable
    .pipeThrough(new TransformStream({
      transform(frame, controller) {
        if (role === "sender") {
          // Inject 8-byte trailer: 4B counter + 4B magic
          const original = new Uint8Array(frame.data);
          const stamped = new Uint8Array(original.length + 8);
          stamped.set(original);
          new DataView(stamped.buffer).setUint32(original.length, ++counter);
          new DataView(stamped.buffer).setUint32(original.length + 4, 0xDEADBEEF);
          frame.data = stamped.buffer;
        } else {
          // Strip 8-byte trailer, read counter
          const view = new DataView(frame.data);
          const magic = view.getUint32(frame.data.byteLength - 4);
          if (magic === 0xDEADBEEF) {
            const count = view.getUint32(frame.data.byteLength - 8);
            self.postMessage({ type: "watermark", counter: count });
            frame.data = frame.data.slice(0, -8);
          }
        }
        controller.enqueue(frame);
      }
    }))
    .pipeTo(writable);
};

see also