Mapping caption cues to VANC packets
The exact task this page solves is turning a per-frame stream of CEA-608/708 cc_data triples into a sequence of ready-to-embed VANC ancillary packets — one packet per video frame, each carrying DID 0x61, SDID 0x01, a data count equal to the CDP length, and a 9-bit SMPTE ST 291-1 checksum. The failure this prevents is a cadence break: at 29.97 fps a caption service must emit exactly one CDP every frame with a fixed 20-pair cc_count, and a frame that carries no new caption text still has to emit a padded packet or the receiver’s caption clock stalls and the service drops. The function below takes the whole timeline as an iterable of per-frame triple lists and yields the packet descriptors that an SDI serializer clocks onto the line.
import struct
from itertools import count
CDP_ID = 0x9669 # SMPTE ST 334-2 CDP identifier
DID_CEA708, SDID_CEA708 = 0x61, 0x01 # SMPTE ST 334-1 caption DID/SDID
# cdp_frame_rate nibble codes (ST 334-2)
FRAME_RATE_CODE = {23.976: 0x1, 24.0: 0x2, 25.0: 0x3, 29.97: 0x4,
30.0: 0x5, 50.0: 0x6, 59.94: 0x7, 60.0: 0x8}
# fixed cc_data pairs per frame by rate (CEA-708 cadence)
CC_COUNT_BY_RATE = {23.976: 25, 24.0: 25, 25.0: 24, 29.97: 20,
30.0: 20, 50.0: 12, 59.94: 10, 60.0: 10}
def _parity9(byte: int) -> int:
"""b0..b7 payload, b8 = even parity (SMPTE ST 291-1). b9=¬b8 is added by the serializer."""
v = byte & 0xFF
return ((bin(v).count("1") & 1) << 8) | v
def cues_to_vanc_packets(frames, frame_rate=29.97):
"""Map per-frame CEA-608/708 cc_data into VANC ANC packet dicts.
frames: iterable of per-frame lists of (cc_valid, cc_type, d1, d2) triples.
Yields {"did", "sdid", "dc", "udw", "checksum"} — exactly one packet per frame.
"""
fr_code = FRAME_RATE_CODE[frame_rate]
cc_cap = CC_COUNT_BY_RATE[frame_rate] # constant pairs/frame
seq = count() # rolling sequence counter
for triples in frames:
triples = list(triples)[:cc_cap] # never exceed the per-frame budget
while len(triples) < cc_cap: # pad short/empty frames
triples.append((0, 0, 0x80, 0x80)) # cc_valid=0 filler (708 pad 0x8080)
s = next(seq) & 0xFFFF # wraps at 65535 — expected
cdp = bytearray()
cdp += struct.pack(">H", CDP_ID) # 0x9669
cdp.append(0) # cdp_length (patched below)
cdp.append((fr_code << 4) | 0x0F) # frame-rate nibble + reserved
cdp.append(0x43) # flags: ccdata_present|svc_active|reserved
cdp += struct.pack(">H", s) # cdp_hdr_sequence_counter
cdp.append(0x72) # ccdata_section id
cdp.append(0xE0 | (len(triples) & 0x1F)) # marker 111 + cc_count
for cc_valid, cc_type, d1, d2 in triples:
cdp.append(0xF8 | ((cc_valid & 1) << 2) | (cc_type & 0x03))
cdp.append(d1 & 0xFF)
cdp.append(d2 & 0xFF)
cdp.append(0x74) # cdp_footer id
cdp += struct.pack(">H", s) # cdp_ftr_sequence_counter == header
cdp.append(0) # packet_checksum (patched below)
cdp[2] = len(cdp) & 0xFF # cdp_length = total bytes
cdp[-1] = (256 - (sum(cdp[:-1]) % 256)) % 256 # CDP checksum: sums to 0 mod 256
udw = list(cdp) # user data words = CDP bytes
dc = len(udw) # data count
# ANC checksum: 9-bit sum of DID, SDID, DC and UDW (parity-carrying), mod 512
cs = sum(_parity9(w) for w in [DID_CEA708, SDID_CEA708, dc] + udw) & 0x1FF
yield {"did": DID_CEA708, "sdid": SDID_CEA708, "dc": dc, "udw": udw, "checksum": cs}
Code walkthrough
The generator contract is the design decision that matters most. cues_to_vanc_packets consumes frames lazily and yields one descriptor per iteration, so a two-hour program never materializes as a list — memory stays flat regardless of runtime, the same streaming discipline the CI gates in async batch caption processing rely on. Each iteration is fully self-contained except for seq, which is the only state that legitimately crosses a frame boundary.
The padding loop is what guarantees cadence. A frame whose upstream decoder produced no new caption bytes still enters the loop with an empty (or short) triples list; the while len(triples) < cc_cap block fills it with cc_valid=0 filler pairs up to the fixed per-rate count. Because cc_cap is looked up from CC_COUNT_BY_RATE, a 29.97 fps service always emits exactly 20 pairs and a 59.94 fps service exactly 10 — the receiver’s caption clock never sees a gap. The leading slice [:cc_cap] enforces the ceiling from the other direction, so an over-full frame is truncated rather than overflowing the 5-bit cc_count field.
The CDP is built in one bytearray with two deferred patches. cdp_length at index 2 is written only after the body is complete, and the checksum at the tail is computed over every byte except itself so the finished packet sums to zero modulo 256 — the discard test a decoder applies. The header and footer both receive the same s, which is what lets a downstream parser confirm the packet was assembled and transported as a unit.
The ANC wrapping computes the two values the SDI card cannot infer: the data count dc, which for a caption insert is simply the CDP length, and the 9-bit checksum. _parity9 folds each byte’s even-parity bit into bit 8, and the checksum sums those parity-carrying values over DID, SDID, DC and the UDW, masked to 9 bits with & 0x1FF. The ancillary data flag (0x000 0x3FF 0x3FF) and the inverse parity bit b9 are added by the physical serializer, which is why the descriptor stops at the checksum.
To verify the output you do not need a card: each yielded descriptor’s udw is a self-checking CDP, so sum(pkt["udw"]) % 256 == 0 and pkt["udw"][2] == pkt["dc"] are the two assertions that catch almost every assembly error, and re-reading bytes 5–6 against the last three bytes of the payload confirms the header and footer sequence counters still agree after any transform. Feed the descriptors to an SDI SDK by mapping [did, sdid, dc] + udw through the serializer’s 10-bit encoder — most SDKs (DeckLink, libklvanc) take exactly that word list and prepend the ADF themselves. Because the function is a generator, you can pair it frame-for-frame with a video writer using zip(video_frames, cues_to_vanc_packets(frames)) without ever buffering the whole program.
Threshold reference table
| Parameter | Value | Source / clause |
|---|---|---|
| CDP identifier | 0x9669 |
SMPTE ST 334-2 |
| ANC DID / SDID | 0x61 / 0x01 |
SMPTE ST 334-1 |
| cc_count (29.97 / 30) | 20 pairs | CEA-708 cadence |
| cc_count (59.94 / 60) | 10 pairs | CEA-708 cadence |
| cc_count field width | 5 bits (max 31) | SMPTE ST 334-2 |
| CDP checksum | Σ bytes ≡ 0 (mod 256) | SMPTE ST 334-2 |
| ANC checksum | 9-bit Σ (mod 512) | SMPTE ST 291-1 |
| Sequence counter | 16-bit, wraps at 65535 | SMPTE ST 334-2 |
| Frame-rate nibble (29.97) | 0x4 |
SMPTE ST 334-2 |
Edge cases & known gotchas
- Empty frame still emits a packet. A frame with no new captions must not be skipped — the padding loop fills it to
cc_capwithcc_valid=0filler so the ancillary cadence is unbroken. Skipping the frame stalls the receiver’s caption clock. - cc_count ceiling. The
[:cc_cap]slice caps triples at the per-rate maximum; feeding more than 31 pairs would also overflow the 5-bitcc_countfield. Upstream pacing must respect the CEA-708 bandwidth, not this function. - Drop-frame timecode section. This mapping omits the optional
time_code_section(0x71). If your plant requires embedded SMPTE ST 12-1 drop-frame timecode in the CDP, insert it between the header and ccdata section and add its bytes before patchingcdp_length— the mod-256 checksum then covers it automatically. - 720p vs 1080i cadence. Both are HD and both key
cc_capoff the frame rate: 720p59.94 emits 10 pairs/frame while 1080i29.97 emits 20. KeyCC_COUNT_BY_RATEon the real signal rate, not the scan format, or the cadence halves or doubles. - Checksum wraparound. The sequence counter is 16-bit and wraps every ~36 minutes at 29.97 fps;
& 0xFFFFmakes that explicit. Never assert monotonicity across the wrap — only that each frame’s header equals its footer. - Field-based interlace order. On 1080i the two fields of a frame each carry caption data, but the CDP is emitted once per frame, not per field. Drive
framesat the frame rate (29.97), not the field rate (59.94), or the cadence doubles and every receiver flags acc_countoverflow.
Integration hook
This function is the timeline-driver for the single-packet anatomy documented in SDI VANC caption insertion: that page builds and validates one CDP and explains every field, while this one runs the cadence across a whole program and yields the stream of descriptors an SDI serializer consumes. The cc_data triples it expects are the decoded output of an upstream caption source — the state-machine decode in parsing SCC with Python libraries produces exactly those byte pairs, and the FFmpeg-side injection of the same service is covered in injecting CEA-608 captions with FFmpeg.
Related
- SDI VANC caption insertion — the parent reference: CDP anatomy, ANC packet fields and 10-bit VANC word encoding.
- Injecting CEA-608 captions with FFmpeg — the file-domain injection of the same caption service.
- Parsing SCC with Python libraries — decoding upstream cc_data into the triples this loop maps.
Part of: Caption Muxing, Packaging & Delivery — the caption packaging and delivery reference across baseband, transport-stream and OTT domains.