Frame-Accurate Ad Markers for Quick Channels: Constructing SCTE-35 Correctly
FAST channels make money on commercial breaks. The whole business model rests on a single assumption: when the channel says "cut to ad," every downstream system listening — the ad server, the SSAI splicer, the smart-TV player — hears it at exactly the same moment, with the exact same duration. SCTE-35 is the standard protocol that carries that message. Get it wrong silently — and silent is the default failure mode — and ads either don't render, fire at the wrong frame, or last the wrong length. The viewer sees a slate. The revenue just isn't there.
This is the engineering story of how mStudio's FAST channels emit standards-conformant SCTE-35 cues from operator-friendly inputs, and why we built it as three cues per break instead of one.
Quick overview
| Aspect | Detail |
|---|---|
| Domain | SCTE-35 ad-break signaling for AWS MediaLive FAST channels |
| Operator workflow | Per-program ad break editor — position in seconds, duration in seconds |
| Cues emitted per break | Three — ad-start TimeSignal, SpliceInsert, ad-end TimeSignal |
| Duration constraints | 10–120 seconds, validated at the data layer |
| Downstream consumers | Players, ad servers, and AWS MediaTailor (designed but not yet wired) are examples. |
| Status | SCTE-35 cue emission in production; SSAI integration in design |
The Business Problem
Every commercial break on a FAST channel is a revenue event. The channel emits a marker that says "an ad will go here, it will last 30 seconds, please prepare." Downstream systems — Google Ad Manager, AWS MediaTailor, regional ad servers, smart-TV player SDKs — read that marker and decide what to splice in.
When the marker is correct, the ad break runs cleanly, the impression counts, and the operator gets paid. When the marker is wrong — wrong duration, wrong shape, wrong format — the ad system either rejects it (no ad served) or accepts it incorrectly (ad of the wrong length, or one that overruns into the next program). Both outcomes cost real money, and both fail silently. The channel keeps streaming. The viewer sees a slate or an awkward cut. The monetisation pipeline just produces lower numbers and no error message to chase.
The engineering goal isn't "support ads." It is: every ad break the operator defines must arrive at every downstream consumer as a standards-conformant SCTE-35 signal, at the exact frame the operator chose, every time.
What SCTE-35 Actually Is (the 90-Second Version)
SCTE-35 is a standard for inserting cue messages into a video stream. The cues don't carry ad content; they carry signals — "ad break starts here," "ad break ends here," "this segment is N seconds long." The downstream system reads those signals and acts on them: an SSAI layer splices a real ad creative into the HLS manifest, a smart-TV player triggers an overlay, an ad server logs a slot opportunity.
Two cue shapes matter for our use case:
- SpliceInsert — the original SCTE-35 cue. Carries a SpliceEventId, a duration in seconds, and an "out of network" flag. Most classic ad servers read this.
- TimeSignal — the newer, more expressive cue. Carries a SegmentationDescriptor with a SegmentationTypeId (52 = Provider Ad Start, 53 = Provider Ad End) and a SegmentationDuration in 90,000-tick units. SSAI systems and modern players prefer this shape because it pairs cleanly across start and end.
Both shapes are correct SCTE-35. Different downstream consumers prefer different ones. Emitting only one shape leaves money on the table.
Why this matters. A naive implementation emits one SpliceInsert per ad break and calls it done. The ad break works on legacy players and breaks silently on SSAI. Half your ad inventory monetises; half doesn't. You won't know which is which until your revenue numbers come in low.
Why Naive Approaches Fail
The shortcuts are tempting because they all almost work.
- "At encode time, insert advertisements into the original video." There is no room for flexibility. The operator can't A/B-test placement, can't change ads after deploy, can't run regional or audience-targeted ads. The whole reason FAST channels exist is monetising the same content multiple ways — burning ads in at encode time forecloses on that.
- "Make use of client-side ad insertion." Ad-blocking is simple. Different code path per device. No server-side personalisation. And it bypasses SSAI entirely, which means lower CPMs.
- "Just emit one SpliceInsert per break." The most common production mistake. Works on some players, silently ignored by SSAI systems that want TimeSignal cues to pair start/end. Partial monetisation, with no error to investigate.
- "Express everything in 90 kHz ticks because the spec mentions them." The spec is more annoying than that. SpliceInsert.Duration is in seconds. SegmentationDuration is in 90 kHz ticks. Mixing them up produces cues that pass schema validation and ship out, then fail at the player as malformed durations. The encoder won't catch it. The player just skips the break.
- "Permit MediaLive to confirm." It won't. MediaLive accepts overlapping breaks, zero-duration breaks, and impossible segmentation durations without complaint. The confirmation has to live before MediaLive sees the action.
The lever we had was the boundary between the operator UI — where ad breaks are just {position, duration} pairs — and the MediaLive schedule, where every cue has to be perfect.
Our Solution
Treat the ad break as a first-class data object end-to-end. The operator defines it in the simplest possible terms. The backend validates it once at the schema layer. The Lambda translates it into a three-cue stack at deploy time, with each cue expressed in the time base its own spec demands. Nothing else in the stack has to know about SCTE-35 — the translation lives in one function, in the same Lambda that owns every other schedule action.
Diagram 1 · End-to-End Ad Marker Pipeline

Architecture
- Frontend ad-break editor — operators add ad breaks per program by specifying a position offset (seconds from program start) and a duration (seconds). Bumper slots are part of the data model and ready for the playout layer.
- AdMarker collection (MongoDB) — one document per program with ad breaks, referencing the video. The adBreaks[] array stores {position, duration} with Mongoose enforcing 10 ≤ duration ≤ 120 at save time. Bumpers carry an adBreakId reference for downstream pairing.
- NestJS backend (schedule.service.ts) — at deploy time, joins each schedule to its AdMarker and renames fields to the Lambda's contract (position → offsetSeconds, duration → durationSeconds). One bulk fetch, no N+1.
- Lambda orchestrator (fastChannel-lambda-fun/index.js) — owns the SCTE-35 translation. For each ad break, emits the three-cue stack into the same BatchUpdateScheduleCommand that carries input switches and watermarks. Action names are versioned per program so the orphan sweep can pair them after a partial deploy.
- AWS MediaLive — receives the actions, emits #EXT-SCTE35 markers in the HLS manifest at the operator's chosen second.
Key Engineering Decisions
1. Three cues per ad break, not one
Every ad break emits three discrete actions against the same logical moment in the program.
Diagram 2 · Single Ad Break Lifecycle

The start and end TimeSignal cues share a SegmentationUpid so downstream systems can pair them deterministically. The SpliceInsert carries a unique SpliceEventId for player-level deduplication. Different consumers read different cues. Emitting all three covers every contract we care about today and every plausible one tomorrow.
Common production mistake. Emit one SpliceInsert and assume the rest of the ecosystem will figure it out. SSAI systems quietly drop breaks that lack matching TimeSignal cues; classic ad servers ignore TimeSignal-only signals. Without all three, every ad break is monetised by some of your downstream stack and missed by the rest — and you find out from the revenue report, not the logs.
2. Two time bases, reconciled in one function
SpliceInsert.Duration is in seconds. SegmentationDuration inside a TimeSignal descriptor is in 90,000-tick units. Same logical duration, two encodings — and the most common SCTE-35 bug in the wild is mixing them up.
Diagram 3 · Time Conversion Logic

The conversion lives in buildProgramActions and only there. There is one canonical place to look when a cue duration ever comes out wrong, and one place to change if the spec ever evolves.
Tradeoff we intentionally made. We could have stored both time bases on the AdMarker document and let the Lambda copy them through. We deliberately didn't: storing only the seconds value means there is only one number for an operator to set, only one number to validate, and only one number that can be wrong. The 90 kHz value is derived, never stored. Derived data can't drift.
3. Position is relative, fire time is absolute
The operator says "ad break at 720 seconds into the program." The Lambda computes the actual UTC fire time as programStartTime + offsetSeconds × 1000 and stamps that onto the action. Every other schedule action — input switch, watermark on, program end — uses the same offset-to-timestamp pipeline. Ad cues land on the same frame boundaries as everything else, with no ambiguity about which clock owns the timeline.
4. Validation at the data layer, not the wire
The AdMarker schema enforces duration ∈ [10, 120] seconds at Mongoose save time. An out-of-range break never reaches the Lambda. Ad breaks that would extend past program end are clipped at build time rather than rejected — operators don't lose their work to a single bad break. The classes of bug that cannot happen are more interesting than the ones that can.
5. Cue emission decoupled from SSAI
What we ship today is the signalling layer. Server-side ad insertion via AWS MediaTailor — which would consume these cues to splice real ad creatives into the HLS manifest — is fully designed and not yet wired in. The deliberate ordering: get the cue layer correct first, in production, used by real channels, before turning on SSAI. When the integration goes live, the cues are already there. The downstream system gets a clean contract to consume from day one.
Why this ordering matters. SSAI debugging is brutal when your cues are wrong, because every failure looks like an SSAI failure even when the cues are at fault. By shipping the cue layer in isolation first and validating it against real players and ad servers, we removed an entire category of integration confusion before it could happen.
Results
- Every ad break an operator defines emits a standards-conformant three-cue SCTE-35 stack in the channel's HLS manifest — at the right second, against the right time base.
- The translation layer lives in one function. Spec changes or new downstream consumers are a single-file change, not a hunt across the codebase.
- Duration validation runs at the data layer, before any cue reaches the encoder. Operator mistakes surface in the UI, not silently at air time — malformed cues cannot reach MediaLive.
- Cue generation is deterministic: identical operator input produces identical SCTE-35 actions, so the same break behaves the same way across every deploy and every channel.
- The signalling layer is shipped and live. The SSAI consumer (MediaTailor) is designed and ready to be wired in — and when it is, every existing channel's cues will already be there waiting for it.
Technology Stack: AWS MediaLive · AWS Lambda · NestJS · MongoDB · TypeScript · Node 18 · SCTE-35 (Scte35TimeSignalSettings, Scte35SpliceInsertSettings) · AWS SDK v3
