SSAI Recipes: Packagers, Players and Origins
Commands and player code that produce a stream LtvAdx stitches, each one run end to end before it was published.
How these were verified (2026-10-10/11)
Each stream below was built with the exact command shown, opened as a test session on our production SSAI service (LtvAdx's 30-second test ad, nothing recorded or billed) and played to the end in each player listed — Chrome players on Linux, Safari on macOS, and an LG webOS TV's browser. The scripts that rebuild the streams and replay every check are in the LtvAdx repository (ltvadx-adserver/scripts/guides/). A tool or device that is not on this page has not been tested — see Not tested yet at the end.
Packagers
| Packager (version tested) | Output | Result |
|---|---|---|
| ffmpeg 6.1 | HLS, MPEG-TS segments | Stitched |
| ffmpeg 6.1 | HLS, fMP4 segments (-hls_segment_type fmp4) | Stitched |
| ffmpeg 6.1 | DASH, SegmentTemplate + SegmentTimeline | Stitched |
| Shaka Packager 3.9.3 | CMAF HLS — separate AAC audio and WebVTT subtitles | Stitched on video, audio and subtitles |
| Shaka Packager 3.9.3 | HLS with --ad_cues (#EXT-X-PLACEMENT-OPPORTUNITY) | Stitched at the cue |
| Shaka Packager 3.9.3 | Single file per track (HLS #EXT-X-BYTERANGE) | Stitched |
| Shaka Packager 3.9.3 | DASH with --generate_static_live_mpd | Stitched |
| Shaka Packager 3.9.3 | DASH without --generate_static_live_mpd | Read as live (type="dynamic"); no breaks without SCTE-35 |
| Shaka Packager 3.9.3 | DASH with --ad_cues, or single file (SegmentBase) | Played without ads (several Periods / SegmentBase are not cut) |
| GPAC MP4Box 26.08 | DASH + HLS (-out manifest.mpd:dual) | Both stitched |
ffmpeg — HLS (TS or fMP4) and DASH
Keyframes on segment boundaries (a fixed GOP, no scene-cut keyframes) let the stitcher cut cleanly. ffmpeg writes no ad cues, so breaks come from the channel's break positions (see Ad breaks below).
# HLS, two renditions with muxed audio — TS segments (for fMP4 add: -hls_segment_type fmp4 -hls_fmp4_init_filename init.mp4) ffmpeg -i v720.mp4 -i v360.mp4 -map 0:v -map 0:a -map 1:v -map 1:a -c copy \ -f hls -hls_time 6 -hls_playlist_type vod \ -var_stream_map "v:0,a:0,name:720 v:1,a:1,name:360" -master_pl_name master.m3u8 \ -hls_segment_filename "out/%v/seg%d.ts" out/%v/index.m3u8 # DASH ffmpeg -i v720.mp4 -i v360.mp4 -map 0:v -map 1:v -map 0:a -c copy \ -f dash -seg_duration 6 -use_template 1 -use_timeline 1 \ -adaptation_sets "id=0,streams=v id=1,streams=a" out/manifest.mpd # the renditions were encoded with a fixed 2 s GOP and no B-frames: # -c:v libx264 -bf 0 -g 50 -keyint_min 50 -sc_threshold 0
Shaka Packager — CMAF HLS and DASH from one command
Two things matter. --generate_static_live_mpd makes a VOD MPD static; without it the MPD is dynamic and is treated as a live stream. And subtitles go to each format in the form its players expect: WebVTT segments for HLS, WebVTT in MP4 for DASH (dash.js stops on plain .vtt DASH segments, with or without LtvAdx).
packager \ in=v720.mp4,stream=video,init_segment=v720/init.mp4,segment_template=v720/$Number$.m4s,playlist_name=v720/index.m3u8 \ in=v360.mp4,stream=video,init_segment=v360/init.mp4,segment_template=v360/$Number$.m4s,playlist_name=v360/index.m3u8 \ in=v720.mp4,stream=audio,init_segment=audio/init.mp4,segment_template=audio/$Number$.m4s,playlist_name=audio/index.m3u8,hls_group_id=audio,hls_name=English \ in=subs.vtt,stream=text,segment_template=subs/$Number$.vtt,playlist_name=subs/index.m3u8,hls_group_id=subs,hls_name=English,hls_only=1 \ in=subs.vtt,stream=text,format=mp4,init_segment=subs-mp4/init.mp4,segment_template=subs-mp4/$Number$.m4s,dash_only=1 \ --segment_duration 6 --generate_static_live_mpd --hls_playlist_type VOD \ --hls_master_playlist_output master.m3u8 --mpd_output manifest.mpd # ad cue at 120 s in the HLS output (HLS only — in DASH --ad_cues splits the Period and the stream plays without ads): # --ad_cues 120
GPAC
MP4Box -dash 6000 -frag 6000 -rap -segment-name '$RepresentationID$_$Number$' -profile live \ -out manifest.mpd:dual v720.mp4#video v360.mp4#video v720.mp4#audio # manifest.mpd (DASH) and manifest.m3u8 (HLS, fMP4) — both stitched
Ad breaks
- Cues in the stream win: HLS
#EXT-X-CUE-OUT/CUE-IN,#EXT-X-DATERANGESCTE35-OUT,#EXT-X-PLACEMENT-OPPORTUNITY(Shaka--ad_cues),#EXT-OATCLS-SCTE35,#EXT-X-SCTE35; DASH SCTE-35EventStream. - Otherwise the channel's break positions (SSAI Setup, e.g.
start,50%,end). - Otherwise a schedule by length: under 3 minutes, one 15-second pre-roll; 3–10 minutes, a 30-second pre-roll and a 15-second post-roll; longer content adds mid-rolls. The 30-second test ad does not fit a 15-second break, so a test session on a short clip without cues shows no ad — use a longer clip or set break positions.
- Live streams need cues: a live break is never invented.
Players
| Player (version tested) | HLS TS | HLS fMP4 / CMAF | DASH |
|---|---|---|---|
| hls.js 1.5.17 | ✓ | ✓ (with audio and WebVTT renditions) | — |
| Shaka Player 4.11.7 | ✓ | ✓ | ✓ |
| Video.js 8.17 (VHS) | ✓ | ✓ | ✓ |
| dash.js 4.7.4 | — | — | ✓ (subtitles as WebVTT in MP4) |
| Safari 17.6, macOS (native HLS) | ✓ | ✓ (with audio and WebVTT renditions; byte-range) | — |
| LG webOS TV browser (SmartTV 10.0, Chromium 79): built-in player | ✓ | ✓ (short stalls, ≤3 s, with separate audio) | — |
| LG webOS TV browser (Chromium 79): hls.js, Shaka, dash.js, Video.js | ✓ (hls.js, Video.js) | ✓ (hls.js, Shaka) | ✓ (Shaka, dash.js, Video.js) |
On the same TV before its browser update (SmartTV 8.5, Chromium 53) the built-in player, hls.js and Shaka played our test streams to the end with the ad; its built-in player also paused for long stretches inside plain content, before any ad. TV apps on webOS run in the same browser engine as the TV's web browser, which is where these were tested.
Every player gets the same thing: the playbackUrl of a session in place of your stream URL. Open the session from your backend (or the app) when the viewer presses play:
const s = await fetch("https://ssai.ltvadx.com/ssai/sessions", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channelId: "YOUR_CHANNEL_ID", originUrl: "https://cdn.example.tv/show/master.m3u8",
householdId: "…", platform: "CTV" }) }).then(r => r.json());
// hls.js
const hls = new Hls(); hls.loadSource(s.playbackUrl); hls.attachMedia(video);
// Shaka Player (HLS or DASH)
const player = new shaka.Player(); await player.attach(video); await player.load(s.playbackUrl);
// dash.js
dashjs.MediaPlayer().create().initialize(video, s.playbackUrl, true);
// Video.js
videojs(video).src({ src: s.playbackUrl, type: s.playbackUrl.endsWith(".mpd") ? "application/dash+xml" : "application/x-mpegURL" });
// Safari / AVPlayer-based players (native HLS)
video.src = s.playbackUrl;CLIENT reporting mode: firing the beacons from the player
With reportingMode: "CLIENT" the ad server fires nothing; the player reads trackingUrl and fires each event's beacons when the playhead reaches it. This snippet (VOD) works with any of the players above — it watches the <video> element. Tested with hls.js and Shaka, pre-roll and mid-roll: impression, start, quartiles and complete each fired once, within a second of their time. Call it right after creating the session.
function ltvadxClientBeacons(video, trackingUrl) {
const events = [], fired = new Set();
// breaks are planned on the player's first playlist request: read again on "playing" if the first read was empty
const load = () => fetch(trackingUrl).then(r => r.json()).then(t => {
if (events.length) return;
for (const avail of t.avails) for (const ad of avail.ads) for (const e of ad.trackingEvents)
events.push({ key: ad.adId + ":" + e.eventType, at: e.startTimeInSeconds, urls: e.beaconUrls });
});
load();
video.addEventListener("playing", () => { if (!events.length) load(); });
let last = 0;
const check = () => {
if (!events.length) return; // keep last at 0 until the data arrives: a pre-roll's impression still fires
const now = video.currentTime;
for (const e of events)
if (!fired.has(e.key) && e.at <= now + 0.25 && e.at >= Math.min(last, now) - 0.25) {
fired.add(e.key);
for (const u of e.urls) fetch(u, { mode: "no-cors", keepalive: true, credentials: "omit" }); // GET
}
last = now;
};
video.addEventListener("timeupdate", check);
video.addEventListener("ended", check);
}Origin checklist
- https only — an
http://origin is refused (400). - Allowed stream hosts — the origin's host must be on the channel's list in SSAI Setup, otherwise the session is refused (400).
- CORS on your CDN — web players fetch your content segments directly from it; allow GET from any origin and the
Rangeheader (byte-range players send it). - Answer within 1.5 s — an origin that is slower, or answers with an error, gets the player redirected to your own playlist: the stream plays, without ads.
- Relative or absolute URLs, query-string tokens — both work; tokens on segment URLs are kept as they are.
- Re-publishing VOD — playlists are cached for 5 minutes (live about 2 s): a VOD re-published at the same URL shows after up to 5 minutes. Publish a new version at a new URL.
- Live — audio, video and subtitle playlists numbered alike (CMAF packagers do), and cues for every break.
Not tested yet
We have not run these ourselves, so there is no recipe for them here. What LtvAdx reads is the same whatever produced the stream: the playlist and cue formats above. AWS Elemental MediaPackage / MediaLive, Wowza, Unified Origin, Harmonic and other commercial packagers; Roku (RAF), tvOS AVPlayer and Android ExoPlayer apps; AWS MediaTailor and Google DAI as stitchers calling our VAST pods. If you run one of these, test it with an SSAI test session (SSAI Setup → Test player) before you go live.