Files
v10/internal/design/spf/features/buffer-management.md
T

298 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: implemented
date: 2026-05-20
definition: sketched
---
# Buffer management
The engine's segment-level buffer policy: deciding which segments to
load, which to evict, how to handle the buffered ranges during seeks
and quality switches. Covers the per-type segment-loader actors that
plan and execute fetches, the forward-buffer / back-buffer policy
primitives, and the dispatcher behaviors that turn gate state +
currentTime + selected tracks into `load` messages.
Sits between `preload-modes` (produces the gate state this feature
consumes) and `mse-mms-pipeline` (owns the `SourceBufferActor` this
feature's operations land on).
This doc captures the **capability surface**: what works, what doesn't,
which behaviors / actors / primitives implement it, and how
seek + playback-rate behavior emerges from the planner's design.
## Status
- **Composition:** `createSimpleHlsEngine` (HLS VoD)
- **Definition depth:** sketched — capability surface and implementation
footprint documented; richer forward-buffer / back-buffer policies and
the codec-change extension (`changeType()`) are tracked as candidates
## Phases of complexity
What's implemented today, organized as capability slices around the
engine's buffer policy. Phases are non-orthogonal in places — seek
handling depends on forward-flush; quality-aware planning depends on
the in-flight tracking — but each row is a slice that earns its place.
| Phase | What | Notes |
|---|---|---|
| Forward-buffer planning | Engine maintains ~30 seconds ahead of the playhead via `getSegmentsToLoad`. Re-plans on segment-boundary crossings (debounced via `segmentStartForTime` — within-segment `currentTime` ticks don't trigger re-planning). Configurable via `forwardBuffer.bufferDuration` | Default 30 s; threaded through both dispatcher (range calc) and actor (planner) |
| Back-buffer eviction | Engine evicts older buffered ranges, keeping `keepSegments` behind the playhead. Bounded memory regardless of session length | Default `keepSegments: 2`; v/a actor only — text-track loader doesn't evict |
| Quality-aware buffer planning | Skip re-loading a segment if equal-or-higher-quality content is already buffered at the same time slot. Compares `trackBandwidth` on actor-tracked segments | Preserves buffered high-quality content during ABR downgrade; replans on upgrade |
| Seek handling | Non-contiguous buffer planning (gap-filling for the `[currentTime, currentTime + bufferDuration)` window) + forward-flush after seek-back. The forward-flush prevents unbounded scattered buffer that can cause `QuotaExceededError` on long-form content | Works because the planner is currentTime-driven and operates over actor-tracked buffered segments rather than `SourceBuffer.buffered` ranges directly |
| In-flight continue / preempt | New `load` message during `'loading'` — if the in-flight segment / init is still needed by the new plan (same track + same id), continue (`abortPending` + schedule remainder); otherwise preempt (`abortAll` + cancel SourceBuffer if needed + replan) | Driven by `inFlightInitTrackId` + `inFlightSegmentId` in actor context; the SourceBuffer cancel is conditional to avoid clobbering an init append that's still needed |
| Streaming append for media segments | Media segment body stream passed to `SourceBufferActor` as it arrives — chunks appended in flight. Init segments accumulate fully before append (`minChunkSize: Infinity`) | Affects time-to-first-frame for the first segment; init must land complete before media-segment chunks can be parsed |
| Per-type segment loading | Video / audio / text dispatcher variants share `setupSegmentLoading` helper + (`preload`, `loadActivated`) → FSM mapping. V/A share `SegmentLoaderActor`; text uses `TextTrackSegmentLoaderActor` (no MSE buffer, no codec, no append distinction) | Cross-type constraint: all three variants honor the same gate FSM, so preload behavior is uniform across rendition types |
| Playback rate | `playbackRate ≠ 1` works (the engine doesn't break) but the forward buffer is not rate-aware — 30 s of media at 2× rate is 15 s of wall-time runway | Today: basic. Consumers can manually override `forwardBuffer.bufferDuration` for higher rates; engine-side adaptation is open work — see *Smarter forward-buffer sizing* below |
## What's not implemented
Today's policy choices are simple-by-design; richer policies are tracked
here as candidate phases. The list will grow as motivating use cases
surface.
- **Smarter forward-buffer sizing** — fixed `bufferDuration` regardless
of context. Three known motivations for a richer policy:
- **Playback-rate-aware** — at 2× rate, 30 s of media is 15 s of
wall-time runway; at 0.5× rate, 60 s (wasteful).
- **`canplaythrough`-equivalent** — bigger buffer when network
throughput is high enough to "playthrough to end without rebuffer."
Mirrors HTMLMediaElement's `canplaythrough` event semantics.
- **Network-conditions / seek-density-aware** — longer runway on slow
networks; shorter runway during heavy seeking.
Each variant is a candidate phase row when implemented.
- **Smarter back-buffer eviction** — fixed `keepSegments` proactive
policy today. Alternative shapes to track:
- **Quota-learning eviction** — record observed `QuotaExceededError`
byte-thresholds and use them as a per-device ceiling for both
eviction *and* forward-buffer fetching. Reference point:
[mux-background-video's `evictBuffer`](https://github.com/muxinc/mux-background-video/blob/main/src/engines/hls-mini/mediasource.ts#L173-L183)
catches the error and evicts past `currentTime - 5 s`, but doesn't
remember the threshold for next time. A smarter variant would track
total buffered bytes; if the error fires at 300 MB but didn't at
299 MB, future planning keeps the buffer below ~300 MB proactively
— both for back-buffer eviction and to constrain forward-buffer
fetching at long `bufferDuration` / high bitrate.
- **Time-based / size-based** — "keep N seconds" or "keep ≤ M MB"
rather than segment count.
- **Loop-around buffer fetching for `loop=true`** — when the media
element has `loop=true`, playback wraps from near-end back to 0. The
planner today sees the wrap as a seek-back (gap-fill works) but
doesn't *anticipate* it. A loop-aware policy would pre-fetch the
beginning while still playing the end.
- **`changeType()` codec-change buffer transitions** — the planner's
Case 1 (removes) deliberately does *not* fire on track switch
("appending new content overwrites existing buffer ranges, and the
actor's time-aligned deduplication keeps the segment model accurate").
Same-codec switches work; cross-codec switches don't.
- **Buffer health observability** — no surfaced state for "buffered
ahead" / "back buffer used." Consumers compute from
`SourceBuffer.buffered` + `state.currentTime` themselves.
## Implementation surface
**Composition:** `packages/spf/src/playback/engines/hls/engine.ts`
dispatchers composed after MSE setup, after `trackCurrentTime` and
`switchVideoQuality`:
```ts
trackCurrentTime,
switchVideoQuality,
loadVideoSegments,
loadAudioSegments,
// ...
syncTextTracks,
setupTextTrackActors,
loadTextTrackSegments,
```
The per-type segment-loader **actors** are owned by the MSE setup
behaviors (`setupVideoBufferActors` / `setupAudioBufferActors` /
`setupTextTrackActors`) — they're constructed there and published on
context for these dispatchers to read.
**Behaviors:**
| Behavior | File | Responsibility |
|---|---|---|
| `loadVideoSegments` / `loadAudioSegments` / `loadTextTrackSegments` | `packages/spf/src/playback/behaviors/dom/load-segments.ts` | Per-type dispatcher; shares `setupSegmentLoading` helper. 4-state FSM (`preconditions-unmet` / `dormant` / `metadata-only` / `full-range`) consumes `(preload, loadActivated)` from `preload-modes`. Sends `load` messages to the variant's loader actor |
**Actors:**
| Actor | File | Role |
|---|---|---|
| `SegmentLoaderActor` (v/a) | `packages/spf/src/playback/actors/dom/segment-loader.ts` | 3-state (`idle` / `loading` / `destroyed`). Planner produces ordered `LoadTask` list (Case 1 removes / Case 2 init / Case 3 segments). In-flight continue/preempt via `inFlightInitTrackId` + `inFlightSegmentId`. Owns the actual fetch + append-to-SourceBufferActor sequence |
| `TextTrackSegmentLoaderActor` | `packages/spf/src/playback/actors/text-track-segment-loader.ts` | Text-track variant satisfying the same `SegmentLoaderLike<Track>` contract for the dispatcher. Different actor shape (no SourceBuffer; appends to `TextTracksActor` cue cache instead) |
**Policy primitives (DOM-free):** `packages/spf/src/media/buffer/`
| Module | Exports | Role |
|---|---|---|
| `forward-buffer.ts` | `getSegmentsToLoad`, `calculateForwardFlushPoint`, `segmentStartForTime`, `ForwardBufferConfig` | Forward buffer planning + segment-boundary debounce primitive |
| `back-buffer.ts` | `calculateBackBufferFlushPoint`, `BackBufferConfig` | Back buffer eviction policy |
**State slots — reads only.** This feature consumes engine state and
emits actor messages; it doesn't write state.
- Reads: `presentation`, `preload`, `currentTime`, `loadActivated`,
`selectedVideoTrackId` (or audio / text per variant)
**Context slots — reads only.**
- Reads: `videoSegmentLoaderActor` / `audioSegmentLoaderActor` /
`textTrackSegmentLoaderActor` (each variant reads only its own type's
loader-actor slot). The actors themselves are owned and written by
`setupVideoBufferActors` / `setupAudioBufferActors` /
`setupTextTrackActors` (under `mse-mms-pipeline` and `subtitles`).
**Downstream:** `SegmentLoaderActor` sends `append-init` /
`append-segment` / `remove` / `cancel` messages to the
`SourceBufferActor` (`mse-mms-pipeline`). Awaits the actor's `'idle'`
snapshot between operations rather than awaiting `send()` directly.
## Config surface
```ts
{
forwardBuffer?: Partial<ForwardBufferConfig>;
// ForwardBufferConfig: { bufferDuration: number } // default 30 (seconds)
backBuffer?: Partial<BackBufferConfig>;
// BackBufferConfig: { keepSegments: number } // default 2
}
```
- `forwardBuffer.bufferDuration` is threaded into the dispatcher
behaviors (for `range` calculation) and into the v/a + text segment-
loader actors (for planner forward-flush + load planning).
- `backBuffer.keepSegments` is threaded into the v/a `SegmentLoaderActor`
only. The text-track loader doesn't perform back-buffer eviction;
cue caches are bounded per-track-lifetime instead.
## Verification
- **Unit tests (behaviors):**
- `packages/spf/src/playback/behaviors/dom/tests/load-segments.test.ts`
dispatcher FSM transitions, range calculation, segment-boundary
debounce, per-type wiring
- `packages/spf/src/playback/behaviors/dom/tests/load-segments-track-switch.test.ts`
in-flight continue/preempt during track switches (ABR + audio
rendition); covers the v/a `SegmentLoaderActor` through the
dispatcher
- `packages/spf/src/playback/behaviors/dom/tests/track-current-time.test.ts`
`currentTime` mirroring (precondition input)
- **Unit tests (policy primitives):**
- `packages/spf/src/media/buffer/tests/forward-buffer.test.ts`
`getSegmentsToLoad`, `calculateForwardFlushPoint`,
`segmentStartForTime` algorithm coverage
- `packages/spf/src/media/buffer/tests/back-buffer.test.ts`
`calculateBackBufferFlushPoint` algorithm coverage
- **Coverage gap:** no direct `packages/spf/src/playback/actors/dom/tests/segment-loader.test.ts`
for the v/a `SegmentLoaderActor`. The actor's planner, continue/
preempt logic, and SourceBuffer-cancel decisions are exercised through
the behavior-level tests. Direct actor-level tests would isolate
regressions from dispatcher changes.
- **Sandbox:**
- `apps/sandbox/src/spf-segment-loading/` — main SPF demo; exercises
forward-buffer, back-buffer, seek, and ABR-driven quality switches
end-to-end
## Open questions
- **Quality-aware filter granularity.** The filter is "≥ bandwidth" —
it preserves any same-bandwidth or higher-bandwidth content. Should
it also consider codec / rendition identity (e.g., HEVC and H.264 at
the same bandwidth might both be "≥" by bps but represent different
rendition choices)? Today's assumption: bandwidth is the canonical
quality comparator.
- **`LoadTask` / `Task` naming collision.** The actor file's own
`@todo` flags: "LoadTask risks confusion with the Task class used for
SourceBufferActor scheduling. These are closer to operation
descriptors or messages than tasks in that sense." Rename pending;
also affects `planTasks` / `scheduleAll`.
- **Direct actor-level tests.** Coverage gap noted above. When does the
cost of behavior-level-only testing exceed the cost of writing actor
tests?
## Related features
- **preload-modes** — produces the gate state this feature consumes.
The 4-state load FSM maps directly to `(preload, loadActivated)`.
- **source-replacement** — segment-loader actors and in-flight fetches
tear down via the same resolved/unresolved cascade that drives source
replacement. New buffer-management behaviors that gate on resolved
presentation must honor the cleanup contract.
- **mse-mms-pipeline** — owns the `SourceBufferActor` this feature's
operations land on, plus the loader-actor lifecycle
(`setupVideoBufferActors` etc.). The fetch path
(`createTrackedFetch` for video) is wired in there.
- **video-abr** — quality-aware buffer planning preserves buffered
high-quality content during ABR downgrade; bandwidth sampling lands
in this feature's fetch path (`fetchBytes = createTrackedFetch`).
- **subtitles** — `loadTextTrackSegments` is the text-track variant of
this feature's dispatcher; `TextTrackSegmentLoaderActor` is the
text-side counterpart to `SegmentLoaderActor`.
- **multi-language-audio** *(coarse)* — Tier 2 audio-flush-on-switch
will use the `remove` message primitive surfaced through this
feature's actor.
- **Audio SourceBuffer flush orchestration** — not a separately-
scoped feature. The primitives (`SourceBufferActor.remove` message
+ `flushBuffer` helper) live in
[mse-mms-pipeline](./mse-mms-pipeline.md) and surface through this
feature's actor; orchestration on `selectedAudioTrackId` change is
part of [multi-language-audio](./multi-language-audio.md)'s Tier 2
mid-stream-switching phase.
- **[ll-hls-support](./ll-hls-support.md)** *(candidate)* — extends the
forward-buffer planner with partial-segment-head tracking past the
last complete segment. Planner extension shape is an open question
in that feature's doc.
- **[dvr-event-stream-support](./dvr-event-stream-support.md)**
*(candidate)* — puts pressure on the back-buffer eviction policy
(default `keepSegments: 2` evicts history before user can back-
seek to it). Variant-specific policy vs configurable threshold is
an open question shared with this doc's "Smarter back-buffer
eviction" section.
- **5.1-surround-selection** / **hevc-variant-selection**
*(candidates)* — cross-codec switches need a `changeType()`
extension beyond today's same-codec planning.
## 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 via `createHlsAudioOnlyEngine`); Phase 3 surfaces
alternative-default-config candidates (shorter
`forwardBuffer.bufferDuration` for audio-only) and a Path-B
candidate (audio-only-tuned buffer-management behavior).
- **[`video-only-mode-override`](../use-cases/video-only-mode-override.md)**
*(coarse)* — Phase 1 baseline constituent (used as-is). Loop-
friendly buffer fetching is the peer `[background-looping-
video]` use case's concern, not this one's; video-only-mode-
override's Phase 3 may surface other defaults but not
loop-around.
## See also
- [presentation-modeling.md](../presentation-modeling.md) — architectural
deep-dive on the format-neutral data shape and per-track resolution;
this feature consumes resolved per-track segments surfaced by that
layer
- [text-track-architecture.md](../text-track-architecture.md) — peer
architectural deep-dive (text-track-loader internals; same SPF
shape as v/a)
- [packages/spf/docs/hls-engine.md](../../../../packages/spf/docs/hls-engine.md)
— engine composition walkthrough (Stage 7: segment loading covers
this feature's dispatcher + actor surface)
- [conventions/behaviors.md](../conventions/behaviors.md) — per-type
specialization details (`setupSegmentLoading` is a canonical
instance)
- [conventions/actors.md](../conventions/actors.md) — actor + tasks
pattern (`SegmentLoaderActor` shape)
- [conventions/signals.md](../conventions/signals.md) — read-only
state-slot conventions (this feature consumes the engine's state
surface without writing to it)