Mapping Spot Schedules to SCTE-104 Triggers
SCTE-104 is the message an automation system speaks to a splicer or encoder, and a splicer is the device that converts that message into the SCTE-35 section that rides in the transport stream. This guide solves one exact task: transforming a scheduled avail — a row in the traffic log with a start time, a duration, and a break number — into a byte-accurate SCTE-104 multiple_operation_message carrying a splice_request_data operation, ready to hand to the injector on the automation-to-splicer path. It is the upstream half of SCTE-35 Ad Signaling for Automated Spot Insertion, which itself lives under Spot Scheduling Validation & Rule Engines. Getting the mapping right matters for audit because the splice_event_id you assign here is the identity that ties an avail to its aired cue; a mismatched or reused id is exactly the fault that surfaces later in as-run reconciliation as an airing that cannot be joined back to the schedule.
The key idea is that SCTE-104 does not carry a stream timestamp at all. Instead of a 90 kHz pts_time, an operator trigger carries a pre_roll_time in milliseconds — how far ahead of the actual splice the message is sent — and lets the splicer compute the on-stream timing from its own program clock. The automation system’s job is therefore not tick arithmetic but correct operation selection: choosing spliceStart versus spliceEnd, allocating stable event ids, and pairing every break’s start with its return.
Prerequisites
- Python 3.11+ — for timezone-aware
datetimehandling and theX | Noneunion syntax used below. pydantic>=2.5,<3— the only third-party dependency; message serialization uses the standard-librarystructmodule, so no SCTE-specific package is required. Pin it exactly in a lockfile.- The splicer’s DPI PID index — the numeric index the automation system uses to address the correct SCTE-35 output PID on the target service; wrong index, wrong service.
- A configured pre-roll — the lead time in milliseconds your facility sends triggers ahead of air (commonly 4000 ms), agreed with the encoder so it has time to arm the splice.
- Read-only access to the validated schedule — avails already normalized to the canonical spot schema, so the mapper transforms identity and timing, not field structure.
Step-by-Step Implementation
The mapper runs as a pure transform over each avail: validate the schedule row, build the splice_request_data operations for the break’s start and return, and assemble them into a multiple_operation_message serialized to the bytes the injector sends. Every step is deterministic — the same avail yields the same message.
Figure — Transformation flow: a scheduled avail becomes a paired spliceStart/spliceEnd under one event id, assembled into a multiple_operation_message and serialized to bytes for the splicer.
Step 1 — Structured logging and the operation vocabulary
Goal: emit audit lines in the traffic-ops timestamp | level | module | spot_id shape and fix the enums and input model. The splice_insert_type vocabulary is the heart of SCTE-104 — it decides whether an operation opens a break, returns from one, or cancels a scheduled splice.
from __future__ import annotations
import logging
import struct
from datetime import datetime, timezone
from enum import IntEnum
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.scte104.trigger_mapper")
MOM_OPID = 0xFFFF # multiple_operation_message indicator
SPLICE_REQUEST_OPID = 0x0101 # splice_request_data operation
class SpliceInsertType(IntEnum):
# The SCTE-104 splice_request_data operation vocabulary.
RESERVED = 0
START_NORMAL = 1 # open a timed local avail (out of network)
START_IMMEDIATE = 2 # open now, no pre-roll (breaking news)
END_NORMAL = 3 # timed return to network
END_IMMEDIATE = 4 # return now
CANCEL = 5 # cancel a previously scheduled splice
class ScheduledAvail(BaseModel):
"""A validated avail row from the traffic schedule."""
spot_id: str
avail_id: str
splice_event_id: int = Field(ge=0, le=0xFFFFFFFF)
start_utc: datetime # When the break opens
duration_ms: int = Field(gt=0) # Break length in milliseconds
avail_num: int = Field(ge=1, le=255) # Position of this avail in the pod
unique_program_id: int = Field(default=0, ge=0, le=0xFFFF)
@field_validator("start_utc")
@classmethod
def _require_utc(cls, v: datetime) -> datetime:
# A naive datetime across DST shifts the pre-roll calculation by an hour.
if v.tzinfo is None:
raise ValueError("start_utc must be timezone-aware (UTC)")
return v.astimezone(timezone.utc)
Step 2 — Map an avail to a splice_request_data operation
Goal: build the 14-byte splice_request_data payload for one edge of the break. The two operator-facing conversions live here: break_duration is expressed in tenths of a second (not milliseconds), and pre_roll_time is the millisecond lead the splicer uses to schedule the splice on its own clock.
class SpliceOperation(BaseModel):
"""One splice_request_data operation (SCTE-104 opID 0x0101)."""
splice_insert_type: SpliceInsertType
splice_event_id: int = Field(ge=0, le=0xFFFFFFFF)
unique_program_id: int = Field(ge=0, le=0xFFFF)
pre_roll_ms: int = Field(ge=0, le=0xFFFF) # 16-bit ms lead time
break_duration_tenths: int = Field(ge=0, le=0xFFFF) # tenths of a second
avail_num: int = Field(ge=0, le=255)
avails_expected: int = Field(default=0, ge=0, le=255)
auto_return: bool = True
def to_bytes(self) -> bytes:
# struct '>' = big-endian, the byte order SCTE-104 messages use.
return struct.pack(
">BIHHHBBB",
int(self.splice_insert_type),
self.splice_event_id,
self.unique_program_id,
self.pre_roll_ms,
self.break_duration_tenths,
self.avail_num,
self.avails_expected,
1 if self.auto_return else 0,
)
def build_operations(avail: ScheduledAvail, pre_roll_ms: int) -> list[SpliceOperation]:
# break_duration on the wire is tenths of a second; a 30.000s avail = 300.
tenths = round(avail.duration_ms / 100)
if tenths > 0xFFFF:
raise ValueError("break_duration exceeds the 16-bit tenths-of-a-second field")
start = SpliceOperation(
splice_insert_type=SpliceInsertType.START_NORMAL,
splice_event_id=avail.splice_event_id,
unique_program_id=avail.unique_program_id,
pre_roll_ms=pre_roll_ms,
break_duration_tenths=tenths,
avail_num=avail.avail_num,
auto_return=True,
)
# The return shares the event id so the splicer pairs it with the start.
end = SpliceOperation(
splice_insert_type=SpliceInsertType.END_NORMAL,
splice_event_id=avail.splice_event_id,
unique_program_id=avail.unique_program_id,
pre_roll_ms=pre_roll_ms,
break_duration_tenths=0, # a return carries no duration
avail_num=avail.avail_num,
auto_return=True,
)
logger.info("%s | mapped avail=%s event_id=%d tenths=%d pre_roll=%dms",
avail.spot_id, avail.avail_id, avail.splice_event_id,
tenths, pre_roll_ms)
return [start, end]
Step 3 — Assemble and serialize the multiple_operation_message
Goal: wrap the operations in a multiple_operation_message — the SCTE-104 container that carries a header, the DPI PID index addressing the target service, a timestamp mode, and an operation count — and serialize the whole thing to bytes with a correct message_size.
class MultipleOperationMessage(BaseModel):
"""The SCTE-104 container an automation system sends to a splicer."""
dpi_pid_index: int = Field(ge=0, le=0xFFFF) # Addresses the SCTE-35 PID
message_number: int = Field(ge=0, le=255) # Wraps 0-255 for de-dup
operations: list[SpliceOperation]
def to_bytes(self) -> bytes:
# Serialize each operation as opID(2) + data_length(2) + data.
ops_blob = b"".join(
struct.pack(">HH", SPLICE_REQUEST_OPID, len(data)) + data
for data in (op.to_bytes() for op in self.operations)
)
# Header up to num_ops, using time_type = 0 (no timestamp; the splicer
# schedules from pre_roll_time). message_size is filled in after we
# know the total length, so pack a placeholder then patch it.
header = struct.pack(
">HHBBBHBBB",
MOM_OPID, # opID = 0xFFFF
0, # message_size placeholder
0, # protocol_version
0, # AS_index
self.message_number,
self.dpi_pid_index,
0, # SCTE35_protocol_version
0, # timestamp time_type = 0 (none)
len(self.operations), # num_ops
)
message = bytearray(header + ops_blob)
struct.pack_into(">H", message, 2, len(message)) # patch message_size
return bytes(message)
def map_avail_to_trigger(avail: ScheduledAvail, dpi_pid_index: int,
pre_roll_ms: int = 4000,
message_number: int = 0) -> bytes:
ops = build_operations(avail, pre_roll_ms)
mom = MultipleOperationMessage(
dpi_pid_index=dpi_pid_index,
message_number=message_number,
operations=ops,
)
blob = mom.to_bytes()
logger.info("%s | SCTE-104 message built: %d bytes, %d ops, pid_index=%d",
avail.spot_id, len(blob), len(ops), dpi_pid_index)
return blob
A representative log line reads 2026-07-17T19:29:53+00:00 | INFO | traffic.scte104.trigger_mapper | SP-0347 | SCTE-104 message built: 48 bytes, 2 ops, pid_index=16. The returned bytes are what the injector sends to the splicer, which converts the splice_request_data operations into the SCTE-35 sections described in How to Insert SCTE-35 Splice Points in Python.
Step 4 — Sequence and de-duplicate across the schedule
Goal: drive the mapper across a schedule so every avail gets a stable event id and a monotonic message_number, and a re-sent trigger is recognisable as a duplicate rather than a second break.
def map_schedule(avails: list[ScheduledAvail], dpi_pid_index: int,
pre_roll_ms: int = 4000) -> list[bytes]:
messages: list[bytes] = []
for seq, avail in enumerate(avails):
# message_number wraps 0-255; the splicer de-dups replays on this plus
# the splice_event_id, so a re-sent trigger is a no-op, not a new break.
blob = map_avail_to_trigger(
avail, dpi_pid_index, pre_roll_ms, message_number=seq % 256,
)
messages.append(blob)
logger.info("mapped %d avails to SCTE-104 triggers on pid_index=%d",
len(avails), dpi_pid_index)
return messages
Verification & Testing
Correctness rests on two properties: determinism (an avail always maps to the same bytes) and structural validity (the message_size header matches the real length and the operation count is honest). Both are assertable against a fixture avail.
AVAIL = ScheduledAvail(
spot_id="SP-0347",
avail_id="AV-2026-0717-1930-03",
splice_event_id=100_347,
start_utc=datetime(2026, 7, 17, 19, 30, 0, tzinfo=timezone.utc),
duration_ms=30_000, # 30-second local avail
avail_num=3,
)
a = map_avail_to_trigger(AVAIL, dpi_pid_index=16)
b = map_avail_to_trigger(AVAIL, dpi_pid_index=16)
assert a == b # deterministic: identical bytes
# The message_size field (bytes 2-3) must equal the real message length.
assert struct.unpack_from(">H", a, 2)[0] == len(a)
# opID is the multiple_operation_message indicator.
assert struct.unpack_from(">H", a, 0)[0] == 0xFFFF
# num_ops sits at byte offset 11 and must be 2 (start + end).
assert a[11] == 2
# Round-trip the first operation: break_duration in tenths = 300 for 30.000s.
# The 12-byte header is followed by a 2+2 op header, so the payload starts at 16.
first_op = a[16:30]
insert_type, event_id = struct.unpack_from(">BI", first_op, 0)
tenths = struct.unpack_from(">H", first_op, 9)[0]
assert insert_type == 1 and event_id == 100_347 and tenths == 300
Run the suite in CI against a golden fixture of (avail, expected_bytes) pairs. A change to the field packing or the tenths-of-a-second conversion that would silently re-time every break then fails the build, the same regression gate the parent SCTE-35 signaling guide applies at the encoder boundary.
Edge Cases & Failure Handling
- Break duration below 100 ms. The tenths-of-a-second field cannot express a sub-100 ms break;
round(duration_ms / 100)collapses it to zero, which a splicer reads as “no duration”. Reject any avail shorter than one tenth of a second at validation rather than emitting a zero-duration start, and reconcile the mis-scheduled row against the source. - Overrun before air. If a sports overrun moves an avail after the trigger is built but before air, do not send a second start — send a
CANCELoperation for the originalsplice_event_id, then a fresh start at the new time. Reusing the same id keeps the change idempotent at the splicer, the same identity discipline that lets make-good routing supersede a displaced spot without double-booking it. - Wrong DPI PID index. An index that points at the wrong service injects the cue into someone else’s stream and airs a local avail on the wrong channel. Treat the index as configuration owned by engineering, validate it against the target service map before the first trigger, and log it on every message so a mis-addressed break is traceable in as-run reconciliation.
FAQ
Why does SCTE-104 use a pre-roll instead of a timestamp like SCTE-35?
Because the automation system does not know the stream’s 90 kHz program clock — only the splicer does. SCTE-104 carries a pre_roll_time in milliseconds so the automation says “splice this far in the future” and lets the splicer translate that into an on-stream pts_time. The conversion from that lead time to a stream timestamp is exactly what the splicer does when it builds the section in How to Insert SCTE-35 Splice Points in Python.
Why is break_duration in tenths of a second here but ticks in SCTE-35?
The two standards were designed for different consumers. SCTE-104 is an operator-facing protocol, so its break_duration uses a coarse, human-scale tenths-of-a-second field. SCTE-35 is decoder-facing and expresses everything on the 90 kHz clock, so the same 30-second break becomes 300 tenths upstream and 2,700,000 ticks downstream. The splicer performs the conversion; the parent signaling guide covers both sides.
Do I send one message per avail or one per break edge?
One multiple_operation_message per avail is the common pattern, carrying both the spliceStart_normal and the spliceEnd_normal operations under a shared splice_event_id. Bundling the pair guarantees the splicer receives a matched entry and exit together, so a break can never open without its return being known — the unbalanced-signal fault that strands a stream in the avail.
How does the event id here relate to reconciliation?
The splice_event_id you assign in the trigger is carried unchanged into the SCTE-35 section the splicer emits, and it is the join key the aired cue is matched on. Allocate ids from a stable per-channel sequence and never reuse one except to deliberately supersede a scheduled splice, so as-run reconciliation can prove every avail that was triggered actually aired.
Related
- SCTE-35 Ad Signaling for Automated Spot Insertion — the parent guide covering the full trigger-to-stream chain, entities, and segmentation descriptors.
- How to Insert SCTE-35 Splice Points in Python — the encoder side that turns the triggers built here into on-stream SCTE-35 sections.
- As-Run Reconciliation and Discrepancy Handling — where the event ids assigned in the trigger are matched against aired cues to prove what fired.