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 | Noneunion syntax and timezone-awaredatetime.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/1001for 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.
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.
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.
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.
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³³.
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.
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_timeprojected more than ~26.5 hours ahead overflows the field.project_ptsraises rather than wrapping, because a wrapped value is a valid-looking timestamp that fires the splice at the wrong moment. Re-anchorClockAnchorto 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_flagand omitssplice_time()entirely, rather than encoding apts_timeof 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.
Related
- SCTE-35 Ad Signaling for Automated Spot Insertion — the parent guide covering the full trigger-to-stream chain and the segmentation-descriptor path.
- Mapping Spot Schedules to SCTE-104 Triggers — the upstream operation messages a splicer converts into the sections this builder produces.
- As-Run Reconciliation and Discrepancy Handling — where a dropped or mistimed splice is caught by matching aired cues against the schedule.