SDI VANC Caption Insertion

Carrying CEA-608 and CEA-708 captions through an SDI plant means embedding them as vertical ancillary (VANC) data, wrapped in a SMPTE ST 334-2 Caption Distribution Packet and mapped into the serial digital signal by SMPTE ST 334-1. There is no sidecar file at this stage: the caption bytes ride in the blanking of the video raster itself, one ancillary packet per frame, and a single wrong checksum or a header/footer sequence mismatch means a downstream decoder drops the service silently. This page is the SDI-domain endpoint of the Caption Muxing, Packaging & Delivery track: where the transport-stream and OTT pages package captions for files and CDNs, this one packages them for baseband. It covers the ANC packet layout, the internal structure of the CDP, the 10-bit VANC word encoding, and the Python that assembles a valid packet you can hand to an SDI card or a libklvanc-style serializer.

The problem is unforgiving because the format is positional and self-checked. Every byte of the CDP contributes to a mod-256 checksum, the ANC packet carries its own 9-bit checksum over its data words, and the caption service is only recovered if the header and footer sequence counters agree. Miss any one and the frame’s captions vanish without an error — the raster still clocks out, the picture is fine, and the caption disappearance surfaces only at an FCC Part 79 audit or a viewer complaint. The rest of this page turns each of those rules into code with a threshold you can assert against.

From per-frame cc_data to an embedded VANC line in the SDI raster Four left-to-right stages. Stage one is a stack of per-frame CEA-608/708 cc_data triples. Stage two assembles them into a Caption Distribution Packet with cdp_header carrying identifier 0x9669 and a sequence counter, a ccdata_section tagged 0x72, and a cdp_footer tagged 0x74 with the matching sequence counter and a mod-256 checksum. Stage three wraps the CDP bytes as the user data words of an ancillary packet with DID 0x61, SDID 0x01, a data count and a 9-bit checksum. Stage four embeds that ancillary packet on VANC line 9 for HD or line 21 for SD inside the SDI signal. SDI VANC caption insertion — cc_data to embedded line One ancillary packet per frame; header and footer sequence counters must agree. cc_data / frame CEA-608/708 triples valid · type · d1 · d2 valid · type · d1 · d2 valid · type · d1 · d2 cc_count pairs (20 at 29.97) CDP (ST 334-2) cdp_header 0x9669 · rate · seq ccdata_section 0x72 · cc_count · triples cdp_footer 0x74 · seq · checksum Σ bytes ≡ 0 (mod 256) ANC packet (ST 291) ADF 000·3FF·3FF DID 0x61 SDID 0x01 DC = data count (CDP length) UDW = CDP bytes as 10-bit words · b8 parity · b9 inverse CS = 9-bit checksum (mod 512) SDI VANC line VANC line 9 (HD) · line 21 (SD) active picture raster one packet per video frame

Problem Framing: What a VANC Caption Insert Must Guarantee

Insertion is correct only when four independent invariants all hold, and each one has a numeric or structural threshold. The CDP identifier must be exactly 0x9669 — a decoder that does not see those two bytes at the start of the user data words never enters the caption state machine. The cdp_length field must equal the actual byte count of the packet, or a strict parser truncates or over-reads the ccdata section. The CDP checksum is a mod-256 value chosen so the whole packet sums to zero; a nonzero residue is a discard condition. And the header and footer sequence counters must be identical for the frame — they are the CDP’s own tamper check that the packet was assembled and transported as a unit.

Layered above the CDP is the ANC packet’s own contract. The DID/SDID pair is 0x61/0x01, the registered identifier for CEA-708 caption data under SMPTE ST 334-1; swap the two and a receiver looking for 0x61 in the DID position finds 0x01 and skips the packet. The data count (DC) must equal the number of user data words, which for a caption insert is exactly the CDP length. The ANC checksum (CS) is a 9-bit sum over DID, SDID, DC and every UDW, taken mod 512. And every one of those values travels the SDI link as a 10-bit word: eight payload bits, an even-parity bit b8, and its inverse in b9. Get the parity wrong and the deserializer flags a word error before your checksum is ever consulted. These invariants are the format-specific analog of the reading-rate and drift thresholds enforced elsewhere in the SCC vs SRT vs WebVTT architecture tradeoff space — here the caption content is already fixed and the job is to package it byte-exactly.

Pipeline Stage & Prerequisites

VANC insertion sits at the very end of the caption path, downstream of format conversion and QC. By the time bytes reach this stage the caption service has already been decided — the cc_data triples exist — and the only remaining question is whether the wrapper is legal. That makes the stage a pure serialization problem, which is why it is expressed in struct and bytearray rather than a parsing library. The upstream cc_data itself typically originates from a CEA-608/708 elementary stream; the file-domain counterpart of that packaging is covered in embedding CEA-608 & CEA-708 in transport streams, and the same caption service is what an OTT path would instead hand to HLS & DASH caption delivery.

Tool / Library Version Role in insertion
Python ≥ 3.9 Runtime; struct, bytearray, walrus operator
struct (stdlib) Big-endian packing of the 16-bit CDP fields
libklvanc ≥ 1.0 Reference C serializer for SDI VANC (validation target)
FFmpeg (ffmpeg/ffprobe) ≥ 6.0 Extract/inspect a53 caption side data for round-trip checks
DeckLink / SDI SDK vendor Clocks the finished 10-bit words onto the physical VANC line

The per-frame mapping loop that feeds this serializer — turning a whole program’s cc_data stream into a sequence of ready-to-embed packets — is the subject of the companion page on mapping caption cues to VANC packets. This page builds and validates a single packet; that one runs the cadence across the timeline.

Step-by-Step Implementation

Step 1 — Assemble the Caption Distribution Packet

The CDP is built as one bytearray so that the length and checksum can be patched in place once the body is complete. The header carries the 0x9669 identifier, a frame-rate nibble, a flags byte, and the sequence counter; the ccdata section carries the per-frame triples; the footer repeats the sequence counter and terminates with the mod-256 checksum.

import struct

CDP_IDENTIFIER = 0x9669          # SMPTE ST 334-2 CDP identifier
CCDATA_ID = 0x72                 # ccdata_section id
CDP_FOOTER_ID = 0x74             # cdp_footer id

# SMPTE ST 334-2 cdp_frame_rate nibble codes
FRAME_RATE_CODES = {
    23.976: 0x1, 24.0: 0x2, 25.0: 0x3, 29.97: 0x4,
    30.0: 0x5, 50.0: 0x6, 59.94: 0x7, 60.0: 0x8,
}

def build_cdp(cc_triples, sequence, frame_rate=29.97):
    """Assemble one CDP. cc_triples: list of (cc_valid, cc_type, d1, d2)."""
    fr_code = FRAME_RATE_CODES[frame_rate]
    body = bytearray()

    # --- cdp_header ---
    body += struct.pack(">H", CDP_IDENTIFIER)            # 0x9669, big-endian
    body.append(0)                                       # cdp_length placeholder
    body.append((fr_code << 4) | 0x0F)                   # frame-rate nibble + reserved 1111
    body.append(0x40 | 0x02 | 0x01)                      # flags: ccdata_present, svc_active, reserved
    body += struct.pack(">H", sequence & 0xFFFF)         # cdp_hdr_sequence_counter

    # --- ccdata_section ---
    body.append(CCDATA_ID)                               # 0x72
    body.append(0xE0 | (len(cc_triples) & 0x1F))         # marker bits 111 + cc_count (max 31)
    for cc_valid, cc_type, d1, d2 in cc_triples:
        # 0xF8 marker | cc_valid<<2 | cc_type — CEA-708 cc_data triple
        body.append(0xF8 | ((cc_valid & 1) << 2) | (cc_type & 0x03))
        body.append(d1 & 0xFF)
        body.append(d2 & 0xFF)

    # --- cdp_footer ---
    body.append(CDP_FOOTER_ID)                           # 0x74
    body += struct.pack(">H", sequence & 0xFFFF)         # cdp_ftr_sequence_counter == header
    body.append(0)                                       # packet_checksum placeholder

    body[2] = len(body) & 0xFF                           # patch cdp_length = total bytes
    body[-1] = (256 - (sum(body[:-1]) % 256)) % 256      # checksum: whole packet ≡ 0 (mod 256)
    return bytes(body)

The two placeholders are the crux: cdp_length is written after the body is known, and the checksum is computed over every byte except itself so that the completed packet sums to zero. Because the header and footer both receive the same sequence argument, they are guaranteed to match — the failure mode this prevents is assembling a packet from two different frames’ worth of state.

Step 2 — Map bytes to 10-bit VANC words with parity

Every byte that leaves the SDI serializer is a 10-bit word. Bit 8 is even parity over bits 0–7, and bit 9 is the inverse of bit 8. That inverse guard is what lets a deserializer distinguish a real data word from the reserved 0x000/0x3FF flag values, which have no legal parity.

def parity9(value: int) -> int:
    """9-bit word: b0..b7 payload, b8 = even parity (SMPTE ST 291-1)."""
    v = value & 0xFF
    return ((bin(v).count("1") & 1) << 8) | v            # b8 makes total ones even

def to_10bit(value: int) -> int:
    """10-bit VANC word: adds b9 as the inverse of b8."""
    w9 = parity9(value)
    b9 = ((w9 >> 8) & 1) ^ 1                              # b9 = NOT b8
    return (b9 << 9) | w9

Step 3 — Wrap the CDP as an ANC packet

The finished CDP becomes the user data words of an ancillary packet. The ancillary data flag (0x000 0x3FF 0x3FF) is prepended by the physical serializer, but DID, SDID, the data count and the checksum are ours to compute. The ANC checksum sums the 9-bit parity-carrying values of DID, SDID, DC and every UDW, modulo 512.

DID_CEA708 = 0x61                # SMPTE ST 334-1 caption DID
SDID_CEA708 = 0x01               # CEA-708 SDID

def wrap_vanc(cdp: bytes) -> dict:
    """Wrap CDP bytes into a VANC ANC packet descriptor."""
    udw = list(cdp)                                       # user data words = CDP bytes
    dc = len(udw)                                         # data count = CDP length
    # ANC checksum: 9-bit sum of DID, SDID, DC and UDW (with parity), mod 512
    cs = sum(parity9(w) for w in [DID_CEA708, SDID_CEA708, dc] + udw) & 0x1FF
    return {
        "adf": (0x000, 0x3FF, 0x3FF),                    # ancillary data flag (added by serializer)
        "did": DID_CEA708,
        "sdid": SDID_CEA708,
        "dc": dc,
        "udw": udw,
        "cs": cs,
        # the line words the SDI card actually clocks out:
        "line_words": [to_10bit(w) for w in [DID_CEA708, SDID_CEA708, dc] + udw],
    }

The line_words list is the deliverable a DeckLink or comparable SDI SDK accepts: the data-carrying words already mapped to 10 bits. In practice you place this ANC packet on VANC line 9 for HD (1080i/720p) rasters and line 21 for SD — line 21 being the analog legacy line-21 position preserved into SDI.

Threshold Reference Table

Every fixed value the insertion asserts, with its source. Tune constants against this table, not against prose.

Field / parameter Value Source / clause Notes
CDP identifier 0x9669 SMPTE ST 334-2 First two UDW bytes; entry condition
ANC DID 0x61 SMPTE ST 334-1 CEA-708 caption data
ANC SDID 0x01 SMPTE ST 334-1 Sub-identifier for 0x61
cdp_frame_rate (29.97) 0x4 SMPTE ST 334-2 Nibble; 0x3=25, 0x5=30, 0x7=59.94
cc_count (29.97/30) 20 pairs CEA-708 5-bit field, max 31
CDP checksum Σ bytes ≡ 0 (mod 256) SMPTE ST 334-2 Over the whole packet
ANC checksum (CS) 9-bit Σ (mod 512) SMPTE ST 291-1 Over DID, SDID, DC, UDW
VANC word parity b8 even, b9 = ¬b8 SMPTE ST 291-1 Per 10-bit word
VANC line (HD) line 9 SMPTE ST 334-1 1080i / 720p
VANC line (SD) line 21 SMPTE ST 334-1 Legacy line-21 position

Verification & Test Pattern

A packet you cannot round-trip is a packet you cannot trust. The verification parses the CDP back out, re-derives the length and checksum, and asserts the header and footer sequence counters agree — the single most common silent corruption in a live plant. Pin it as a pytest so a refactor cannot weaken a field.

import struct
import pytest
from vanc_insert import build_cdp, wrap_vanc, DID_CEA708, SDID_CEA708

def parse_cdp(cdp: bytes) -> dict:
    assert struct.unpack(">H", cdp[0:2])[0] == 0x9669, "bad CDP identifier"
    assert cdp[2] == len(cdp), "cdp_length != actual byte count"
    assert sum(cdp) % 256 == 0, "CDP checksum residue is nonzero"
    hdr_seq = struct.unpack(">H", cdp[5:7])[0]
    ftr_seq = struct.unpack(">H", cdp[-3:-1])[0]          # footer: id, seq(2), checksum(1)
    assert hdr_seq == ftr_seq, f"sequence mismatch {hdr_seq} != {ftr_seq}"
    return {"length": cdp[2], "seq": hdr_seq}

TRIPLES = [(1, 0, 0x94, 0x20), (1, 0, 0x54, 0x45)]        # pop-on control + 'TE'

def test_cdp_round_trips():
    cdp = build_cdp(TRIPLES, sequence=42, frame_rate=29.97)
    meta = parse_cdp(cdp)
    assert meta["seq"] == 42

def test_anc_checksum_is_9_bit():
    cdp = build_cdp(TRIPLES, sequence=7)
    pkt = wrap_vanc(cdp)
    assert pkt["did"] == DID_CEA708 and pkt["sdid"] == SDID_CEA708
    assert pkt["dc"] == len(cdp)
    assert 0 <= pkt["cs"] <= 0x1FF                        # checksum fits 9 bits

def test_footer_tamper_is_caught():
    cdp = bytearray(build_cdp(TRIPLES, sequence=1))
    cdp[-3] ^= 0xFF                                       # corrupt footer sequence counter
    with pytest.raises(AssertionError):
        parse_cdp(bytes(cdp))

Troubleshooting / Failure Modes

Decoder ignores the service entirely : Root cause: the CDP identifier is not 0x9669 at the start of the UDW, usually because the ADF or a stray word shifted the offset. Fix: assert struct.unpack(">H", cdp[0:2])[0] == 0x9669 before wrapping, and confirm the serializer is not prepending its own header into the UDW region.

Captions flicker or drop every few seconds : Root cause: header and footer sequence counters disagree, so a strict receiver treats the CDP as corrupt and discards that frame. Fix: pass a single sequence value to both header and footer, as build_cdp does, and increment it exactly once per frame.

Wrong CEA-608 vs CEA-708 language decoded : Root cause: DID and SDID were swapped, so 0x01 lands in the DID position. Fix: keep DID = 0x61, SDID = 0x01; never derive one from the other. A receiver keys on DID first.

Intermittent word errors on the deserializer : Root cause: the 10-bit parity is inverted — b8 computed as odd parity, or b9 not set to the inverse of b8. Fix: use parity9/to_10bit; b8 must make the count of ones in b0–b8 even, and b9 must be its complement.

Checksum rejected downstream despite correct content : Root cause: mixing the two checksum domains — applying the CDP’s mod-256 rule to the ANC packet or vice versa. Fix: the CDP checksum makes the packet sum to 0 mod 256; the ANC CS is a 9-bit sum mod 512 over DID/SDID/DC/UDW. They are independent.

Wrong frame-rate code trips a cadence check : Root cause: the cdp_frame_rate nibble does not match the video raster (e.g. 0x5 for 30 on a 29.97 signal), so the receiver expects a different cc_count cadence. Fix: source the nibble from FRAME_RATE_CODES keyed on the real signal rate.

Operational Notes

At single-packet scale this is deterministic arithmetic, but a live insert runs at frame rate for the length of the program, and that turns two operational concerns to the front. First, the sequence counter is a rolling 16-bit value — it wraps every ~36 minutes at 29.97 fps — so treat wraparound as normal and never assert monotonicity across the boundary; only assert that a given frame’s header equals its footer. Second, empty frames still need a packet: a video frame with no new caption data must carry a padded CDP with valid-flag-clear filler triples so the ancillary cadence stays constant, which is exactly the padding logic the mapping caption cues to VANC packets loop applies frame by frame.

When the cc_data originates upstream from an SCC or elementary-stream source, decode it into clean triples first — the state-machine decode in parsing SCC with Python libraries produces the byte pairs this serializer expects — and validate the finished line words against a reference implementation such as libklvanc before trusting them on air. Keep the serializer stateless per frame: the only state that should cross a frame boundary is the sequence counter itself.

Part of: Caption Muxing, Packaging & Delivery — the caption packaging and delivery reference across baseband, transport-stream and OTT domains.