How to Inject AMF Timed Metadata as SCTE-35 Markers on VOD stream (MP4) via SMIL

Abdallah_Monzer
Tue, 24 Jun 2025 07:54:00 +0000
Wowza Streaming Engine

Hi,

I’m working on a VOD streaming workflow using vod application (not live stream). I have a sample.mp4 file, and a SMIL file that references it.

Example playback URL:
http://localhost:1935/vod/smil:playlist2.smil/playlist.m3u8

Goal:
I want to implement server-side ad insertion (SSAI) by injecting AMF Packets as SCTE-35 markers (on fly) during HLS playback. These markers will be converted to ID3 tags for the ad server to recognize and trigger ad replacement (stitch).

Challenge:
The ModuleAdMarker and newly added moduleAdInsertion in Wowza supports only MPEG-TS sources for live stream.
In my case, the source is MP4 (referenced via SMIL) and the workflow in vod stream, so these modules don’t work.

What I’m trying to do:

Inject AMF timed metadata (SCTE-35) into the stream dynamically during the MP4-to-HLS process.

These AMF packets should convert into ID3 tags within the HLS stream.

Ideally, the SCTE-35 cue points could be read from a JSON file located next to the SMIL file in order to be dynamic.

example of the cue tags must included within the HLS playlist:
EXT-X-DATERANGE, EXT-X-CUE-IN, EXT-X-CUE-OUT tags, etc..

Custom Module: ModuleCueInjector (Current Approach)

Here’s what I’ve implemented so far:

I created a custom module, compiled using the Wowza IDE, and placed the .jar file in [Wowza-install-dir]/lib. Then I added the module reference to conf/vod/Application.xml — and the module loads successfully.

In onAppStart, I register a listener (VODListener) that implements IHTTPStreamerCupertinoVODActionNotify2.

When a user starts the stream, the onFillChunkStart method is triggered every 10 seconds.

At specific timestamps (e.g., 10s and 30s), I inject AMF packets to represent SCTE-35 markers.

As I understand it, onFillChunkDataPacket should then be triggered to convert these AMF packets into ID3 tags within the HLS chunks.

Questions:

This part is still unclear for me:

The onFillChunkDataPacket is not triggered at all.

Am I following the correct overall workflow?

Should I be using a different interface or hook?

Is this workflow even feasible for MP4-based VOD streams via SMIL?

Is this feature (dynamic AMF → ID3 injection in VOD via SMIL) officially supported or practical?

package com.mycompany.wowza;

import com.wowza.wms.httpstreamer.model.*;
import com.wowza.wms.amf.AMFDataItem;
import com.wowza.wms.module.ModuleBase;
import com.wowza.wms.amf.*;
import com.wowza.wms.httpstreamer.cupertinostreaming.httpstreamer.HTTPStreamerApplicationContextCupertinoStreamer;
import com.wowza.wms.logging.WMSLogger;
import com.wowza.wms.httpstreamer.model.IHTTPStreamerSession;
import java.util.concurrent.ConcurrentHashMap;
import com.wowza.wms.amf.AMFPacket;
import com.wowza.wms.httpstreamer.cupertinostreaming.file.IHTTPStreamerCupertinoIndex;
import com.wowza.wms.httpstreamer.cupertinostreaming.file.IHTTPStreamerCupertinoIndexItem;
import com.wowza.wms.httpstreamer.cupertinostreaming.httpstreamer.IHTTPStreamerCupertinoVODActionNotify2;
import com.wowza.wms.httpstreamer.cupertinostreaming.livestreampacketizer.LiveStreamPacketizerCupertinoChunk;
import com.wowza.wms.media.mp3.model.idtags.ID3Frames;
import com.wowza.wms.media.mp3.model.idtags.ID3V2FrameTextInformationUserDefined;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import com.wowza.wms.amf.AMFDataList;
import com.wowza.wms.application.IApplicationInstance;

public class ModuleCueInjector extends ModuleBase {

    public void onAppStart(IApplicationInstance appInstance) {
        String name = appInstance.getApplication().getName() + "/" + appInstance.getName();
        getLogger().info("ModuleCueInjector: onAppStart: " + name);

        IHTTPStreamerApplicationContext appContext = appInstance.getHTTPStreamerApplicationContext("cupertinostreaming", true);
        if (appContext == null)
        {
        	getLogger().warn("ModuleCueInjector#onAppStart: Cupertino streamer application context not found for: " + appInstance.getContextStr());
        	return;
        }

        if (appContext instanceof HTTPStreamerApplicationContextCupertinoStreamer) {
            HTTPStreamerApplicationContextCupertinoStreamer cupertinoAppContext = (HTTPStreamerApplicationContextCupertinoStreamer) appContext;

            cupertinoAppContext.addVODActionListener(new VODListener(getLogger()));
            getLogger().info("ModuleCueInjector#onAppStart[" + appInstance.getContextStr() + "]: VODListener registered.");
        }
    }

    class VODListener implements IHTTPStreamerCupertinoVODActionNotify2 {

        private WMSLogger logger;
        private ConcurrentHashMap<IHTTPStreamerCupertinoIndex, Integer> nextCueIndexMap = new ConcurrentHashMap<>();

		private final String INJECTOR_EVENT_NAME = "onSCTE35Inject";
        private final String SCTE35_CUE_DATA = "/DAIAAAAAAAAAAAQFAAUASABZAP/wBQb+AAAAAAAwAiBDVUVJAAAAAABgAAEBAQABAIAAAAAAAAA=";
        private final double[] CUE_TIMES_SECONDS = {10.0, 30.0};

        public VODListener(WMSLogger logger) {
            this.logger = logger;
        }

        @Override
        public void onOpen(IHTTPStreamerCupertinoIndex fileIndex, IHTTPStreamerApplicationContext appContext, IHTTPStreamerSession httpStreamerSession, String rawStreamName, String streamExt, String streamName) {
            logger.info("ModuleCueInjector.VODListener.onOpen: " + streamName);
            nextCueIndexMap.put(fileIndex, 0);
        }

        @Override
        public void onFillChunkStart(
                IHTTPStreamerCupertinoIndex fileIndex,
                IHTTPStreamerCupertinoIndexItem item,
                LiveStreamPacketizerCupertinoChunk chunk,
                boolean audioOnly) {
                logger.info("ModuleCueInjector: onFillChunkStart start: " + item.getStartTimecode());
                logger.info("ModuleCueInjector: onFillChunkStart end: " + item.getStopTimecode());

            Integer nextCueIndex = nextCueIndexMap.get(fileIndex);

            // Stop if we have no more cues to inject for this stream
            if (nextCueIndex == null || nextCueIndex >= CUE_TIMES_SECONDS.length) {
                return;
            }

            double cueTime = CUE_TIMES_SECONDS[nextCueIndex];

            if (item.getStartTimecode() >= (cueTime * 1000)) {
                logger.info("ModuleCueInjector: onFillChunkStart: cue time reached for cue #" + (nextCueIndex + 1) + " at " + cueTime + "s");
                 try {
                    logger.info("ModuleCueInjector: onFillChunkStart: injecting cue: processing..");
 					// Step 1: Create a custom AMF packet to trigger onFillChunkDataPacket
 					AMFDataList amfList = new AMFDataList();
 					amfList.add(new AMFDataItem(INJECTOR_EVENT_NAME));

 					ByteArrayOutputStream baos = new ByteArrayOutputStream();
 					DataOutputStream out = new DataOutputStream(baos);
 					amfList.serialize(out);

 					byte[] dataBytes = baos.toByteArray();

 					// Create a new AMFPacket, set its properties, and add it to the chunk
 					AMFPacket amfPacket = new AMFPacket();
 					amfPacket.setType(18);
 					amfPacket.setDataBuffer(dataBytes);
 					amfPacket.setSize(dataBytes.length);
 					amfPacket.setTimecode(item.getStartTimecode());
 					amfPacket.setAbsTimecode(item.getStartTimecode());

 					// Add the packet to the chunk's list.
 					chunk.addDataPacket(amfPacket);

 					logger.info("ModuleCueInjector: Injected temporary AMF packet at " + item.getStartTimecode() + "ms");

 					// Increment the index to look for the next cue point
 					nextCueIndexMap.put(fileIndex, nextCueIndex + 1);

 				} catch (Exception e) {
 					logger.error("ModuleCueInjector: Failed to inject temporary AMF packet.", e);
 				}
             }else{
                 logger.info("ModuleCueInjector: onFillChunkStart: cue time not reached");
             }
        }

		@Override
		public void onFillChunkDataPacket(IHTTPStreamerCupertinoIndex fileIndex, IHTTPStreamerCupertinoIndexItem item, LiveStreamPacketizerCupertinoChunk chunk, boolean audioOnly, AMFPacket packet, ID3Frames id3Frames) {
        	logger.info("ModuleCueInjector: onFillChunkDataPacket");

			byte[] buffer = packet.getData();
			if (buffer == null)
				return;

			try {
				AMFDataList amfList = new AMFDataList(buffer);

				if (amfList.size() > 0 && amfList.get(0).getType() == AMFData.DATA_TYPE_STRING) {
					String eventName = amfList.getString(0);

					// Step 2: Check if this is our custom AMF packet
					if (INJECTOR_EVENT_NAME.equals(eventName)) {

						logger.info("ModuleCueInjector: Caught tempofry AMF packet. Converting to ID3 tag.");

						// Create the SCTE-35 ID3 frame
						ID3V2FrameTextInformationUserDefined scteFrame = new ID3V2FrameTextInformationUserDefined();
						scteFrame.setDescription("SCTE35-OUT");
						scteFrame.setValue(SCTE35_CUE_DATA);

						// Add the frame to the id3Frames container provided by Wowza.
						id3Frames.putFrame(scteFrame);

						logger.info("ModuleCueInjector: Successfully converted AMF packet to SCTE-35 ID3 tag.");
					}
				}
			} catch (Exception e) {
				logger.error("ModuleCueInjector: Failed to process AMF data packet.", e);
			}
		}

        @Override
        public void onDestroy(IHTTPStreamerCupertinoIndex fileIndex) {
        	 logger.info("ModuleCueInjector.VODListener.onDestroy");
             if (fileIndex != null) {
                nextCueIndexMap.remove(fileIndex);
             }
        }

		@Override
		public void onFillChunkEnd(IHTTPStreamerCupertinoIndex fileIndex, IHTTPStreamerCupertinoIndexItem item, LiveStreamPacketizerCupertinoChunk chunk, boolean audioOnly) {
		}
    }
}

Thanks

2 posts - 2 participants