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)OutputResult
ffmpeg 6.1HLS, MPEG-TS segmentsStitched
ffmpeg 6.1HLS, fMP4 segments (-hls_segment_type fmp4)Stitched
ffmpeg 6.1DASH, SegmentTemplate + SegmentTimelineStitched
Shaka Packager 3.9.3CMAF HLS — separate AAC audio and WebVTT subtitlesStitched on video, audio and subtitles
Shaka Packager 3.9.3HLS with --ad_cues (#EXT-X-PLACEMENT-OPPORTUNITY)Stitched at the cue
Shaka Packager 3.9.3Single file per track (HLS #EXT-X-BYTERANGE)Stitched
Shaka Packager 3.9.3DASH with --generate_static_live_mpdStitched
Shaka Packager 3.9.3DASH without --generate_static_live_mpdRead as live (type="dynamic"); no breaks without SCTE-35
Shaka Packager 3.9.3DASH with --ad_cues, or single file (SegmentBase)Played without ads (several Periods / SegmentBase are not cut)
GPAC MP4Box 26.08DASH + 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-DATERANGE SCTE35-OUT, #EXT-X-PLACEMENT-OPPORTUNITY (Shaka --ad_cues), #EXT-OATCLS-SCTE35, #EXT-X-SCTE35; DASH SCTE-35 EventStream.
  • 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 TSHLS fMP4 / CMAFDASH
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 Range header (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.