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

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

Hi,
If you’re still waiting on an answer here, we recommend opening a formal Wowza support ticket so our team can review your specifics and do our best to assist you directly:
How to open a Wowza support ticket
If it turns out that your request needs deeper development-level escalation—especially around custom module development or advanced workflows—we may then suggest working with our Professional Services Team, who specialize in custom solutions tailored to specific project needs.
Hope this helps! Let us know if you need guidance getting started with a ticket.