Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- Nothing yet
### Added

- New URL option `svta_` for SVTA2053 Ad Creative Signaling (payload version 2, issue #310). Ad-creative
windows of the live timeline are marked with an `EventStream` of scheme
`urn:svta:advertising-wg:ad-creative-signaling`, one `Event` per creative whose node data is the v2 JSON
payload: identifiers, duration and the tracking URLs to fire while the creative plays. It uses the same
break-schedule grammar as `sgai_` (`svta_30:15,90:15` or `svta_p60:20`), with `;ads=<n>` to split a break
into several creatives and `;skip=`, `;click=`, `;verif=`, `;pod=`, `;ts=` for the rest of the payload —
for example `/livesim2/svta_p60:20;ads=2/testpic_2s/Manifest.mpd?sessionId=alice`. As for `sgai_`, the
video track serves the generated AD BREAK countdown slate inside each window, so the signaled creative is
visible. The tracking URLs use the VAST event names and point back at livesim2's own `/sgai/beacon`
endpoint with the session and break ids baked in (a player fires them verbatim), so the whole
ad-measurement round trip can be watched live at `/sgai/session_status`. Shaka Player supports the scheme
out of the box. See the [README](README.md#svta2053-ad-creative-signaling).
- New `svta=<0|1>` setting on the `sgai_` option. With `sgai_...;svta=1` every ad Period of the List MPD
returned by the ad-decisioning endpoint also gets an SVTA2053 `EventStream` describing the creative that
Period imports, using the real catalog id and duration. Opt-in, since a player acting on both that and
the existing callback beacons would report each tracking point twice.

### Changed

- The ad-break schedule (fixed or periodic breaks, the break instances signaled in an MPD, and the AD BREAK
slate window) is now shared between `sgai_` and `svta_` in `AdBreaks`. The signaled set of a periodic
schedule additionally keeps breaks that ended but are still inside the timeshift buffer for `svta_`, so a
viewer seeking back still sees the ad-creative signaling.

## [1.13.0] - 2026-08-11

Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,47 @@ break id via Annex I). The per-session ad decisions and beacons are recorded and
live at `/sgai/session_status?sid=<sessionId>` or via the API at `/api/sgai/sessions[/{sid}]` and
`/api/sgai/ads` (the ad catalog).

## SVTA2053 ad creative signaling

livesim2 can signal ad creatives with the SVTA's own scheme, **SVTA2053 Ad Creative Signaling**
(payload version 2). Ad-creative windows of the live timeline are marked with an `EventStream` of
scheme `urn:svta:advertising-wg:ad-creative-signaling` holding one `Event` per creative, whose
node data is the v2 JSON payload: the creative's identifiers, its duration, and the tracking URLs
the player should fire while it plays. This complements the Ed.6 SGAI mechanism above: SGAI
*replaces* a break with personalized ads, whereas SVTA2053 *describes* the creatives that are in
the presentation, which is what ad-measurement workflows consume.

Enable it with the `svta_` URL option, which uses the same break schedule grammar as `sgai_`:

- Fixed breaks: `svta_30:15`, or a comma-separated list `svta_30:15,90:15`.
- Periodic breaks: `svta_p60:20` (a 20 s creative at every full UTC minute, wall-clock anchored).
- Options are appended with `;key=val`:

| Option | Meaning | Default |
| ------ | ------- | ------- |
| `ads=<n>` | creatives the break is split into (1-20) | 1 |
| `skip=<s>` | `skipOffset` in seconds on each creative | none |
| `click=<0\|1>` | add `clickThrough` and a `clickTracking` beacon | 0 |
| `verif=<0\|1>` | add an ad-verification resource | 0 |
| `pod=<0\|1>` | also signal the whole break as a pod (`podStart`/`podEnd`) | 0 |
| `ts=<n>` | `EventStream@timescale` | 90000 |

For example `svta_p60:20;ads=2` signals two 10 s creatives every UTC minute. As for `sgai_`, the
video track serves the generated **AD BREAK** countdown slate inside each window, so the signaled
creative is visible. `svta_` cannot be combined with `sgai_` or with the multi-period options.

The tracking events are the VAST names (`impression`, `start`, `firstQuartile`, `midpoint`,
`thirdQuartile`, `complete`, plus `clickTracking` with `click=1`) and their URLs point back at
livesim2's own `/sgai/beacon` endpoint, with the session and break ids baked in — a player fires
these URLs verbatim, so nothing can add them later. Add `?sessionId=<id>` to the MPD URL to key
the beacons to a viewer and watch the whole round trip live at
`/sgai/session_status?sid=<sessionId>`. Shaka Player supports the scheme out of the box, which
makes an end-to-end ad-measurement test possible without any SSAI stack.

The SGAI ad pods can carry the same signaling: `sgai_…;svta=1` adds an SVTA2053 `EventStream` to
every ad Period of the returned List MPD, describing the creative that Period imports. It is
opt-in because a player acting on both signalings would report each tracking point twice.

## DASH Content Steering

livesim2 can be used to demonstrate and test client behavior for **DASH Content Steering**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ import (
"github.com/Eyevinn/mp4ff/mp4"
)

// SGAI slate: during a Replace-event break window the main video track serves generated
// "AD BREAK <countdown>" segments instead of the normal content, so the underlying stream
// visibly is "the ad to be replaced". A player that executes the Alternative-MPD event
// shows a personalized ad pod over this window; one that does not shows the slate. The
// main MPD is untouched (single Period, continuous timeline) — per Ed.6 (example G.29-1)
// the replaced interval is signaled by the event only and needs no Period of its own.
// Ad-break slate: during an ad-break window (sgai_ or svta_) the main video track serves
// generated "AD BREAK <countdown>" segments instead of the normal content, so the underlying
// stream visibly is the ad. With sgai_, a player that executes the Alternative-MPD event shows
// a personalized ad pod over this window and one that does not shows the slate; with svta_ the
// slate is the signaled creative itself. The main MPD is untouched (single Period, continuous
// timeline) — per Ed.6 (example G.29-1) the replaced interval is signaled by the event only and
// needs no Period of its own.
//
// Each slate segment splices seamlessly into the existing avc1 track: the IDR and P_Skip
// frames are encoded against the representation's own SPS/PPS (taken from its init
Expand Down Expand Up @@ -210,55 +211,21 @@ func slateGenFor(vodFS fs.FS, a *asset, rep *RepData) (*slateGen, error) {
return nil, fmt.Errorf("no slate generator for %s: %w", key, err)
}

// breakWindowAt returns the end (in ms since the epoch) and the occurrence/event id of the
// break whose window contains the wall-clock time wallMS, or ok=false when wallMS is outside
// every break. The id matches the Replace event id the live MPD signals for that break
// (periodic: breakStart/period + 1; fixed: 1-based index — see breakInstances in sgai.go), so
// the slate, the players' ad log and the beacons all show the same event id.
// Unlike breakInstances (which windows the *signaled* events around the request time), this is
// purely a function of the asked-for time: a segment inside a past break that is still in the
// timeshift buffer must keep its slate no matter when it is requested.
func (c *SGAIConfig) breakWindowAt(wallMS int64, astS int) (int64, uint64, bool) {
if c.Periodic != nil {
pMS := int64(c.Periodic.PeriodS) * 1000
dMS := int64(c.Periodic.DurationS) * 1000
if wallMS < int64(astS)*1000 {
return 0, 0, false
}
pos := wallMS % pMS
breakStartMS := wallMS - pos
// A periodic occurrence that starts before the availabilityStartTime is never
// signaled (breakInstances drops t < astS, since its Event@presentationTime would be
// negative), so it must not be slated either. Otherwise the first break straddling
// the AST would show an "AD BREAK" countdown carrying an event id the MPD never
// advertised, which no player could fill.
if pos < dMS && breakStartMS >= int64(astS)*1000 {
breakStartSec := breakStartMS / 1000
return breakStartMS + dMS, uint64(breakStartSec/int64(c.Periodic.PeriodS)) + 1, true
}
// adBreakForSegment returns the end (in ms since the epoch) and the event id of the break
// whose window contains the start of the segment described by meta, or ok=false when the
// segment starts outside every break (or the stream has no ad breaks at all). Segment times
// are media times relative to the availabilityStartTime (cfg.StartTimeS).
func adBreakForSegment(cfg *ResponseConfig, meta segMeta) (int64, uint64, bool) {
sched := adBreaksFor(cfg)
if sched == nil {
return 0, 0, false
}
for i, b := range c.Breaks {
startMS := (int64(astS) + int64(b.OffsetS)) * 1000
endMS := startMS + int64(b.DurationS)*1000
if wallMS >= startMS && wallMS < endMS {
return endMS, uint64(i + 1), true
}
}
return 0, 0, false
}

// sgaiBreakForSegment returns the end (in ms since the epoch) and the event id of the break
// whose window contains the start of the segment described by meta, or ok=false when the
// segment starts outside every break. Segment times are media times relative to the
// availabilityStartTime (cfg.StartTimeS).
func sgaiBreakForSegment(cfg *ResponseConfig, meta segMeta) (int64, uint64, bool) {
segStartMS := int64(cfg.StartTimeS)*1000 + int64(meta.newTime)*1000/int64(meta.timescale)
return cfg.SGAI.breakWindowAt(segStartMS, cfg.StartTimeS)
return sched.windowAt(segStartMS, cfg.StartTimeS)
}

// applySGAISlate replaces the samples of a video segment with a generated
// "AD BREAK <countdown>" slate when the segment starts inside an SGAI break window.
// applyAdBreakSlate replaces the samples of a video segment with a generated
// "AD BREAK <countdown>" slate when the segment starts inside an ad-break window.
// The countdown shows the seconds left of the break, updated with an IDR at every
// second change, counting down to zero when the live content returns. Returns the
// replacement segment, or nil when the segment is outside every break (or the rep
Expand All @@ -267,10 +234,10 @@ func sgaiBreakForSegment(cfg *ResponseConfig, meta segMeta) (int64, uint64, bool
// The slate keeps the original segment's exact sample timing: same sample count and
// durations, same tfdt/sequence number (already rewritten by the caller), and a constant
// composition offset equal to the original segment's reorder delay.
func applySGAISlate(vodFS fs.FS, a *asset, cfg *ResponseConfig, meta segMeta,
func applyAdBreakSlate(vodFS fs.FS, a *asset, cfg *ResponseConfig, meta segMeta,
seg *mp4.MediaSegment) (*mp4.MediaSegment, error) {

breakEndMS, breakID, ok := sgaiBreakForSegment(cfg, meta)
breakEndMS, breakID, ok := adBreakForSegment(cfg, meta)
if !ok {
return nil, nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,15 @@ func TestApplySGAISlate(t *testing.T) {
rep := &RepData{ID: "V300", InitURI: "V300/init.mp4"}
cfg := NewResponseConfig()
cfg.SGAI = &SGAIConfig{
Periodic: &SGAIPeriodic{PeriodS: 60, DurationS: 20},
AdBreaks: AdBreaks{Periodic: &AdBreakPeriodic{PeriodS: 60, DurationS: 20}},
ResolveOffsetS: 60,
}
seg := slateTestSegment(t)
origNrSamples := int(seg.Fragments[0].Moof.Traf.Trun.SampleCount())

// Segment at media time 0 = epoch with AST = epoch: inside the break [0, 20s).
meta := segMeta{rep: rep, newTime: 0, newNr: 42, timescale: 90000}
slate, err := applySGAISlate(vodFS, a, cfg, meta, seg)
slate, err := applyAdBreakSlate(vodFS, a, cfg, meta, seg)
require.NoError(t, err)
require.NotNil(t, slate, "segment inside the break window is slated")

Expand Down Expand Up @@ -152,14 +152,14 @@ func TestApplySGAISlate(t *testing.T) {

// A segment at 30s is outside every break occurrence -> no substitution.
meta = segMeta{rep: rep, newTime: 30 * 90000, newNr: 57, timescale: 90000}
slate, err = applySGAISlate(vodFS, a, cfg, meta, seg)
slate, err = applyAdBreakSlate(vodFS, a, cfg, meta, seg)
require.NoError(t, err)
assert.Nil(t, slate)

// A rep that cannot be slated (bad init) is skipped without error.
badRep := &RepData{ID: "missing", InitURI: "missing/init.mp4"}
meta = segMeta{rep: badRep, newTime: 0, newNr: 1, timescale: 90000}
slate, err = applySGAISlate(vodFS, a, cfg, meta, seg)
slate, err = applyAdBreakSlate(vodFS, a, cfg, meta, seg)
require.NoError(t, err)
assert.Nil(t, slate)
}
Expand All @@ -171,7 +171,7 @@ func TestApplySGAISlateFallsBackToRepSampleDuration(t *testing.T) {
vodFS := os.DirFS("testdata")
a := &asset{AssetPath: "assets/testpic_2s"}
cfg := NewResponseConfig()
cfg.SGAI = &SGAIConfig{Periodic: &SGAIPeriodic{PeriodS: 60, DurationS: 20}, ResolveOffsetS: 60}
cfg.SGAI = &SGAIConfig{AdBreaks: AdBreaks{Periodic: &AdBreakPeriodic{PeriodS: 60, DurationS: 20}}, ResolveOffsetS: 60}
meta := segMeta{newTime: 0, newNr: 7, timescale: 90000}

// Strip the per-sample (trun) and default (tfhd) durations to mimic such a segment.
Expand All @@ -188,7 +188,7 @@ func TestApplySGAISlateFallsBackToRepSampleDuration(t *testing.T) {
// With RepData.DefaultSampleDuration set (as read from trex at load time), the slate is
// produced and every sample uses that duration.
meta.rep = &RepData{ID: "V300", InitURI: "V300/init.mp4", DefaultSampleDuration: 3000}
slate, err := applySGAISlate(vodFS, a, cfg, meta, stripped())
slate, err := applyAdBreakSlate(vodFS, a, cfg, meta, stripped())
require.NoError(t, err)
require.NotNil(t, slate)
for _, s := range slate.Fragments[0].Moof.Traf.Trun.Samples {
Expand All @@ -197,57 +197,36 @@ func TestApplySGAISlateFallsBackToRepSampleDuration(t *testing.T) {

// With no duration source at all, it still errors rather than emit a malformed slate.
meta.rep = &RepData{ID: "V300", InitURI: "V300/init.mp4"}
_, err = applySGAISlate(vodFS, a, cfg, meta, stripped())
_, err = applyAdBreakSlate(vodFS, a, cfg, meta, stripped())
require.Error(t, err)
}

func TestSGAIBreakForSegment(t *testing.T) {
func TestAdBreakForSegment(t *testing.T) {
cfg := NewResponseConfig()
cfg.SGAI = &SGAIConfig{Breaks: []SGAIBreak{{OffsetS: 30, DurationS: 15}}}
cfg.SGAI = &SGAIConfig{AdBreaks: AdBreaks{Breaks: []AdBreak{{OffsetS: 30, DurationS: 15}}}}

// Segment starting at 30s (in 90k ticks) is in the break; end reported in epoch ms.
// The event id is the 1-based break index (matches the live MPD's Replace event id).
endMS, id, ok := sgaiBreakForSegment(cfg, segMeta{newTime: 30 * 90000, timescale: 90000})
endMS, id, ok := adBreakForSegment(cfg, segMeta{newTime: 30 * 90000, timescale: 90000})
assert.True(t, ok)
assert.Equal(t, int64(45_000), endMS)
assert.Equal(t, uint64(1), id)

// Just before and at the break end: not in the break.
_, _, ok = sgaiBreakForSegment(cfg, segMeta{newTime: 28 * 90000, timescale: 90000})
_, _, ok = adBreakForSegment(cfg, segMeta{newTime: 28 * 90000, timescale: 90000})
assert.False(t, ok)
_, _, ok = sgaiBreakForSegment(cfg, segMeta{newTime: 45 * 90000, timescale: 90000})
_, _, ok = adBreakForSegment(cfg, segMeta{newTime: 45 * 90000, timescale: 90000})
assert.False(t, ok)

// Periodic: membership is a pure function of the segment time — a long-past break
// (still in the timeshift buffer) keeps its slate no matter when it is requested.
// The event id is the occurrence number since the epoch (breakStart/period + 1).
per := &SGAIConfig{Periodic: &SGAIPeriodic{PeriodS: 60, DurationS: 20}}
endMS, id, ok = per.breakWindowAt(999_970_000, 0) // 10s into the break [999_960s, 999_980s)
// svta_ drives the same slate from its own schedule.
svtaCfg := NewResponseConfig()
svtaCfg.SVTA = &SVTAConfig{AdBreaks: AdBreaks{Breaks: []AdBreak{{OffsetS: 30, DurationS: 15}}}, AdsPerBreak: 1}
endMS, id, ok = adBreakForSegment(svtaCfg, segMeta{newTime: 30 * 90000, timescale: 90000})
assert.True(t, ok)
assert.Equal(t, int64(999_980_000), endMS)
assert.Equal(t, uint64(16667), id) // 999_960/60 + 1
_, _, ok = per.breakWindowAt(999_985_000, 0) // between breaks
assert.False(t, ok)
endMS, id, ok = per.breakWindowAt(60_000, 0) // exactly at a break start
assert.True(t, ok)
assert.Equal(t, int64(80_000), endMS)
assert.Equal(t, uint64(2), id) // 60/60 + 1: the 2nd occurrence since the epoch
_, _, ok = per.breakWindowAt(80_000, 0) // exactly at the break end
assert.False(t, ok)
assert.Equal(t, int64(45_000), endMS)
assert.Equal(t, uint64(1), id)

// A break occurrence that started before the availabilityStartTime is not signaled by
// breakInstances (its presentationTime would be negative), so breakWindowAt must not slate
// it either: with astS=65 the occurrence at [60s, 80s) straddles the AST and is skipped,
// even though the request time (70s) is inside the window and after the AST.
_, _, ok = per.breakWindowAt(70_000, 65)
// No ad-signaling option at all: no slate.
_, _, ok = adBreakForSegment(NewResponseConfig(), segMeta{newTime: 30 * 90000, timescale: 90000})
assert.False(t, ok)
// breakInstances agrees: the straddling [60s,80s) break is dropped; the first signaled
// occurrence is the next full one at [120s,140s) with id 120/60+1 = 3.
insts := per.breakInstances(70_000, 65)
assert.Equal(t, 1, len(insts))
assert.Equal(t, uint64(3), insts[0].id)
// That next full occurrence is both signaled and slated, with matching event ids.
_, id, ok = per.breakWindowAt(130_000, 65)
assert.True(t, ok)
assert.Equal(t, uint64(3), id)
}
Loading
Loading