---
status: implemented
date: 2026-05-20
definition: sketched
---

# MSE / MMS pipeline

The lifecycle that makes a `MediaSource` a valid driver of the
`HTMLMediaElement`: create + attach the MediaSource, set up per-type
SourceBuffers wrapped in actors, propagate presentation duration, and
coordinate `endOfStream()` once playback reaches the appended tail. Spans
both standard `MediaSource` and Safari's `ManagedMediaSource`.

This doc captures the **capability surface**: what works, what doesn't,
which behaviors / actors / helpers implement it, and how it relates to
other features. Segment loading orchestration and buffer flushing are
sibling features — this one ends at the lifecycle boundary, not at "data
appears in the buffer."

## Status

- **Composition:** `createSimpleHlsEngine` (HLS VoD)
- **Definition depth:** sketched — capability surface and implementation
  footprint documented; `MediaSourceActor` factoring is an open
  follow-up tracked under sibling candidates. Audio SourceBuffer flush
  orchestration is part of [multi-language-audio](./multi-language-audio.md)'s
  Tier 2 mid-stream-switching phase, not a separately-scoped feature

## Phases of complexity

What's implemented today, organized as platform / capability slices. Each
row is a slice that could in principle stand alone; in practice they
share the same four behaviors and one actor.

| Phase | What | Notes |
|---|---|---|
| MediaSource attach / detach lifecycle | Create `MediaSource`, attach to element, await `'open'`, publish on `context.mediaSource`; detach + clear on source reset | `setupMediaSource` rides `resolvePresentation`'s resolved/unresolved transitions for source resets — direct URL replacement is structural, not a special case |
| Per-type SourceBuffer + actor setup | One `SourceBuffer` per type (CMAF A+V), each wrapped in `SourceBufferActor` + `SegmentLoaderActor`; gates only on that type's selection + codecs | `setupVideoBufferActors` / `setupAudioBufferActors` share `setupBufferActors`. The behaviors are decoupled — no cross-type coupling in `stateKeys` |
| Firefox `mozHasAudio` cross-type invariant | Both `addSourceBuffer` calls land in the same `runPending` iteration so the video buffer exists by the time any append begins | Preserves Firefox's permanent-`mozHasAudio=false` guard. Carried by SPF effect coalescing + composition order (video registered before audio in the engine). Sandbox repro at `apps/sandbox/src/firefox-mse-repro/` |
| ManagedMediaSource (Safari) | `preferManaged: true` → MMS via `srcObject` + `disableRemotePlayback`; standard MSE via `createObjectURL` otherwise | Same lifecycle shape, different attach surface. `preferManaged` is hardcoded `true` today (no config knob) |
| Initial `mediaSource.duration` write | Write `presentation.duration` to `mediaSource.duration` exactly once per MediaSource, gated on MS open + sourceBuffers idle + clamp ≥ `getMaxBufferedEnd` | `updateMediaSourceDuration`; idempotent — leaves any non-NaN value alone. `Infinity` supported for live |
| End-of-stream coordination | Call `mediaSource.endOfStream()` once every active actor's currently-loading track has its last segment appended + the playhead reaches that segment; re-arm on each `open → ended → open` cycle | `endOfStream`; sets final duration from `getMaxBufferedEnd` first to keep the value deterministic against CMAF timestamp drift |

## What's not implemented

- **Mid-stream same-codec buffer flush orchestration** — `SourceBufferActor`
  accepts `remove` messages and `flushBuffer` exists in `media/dom/mse/`,
  but no SPF behavior drives flushing on language switch or other
  mid-stream cleanup. Belongs to [multi-language-audio](./multi-language-audio.md)'s
  Tier 2 mid-stream-switching phase, which orchestrates flush on top
  of this feature's `remove`-message + `flushBuffer` primitives.
- **`changeType()` codec-change switching** — cross-tick mid-stream track
  switch after appends begin is "out of scope for this behavior" per
  `setup-buffer-actors.ts`. Routes to `[5.1-surround-selection]` and
  `[hevc-variant-selection]` (where the codec change motivates the
  buffer-recreation or `changeType` path).
- **`MediaSourceActor` abstraction** — `endOfStream` and
  `updateMediaSourceDuration` both subscribe to readyState via
  `onMediaSourceReadyStateChange` + a behavior-local signal; both wait
  for buffers idle. A `MediaSourceActor` whose snapshot exposes
  `readyState` + accepts `duration-write` / `end-of-stream` /
  `add-source-buffer` messages would coalesce these call sites. Hinted at
  in both behaviors' JSDoc.
- **`preferManaged` opt-out** — hardcoded `true`. No engine config to
  force standard MSE on Safari (for testing parity or debugging).
- **Continuous live-duration sync** — `Infinity` is supported on the
  initial write, but `updateMediaSourceDuration`'s "exactly once"
  contract doesn't re-sync if `presentation.duration` drifts mid-source.
  Likely fine for live (one continuous `Infinity`), but
  [live-stream-support](./live-stream-support.md) may surface
  counterexamples.
- **`setLiveSeekableRange` / `clearLiveSeekableRange`** — neither is
  called today. Live streams under `duration === Infinity` have an
  empty `HTMLMediaElement.seekable` without the explicit setter.
  Belongs to [live-stream-support](./live-stream-support.md)'s Live
  edge tracking + Terminated state transition phases; lives at this
  feature's MSE boundary.

## Implementation surface

**Composition:** `packages/spf/src/playback/engines/hls/engine.ts` — MSE
behaviors composed between presentation duration calculation and segment
loading. Video buffer setup registered before audio for the Firefox
invariant.

**Behaviors:**

| Behavior | File | Responsibility |
|---|---|---|
| `setupMediaSource` | `packages/spf/src/playback/behaviors/dom/setup-mediasource.ts` | Create + attach MediaSource, await `'open'`, publish; detach + clear on source reset |
| `setupVideoBufferActors` | `packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts` | Per-type video buffer + actor setup; sole writer of `bandwidthState` via `createTrackedFetch` |
| `setupAudioBufferActors` | `packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts` | Per-type audio buffer + actor setup; uses plain `fetchStream` (no bandwidth sampling today) |
| `updateMediaSourceDuration` | `packages/spf/src/playback/behaviors/dom/update-mediasource-duration.ts` | Write `mediaSource.duration = presentation.duration` once per MS, gated on MS open + buffers idle + spec clamp |
| `endOfStream` | `packages/spf/src/playback/behaviors/dom/end-of-stream.ts` | Drive each `open → ended` transition once last segments + playhead align; re-arm on cycles |

**Actor:**

| Actor | File | Role |
|---|---|---|
| `SourceBufferActor` | `packages/spf/src/playback/actors/dom/source-buffer.ts` | Serializes `append-init` / `append-segment` / `remove` / `batch` / `cancel` via `SerialRunner` on a single `SourceBuffer`; snapshot exposes `'idle'` / `'updating'` for downstream gating (canonical idle gate for `endOfStream`) |

**DOM-bound helpers:** `packages/spf/src/media/dom/mse/`

| Module | Role |
|---|---|
| `mediasource-setup.ts` | `createMediaSource({ preferManaged })`, `attachMediaSource` (MMS `srcObject` + `disableRemotePlayback` / MSE `createObjectURL` branch), `createSourceBuffer`, `buildMimeCodec`, `isCodecSupported`, `onMediaSourceReadyStateChange`, `waitForMediaSourceOpen`, `supportsMediaSource`, `supportsManagedMediaSource` |
| `append-segment.ts` | `appendSegment` — ArrayBuffer + streaming append primitive (used by `SourceBufferActor`'s append tasks) |
| `buffer-flusher.ts` | `flushBuffer` — range removal primitive (used by `SourceBufferActor`'s `remove` task; [multi-language-audio](./multi-language-audio.md)'s Tier 2 mid-stream-switching phase consumes via the actor message, not directly) |
| `duration.ts` | `shouldUpdateDuration`, `waitForSourceBuffersReady`, `getMaxBufferedEnd` (spec-clamp helper) |
| `end-of-stream.ts` | `isLastSegmentAppended` predicate |
| `mediasource.d.ts` | `ManagedMediaSource` global type augmentation (Safari-only API, not in standard DOM lib) |

**State slots — reads only.** Every MSE behavior has read-only state
signatures. Engine state flows in; DOM mutations flow out.

- Reads: `presentation` (all four behaviors), `currentTime` (`endOfStream`),
  `selectedVideoTrackId` (`setupVideoBufferActors`), `selectedAudioTrackId`
  (`setupAudioBufferActors`)

**Context slots:**

- `context.mediaSource` — sole writer `setupMediaSource`. Readers:
  `setupVideoBufferActors`, `setupAudioBufferActors`,
  `updateMediaSourceDuration`, `endOfStream`, `loadVideoSegments`,
  `loadAudioSegments`.
- `context.videoBufferActor` + `context.videoSegmentLoaderActor` — sole
  writer `setupVideoBufferActors`. Readers: `loadVideoSegments`,
  `endOfStream`.
- `context.audioBufferActor` + `context.audioSegmentLoaderActor` — sole
  writer `setupAudioBufferActors`. Readers: `loadAudioSegments`,
  `endOfStream`.

**DOM-property multi-writer.** `mediaSource.duration` is written by both
`updateMediaSourceDuration` (initial, once-per-MS while NaN) and
`endOfStream` (final, from `getMaxBufferedEnd` before EOS). Decision
domains are non-overlapping — `updateMediaSourceDuration`'s idempotency
on non-NaN keeps it out of the EOS path. Not a state-signal multi-writer;
the convention from `conventions/signals.md` doesn't apply directly.

## Config surface

This feature has essentially no engine-level config surface today —
behaviors read `presentation` for codecs + duration and operate on
context-published resources, with no tuning knobs of their own.
`preferManaged: true` is hardcoded in `setupMediaSource`. Related
engine config (`forwardBuffer`, `backBuffer`, `bandwidth`, `quality`)
flows through `setupVideoBufferActors`'s SegmentLoaderActor construction
but belongs to `buffer-management` (`forwardBuffer` / `backBuffer`) and
`video-abr` (`bandwidth` / `quality`) respectively.

## Verification

- **Unit tests:**
  - `packages/spf/src/playback/behaviors/dom/tests/setup-mediasource.test.ts`
  - `packages/spf/src/playback/behaviors/dom/tests/setup-buffer-actors.test.ts`
  - `packages/spf/src/playback/behaviors/dom/tests/update-mediasource-duration.test.ts`
  - `packages/spf/src/playback/behaviors/dom/tests/end-of-stream.test.ts`
  - `packages/spf/src/playback/actors/dom/tests/source-buffer.test.ts`
  - `packages/spf/src/media/dom/mse/tests/*` — helper-level coverage
    (MS/MMS detection, attach branch, duration helpers, EOS predicate)
- **Sandbox:**
  - `apps/sandbox/src/spf-segment-loading/` — main SPF MSE pipeline
    demo; exercises full lifecycle end-to-end
  - `apps/sandbox/src/firefox-mse-repro/` — Firefox `mozHasAudio`
    invariant repro; load-bearing for verifying composition order +
    `runPending` semantics survive future refactors
  - `apps/sandbox/src/simple-hls-html/` / `simple-hls-react/` — engine
    integration through the adapter layer

## Open questions

- **`MediaSourceActor`?** Both `endOfStream` and
  `updateMediaSourceDuration` carry behavior-local `msIsOpen` mirrors and
  buffers-idle waits. An actor that owns the MediaSource (snapshot for
  `readyState`; messages for `duration-write` / `end-of-stream` /
  `add-source-buffer`) would coalesce three call sites and prepare for a
  future loop-mode (auto-fetch earlier segments mid-`ended`) where MS and
  SourceBuffer coordination grows. Hinted at in both behaviors' JSDoc.
- **`preferManaged` as config.** Should there be an engine-level opt-out
  for testing standard MSE on Safari, or remain hardcoded?
- **Two-fire `endOfStream()` on mid-end ABR switches.** Accepted today
  as the price of dropping `selectedTrackId` dependence in
  `endOfStream`. Worth flagging if it ever surfaces downstream issues
  (e.g., spurious `ended` events on the element between the two fires).

## Related features

- **preload-modes** — gates this feature indirectly. `setupMediaSource`
  rides `resolvePresentation`'s resolved/unresolved transitions, which
  only flip to resolved once the preload gate (`preload !== 'none'` or
  `loadActivated`) is open.
- **capability-probing** *(candidate)* — owns the upstream codec
  filtering that would prevent `createSourceBuffer`'s late-failure
  throw from firing in practice. Today's `isCodecSupported` helper is
  the seed primitive; capability-probing wraps it into a uniform
  surface and adds multivariant-level filtering before selection.
- **source-replacement** — the resolved/unresolved lifecycle
  `setupMediaSource` rides is the canonical mechanism for in-place
  source replacement. Detach-on-state-exit is what makes URL changes
  work without recreating the engine.
- **subtitles** — text tracks share the per-type segment-loading FSM
  but do **not** touch MSE (no SourceBuffer for text); cleanly separated
  by `media/dom/mse/` not appearing in the text path.
- **video-abr** — `setupVideoBufferActors` is the sample producer
  (`createTrackedFetch` writes `bandwidthState`); ABR consumes. Sampling
  lives here, selection lives there.
- **multi-language-audio** — its Tier 2 "audio SourceBuffer flush on
  switch" orchestrates flush on top of this feature's `remove`-message
  + `flushBuffer` primitives. The orchestration belongs in
  multi-language-audio (not a separately-scoped buffer-flushing
  feature).
- **buffer-management** — sibling feature for the per-type load-FSM
  and segment planning that runs *on top of* the buffers + actors this
  feature stands up. Sends `append-init` / `append-segment` / `remove`
  / `cancel` messages to the `SourceBufferActor` documented here.
- **5.1-surround-selection** *(not yet documented, candidate)* —
  cross-codec switching via `changeType()`; out of scope for this
  feature's same-codec lifecycle.
- **hevc-variant-selection** *(not yet documented, candidate)* —
  same pattern as 5.1 but for video codec swap.
- **live-stream-support** — `Infinity` duration + the EOS picture
  differ; the "exactly once" duration contract is the spot to revisit.
  Also the home for the `setLiveSeekableRange` / `clearLiveSeekableRange`
  DOM-exposure surface (see *What's not implemented* above).
- **drm-support** *(not yet documented, candidate, issue #1411)* —
  key-system readiness would gate MSE setup + append per `clusters.md`.

## Use cases that compose this feature

- **[`audio-only-mode-override`](../use-cases/audio-only-mode-override.md)**
  *(partial — Phase 1 landed)* — Phase 1 baseline constituent. Used
  as-is — `MediaSource` + `endOfStream` gate compose unchanged across
  variants per the uniform-across-tracks discipline (`endOfStream`
  reads `mediaSource.sourceBuffers` aggregately). Verified end-to-end
  in `engine-audio-only.test.ts` against both audio-only and mixed-AV
  manifests.
- **[`video-only-mode-override`](../use-cases/video-only-mode-override.md)**
  *(coarse)* — Phase 1 baseline constituent. Used as-is; the
  Firefox `mozHasAudio` cross-type invariant documented here is
  more pointedly relevant — the variant must produce
  `mozHasAudio=false` cleanly under subtractive-audio composition.
  Phase 1 includes empirical verification.

## See also

- [presentation-modeling.md](../presentation-modeling.md) — architectural
  deep-dive on the format-neutral data shape and per-track resolution
  layer; setup behaviors here gate on `isResolvedPresentation` from
  that layer
- [text-track-architecture.md](../text-track-architecture.md) — peer
  architectural deep-dive (different domain, same SPF shape)
- [packages/spf/docs/hls-engine.md](../../../../packages/spf/docs/hls-engine.md)
  — full engine composition walkthrough (Stage 5: MSE setup; Stage 8:
  end-of-stream)
- [conventions/behaviors.md](../conventions/behaviors.md) — per-type
  specialization details
- [conventions/signals.md](../conventions/signals.md) — multi-writer
  slot conventions (relevant for `mediaSource.duration` as a non-signal
  multi-writer footnote)
