eyevinn-channel-engine 5.1.2 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/nodejs.yml +1 -1
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/rc.yml +1 -1
- package/CONTRIBUTING.md +40 -0
- package/Dockerfile +1 -1
- package/README.md +70 -8
- package/dist/engine/chaos_monkey.d.ts +1 -1
- package/dist/engine/chaos_monkey.js +1 -0
- package/dist/engine/event_stream.d.ts +1 -6
- package/dist/engine/event_stream.js +2 -0
- package/dist/engine/memcached_state_store.d.ts +1 -13
- package/dist/engine/memcached_state_store.js +4 -0
- package/dist/engine/memory_state_store.d.ts +1 -13
- package/dist/engine/memory_state_store.js +5 -0
- package/dist/engine/playhead_state.d.ts +0 -26
- package/dist/engine/playhead_state.js +9 -0
- package/dist/engine/preflight.d.ts +1 -1
- package/dist/engine/preflight.js +1 -0
- package/dist/engine/redis_state_store.d.ts +1 -19
- package/dist/engine/redis_state_store.js +5 -0
- package/dist/engine/server.d.ts +27 -0
- package/dist/engine/server.js +140 -6
- package/dist/engine/session.d.ts +1 -148
- package/dist/engine/session.js +398 -11
- package/dist/engine/session_live.d.ts +1 -181
- package/dist/engine/session_live.js +19 -4
- package/dist/engine/session_live_state.d.ts +0 -23
- package/dist/engine/session_live_state.js +5 -0
- package/dist/engine/session_state.d.ts +0 -45
- package/dist/engine/session_state.js +10 -0
- package/dist/engine/shared_state_store.d.ts +1 -25
- package/dist/engine/shared_state_store.js +10 -0
- package/dist/engine/state_store_types.d.ts +32 -0
- package/dist/engine/state_store_types.js +5 -0
- package/dist/engine/stream_switcher.d.ts +1 -40
- package/dist/engine/stream_switcher.js +108 -20
- package/dist/engine/subtitle_slicer.d.ts +1 -8
- package/dist/engine/subtitle_slicer.js +1 -0
- package/dist/engine/util.d.ts +1 -29
- package/dist/engine/util.js +2 -1
- package/dist/examples/adbreak.d.ts +1 -0
- package/dist/examples/adbreak.js +104 -0
- package/dist/index.d.ts +1 -1
- package/dist/package.json +20 -4
- package/docs/design/mixing-muxed-demuxed-audio.md +153 -0
- package/docs/design/subtitles-live-mix-demux.md +135 -0
- package/docs/reference.md +67 -0
- package/engine/chaos_monkey.ts +17 -0
- package/engine/{event_stream.js → event_stream.ts} +5 -3
- package/engine/{memcached_state_store.js → memcached_state_store.ts} +19 -12
- package/engine/memory_state_store.ts +71 -0
- package/engine/{playhead_state.js → playhead_state.ts} +92 -82
- package/engine/{preflight.js → preflight.ts} +1 -1
- package/engine/{redis_state_store.js → redis_state_store.ts} +216 -208
- package/engine/server.ts +210 -8
- package/engine/{session.js → session.ts} +421 -22
- package/engine/{session_live.js → session_live.ts} +54 -31
- package/engine/{session_live_state.js → session_live_state.ts} +129 -120
- package/engine/{session_state.js → session_state.ts} +269 -248
- package/engine/{shared_state_store.js → shared_state_store.ts} +31 -19
- package/engine/state_store_types.ts +35 -0
- package/engine/{stream_switcher.js → stream_switcher.ts} +133 -34
- package/engine/{subtitle_slicer.js → subtitle_slicer.ts} +10 -8
- package/engine/{util.js → util.ts} +28 -24
- package/examples/adbreak.ts +115 -0
- package/index.ts +2 -0
- package/package.json +20 -4
- package/spec/engine/custom_vod_request_params_spec.ts +99 -0
- package/spec/engine/drm_spec.ts +128 -0
- package/spec/engine/{engine_spec.js → engine_spec.ts} +44 -1
- package/spec/engine/{ha_spec.js → ha_spec.ts} +7 -4
- package/spec/engine/{init_switching_spec.js → init_switching_spec.ts} +10 -6
- package/spec/engine/{interface_spec.js → interface_spec.ts} +16 -4
- package/spec/engine/{playhead_live_spec.js → playhead_live_spec.ts} +2 -1
- package/spec/engine/{playhead_spec.js → playhead_spec.ts} +15 -8
- package/spec/engine/session_spec.ts +800 -0
- package/spec/engine/stream_switcher_audio_reconcile_spec.ts +158 -0
- package/spec/engine/{stream_switcher_spec.js → stream_switcher_spec.ts} +111 -13
- package/spec/engine/subtitle_missing_language_spec.ts +196 -0
- package/spec/engine/{subtitle_spec.js → subtitle_spec.ts} +28 -27
- package/spec/helpers/ts-resolve.js +65 -0
- package/spec/support/jasmine.json +1 -1
- package/spec/testvectors/hls_subs_no_language/config.subtitleTracks.json +12 -0
- package/spec/testvectors/hls_subs_no_language/evidence_dummy_segments_name_mismatch.m3u8 +24 -0
- package/spec/testvectors/hls_subs_no_language/evidence_real_cues_name_match.m3u8 +24 -0
- package/spec/testvectors/hls_subs_no_language/french.m3u8 +9 -0
- package/spec/testvectors/hls_subs_no_language/master.m3u8 +11 -0
- package/spec/testvectors/hls_subs_no_language/video.m3u8 +25 -0
- package/spec/testvectors/slate/master.m3u8 +3 -0
- package/spec/testvectors/slate/media.m3u8 +9 -0
- package/tsconfig.json +21 -4
- package/tsconfig.spec.json +16 -0
- package/engine/chaos_monkey.js +0 -17
- package/engine/memory_state_store.js +0 -63
- package/spec/engine/session_spec.js +0 -116
- /package/spec/engine/{session_live_spec.js → session_live_spec.ts} +0 -0
- /package/spec/engine/{util_spec.js → util_spec.ts} +0 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Design note: mixing muxed- and demuxed-audio sources in one channel
|
|
2
|
+
|
|
3
|
+
Scoping note for issue #381 (parent #271; implementation sub-issues #382/#383).
|
|
4
|
+
This document defines the target behaviour and acceptance criteria before any
|
|
5
|
+
implementation is started. It contains no code.
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
A channel is fed a sequence of VOD (and optionally live) sources by the asset
|
|
10
|
+
manager. Today those sources are assumed to be homogeneous with respect to
|
|
11
|
+
audio packaging: either every source is *muxed* (audio interleaved in the same
|
|
12
|
+
rendition as video) or every source is *demuxed* (audio delivered as a separate
|
|
13
|
+
`EXT-X-MEDIA` audio group). The channel-level `useDemuxedAudio` flag
|
|
14
|
+
(`engine/server.ts`) picks one mode for the whole channel and every source is
|
|
15
|
+
expected to comply with it.
|
|
16
|
+
|
|
17
|
+
When a channel's schedule mixes the two — e.g. a muxed VOD followed by a
|
|
18
|
+
demuxed VOD on a `useDemuxedAudio: true` channel — the mismatched source fails
|
|
19
|
+
to load rather than being reconciled. This is grounded in the current code (see
|
|
20
|
+
"Affected code paths" below): `engine/session.ts` passes
|
|
21
|
+
`forcedDemuxMode: this.use_demuxed_audio` into the underlying `HLSVod`
|
|
22
|
+
(`@eyevinn/hls-vodtolive`), and that library **rejects with
|
|
23
|
+
`Error("The vod is not a demux vod")`** when a source presented under forced
|
|
24
|
+
demux mode carries no audio group. So a muxed source on a demuxed channel is a
|
|
25
|
+
hard load failure today, not a soft mismatch.
|
|
26
|
+
|
|
27
|
+
## (1) Target behaviour
|
|
28
|
+
|
|
29
|
+
For a single channel whose sources mix muxed-audio and demuxed-audio inputs:
|
|
30
|
+
|
|
31
|
+
- The sources must play **back-to-back across the VOD boundary without breaking
|
|
32
|
+
the manifest** — the output master playlist and the output media/audio
|
|
33
|
+
playlists must remain valid and internally consistent across the transition,
|
|
34
|
+
and a compliant player must continue playback across the boundary without a
|
|
35
|
+
fatal error or a manifest reload failure.
|
|
36
|
+
- A brief, bounded audio artefact at the discontinuity (one segment of silence
|
|
37
|
+
or a single hiccup, on the order of the average segment duration) is
|
|
38
|
+
acceptable for a first pass; a persistent audio/video desync or a manifest
|
|
39
|
+
that a player rejects is **not** acceptable.
|
|
40
|
+
- "Mix" here covers the VOD→VOD case as the primary target. Live-mix
|
|
41
|
+
(VOD↔live) transitions with mixed packaging are in scope for the acceptance
|
|
42
|
+
criteria only insofar as they must not regress the existing live-mix +
|
|
43
|
+
demux behaviour; full live-mix mixed-packaging support may be deferred to a
|
|
44
|
+
follow-up if it proves materially harder.
|
|
45
|
+
|
|
46
|
+
## (2) Output manifest mode — recommendation
|
|
47
|
+
|
|
48
|
+
**Normalise to a single, consistent output presentation for the whole channel.**
|
|
49
|
+
Do not preserve per-source packaging in the output. A channel that flips the
|
|
50
|
+
output between muxed and demuxed at each VOD boundary would force the player to
|
|
51
|
+
re-init its audio pipeline mid-stream and is the most likely thing to break
|
|
52
|
+
playback.
|
|
53
|
+
|
|
54
|
+
Concretely: the channel's output mode is decided once, by the existing
|
|
55
|
+
`useDemuxedAudio` flag.
|
|
56
|
+
|
|
57
|
+
- `useDemuxedAudio: true` → output is **always demuxed**: an `EXT-X-MEDIA`
|
|
58
|
+
audio group is present and every served segment (from every source,
|
|
59
|
+
regardless of that source's own packaging) participates in that single
|
|
60
|
+
consistent audio-group presentation.
|
|
61
|
+
- `useDemuxedAudio: false` → output is **always muxed**: no separate audio
|
|
62
|
+
group; audio is carried in the video rendition for every source.
|
|
63
|
+
|
|
64
|
+
The flag stays the single source of truth for output presentation; the new work
|
|
65
|
+
is making non-conforming sources fit the chosen output mode rather than failing.
|
|
66
|
+
|
|
67
|
+
## (3) Cross-packaging handling (muxed source, demuxed output — and vice-versa)
|
|
68
|
+
|
|
69
|
+
**On-the-fly remultiplexing / transcoding is explicitly out of scope.** The
|
|
70
|
+
engine is a manifest-manipulation / VOD2Live layer; it does not open, decode,
|
|
71
|
+
demux, or re-encode media segments, and it should not start doing so here.
|
|
72
|
+
|
|
73
|
+
Given that constraint, the two cross-packaging directions resolve as:
|
|
74
|
+
|
|
75
|
+
- **Muxed source, demuxed output (`useDemuxedAudio: true`)**: require the muxed
|
|
76
|
+
source to **already carry a separate audio rendition** (an `EXT-X-MEDIA`
|
|
77
|
+
audio group) that the engine can route into the output audio group. A source
|
|
78
|
+
that is *only* muxed (video-with-embedded-audio and no audio rendition)
|
|
79
|
+
cannot be losslessly presented as demuxed without extracting the audio track,
|
|
80
|
+
which is remux — out of scope. Such a source must be handled deterministically
|
|
81
|
+
(see acceptance criteria: reject-and-skip via the existing error/unassign
|
|
82
|
+
path, not a crash), not silently mis-served.
|
|
83
|
+
- **Demuxed source, muxed output (`useDemuxedAudio: false`)**: the engine
|
|
84
|
+
selects a single audio rendition from the source's audio group and presents
|
|
85
|
+
the source as muxed in the output. Where the source's video rendition does
|
|
86
|
+
not already contain that audio, this again requires the player to be served
|
|
87
|
+
audio it can associate with the video without a remux; if that cannot be done
|
|
88
|
+
losslessly it falls under the same deterministic reject-and-skip rule.
|
|
89
|
+
|
|
90
|
+
In short: **the engine reconciles packaging by routing existing renditions, not
|
|
91
|
+
by producing new media.** A source that would require new media to fit the
|
|
92
|
+
output mode is rejected cleanly rather than breaking the channel.
|
|
93
|
+
|
|
94
|
+
## (4) Affected code paths
|
|
95
|
+
|
|
96
|
+
Verified against the current tree (the engine files are TypeScript, `*.ts`, not
|
|
97
|
+
`*.js` as the parent issue's shorthand suggested):
|
|
98
|
+
|
|
99
|
+
- **`engine/server.ts`** — declares and stores the `useDemuxedAudio` option
|
|
100
|
+
(`ChannelEngineOpts.useDemuxedAudio`, the private `useDemuxedAudio` field,
|
|
101
|
+
and its default-to-`false` handling), and forwards it into the session /
|
|
102
|
+
session-live / stream-switcher configs it constructs. This is where the
|
|
103
|
+
"output mode is a single channel-level decision" contract lives.
|
|
104
|
+
- **`engine/session.ts`** — reads the flag into `use_demuxed_audio` and, at VOD
|
|
105
|
+
creation, passes `forcedDemuxMode: this.use_demuxed_audio` into `new
|
|
106
|
+
HLSVod(...)`. This is the exact point where a muxed source on a demuxed
|
|
107
|
+
channel currently causes the underlying library to reject with "The vod is
|
|
108
|
+
not a demux vod". The reconciliation/validation decided above must be applied
|
|
109
|
+
here (and it must feed the existing per-item error handling rather than
|
|
110
|
+
throwing uncaught).
|
|
111
|
+
- **`engine/session_live.ts`** — mirrors the demuxed handling for the live
|
|
112
|
+
path (`this.useDemuxedAudio`, the audio-disc-sequence and audio-segment
|
|
113
|
+
branches). Any normalisation must keep the live path's audio group consistent
|
|
114
|
+
with the VOD path across a live-mix transition.
|
|
115
|
+
- **`engine/stream_switcher.ts`** — gates transitions on the presence of audio
|
|
116
|
+
segments when `useDemuxedAudio` is set (the `isValid` /
|
|
117
|
+
`ItemIsEmpty(segments.audioSegments)` checks and the numerous
|
|
118
|
+
`this.useDemuxedAudio` branches around the switch logic). This is where a
|
|
119
|
+
packaging mismatch at a boundary is currently detected as "no audio
|
|
120
|
+
segments"; the reject-and-skip vs. reconcile decision must be enforced
|
|
121
|
+
consistently here too, so the switcher never advances into a state that
|
|
122
|
+
produces an invalid manifest.
|
|
123
|
+
|
|
124
|
+
## Acceptance criteria
|
|
125
|
+
|
|
126
|
+
The implementation sub-issues (#382/#383) can be validated against this list:
|
|
127
|
+
|
|
128
|
+
- [ ] The channel's output presentation is decided solely by `useDemuxedAudio`
|
|
129
|
+
and is identical for every source in the channel (never flips
|
|
130
|
+
muxed/demuxed mid-stream).
|
|
131
|
+
- [ ] A schedule that alternates a demuxed source and a *muxed source that
|
|
132
|
+
already carries an audio rendition* on a `useDemuxedAudio: true` channel
|
|
133
|
+
plays across the VOD boundary and produces a valid master + media + audio
|
|
134
|
+
playlist at and after the transition.
|
|
135
|
+
- [ ] The same alternation on a `useDemuxedAudio: false` channel produces valid
|
|
136
|
+
muxed output across the boundary.
|
|
137
|
+
- [ ] A muxed-only source (no audio rendition) presented to a
|
|
138
|
+
`useDemuxedAudio: true` channel is handled deterministically: it is
|
|
139
|
+
rejected/skipped through the existing error → unassign → next-item path
|
|
140
|
+
(per the repo's "on any unexpected failure" rule) and does **not** crash
|
|
141
|
+
the session, wedge the stream switcher, or emit an invalid manifest.
|
|
142
|
+
- [ ] No segment-level remux/transcode is introduced anywhere in
|
|
143
|
+
`engine/session.ts`, `engine/session_live.ts`, or
|
|
144
|
+
`engine/stream_switcher.ts` (reconciliation is rendition routing only).
|
|
145
|
+
- [ ] Existing homogeneous-packaging behaviour (all-muxed channel, all-demuxed
|
|
146
|
+
channel) is unchanged — the current session / session_live /
|
|
147
|
+
stream_switcher specs still pass.
|
|
148
|
+
- [ ] A regression spec drives a mixed-packaging schedule through the same
|
|
149
|
+
seam the engine uses (VOD load → live media sequence render, as in the
|
|
150
|
+
existing `spec/engine/session_spec.ts`) and asserts a valid transition
|
|
151
|
+
rather than the current `"The vod is not a demux vod"` rejection.
|
|
152
|
+
- [ ] The README "Supported Source Formats" section is updated to describe
|
|
153
|
+
mixed-packaging support once implemented.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Design note: subtitles in the live-mix + demux setting
|
|
2
|
+
|
|
3
|
+
Scoping note for issue #402. This document defines what "subtitle support in the
|
|
4
|
+
live-mix + demux setting" should mean and the acceptance criteria a follow-up
|
|
5
|
+
implementation issue can be filed against. It contains no code.
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
`README.md`, directly under the "Supported Source Formats" compliance table,
|
|
10
|
+
states:
|
|
11
|
+
|
|
12
|
+
> NOTE: The engine does not support subtitles in the live-mix + demux setting.
|
|
13
|
+
|
|
14
|
+
The table backs this up: the `Mix w. Live & Subtitles` column is `No` for both
|
|
15
|
+
the `HLS + TS` and `HLS + CMAF` rows.
|
|
16
|
+
|
|
17
|
+
This is grounded in the current code:
|
|
18
|
+
|
|
19
|
+
- The VOD-only path (`engine/session.ts`) **does** implement subtitles — it has
|
|
20
|
+
a working `getCurrentSubtitleManifestAsync(...)` and the
|
|
21
|
+
`useVTTSubtitles` / `dummySubtitleEndpoint` / `subtitleSliceEndpoint`
|
|
22
|
+
configuration wiring.
|
|
23
|
+
- The live path (`engine/session_live.ts`) does **not**: its
|
|
24
|
+
`getCurrentSubtitleManifestAsync(...)` is a stub that logs
|
|
25
|
+
`"getCurrentSubtitleManifestAsync is NOT Implemented"` and returns nothing.
|
|
26
|
+
A code comment in that file explicitly notes subtitle support for live-mix is
|
|
27
|
+
future work.
|
|
28
|
+
- `engine/stream_switcher.ts` has no subtitle handling at all; it reconciles
|
|
29
|
+
video and (when demuxed) audio across a transition, but nothing for a
|
|
30
|
+
subtitle rendition.
|
|
31
|
+
|
|
32
|
+
So today, once a channel enters the live-mix path, any subtitle rendition the
|
|
33
|
+
VOD side was serving has no live-side counterpart and no switcher continuity —
|
|
34
|
+
the gap the README documents.
|
|
35
|
+
|
|
36
|
+
## (a) What "supported" means — the explicit target
|
|
37
|
+
|
|
38
|
+
Two candidate definitions:
|
|
39
|
+
|
|
40
|
+
1. **Continuity**: the subtitle track keeps playing across the mix transition —
|
|
41
|
+
the output subtitle rendition stays present and valid through the VOD→live
|
|
42
|
+
(and live→VOD) boundary so a player selecting subtitles never loses them.
|
|
43
|
+
2. **No-crash-only**: subtitles may drop during the live portion, but audio and
|
|
44
|
+
video must not break and the manifest must stay valid.
|
|
45
|
+
|
|
46
|
+
**Chosen target: (1) continuity.**
|
|
47
|
+
|
|
48
|
+
Justification: the compliance table's `Subtitles` column is already `Yes` for
|
|
49
|
+
the non-live cases, so users who reach live-mix reasonably expect subtitles to
|
|
50
|
+
keep working, not to silently disappear. A player that is told (via
|
|
51
|
+
`EXT-X-MEDIA TYPE=SUBTITLES`) that a subtitle group exists and then finds it
|
|
52
|
+
empty/absent during the live window is a worse, more confusing experience than
|
|
53
|
+
a clean drop — and "no-crash-only" is largely already true in practice (the
|
|
54
|
+
current failure mode is a missing/blank subtitle manifest, not a video crash),
|
|
55
|
+
so it would be a near-no-op target. Committing to continuity is the change that
|
|
56
|
+
actually closes the documented gap.
|
|
57
|
+
|
|
58
|
+
Continuity is defined concretely as: across the mix boundary the output master
|
|
59
|
+
playlist continues to advertise the same `EXT-X-MEDIA TYPE=SUBTITLES` group,
|
|
60
|
+
and the subtitle media playlist for that group continues to return valid,
|
|
61
|
+
monotonically advancing segments (real cues where the source has them, and
|
|
62
|
+
timed empty/dummy WebVTT segments where the live source has no cues) with no
|
|
63
|
+
gap that a compliant player rejects.
|
|
64
|
+
|
|
65
|
+
## (b) Which compliance-table cells this closes, and format scope
|
|
66
|
+
|
|
67
|
+
The two currently-`No` cells are `Mix w. Live & Subtitles` for `HLS + TS` and
|
|
68
|
+
for `HLS + CMAF`.
|
|
69
|
+
|
|
70
|
+
**First-pass scope: `HLS + CMAF` only.** Recommendation is to close the
|
|
71
|
+
`HLS + CMAF` cell first and leave `HLS + TS` for a follow-up.
|
|
72
|
+
|
|
73
|
+
Rationale: the demux + subtitle plumbing on the VOD side (and the existing
|
|
74
|
+
livemix-demux support noted in the README) is exercised most with CMAF/fMP4
|
|
75
|
+
content, and segment-aligned CMAF makes the timed-empty-segment continuity
|
|
76
|
+
approach simpler to reason about at the boundary. `HLS + TS` can follow once the
|
|
77
|
+
CMAF path is proven, reusing the same switcher continuity logic. The
|
|
78
|
+
acceptance criteria below are written so the `HLS + TS` cell can be closed later
|
|
79
|
+
by re-running the same fixture shape against TS content without a redesign.
|
|
80
|
+
|
|
81
|
+
So: this note's implementation issue closes the **`HLS + CMAF`** `Mix w. Live &
|
|
82
|
+
Subtitles` cell; the `HLS + TS` cell is explicitly deferred.
|
|
83
|
+
|
|
84
|
+
## (c) Concrete test fixture
|
|
85
|
+
|
|
86
|
+
A live-mix session with an active subtitle track transitioning across a VOD
|
|
87
|
+
boundary:
|
|
88
|
+
|
|
89
|
+
- A `useDemuxedAudio: true`, `useVTTSubtitles: true` channel.
|
|
90
|
+
- Schedule: a **VOD source that carries a WebVTT subtitle rendition** →
|
|
91
|
+
transition into a **live source** (the live-mix path) → transition back to a
|
|
92
|
+
VOD source with subtitles.
|
|
93
|
+
- The live source is the `Mix w. Live` case, so it drives the
|
|
94
|
+
`engine/session_live.ts` + `engine/stream_switcher.ts` paths rather than the
|
|
95
|
+
VOD-only `engine/session.ts` path.
|
|
96
|
+
- Assertion seam: render the subtitle media playlist (the output of the
|
|
97
|
+
subtitle-manifest path) at media sequences spanning the VOD→live→VOD
|
|
98
|
+
boundaries and assert the subtitle group remains advertised in the master and
|
|
99
|
+
the subtitle playlist stays valid and continuous across each boundary — using
|
|
100
|
+
the same offline render-and-assert approach as the existing
|
|
101
|
+
`spec/engine/subtitle_spec.ts` / `spec/engine/session_spec.ts`, so it runs in
|
|
102
|
+
CI without a live encoder.
|
|
103
|
+
|
|
104
|
+
The fixture should reuse existing subtitle test vectors where possible (e.g. the
|
|
105
|
+
`hls_subs*` / `hls_subs7_demux` vectors in `@eyevinn/hls-vodtolive/testvectors`)
|
|
106
|
+
plus the existing local `spec/testvectors/subtitle_file*.webvtt` files, paired
|
|
107
|
+
with a live playlist fixture for the mix segment.
|
|
108
|
+
|
|
109
|
+
## Acceptance criteria
|
|
110
|
+
|
|
111
|
+
A follow-up `feat:` implementation issue can be validated against:
|
|
112
|
+
|
|
113
|
+
- [ ] "Supported" is resolved to a single explicit target — **subtitle
|
|
114
|
+
continuity across the mix transition** (not merely no-crash) — as defined
|
|
115
|
+
in section (a).
|
|
116
|
+
- [ ] Format scope is stated: **`HLS + CMAF` in the first pass; `HLS + TS`
|
|
117
|
+
deferred to a follow-up.**
|
|
118
|
+
- [ ] A named test fixture exists: a live-mix session (`useDemuxedAudio: true`,
|
|
119
|
+
`useVTTSubtitles: true`) with an active WebVTT subtitle track
|
|
120
|
+
transitioning across a VOD→live→VOD boundary, asserted offline in CI.
|
|
121
|
+
- [ ] `engine/session_live.ts` `getCurrentSubtitleManifestAsync(...)` returns a
|
|
122
|
+
valid subtitle manifest for the live window instead of the current
|
|
123
|
+
"NOT Implemented" stub.
|
|
124
|
+
- [ ] `engine/stream_switcher.ts` reconciles the subtitle rendition across a
|
|
125
|
+
transition the same way it already reconciles the demuxed audio
|
|
126
|
+
rendition, so the subtitle group is never dropped from the output master
|
|
127
|
+
mid-channel.
|
|
128
|
+
- [ ] Across the boundary the output subtitle playlist advances monotonically
|
|
129
|
+
with no gap a compliant player rejects (real cues where present, timed
|
|
130
|
+
empty WebVTT segments where the live source has none).
|
|
131
|
+
- [ ] The README compliance table's `Mix w. Live & Subtitles` cell for
|
|
132
|
+
`HLS + CMAF` is changed from `No` to `Yes`, and the note under the table
|
|
133
|
+
is updated to reflect the remaining `HLS + TS` gap.
|
|
134
|
+
- [ ] Existing subtitle and live-mix specs still pass (no regression to the
|
|
135
|
+
VOD-only subtitle path or to livemix-demux without subtitles).
|
package/docs/reference.md
CHANGED
|
@@ -28,3 +28,70 @@ uri | HLSURI | string | URI to HLS master manifest for the VOD
|
|
|
28
28
|
title (optional) | TITLE | string | The title of the asset
|
|
29
29
|
playlistPosition (optional) | POSITION | id | Current position of the VOD in the playlist
|
|
30
30
|
|
|
31
|
+
## Ad-break configuration (HLS interstitials)
|
|
32
|
+
|
|
33
|
+
The engine can annotate a channel's manifest with SGAI HLS-interstitial
|
|
34
|
+
[EXT-X-DATERANGE](https://datatracker.ietf.org/doc/html/rfc8216) tags
|
|
35
|
+
(`CLASS="com.apple.hls.interstitial"`) so that an HLS player performs
|
|
36
|
+
client-side ad replacement at the VOD boundary. Ad breaks are configured
|
|
37
|
+
through the `adBreak` object, which can be set either as an engine-level
|
|
38
|
+
default (on `ChannelEngineOpts`) or per channel (on a `Channel`). A
|
|
39
|
+
per-channel `adBreak` overrides the engine-level default. Ad breaks default
|
|
40
|
+
to **disabled** when neither is set.
|
|
41
|
+
|
|
42
|
+
### `adBreak` fields
|
|
43
|
+
|
|
44
|
+
Field | Type | Default | Description
|
|
45
|
+
----- | ---- | ------- | -----------
|
|
46
|
+
enabled | boolean | `false` | Master enable flag. When `false`, no interstitial metadata is emitted and the served manifest is byte-identical to the non-ad-break output.
|
|
47
|
+
adServerUri | string | (none) | URL of the ad-serving endpoint queried to fill a break. **Required** when `enabled` is `true`, and validated to be an absolute `http`/`https` URL — an enabled break with a missing or invalid `adServerUri` throws at engine construction.
|
|
48
|
+
slate (optional) | object | (none) | Slate reference shown while the break is being resolved and used as the fallback asset if the ad-serving endpoint cannot be resolved. Omit to fall back to the channel/engine slate configuration.
|
|
49
|
+
|
|
50
|
+
### `adBreak.slate` fields
|
|
51
|
+
|
|
52
|
+
Field | Type | Default | Description
|
|
53
|
+
----- | ---- | ------- | -----------
|
|
54
|
+
uri | string | (none) | URI to the slate HLS VOD.
|
|
55
|
+
repetitions (optional) | number | `10` | Number of times the slate VOD is repeated to fill the break window.
|
|
56
|
+
duration (optional) | number | `4000` | Duration of a single slate repetition, in milliseconds. Together with `repetitions` this drives the interstitial `PLANNED-DURATION`.
|
|
57
|
+
|
|
58
|
+
### Ad replacement flow
|
|
59
|
+
|
|
60
|
+
When a break is opened on an enabled channel:
|
|
61
|
+
|
|
62
|
+
1. The engine queries the configured `adServerUri` for an ad asset (with an
|
|
63
|
+
`Accept: application/json` request and a 2-second timeout).
|
|
64
|
+
2. The endpoint answers with a JSON body in one of these shapes:
|
|
65
|
+
|
|
66
|
+
Response body | Interstitial attribute
|
|
67
|
+
------------- | ----------------------
|
|
68
|
+
`{ "assetUri": "<uri>" }` | `X-ASSET-URI` (single asset)
|
|
69
|
+
`{ "assetList": "<uri>" }` | `X-ASSET-LIST` (an asset-list endpoint)
|
|
70
|
+
`{ "assets": ["<uri>", ...] }` | `X-ASSET-URI` from the first entry
|
|
71
|
+
|
|
72
|
+
3. The resolved asset is written into an EXT-X-DATERANGE HLS interstitial on
|
|
73
|
+
the served manifest, and the HLS player fetches and plays the ad in place
|
|
74
|
+
of (client-side ad replacement), leaving the underlying VOD2Live stream
|
|
75
|
+
untouched.
|
|
76
|
+
4. If the endpoint times out, returns a non-2xx status, or returns a
|
|
77
|
+
malformed/empty body, the engine logs and falls back to the configured
|
|
78
|
+
slate (or the channel/engine slate) so the break still renders — the
|
|
79
|
+
session tick is never interrupted.
|
|
80
|
+
|
|
81
|
+
See `examples/adbreak.ts` for a runnable channel wired with an ad break.
|
|
82
|
+
|
|
83
|
+
## Custom VOD request parameters
|
|
84
|
+
|
|
85
|
+
The engine can be configured with an allowlist of query-parameter names that are
|
|
86
|
+
permitted to be forwarded to the asset manager's `getNextVod()` request. This is
|
|
87
|
+
set through the `customVodRequestParams` option on `ChannelEngineOpts`. Only
|
|
88
|
+
parameters whose name appears in the allowlist may be passed through; every other
|
|
89
|
+
query parameter is ignored. When the option is omitted the allowlist is empty, so
|
|
90
|
+
no parameters are forwarded (the default, current behaviour).
|
|
91
|
+
|
|
92
|
+
### `ChannelEngineOpts` fields
|
|
93
|
+
|
|
94
|
+
Field | Type | Default | Description
|
|
95
|
+
----- | ---- | ------- | -----------
|
|
96
|
+
customVodRequestParams (optional) | string[] | `[]` (empty — no pass-through) | Allowlist of query-parameter names permitted to be forwarded to `getNextVod()`. Config surface only for now; the forwarding itself is wired up in follow-up work.
|
|
97
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const debug = require('debug')('chaos-monkey');
|
|
2
|
+
|
|
3
|
+
const CHAOS_MONKEY_PROBABILITY: string | number = process.env.CHAOS_MONKEY_PROBABILITY || 0.5;
|
|
4
|
+
const doChaos = (): boolean => process.env.CHAOS_MONKEY === "true" && Math.random() < (CHAOS_MONKEY_PROBABILITY as number);
|
|
5
|
+
|
|
6
|
+
const loadVod = async <T>(p: Promise<T>): Promise<T> => {
|
|
7
|
+
if (doChaos()) {
|
|
8
|
+
debug("Chaos Monkey is doing stuff");
|
|
9
|
+
throw new Error("Chaos monkey prevented you from loading a VOD");
|
|
10
|
+
} else {
|
|
11
|
+
return await p;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
loadVod
|
|
17
|
+
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
class EventStream {
|
|
2
|
-
|
|
2
|
+
private _session: any;
|
|
3
|
+
|
|
4
|
+
constructor(session: any) {
|
|
3
5
|
this._session = session;
|
|
4
6
|
}
|
|
5
7
|
|
|
6
|
-
poll() {
|
|
8
|
+
poll(): Promise<string> {
|
|
7
9
|
return new Promise((resolve, reject) => {
|
|
8
|
-
let event = {};
|
|
10
|
+
let event: any = {};
|
|
9
11
|
event = this._session.consumeEvent();
|
|
10
12
|
if (event) {
|
|
11
13
|
resolve(JSON.stringify(event));
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
const MemcacheClient = require("memcache-client");
|
|
2
2
|
const debug = require("debug")("memcached-state-store");
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import type { IStateStore } from "./state_store_types";
|
|
5
|
+
|
|
6
|
+
// Type-only migration (#373): a faithful port of the original JS.
|
|
7
|
+
|
|
8
|
+
class MemcachedStateStore implements IStateStore {
|
|
9
|
+
keyPrefix: string;
|
|
10
|
+
client: any;
|
|
11
|
+
|
|
12
|
+
constructor(keyPrefix: string, opts: any) {
|
|
6
13
|
this.keyPrefix = keyPrefix;
|
|
7
14
|
if (opts.version) {
|
|
8
15
|
const prependPrefix = opts.version.replace(/\./g, "X");
|
|
@@ -12,9 +19,9 @@ class MemcachedStateStore {
|
|
|
12
19
|
this.client = new MemcacheClient({ server: opts.memcachedUrl, cmdTimeout: 10000 });
|
|
13
20
|
}
|
|
14
21
|
|
|
15
|
-
async initAsync(id, initData) {
|
|
22
|
+
async initAsync(id: string, initData: Record<string, any>): Promise<Record<string, any>> {
|
|
16
23
|
const isInitiated = await this.getAsync(id, "_initiated");
|
|
17
|
-
let data = {};
|
|
24
|
+
let data: Record<string, any> = {};
|
|
18
25
|
if (!isInitiated) {
|
|
19
26
|
for(const key of Object.keys(initData)) {
|
|
20
27
|
debug(`${this.keyPrefix}:${id}: Initiating key ${key} with init data`);
|
|
@@ -31,16 +38,16 @@ class MemcachedStateStore {
|
|
|
31
38
|
return data;
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
async resetAsync(id, initData) {
|
|
41
|
+
async resetAsync(id: string, initData: Record<string, any>): Promise<void> {
|
|
35
42
|
await this.setAsync(id, "_initiated", false);
|
|
36
43
|
await this.initAsync(id, initData);
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
async resetAllAsync() {
|
|
46
|
+
async resetAllAsync(): Promise<void> {
|
|
40
47
|
console.error("Shared Storage Reset Failed.\nMemcache-client: Flush All Command Not Implemented Yet");
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
async getAsync(id, key) {
|
|
50
|
+
async getAsync(id: string, key: string): Promise<any> {
|
|
44
51
|
const storeKey = "" + this.keyPrefix + id + key;
|
|
45
52
|
const data = await this.client.get(storeKey);
|
|
46
53
|
if (data) {
|
|
@@ -49,19 +56,19 @@ class MemcachedStateStore {
|
|
|
49
56
|
return null;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
async setAsync(id, key, value) {
|
|
59
|
+
async setAsync(id: string, key: string, value: any): Promise<any> {
|
|
53
60
|
const storeKey = "" + this.keyPrefix + id + key;
|
|
54
61
|
await this.client.set(storeKey, JSON.stringify(value));
|
|
55
62
|
return value;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
|
-
async setVolatileAsync(id, key, value) {
|
|
65
|
+
async setVolatileAsync(id: string, key: string, value: any): Promise<any> {
|
|
59
66
|
throw new Error("memcached: setVolatileAsync not implemented yet");
|
|
60
67
|
}
|
|
61
68
|
|
|
62
|
-
async removeAsync(id, key) {
|
|
69
|
+
async removeAsync(id: string, key: string): Promise<any> {
|
|
63
70
|
throw new Error("memcached: removeAsync not implemented yet");
|
|
64
|
-
}
|
|
71
|
+
}
|
|
65
72
|
}
|
|
66
73
|
|
|
67
|
-
module.exports = MemcachedStateStore;
|
|
74
|
+
module.exports = MemcachedStateStore;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const debug = require("debug")("memory-state-store");
|
|
2
|
+
|
|
3
|
+
import type { IStateStore, SharedStates } from "./state_store_types";
|
|
4
|
+
|
|
5
|
+
// Type-only migration (#373): a faithful port of the original JS. Runtime
|
|
6
|
+
// behavior and the exported constructor signature are unchanged.
|
|
7
|
+
|
|
8
|
+
class MemoryStateStore implements IStateStore {
|
|
9
|
+
sharedStates: SharedStates;
|
|
10
|
+
globalSharedStates: { [key: string]: any };
|
|
11
|
+
|
|
12
|
+
constructor(type?: string, opts?: any) {
|
|
13
|
+
this.sharedStates = {};
|
|
14
|
+
this.globalSharedStates = {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async initAsync(id: string, initData: Record<string, any>): Promise<any> {
|
|
18
|
+
if (!this.sharedStates[id]) {
|
|
19
|
+
this.sharedStates[id] = {};
|
|
20
|
+
Object.keys(initData).forEach((key) => {
|
|
21
|
+
(this.sharedStates[id] as { [key: string]: any })[key] = initData[key];
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
return this.sharedStates[id];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async resetAsync(id: string, initData: Record<string, any>): Promise<void> {
|
|
28
|
+
this.sharedStates[id] = null;
|
|
29
|
+
await this.initAsync(id, initData);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async resetAllAsync(): Promise<void> {
|
|
33
|
+
this.sharedStates = {};
|
|
34
|
+
this.globalSharedStates = {};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async getAsync(id: string, key: string): Promise<any> {
|
|
38
|
+
let value;
|
|
39
|
+
if (id === "" || id === null) {
|
|
40
|
+
value = this.globalSharedStates[key];
|
|
41
|
+
} else {
|
|
42
|
+
if (!this.sharedStates[id]) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
value = (this.sharedStates[id] as { [key: string]: any })[key];
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async setAsync(id: string, key: string, value: any): Promise<any> {
|
|
51
|
+
if (id === "" || id === null) {
|
|
52
|
+
this.globalSharedStates[key] = value;
|
|
53
|
+
} else {
|
|
54
|
+
if (!this.sharedStates[id]) {
|
|
55
|
+
this.sharedStates[id] = {};
|
|
56
|
+
}
|
|
57
|
+
(this.sharedStates[id] as { [key: string]: any })[key] = value;
|
|
58
|
+
return (this.sharedStates[id] as { [key: string]: any })[key];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async setVolatileAsync(id: string, key: string, value: any): Promise<any> {
|
|
63
|
+
return await this.setAsync(id, key, value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async removeAsync(id: string, key: string): Promise<void> {
|
|
67
|
+
delete (this.sharedStates[id] as { [key: string]: any })[key];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = MemoryStateStore;
|