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 toconf/vod/Application.xml
— and the module loads successfully. - In
onAppStart
, I register a listener (VODListener
) that implementsIHTTPStreamerCupertinoVODActionNotify2
. - 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