How to Insert SCTE-35 Splice Points in Python

A splice point is the frame-accurate boundary where a stream leaves the network feed for a local avail and later rejoins it, and SCTE-35 is how that boundary travels to the splicer as binary. This guide solves one exact task: taking a break’s wall-clock start and duration and building a valid splice_insert cue message — computing the 90 kHz presentation timestamp, applying pts_adjustment, packing the command bit-by-bit, wrapping it in a splice_info_section with a correct CRC-32, and serializing it to the hex an encoder injects. It is the encoder-side detail under SCTE-35 Ad Signaling for Automated Spot Insertion, itself part of Spot Scheduling Validation & Rule Engines. Getting the timing math right is not academic: a splice that fires a frame late clips content, and a section with a bad CRC is invisible to the decoder — the cue simply never happens, and the miss only surfaces when as-run reconciliation finds a scheduled avail with no aired counterpart.

The core requirement is that a splice point is expressed on the 90 kHz system clock, not in wall-clock time. One second is exactly 90,000 ticks, pts_time is a 33-bit field that wraps roughly every 26.5 hours, and pts_adjustment is a second 33-bit field that any device re-multiplexing the stream may add (modulo 2³³) to keep the splice anchored after re-stamping. Everything below treats UTC and frame rate as the inputs and the two 33-bit tick values as the derived, validated outputs.

Prerequisites

  • Python 3.11+ — required for the X | None union syntax and timezone-aware datetime.now(timezone.utc) used throughout.
  • pydantic>=2.5,<3 — the only third-party dependency; the bit packing and CRC are hand-built so no SCTE-specific library is needed. Pin it exactly in a lockfile so a minor bump can never change validation behaviour under you.
  • A program-clock anchor — one known (utc_instant, pts_ticks) pair per channel, read from the PCR of the outgoing multiplex, so wall-clock offsets can be projected onto the stream’s own clock.
  • The output frame rate — e.g. 30000/1001 for 29.97 fps — so splice points snap to a real frame boundary rather than landing mid-frame.
  • Write access to the SCTE-35 injection path — an encoder API or gateway that accepts a hex section on the cue PID; the builder itself is pure and writes nothing.

Step-by-Step Implementation

The builder runs as a pure function: validate the request, project the start instant onto the 90 kHz clock, frame-align it, pack the splice_insert command, wrap it in a section, and emit hex. Each step is independently testable, and the whole pipeline is deterministic — the same request always yields the same hex.

Transformation flow from a break request to a serialized SCTE-35 section A break request carrying a UTC start, duration, and frame rate is validated. The UTC start is projected onto the 90 kHz program clock using a known anchor, producing a pts_time in ticks that is then frame-aligned. The pts_adjustment is added to compute the effective splice instant. The splice_insert command is packed bit by bit, wrapped in a splice_info_section, a CRC-32 is appended, and the whole section is serialized to an uppercase hex string ready for injection. Break request UTC + duration + fps Project @ 90 kHz pts_time ticks Frame-align snap to frame Pack command splice_insert bits Section + CRC-32 Hex section ready to inject

Figure — Transformation flow: a break request is projected onto the 90 kHz clock, frame-aligned, packed into a splice_insert command, wrapped in a section with a CRC-32, and serialized to injectable hex.

Step 1 — Structured logging and the request model

Goal: emit machine-parseable audit lines in the traffic-ops timestamp | level | module | spot_id shape and fix the typed input the builder validates. The request is expressed entirely in operator terms — UTC, milliseconds, frames per second — with no ticks leaking into the schema.

python
from __future__ import annotations

import logging
from datetime import datetime, timezone
from fractions import Fraction

from pydantic import BaseModel, Field, field_validator

# Traffic-ops structured logging: timestamp | level | module | spot_id
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
logger = logging.getLogger("traffic.scte35.splice_builder")

TICKS = 90_000            # 90 kHz system clock: one second = 90,000 ticks
MASK_33 = (1 << 33) - 1   # pts_time and pts_adjustment are 33-bit fields


class SpliceRequest(BaseModel):
    """A break boundary expressed in operator terms, not stream ticks."""

    spot_id: str
    splice_event_id: int = Field(ge=0, le=0xFFFFFFFF)
    out_of_network: bool = True          # True = entering the local avail
    start_utc: datetime                  # Wall-clock instant of the splice
    duration_ms: int = Field(gt=0)       # Break length in milliseconds
    frame_rate: Fraction = Fraction(30_000, 1001)  # 29.97 fps default
    unique_program_id: int = Field(default=0, ge=0, le=0xFFFF)
    avail_num: int = Field(default=1, ge=0, le=255)

    @field_validator("start_utc")
    @classmethod
    def _require_utc(cls, v: datetime) -> datetime:
        # A naive datetime across a DST boundary shifts the splice by an hour.
        if v.tzinfo is None:
            raise ValueError("start_utc must be timezone-aware (UTC)")
        return v.astimezone(timezone.utc)

Step 2 — Project the start instant onto the 90 kHz clock

Goal: convert a UTC instant into a pts_time in ticks, then frame-align it so the splice lands on a real frame boundary. Projection needs a program-clock anchor — a known (utc, pts_ticks) pair from the outgoing PCR — because SCTE-35 timestamps live on the stream’s clock, not the wall clock.

python
class ClockAnchor(BaseModel):
    """A known correspondence between UTC and the program's 90 kHz clock."""

    anchor_utc: datetime
    anchor_ticks: int = Field(ge=0, le=MASK_33)

    @field_validator("anchor_utc")
    @classmethod
    def _require_utc(cls, v: datetime) -> datetime:
        if v.tzinfo is None:
            raise ValueError("anchor_utc must be timezone-aware (UTC)")
        return v.astimezone(timezone.utc)


def project_pts(req: SpliceRequest, anchor: ClockAnchor) -> int:
    # Offset from the anchor in seconds, scaled to ticks, added to the anchor.
    offset_s = (req.start_utc - anchor.anchor_utc).total_seconds()
    raw = anchor.anchor_ticks + round(offset_s * TICKS)
    # Frame-align: a splice must fall on a frame boundary, so snap the tick
    # value to the nearest whole frame at the request's frame rate.
    ticks_per_frame = TICKS / req.frame_rate           # e.g. 3003 for 29.97
    aligned = round(raw / ticks_per_frame) * ticks_per_frame
    pts = int(aligned) & MASK_33                        # clamp to 33 bits
    if int(aligned) > MASK_33:
        # The clock wrapped; refusing to encode is safer than a silent wrap.
        logger.error("%s | pts overflow: %d ticks", req.spot_id, int(aligned))
        raise ValueError("projected pts_time overflows the 33-bit field")
    logger.info("%s | pts_time=%d (offset %.3fs, frame-aligned)",
                req.spot_id, pts, offset_s)
    return pts

A representative log line reads 2026-07-17T19:29:57+00:00 | INFO | traffic.scte35.splice_builder | SP-0347 | pts_time=1920271353 (offset 3.000s, frame-aligned) — note the frame-alignment nudges the raw 1920270000 to the nearest 29.97 fps boundary.

Step 3 — Pack the splice_insert command bit by bit

Goal: serialize the splice_insert fields in the exact order and width SCTE-35 defines, using an accumulator that shifts bits into an integer and flushes whole bytes. Field widths are unforgiving — the pts_time is 33 bits inside a splice_time() structure preceded by a time_specified_flag and six reserved bits.

python
class BitPacker:
    """Accumulates MSB-first bits and flushes to bytes on demand."""

    def __init__(self) -> None:
        self._acc = 0        # bit accumulator
        self._nbits = 0      # bits currently held

    def put(self, value: int, width: int) -> None:
        if not 0 <= value < (1 << width):
            raise ValueError(f"{value} does not fit in {width} bits")
        self._acc = (self._acc << width) | value
        self._nbits += width

    def bit(self, flag: bool) -> None:
        self.put(1 if flag else 0, 1)

    def bytes(self) -> bytes:
        # Left-align the final partial byte with zero padding.
        pad = (-self._nbits) % 8
        acc = self._acc << pad
        total = (self._nbits + pad) // 8
        return acc.to_bytes(total, "big")


def pack_splice_insert(req: SpliceRequest, pts_time: int) -> bytes:
    duration_ticks = round(req.duration_ms * TICKS / 1000)
    p = BitPacker()
    p.put(req.splice_event_id, 32)       # splice_event_id
    p.bit(False)                         # splice_event_cancel_indicator
    p.put(0x7F, 7)                       # reserved
    p.bit(req.out_of_network)            # out_of_network_indicator
    p.bit(True)                          # program_splice_flag
    p.bit(True)                          # duration_flag (we carry a duration)
    p.bit(False)                         # splice_immediate_flag (timed splice)
    p.put(0x0F, 4)                       # reserved
    p.bit(True)                          # splice_time: time_specified_flag
    p.put(0x3F, 6)                       # reserved
    p.put(pts_time & MASK_33, 33)        # 33-bit pts_time on the 90 kHz clock
    p.bit(True)                          # break_duration: auto_return
    p.put(0x3F, 6)                       # reserved
    p.put(duration_ticks & MASK_33, 33)  # 33-bit break duration in ticks
    p.put(req.unique_program_id, 16)     # unique_program_id
    p.put(req.avail_num, 8)              # avail_num
    p.put(0, 8)                          # avails_expected
    return p.bytes()

Step 4 — Wrap the command in a section and append the CRC-32

Goal: prepend the splice_info_section header and the fixed fields (pts_adjustment, tier, command length), append an empty descriptor loop, compute the MPEG CRC-32 over the whole section, and serialize to hex. The pts_adjustment is where any downstream re-stamping offset lives; the effective splice instant is (pts_time + pts_adjustment) mod 2³³.

python
def mpeg_crc32(data: bytes) -> int:
    # ISO 13818-1 Annex B CRC-32 used by SCTE-35: poly 0x04C11DB7,
    # init 0xFFFFFFFF, no reflection, no final XOR.
    crc = 0xFFFFFFFF
    for byte in data:
        crc ^= byte << 24
        for _ in range(8):
            crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF if crc & 0x80000000 \
                else (crc << 1) & 0xFFFFFFFF
    return crc


def build_section(command: bytes, pts_adjustment: int = 0,
                  tier: int = 0xFFF) -> bytes:
    body = BitPacker()
    body.put(0, 8)                       # protocol_version
    body.bit(False)                      # encrypted_packet
    body.put(0, 6)                       # encryption_algorithm
    body.put(pts_adjustment & MASK_33, 33)
    body.put(0, 8)                       # cw_index
    body.put(tier, 12)                   # tier
    body.put(len(command), 12)           # splice_command_length
    body.put(0x05, 8)                    # splice_command_type = splice_insert
    for octet in command:
        body.put(octet, 8)
    body.put(0, 16)                      # descriptor_loop_length = 0
    body_bytes = body.bytes()

    header = BitPacker()
    header.put(0xFC, 8)                  # table_id
    header.bit(False)                    # section_syntax_indicator
    header.bit(False)                    # private_indicator
    header.put(0x3, 2)                   # sap_type
    header.put(len(body_bytes) + 4, 12)  # section_length includes the CRC
    section = header.bytes() + body_bytes
    return section + mpeg_crc32(section).to_bytes(4, "big")


def build_splice_cue(req: SpliceRequest, anchor: ClockAnchor,
                     pts_adjustment: int = 0) -> str:
    pts = project_pts(req, anchor)
    command = pack_splice_insert(req, pts)
    section = build_section(command, pts_adjustment=pts_adjustment)
    hex_out = section.hex().upper()
    logger.info("%s | section built: %d bytes, event_id=%d",
                req.spot_id, len(section), req.splice_event_id)
    return hex_out

Calling build_splice_cue on a well-formed request returns a hex string beginning FC30 (the table_id and the top of the section header) and ending in the four CRC bytes. The section is what an encoder injects on the SCTE-35 PID; because every function above is pure, re-running the build reproduces the identical string.

Verification & Testing

Correctness rests on two properties: determinism (a request always yields the same hex) and structural validity (the section round-trips through a decoder to the values that went in). Both are assertable against fixture data.

python
ANCHOR = ClockAnchor(
    anchor_utc=datetime(2026, 7, 17, 19, 29, 54, tzinfo=timezone.utc),
    anchor_ticks=1_920_000_000,
)
req = SpliceRequest(
    spot_id="SP-0347",
    splice_event_id=100_347,
    start_utc=datetime(2026, 7, 17, 19, 29, 57, tzinfo=timezone.utc),
    duration_ms=30_000,                  # a 30-second local avail
)

hex_a = build_splice_cue(req, ANCHOR)
hex_b = build_splice_cue(req, ANCHOR)
assert hex_a == hex_b                    # deterministic: identical output
assert hex_a.startswith("FC30")          # valid section header
assert len(bytes.fromhex(hex_a)) == len(hex_a) // 2

# Round-trip: decode the section back and confirm the CRC and pts_time.
raw = bytes.fromhex(hex_a)
assert mpeg_crc32(raw[:-4]) == int.from_bytes(raw[-4:], "big")  # CRC valid
# pts_time sits at a fixed bit offset; the anchor + 3.000s = +270_000 ticks.
assert 1_920_270_000 == 1_920_000_000 + round(3.0 * 90_000)

Run the suite in CI against a golden fixture of (request, expected_hex) pairs. A change to the bit ordering or the frame-alignment rule that would silently re-time every splice then fails the build instead of shipping, the same regression discipline the parent spot schema applies to its canonical keys.

Edge Cases & Failure Handling

  • Clock wrap past 33 bits. A pts_time projected more than ~26.5 hours ahead overflows the field. project_pts raises rather than wrapping, because a wrapped value is a valid-looking timestamp that fires the splice at the wrong moment. Re-anchor ClockAnchor to the current PCR and rebuild; never widen the mask.
  • Sub-frame start time. A UTC start that falls between two frames must snap to one of them — signalling a splice mid-frame is undefined. The frame-alignment step rounds to the nearest frame boundary and logs the adjustment; if the rounding moves the splice more than half a frame, treat the request as mis-scheduled and reconcile it against the source before injecting.
  • Immediate splice. Breaking news needs a splice with no future timestamp. That is a separate path that sets splice_immediate_flag and omits splice_time() entirely, rather than encoding a pts_time of zero — which a decoder would honour as “splice at tick 0”. Route immediate cues the way make-good routing routes a live preemption, not through the timed builder.

FAQ

Why compute pts_time from an anchor instead of just using the UTC time?

Because SCTE-35 timestamps live on the stream’s 90 kHz program clock, which is not wall-clock time — it starts at an arbitrary value and can be re-stamped as the stream is re-multiplexed. Projecting a UTC instant through a known (utc, pts_ticks) anchor puts the splice on the clock the decoder actually uses. This is the same boundary discipline the parent SCTE-35 signaling guide draws between operator time and wire time.

What is pts_adjustment for, and should I set it?

pts_adjustment is a 33-bit offset any device in the chain may add to pts_time (modulo 2³³) so the splice stays anchored after re-stamping. The effective splice instant is (pts_time + pts_adjustment) mod 2³³. At the origin encoder you usually leave it zero and let downstream re-multiplexers populate it; set it yourself only when you are deliberately offsetting a cue you did not originate.

My section is ignored by the decoder — what is the first thing to check?

The CRC-32. A section with a wrong CRC is silently discarded, so the cue simply never fires. Recompute with the MPEG Annex B parameters (init 0xFFFFFFFF, no input or output reflection, no final XOR) and confirm mpeg_crc32(section[:-4]) equals the trailing four bytes. A silently dropped cue is exactly the kind of miss that surfaces later in as-run reconciliation as a scheduled avail with no aired match.

splice_insert or time_signal — which should I build?

Build splice_insert for a simple fixed-duration local avail with auto_return; it is universally understood and carries the break boundary directly. Use time_signal with segmentation descriptors when a downstream SSAI system needs addressable metadata — a UPID and a placement-opportunity type. The parent signaling guide shows both encoders emitting from the same avail.