Access MPEG-TS SCTE-35 tags for HLS streaming

This article provides an example of how to use the Wowza Streaming Engine™ Java API to access and insert MPEG-TS SCTE-35 tags when streaming an HLS live stream. We recommend using the ModuleAdMarkers class over the legacy stream target API method.

Overview


With the ModuleAdMarkers class, you can prepare Apple HLS streams for ad insertion based on SCTE-35 events in live MPEG-TS source streams. The resulting Apple HLS live streams are written to disk with the chunks broken at ad-insertion points. The media playlist contains EXT-X-CUE-IN, EXT-X-CUE-OUT-CONT, and EXT-X-CUE-OUT custom headers at those ad insertion markers.

This module is beneficial because it allows the splitting of chunks at exact locations so you can switch to an ad halfway through an HLS segment, a task previously impossible with Wowza Streaming Engine versions before 4.8.26. Additionally, you can stream directly from Wowza Streaming Engine and insert the SCTE-35 ad markers without creating a stream target. Bypassing stream target creation helps to achieve typical HLS latency. 

Prerequisites


  • Wowza Streaming Engine 4.8.26 or later is required.
  • All source streams must be MPEG-TS-based and contain SCTE-35 ad markers.

Installation


Copy the following ad markers module and compile it with the Wowza IDE.

package com.wowza.example.cue;

import com.wowza.wms.amf.*;
import com.wowza.wms.application.IApplicationInstance;
import com.wowza.wms.httpstreamer.cupertinostreaming.livestreampacketizer.*;
import com.wowza.wms.media.mp3.model.idtags.ID3Frames;
import com.wowza.wms.module.ModuleBase;
import com.wowza.wms.stream.IMediaStream;
import com.wowza.wms.stream.livepacketizer.*;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

import static com.wowza.wms.amf.AMFData.*;
import static com.wowza.wms.rtp.depacketizer.RTPDePacketizerMPEGTSMonitorCUE.AMF_SCTE_HANDLER_NAME;

public class ModuleAdMarkers extends ModuleBase
{
    public static final Class<ModuleAdMarkers> CLASS = ModuleAdMarkers.class;
    public static final String CLASS_NAME = CLASS.getSimpleName();
    private IApplicationInstance appInstance;

    // Call this method when the application starts to set up a listener for live stream packetizer events
    public void onAppStart(IApplicationInstance appInstance)
    {
        getLogger(CLASS, appInstance).info(String.format("%s.onAppStart [%s]", CLASS_NAME, appInstance.getContextStr()));
        this.appInstance = appInstance;

        appInstance.addLiveStreamPacketizerListener(new LiveStreamPacketizerActionNotifyBase()
        {
            @Override
            public void onLiveStreamPacketizerInit(ILiveStreamPacketizer packetizer, String streamName)
            {
                // When the live stream packetizer is initialized, check if it's of type LiveStreamPacketizerCupertino
                if (packetizer instanceof LiveStreamPacketizerCupertino)
                {
                    IMediaStream stream = appInstance.getStreams().getStream(streamName);
                    ((LiveStreamPacketizerCupertino)packetizer).setDataHandler(new LiveStreamPacketizerCupertinoDataHandlerCue(
                            (LiveStreamPacketizerCupertino) packetizer, stream));
                }
            }
        });
    }

    // Define a class to process ad cue-related data during the packetization of the live stream
    class LiveStreamPacketizerCupertinoDataHandlerCue implements IHTTPStreamerCupertinoLivePacketizerDataHandler2
    {
        private final Map<Long, OnCueEvent> events = new ConcurrentHashMap<>();
        private final LiveStreamPacketizerCupertino liveStreamPacketizer;
        private final IMediaStream stream;

        public LiveStreamPacketizerCupertinoDataHandlerCue(LiveStreamPacketizerCupertino liveStreamPacketizer, IMediaStream stream)
        {
            this.liveStreamPacketizer = liveStreamPacketizer;
            this.stream = stream;
        }

        // Handle the splice event from the source stream
        @Override
        public void onFillChunkDataPacket(LiveStreamPacketizerCupertinoChunk chunk, CupertinoPacketHolder holder, AMFPacket packet, ID3Frames id3Frames)
        {
            int rendition = chunk.getRendition().getRendition();
            // Extract the actual SCTE data from the stream packets
            extractSCTEData(packet, rendition).ifPresent(data -> {
                AMFDataObj commandObj = data.getObject("command");
                String command = commandObj.getString("SpliceCommand");
                if (command.equalsIgnoreCase("insert"))
                {
                    AMFDataObj eventObj = commandObj.getObject("event");
                    long eventId = eventObj.getLong("eventID");
                    // Create new event and add it to the events map
                    events.computeIfAbsent(eventId, id -> {
                        boolean spliceOut = eventObj.getBoolean("outOfNetwork");
                        // Only processing splice_out events. splice_in calculated by the event duration
                        if (!spliceOut)
                            return null;
                        AMFDataObj spliceTimeObj = eventObj.getObject("spliceTime");
                        long spliceTimecode = spliceTimeObj.getLong("spliceTimeMS");
                        boolean durationFlag = eventObj.getBoolean("durationFlag");
                        long breakDuration = durationFlag ? (long) (eventObj.getDouble("breakDuration") / 90) : 0L;
                        // Don't create an event if we don't have a duration
                        if (breakDuration <= 0)
                            return null;
                        OnCueEvent newEvent = new OnCueEvent(spliceTimecode, breakDuration);
                        // Check if this event is in the current chunk and split if needed
                        checkAdjustChunkEnd(newEvent.startTime);
                        return newEvent;
                    });
                }
            });
        }

        private Optional<AMFDataObj> extractSCTEData(AMFPacket packet, int rendition)
        {
            byte[] buffer = packet.getData();
            if (buffer == null || packet.getSize() <= 2)
                return Optional.empty();
            int offset = buffer[0] != 0 ? 0 : 1;
            AMFDataList amfList = new AMFDataList(buffer, offset, buffer.length - offset);
            if (amfList.size() <= 1)
                return Optional.empty();
            if (amfList.get(0).getType() != DATA_TYPE_STRING && amfList.get(1).getType() != DATA_TYPE_OBJECT)
                return Optional.empty();
            String handlerName = amfList.getString(0);
            AMFDataObj data = amfList.getObject(1);
            if (!handlerName.equalsIgnoreCase(AMF_SCTE_HANDLER_NAME))
                return Optional.empty();
            getLogger(CLASS, appInstance).info(String.format("%s.extractSCTEData [%s] timecode: %d, data: %s", CLASS_NAME, stream.getContextStr(), packet.getAbsTimecode(), data));
            return Optional.ofNullable(data);
        }

        // Modify the segment stop timecode for the current segment so the new segment starts at the correct point
        private void checkAdjustChunkEnd(long timecode)
        {
            long chunkStart = liveStreamPacketizer.getSegmentStartKeyTimecode();
            long chunkEnd = liveStreamPacketizer.getSegmentStopKeyTimecode();
            if (timecode > chunkStart && timecode < chunkEnd)
            {
                getLogger(CLASS, appInstance).info(String.format("%s.checkAdjustChunkEnd [%s] timecode: %d, chunkStart: %d, chunkEnd: %d", CLASS_NAME, stream.getContextStr(), timecode, chunkStart, chunkEnd));
                liveStreamPacketizer.setSegmentStopKeyTimecode(timecode);
            }
        }
  
        // Detect an event in the stream and store it in a map with an event ID
        @Override
        public void onFillChunkStart(LiveStreamPacketizerCupertinoChunk chunk)
        {
            // Clear any events that expired in the previous chunk
            events.entrySet().removeIf(e -> e.getValue().expired);
            // Loop through events and see if we need to split the current chunk for the event start or end
            events.forEach((id, event) -> {
                checkAdjustChunkEnd(event.startTime);
                checkAdjustChunkEnd(event.startTime + event.duration);
            });
        }

        // Determine if we've got to add a tag for a specific segment or chunk
        // Depending on where we're in the event, we create a EXT-X-CUE-OUT, EXT-X-OUT-CONT, or EXT-X-CUE-IN tag
        @Override
        public void onFillChunkEnd(LiveStreamPacketizerCupertinoChunk chunk, long timecode)
        {
            CupertinoUserManifestHeaders chunkHeaders = chunk.getUserManifestHeaders();
            events.forEach((id, event) -> {
                long elapsed = chunk.getStartTimecode() - event.startTime;
                // Define first chunk for event
                // Add EXT-X-CUE-OUT tag the very first time we encounter the event to signal we should go to an ad break
                if (event.startTime >= chunk.getStartTimecode() && event.startTime < timecode)
                {
                    String tag = String.format("EXT-X-CUE-OUT:%.3f", event.duration / 1000d);
                    chunkHeaders.addHeader(tag);
                }
                // Define continuation chunk
                // As the ad progresses, add the EXT-X-CUE-OUT-CONT tag for each chunk to signal elapsed time/duration and inform the player we're still in the ad break
                else if (elapsed > 0 && elapsed < event.duration)
                {
                    String tag = String.format("EXT-X-CUE-OUT-CONT: %.3f/%.3f", elapsed / 1000d, event.duration / 1000d);
                    chunkHeaders.addHeader(tag);
                }
                // Define first chunk after event
                // At the end of the ad break, add the EXT-X-CUE-IN tag to say we've hit the ad duration and are back in the program 
                else if (elapsed >= event.duration)
                {
                    chunkHeaders.addHeader("EXT-X-CUE-IN");
                    event.expired = true;
                }
            });
        }

        // Method included but not used in the module
        @Override
        public void onFillChunkMediaPacket(LiveStreamPacketizerCupertinoChunk chunk, CupertinoPacketHolder holder, AMFPacket packet)
        {
            // no-op
        }
    }

    // Create class to ad marker events and contain information about start time, duration, and expiration status of the event
    static class OnCueEvent
    {
        final long startTime;
        final long duration;
        boolean expired = false;
        public OnCueEvent(long startTime, long duration)
        {
            this.startTime = startTime;
            this.duration = duration;
        }
    }
}

Configuration


Add the following definition to your application configuration to enable this module in Wowza Streaming Engine Manager. See Configure modules for details.

Name
ModuleAdMarkers
Description Inserts SCTE-35 ad markers for HLS live streams.
Fully Qualified Class Name com.wowza.example.cue.ModuleAdMarkers

Properties


After enabling the module, add these custom properties to your application in Wowza Streaming Engine Manager. See Add a custom property for details.

Path
/Root/Application/RTP
Name rtpDePacketizerMPEGTSListenerClass
Type String
Value com.wowza.wms.rtp.depacketizer.RTPDePacketizerMPEGTSMonitorCUE
Notes Default MPEG-TS monitor implementation for ad marker (SCTE-35) ingestion.

Path /Root/Application/RTP
Name rtpDePacketizerMPEGTSMonitorCUEDebugLog
Type Boolean
Value true
Notes Enables debugging and logging for the RTPDePacketizerMPEGTSMonitorCUE class.

Usage


With a successful setup, you can start a stream on your application and query a Cupertino-packetized HLS stream to view the SCTE tags.

See About Apple HLS headers for steps to request the playlist and then the chunklist in the initial server response. You can run a cURL command against any Cupertino-packetized HLS playback URL or media playlist to get information about the stream's contents or playlist.

The Wowza Streaming Engine chunklist response to the client includes the new SCTE-35 headers. From a server-side ad insertion point of view, you can take these markers and replace each corresponding segment by swapping it with an actual ad.

For instance, this example HLS stream starts out not in an ad break. It then goes to a 110-second ad break when the EXT-X-CUE-OUT tag is inserted:

#EXTM3U
#EXT-X-VERSION: 3
#EXT-X-TARGETDURATION: 7
#EXT-X-MEDIA-SEQUENCE: 39
#EXT-X-DISCONTINUITY-SEQUENCE: 0
#EXTINF:6.0,
media_w1775459898_39.ts
#EXTINF:5.667,
media_w1775459898_40.ts
#EXTINF: 3.0,
#EXT-X-CUE-OUT: 110.000
media_w1775459898_41.ts

In the ad break, the EXT-X-CUE-OUT-CONT tag then indicates the elapsed time and duration for the advertisement:

#EXTINF:3.133,
#EXT-X-CUE-OUT-CONT: 0.001/110.000
media_w1775459898_42.ts
#EXTINF:5.967,
#EXT-X-CUE-OUT-CONT: 3.134/110.000
media_w1775459898_43.ts
#EXTINF:6.0,
#EXT-X-CUE-OUT-CONT: 9.101/110.000
media_w1775459898_44.ts

When the ad duration is up, the EXT-X-CUE-IN marker gets us back into the program, and the ad break is finished:

#EXTINF:5.833,
#EXT-X-CUE-OUT-CONT: 99.101/110.000
media_w1775459898_59.ts
#EXTINF:5.067,
#EXT-X-CUE-OUT-CONT: 104.934/110.000
media_w1775459898_60.ts
#EXTINF:1.0,
#EXT-X-CUE-IN
media_w1775459898_61.ts
#EXTINF:6.0,
media_w1775459898_62.ts
#EXTINF:6.0,
media_w1775459898_63.ts

More resources