C2PA3 min

Signing a 2 GB Video in a Mobile Browser: Streaming C2PA with Lazy Blobs and BMFF Offset Patching

Signing a 2 GB Video in a Mobile Browser: Streaming C2PA with Lazy Blobs and BMFF Offset Patching

C2PA demos are easy when the asset is a small JPEG. The engineering begins when the runtime is a mobile browser and the asset is a gigabyte-scale video. You cannot materialize the file, you cannot hash it in one pass, and inserting the manifest physically shifts every chunk offset inside the container. This post walks through how our asset layer handles all three, because drone footage, dashcams, and field-captured claims video do not come in demo sizes.

The memory problem: browsers are not servers

Whole-file buffering works until it does not. A 2 GB video in a mobile browser tab is a crash waiting for a user. The out-of-memory kill is silent on iOS and sudden on Android, and neither produces an error you can catch.

So the manifest code in our library never touches the asset directly. It talks to an AssetDataReader interface with four capabilities: range reads, range replacement, blob composition, and stream writes. The interface is deliberately small:

interface AssetDataReader {  getDataLength(): number;  getDataRange(start?: number, length?: number):    Promise<Uint8Array>;  getBlob(): Promise<Blob>;  writeToStream(stream: WritableStream<Uint8Array>):    Promise<void>;  replaceRange(position: number, data: Uint8Array): void;  assemble(parts: AssemblePart[]): AssetDataReader;}

Every container format, JPEG, PNG, MP4, HEIF, MP3, implements this interface. The signing pipeline never needs to know which format it is operating on. This one abstraction is what makes the rest of the post possible.

Lazy Blob segments: only modifications become buffers

The BlobDataReader models an asset as a list of segments. Each segment is either a lazy slice (a reference into the original Blob, with a start offset and length) or an eager data buffer holding new or modified bytes:

type Segment = { start: number; length: number } & (  | { type: 'slice'; blob: Blob; blobStart: number }  | { type: 'data'; data: Uint8Array });

When you replace a range (to insert or update the manifest), the segment list is spliced: the segment before the replacement is trimmed, the replacement data is inserted as an eager buffer, and the segment after the replacement is trimmed. The original bytes are never copied. The segment list grows by at most two entries per replacement, and the Blob references remain lazy.

Writing the result streams segments out in 64 MB chunks via writeToStream:

async writeToStream(stream: WritableStream<Uint8Array>) {  const CHUNK_SIZE = 64 * 1024 * 1024; // 64 MB  const writer = stream.getWriter();  for (const seg of this.segments) {    if (seg.type === 'data') {      await writer.write(seg.data);    } else {      let offset = 0;      while (offset < seg.length) {        const chunkSize = Math.min(          CHUNK_SIZE, seg.length - offset);        const slice = seg.blob.slice(          seg.blobStart + offset,          seg.blobStart + offset + chunkSize);        await writer.write(          new Uint8Array(await slice.arrayBuffer()));        offset += chunkSize;      }    }  }  await writer.close();}

At no point does the whole file exist in memory. The only buffers are the 64 MB streaming chunk and whatever eager data segments were created by replacements (typically the manifest itself, which is a few kilobytes to a few hundred kilobytes). One honest note on language: we call this bounded-memory, not zero-memory, and the bound is the chunk size plus the manifest.

The assemble method is more interesting than it looks. When the JPEG or BMFF container needs to rebuild the file layout (inserting manifest space, reordering boxes), it produces a list of AssemblePart objects. Each part is either eager data or a lazy source reference (offset + length into the original blob). The BlobDataReader builds a new segment list from these parts without reading any of the original data:

if (part.sourceOffset !== undefined && part.length) {  // Lazy reference to original source blob  newSegments.push({    type: 'slice',    start: part.position,    length: part.length,    blob: this.sourceBlob,    blobStart: part.sourceOffset,  });}

This means rebuilding a 2 GB file's layout is an O(n) operation over the parts list, not an O(file size) copy.

Incremental hashing with C2PA exclusion ranges

C2PA data hash assertions must skip the bytes where the manifest itself lives, described as exclusion ranges in the spec. A hash that includes its own bytes would be circular. The hashWithExclusions function sorts the exclusions, walks the ranges in between in 1 MB chunks, and feeds a streaming digest:

static async hashWithExclusions(  asset: Asset,  exclusions: HashExclusionRange[],  algorithm: HashAlgorithm,): Promise<Uint8Array> {  exclusions.sort((a, b) => a.start - b.start);  const digest = Crypto.streamingDigest(algorithm);  const CHUNK_SIZE = 1024 * 1024; // 1 MB  let currentPosition = 0;  for (const exclusion of exclusions) {    // Hash data up to this exclusion    if (exclusion.start > currentPosition)      await processRange(currentPosition,        exclusion.start - currentPosition);    // Handle offset markers (BMFF-specific)    if (exclusion.offsetMarker) {      const offsetBytes = new Uint8Array(8);      new DataView(offsetBytes.buffer)        .setBigInt64(0, BigInt(exclusion.start), false);      digest.update(offsetBytes);      currentPosition = exclusion.start;    } else {      currentPosition = exclusion.start + exclusion.length;    }  }  // Hash remaining data  await processRange(currentPosition,    asset.getDataLength() - currentPosition);  return digest.final();}

The offsetMarker case is a BMFF-specific subtlety: the V2 hash specification requires that certain positions in the file (the start of each exclusion) be replaced with their offset value rather than skipped entirely. Getting this wrong produces a valid hash of the wrong thing, which is harder to debug than an outright failure.

A detail worth knowing: WebCrypto has no incremental hashing API. crypto.subtle.digest takes a buffer and returns a hash; there is no update/final pattern. The streaming digest rides on @noble/hashes, with the provider interface hiding that choice from the rest of the library. On platforms with native incremental hashing (React Native via native callbacks), the same Crypto.streamingDigest call dispatches to the native implementation.

BMFF surgery: inserting a manifest shifts the whole file

Inserting the manifest shifts mdat. iloc/stco/co64 offsets must be patched or the video breaks.

MP4 and HEIF are box-structured (BMFF, ISO base media file format). Inserting a C2PA manifest means rebuilding the box layout: find and remove any existing C2PA box, compute the new box size, and insert the new manifest right after ftyp. Our parser is lazy about this: large, non-critical boxes are recorded by position and size without reading their content, so parsing a 2 GB file does not mean reading 2 GB.

Here is the part that breaks naive implementations. Moving bytes shifts mdat, the actual media payload, and several box types store absolute file offsets into it:

  • stco: Sample Table Chunk Offsets (32-bit). Every chunk of audio/video data has a byte offset.
  • co64: Same thing, 64-bit. Used when the file exceeds 4 GB, but also by some encoders regardless of size.
  • iloc: Item Location. HEIF uses this to locate items (images, metadata) within the file.

If you insert manifest bytes without patching these offsets, you get a signed, valid C2PA manifest attached to a video that plays corrupted audio, shows the wrong frame at every seek point, or does not play at all. The C2PA signature verifies. The video is broken. No validator catches this.

Our containsOffsetSensitiveData walks box trees recursively to find these box types:

private containsOffsetSensitiveData(box: Box<object>):  boolean {  if (['stco', 'co64', 'iloc'].includes(box.type))    return true;  for (const child of box.children ?? [])    if (this.containsOffsetSensitiveData(child))      return true;  return false;}

When the rebuild detects offset-sensitive data after the insertion point, it patches the absolute offsets in place by adding the manifest's byte size. The alternative, re-encoding the video, is not an option for a library that must work in a browser without FFmpeg.

JPEG: a different kind of surgery

JPEGs carry manifests in APP11 segments (marker 0xEB), following the JPEG XT extension mechanism. The manifest bytes are split across multiple 64 KB segments (the JPEG segment length field is 16 bits, capping payload at 65531 bytes) with a box instance ID and sequence number linking them.

Our parser walks segments until the start-of-scan marker (0xDA), at which point the compressed image data begins and segment headers stop. The JUMBF content is reassembled from the APP11 segments and validated: sequence numbers must be contiguous, lengths must match, and only one C2PA store may exist per file.

Inserting manifest space means removing old APP11 segments, computing how many new segments are needed for the manifest size, creating stub segments after APP0, and rebuilding the file. Unlike BMFF, JPEG does not have offset-sensitive data to patch, because JPEG's architecture does not use absolute file offsets. The trade-off is the segment-splitting complexity.

What we would tell an earlier version of ourselves

Start with the reader abstraction, not the container code. Every container format becomes dramatically simpler once byte access is someone else's problem. We built the container parsers first and added the reader interface later, and the refactoring was expensive. If we were starting over, AssetDataReader would be commit one.

Treat offset patching as a first-class test case from day one. A manifest that validates in C2PA tooling but breaks the video is a defect that only your users will find, because no C2PA validator plays the video. We caught this class of bugs by running signed videos through FFprobe and comparing frame counts.

Frequently asked questions

Does this work for fragmented MP4? The lazy box parsing and offset patching cover the standard non-fragmented layouts. Fragmented MP4 (where moof/mdat pairs repeat) and BMFF v2 top-level-box hashing are follow-up territory, and we will write about them when the implementation settles.

How large a file can this handle? Bounded by browser Blob limits, not by our buffers. Chrome and Firefox support Blobs well beyond 2 GB. Safari has historically been more conservative. We test regularly with 1.5 GB videos on mobile Safari.

Why 64 MB chunks for streaming, 1 MB for hashing? Streaming writes benefit from fewer I/O round trips, so larger chunks reduce overhead. Hashing benefits from bounded memory at the hashing callsite, where 1 MB keeps the working set small even when hashing a 2 GB file. Different trade-offs, different constants.

Is this specific to TrustNXT's services? No. The asset layer is part of the core c2pa-ts library and signs through the standard Signer interface (detailed in our signer interface post). Any Signer implementation, browser WebCrypto, native callbacks, or server-side, works with the same streaming pipeline.

Latest articles

How to find us

Want to learn more?