Video Verification Pipeline
C2PA from video containers, keyframe extraction, VPDQ, and TMK fingerprinting.
The previous section covered how images are stripped, hashed, encrypted, and stored. Video follows the same high-level pipeline — C2PA extraction, ZK proof generation, metadata stripping, encryption — but adds three video-specific challenges: extracting C2PA manifests from video containers, fingerprinting across keyframes, and generating temporal fingerprints for cross-codec deduplication.
C2PA in video containers
Video files use different container formats than images, but the C2PA manifest inside is the same JUMBF structure. The difference is where the JUMBF bytes live.
| Container | Detection | C2PA location |
|---|---|---|
| MP4 (ISO BMFF) | ftyp box at bytes 4-7, brand: isom, mp42 | Top-level uuid box with C2PA UUID |
| MOV (QuickTime) | ftyp box with qt brand | Top-level uuid box (same as MP4) |
| AVI (RIFF) | RIFF header + AVI at offset 8 | UUID box within RIFF structure |
The container parser uses mp4box.js to parse ISO BMFF containers. It searches for a top-level uuid box matching one of two known C2PA UUIDs — the c2pa-rs UUID (most common) or the C2PA specification reference UUID. If direct property access fails, the parser falls back to recursive tree search, then to a content heuristic scanning for "manifest" strings in UUID box payloads.
Once the UUID box is found, the raw JUMBF bytes are extracted and fed into the same parser used for images. From this point forward, the C2PA extraction pipeline is identical — COSE signature parsing, certificate chain extraction, claim hash computation, and circuit input building all work the same way regardless of whether the source is a JPEG or an MP4.
Keyframe extraction
Video fingerprinting requires extracting individual frames from the video. Apertrue uses the browser's WebCodecs API for hardware-accelerated video decoding — frames are decoded on the GPU without downloading the entire video into JavaScript memory.
Decoding pipeline
- Container demuxing. mp4box.js extracts all samples from the first video track, including per-sample metadata: composition timestamp, duration, and sync flag (whether the sample is a keyframe).
- Codec configuration. The codec description bytes are serialised from the sample's description box —
avcCfor H.264,hvcCfor H.265/HEVC,av1Cfor AV1. These configure the hardware decoder. - Keyframe filtering. Only sync samples (keyframes) are decoded. A minimum interval of 0.5 seconds between keyframes prevents over-sampling rapid-fire IDR frames. Default extraction limit: 32 keyframes.
- Hardware decoding. Each keyframe is fed to a
VideoDecoderas anEncodedVideoChunk. The decoder outputsVideoFrameobjects backed by GPU memory. - Frame rendering. Each
VideoFrameis drawn to anOffscreenCanvas, converting from YUV to RGB. The pixel data is extracted viagetImageData()and the alpha channel is dropped — output is a 3-channel RGB byte array with width, height, and timestamp. - GPU memory release. Each
VideoFrameis.close()d immediately after rendering to release GPU memory. Failing to close frames causes GPU memory leaks that crash the tab.
| Codec | Sample entry | Config box | Browser support |
|---|---|---|---|
| H.264 | avc1 | avcC (AVCDecoderConfigurationRecord) | Chrome 94+, Safari 16.4+, Edge 94+ |
| H.265/HEVC | hevc | hvcC (HEVCDecoderConfigurationRecord) | Safari 16.4+, Chrome 107+ (hardware) |
| AV1 | av01 | av1C (AV1CodecConfigurationRecord) | Chrome 94+, Edge 94+ |
Per-keyframe PDQ hashing
Each extracted keyframe is perceptually hashed using pdq-wasm — the same WASM module used for image PDQ hashing. The hash function takes the RGB pixel data, width, height, and channel count, and produces a 256-bit hash plus a quality score (0-100).
A typical 1-hour video at 24 fps contains roughly 90 keyframes. The default extraction limit of 32 selects keyframes evenly distributed across the video's duration. Each keyframe hash is stored as a 64-character hex string alongside its frame number, quality score, and timestamp.
vPDQ — video perceptual matching
vPDQ (Video Perceptual Distance Quality) is a video matching algorithm published by Meta as part of ThreatExchange. Apertrue uses a clean-room TypeScript implementation of the published algorithm. vPDQ treats a video as a bag of frame hashes and compares two videos by matching frames pairwise.
Matching algorithm
- Quality filtering. Frames with a PDQ quality score below 50 are discarded. Low-quality frames (dark scenes, heavy motion blur) produce unreliable hashes.
- Nearest-neighbor search. For each query frame, the algorithm computes the Hamming distance against every candidate frame and finds the closest match. Hamming distance counts the number of differing bits between two 256-bit hashes (range: 0-256). If the distance is 0 (perfect match), the search short-circuits.
- Match threshold. A frame pair counts as a match if the Hamming distance is at most 31 bits (default). This allows for minor visual differences from re-encoding, color correction, or cropping.
- Overall score. The match percentage is the fraction of query frames that found a match in the candidate set. Two videos are considered a match if at least 80% of query frames match (default).
| Parameter | Default | Range | Purpose |
|---|---|---|---|
| Distance threshold | 31 bits | 0-256 | Max Hamming distance for a frame match |
| Match threshold | 80% | 0-100% | Min percentage of matched query frames |
| Min quality | 50 | 0-100 | Quality floor for frame inclusion |
vPDQ is order-agnostic — frame order does not affect the match. A video that has been trimmed, re-ordered, or had segments removed will still match on the frames that remain unchanged. This makes vPDQ robust against common video editing operations.
TMK — temporal media fingerprint
vPDQ compares videos frame-by-frame, but it misses temporal patterns — a video played at 2x speed has different frame timestamps but the same visual content. TMK (Temporal Match Kernel) solves this by generating a fixed-size fingerprint that captures the temporal structure of the video using Fourier analysis.
TMK is also published by Meta as TMK+PDQF. Apertrue implements accumulation in TypeScript (browser-side) and scoring in both TypeScript and Rust (backend).
Fingerprint generation
- Temporal resampling. The video's native framerate is resampled to a fixed 15 FPS. Higher framerates (30, 60) skip frames; lower framerates (10, 12) duplicate frames. This normalises the temporal axis across different source formats.
- PDQ to float vectors. Each frame's 256-bit PDQ hash is converted to a 256-dimensional float vector: set bits become +1.0, clear bits become -1.0. This preserves the sign pattern of the DCT output while enabling floating-point arithmetic.
- Pure average. An unnormalised running sum of all frame feature vectors, divided by the frame count at finalisation. This captures the "average appearance" of the video.
- Fourier accumulation. For each frame at time t, the L2-normalised feature vector is weighted by cosine and sine terms at four different periods and 32 coefficients per period. The periods are 2731, 4391, 9767, and 14653 frames at 15 FPS — roughly 3, 5, 11, and 16 minutes. This captures how the visual content changes over time at multiple temporal scales.
- Finalisation. Each Fourier feature is L2-normalised and scaled by the square root of its Poullot coefficient (Bessel-derived weighting that emphasizes low frequencies). The result is serialised as raw float32 values.
Fingerprint structure
| Component | Dimensions | Size | Purpose |
|---|---|---|---|
| Pure average | 256 floats | 1,024 bytes | Average visual appearance |
| Cosine features | 4 periods x 32 coefficients x 256 | 131,072 bytes | Temporal frequency (even) |
| Sine features | 4 periods x 32 coefficients x 256 | 131,072 bytes | Temporal frequency (odd) |
| Total | 65,792 floats | 263,168 bytes (~257 KB) | Complete temporal fingerprint |
Matching
TMK matching runs in two levels:
- Level 1 — fast pre-filter. Cosine similarity of the pure average vectors. This is a single dot product — O(256) — that quickly rejects obviously different videos. Threshold: 0.7 (default).
- Level 2 — temporal alignment. For each of the four Fourier periods, the algorithm tries all possible temporal offsets and finds the alignment that maximizes the score. This handles videos that are the same content but start at different points. The maximum across all offsets and periods is normalised to produce a 0-1 score. Threshold: 0.7 (default).
A match requires both levels to exceed their thresholds. Level 1 catches obvious duplicates cheaply. Level 2 catches temporally shifted or speed-changed duplicates that Level 1 might miss.
How video differs from images
| Aspect | Images | Videos |
|---|---|---|
| C2PA location | JUMBF in APP11 (JPEG) or iTXt (PNG) | UUID box in ISO BMFF container |
| Container parsing | Format-specific marker parsing | mp4box.js ISO BMFF parser |
| Perceptual hash | Single PDQ (256-bit) | PDQ per keyframe (up to 32) + TMK (257 KB) |
| Matching | Direct hash comparison | vPDQ (bag-of-hashes) + TMK (temporal alignment) |
| ZK proofs | Single split proof (ProofA + ProofB) | Same — C2PA signature verified identically |
| Stored metadata | Content hash, single PDQ | Content hash, keyframe PDQs, TMK fingerprint, duration, codec, resolution |
| Thumbnail | Resized JPEG from image | Canvas capture at 1-second mark |
The key insight is that video verification is image verification plus fingerprinting. The C2PA cryptographic chain (signature, certificate, trust list, hash chain) is verified identically. The additional complexity is in generating fingerprints that enable near-duplicate detection across re-encodes, crops, and temporal edits.
Video in the upload flow
- C2PA extraction. The video container is parsed, the C2PA UUID box is located, and the JUMBF manifest is extracted. From here, the standard C2PA pipeline runs — signature parsing, certificate extraction, trust tier determination.
- ZK proof generation. If C2PA is present, the split-proof pipeline runs identically to images — ProofA verifies the certificate chain, ProofB verifies the COSE signature, and the aggregation tree produces a root commitment.
- Keyframe extraction and hashing. In parallel with proof generation, the WebCodecs pipeline decodes up to 32 keyframes and computes PDQ hashes for each. These become the video's vPDQ feature set.
- TMK fingerprint generation. Also in parallel, the TMK accumulator processes the full video at 15 FPS. Unlike keyframe extraction (which only decodes sync samples), TMK processes every frame to capture the complete temporal structure. The 257 KB fingerprint is included in the upload metadata.
- Thumbnail capture. A single frame is captured at the 1-second mark (or at 0 for clips shorter than 1 second) by seeking a
<video>element and rendering to canvas. The JPEG is stored as a data URL in the media index. - Encryption and upload. The full video file is encrypted with AES-256-GCM (same as images) and uploaded as an encrypted blob. Video decryption uses a dedicated web worker to avoid blocking the main thread during playback.
Data sizes
| Item | Size |
|---|---|
| PDQ hash (single frame) | 32 bytes (256 bits) |
| PDQ hash (hex) | 64 characters |
| 32 keyframe hashes + metadata | ~3.2 KB |
| TMK fingerprint | 263,168 bytes (~257 KB) |
| Video metadata struct | ~200 bytes |
| C2PA JUMBF superbox | 5-50 KB (varies) |
The next section covers the verification and share flow — how viewers verify an image's provenance and how verified content is shared with its trust signals intact.