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_cap with cc_valid=0 filler 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-bit cc_count field. 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 patching cdp_length — the mod-256 checksum then covers it automatically.
  • 720p vs 1080i cadence. Both are HD and both key cc_cap off the frame rate: 720p59.94 emits 10 pairs/frame while 1080i29.97 emits 20. Key CC_COUNT_BY_RATE on 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; & 0xFFFF makes 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 frames at the frame rate (29.97), not the field rate (59.94), or the cadence doubles and every receiver flags a cc_count overflow.

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.

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