Files
v10/internal/design/spf/decisions.md
T
2026-04-08 13:38:24 -07:00

347 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: draft
date: 2026-03-11
---
# Design Decisions
> **This document reflects decisions made in the current SPF codebase — not settled long-term choices.** The implementation is highly tentative and expected to undergo significant architectural change. The decisions recorded here capture lessons learned from the initial pass (source buffer creation order, end-of-stream gating, streaming response bodies, etc.) and are worth preserving as context, but many will be revisited as the underlying primitives and architecture evolve. See [primitives.md](primitives.md) for the forward-looking design and its open questions.
Rationale behind SPF's key choices.
---
## Actor/Reactor Pattern (from text track spike)
These decisions were made or confirmed during the text track architecture spike
(videojs/v10#1158). See [text-track-architecture.md](text-track-architecture.md) for
the full reference implementation and assessment.
---
### `monitor`-before-state ordering as a load-bearing guarantee
**Decision:** `monitor` effects in `createMachineReactor` always run before per-state effects.
This ordering guarantee is documented in `createMachineReactor`'s source and must be preserved.
**Rationale:** Per-state effects rely on invariants established by `monitor` functions.
When a `monitor` function returns a new state, the framework calls `transition()` and the
snapshot updates before any per-state effect fires — so per-state effects that no-op when
`snapshot.value !== expectedState` do so correctly without needing to re-check conditions
themselves.
**Caveat:** The guarantee is specific to `createMachineReactor`'s registration order. It depends
on the TC39 `signal-polyfill`'s `Watcher` preserving insertion order in `getPending()`
not a formal guarantee of the TC39 Signals proposal.
---
### `deriveState` pattern for transition logic
**Decision:** Transition conditions live in a pure `deriveState` function, wrapped in a
`computed()` signal outside any effect body, consumed by the `monitor` field to drive
transitions. The `monitor` function returns the target state; the framework handles the
comparison and transition.
**Rationale:** Keeps the `monitor` function minimal and machine-readable; makes transition
conditions independently testable as a plain function; prevents the inline computed
anti-pattern (see [actor-reactor-factories.md](actor-reactor-factories.md)).
---
### Actors in owners as the lifecycle contract
**Decision:** Actors created by a reactor are written to the shared `owners` signal.
The engine's `destroy()` generically destroys any value in owners with a `destroy()`
method. The reactor does not destroy its own actors.
**Rationale:** Keeps reactor cleanup simple — no tracking of which actors were created,
no custom destroy logic. Gives the engine a single, uniform cleanup point. The tradeoff
is an implicit contract: callers using a reactor outside the engine must destroy actors
from owners before destroying the reactor.
---
### Entry-reset as a defensive pattern for actor-creating states
**Decision:** States that create actors (`'setting-up'`) and states that are reset points
(`'preconditions-unmet'`) both call `teardownActors()` on entry. `teardownActors` is a
guarded no-op when actors are already `undefined`, preventing spurious signal writes.
**Rationale:** Any transition to a reset state may arrive from a state where actors were
alive. Defensive teardown on *both* states eliminates the need to track "did I come from
an actor-alive state?" — the entry effect is always safe to run.
---
## Architecture
### Reactor / Actor Separation
**Decision:** Feature files are split into thin reactors (observe state → send messages) and stateful actors (execute work, own context). Reactors contain no async logic; actors contain no subscription logic.
**Alternatives:**
- **Monolithic feature classes** — common in traditional players; merge observation and execution in one class. Harder to test, harder to reason about what's in-flight.
- **Pure state machines** — encode all transitions as state; no actors. Eliminates side effects but makes async work (fetch, SourceBuffer) awkward to represent.
**Rationale:** MSE operations have inherent ordering constraints (one `appendBuffer` at a time). Actors model this naturally as a serial queue. Reactors stay simple because they do nothing async — if the actor is busy, the message waits in the queue.
---
### Actor Model for SourceBuffer
**Decision:** Each `SourceBuffer` is wrapped by a `SourceBufferActor` that serializes all operations through a `SerialRunner`. The actor owns a context snapshot (buffered segments, status) that other features can read synchronously.
**Alternatives:**
- **Direct SourceBuffer calls** — simpler initially, but requires callers to gate on `SourceBuffer.updating` everywhere. Spreads the serialization concern across multiple features.
- **Promise chain** — chain `.then()` calls on each operation. Loses the ability to inspect queue state or abort mid-chain.
**Rationale:** Centralizing serialization in the actor makes every caller simpler. The context snapshot — especially `status: 'idle' | 'updating'` and the `segments` list — is read by `endOfStream` and `loadSegments` without needing to query MSE directly.
---
### Single Event Stream Across Features
**Decision:** All features in `PlaybackEngine` share one typed event stream. Each feature casts its events via `@ts-expect-error` to fit the union type.
**Alternatives:**
- **Per-feature event streams** — cleaner types, but requires each feature to wire its own stream and increases object allocation.
- **No event stream** — features communicate only through state patches. Loses the ability to fire point-in-time events (e.g., "segment appended") without polluting state.
**Rationale:** Shared stream simplifies the wiring in `PlaybackEngine` without meaningful runtime cost. The type cast is localized to one line per feature.
---
## Segment Loading
### Three-Case Load Planning
**Decision:** `SegmentLoaderActor.planTasks()` runs in exactly three passes: removes first, then init, then media segments. The order is fixed.
**Alternatives:**
- **Interleaved planning** — decide removes and appends together. Harder to follow; removes must always precede appends for the same time range.
- **Single-pass with branching** — one loop that handles all cases. Conflates concerns; harder to test each case independently.
**Rationale:** The three cases map cleanly to the three things that can happen at any segment boundary: clean up stale buffer, switch to a new track's init, load new content. Fixed order prevents ordering bugs.
---
### In-Flight Preemption vs. Continuation
**Decision:** When a new `load` message arrives while work is in progress, the actor checks whether the in-flight task is still needed. If yes, it completes and queues remaining tasks (continue). If no, it aborts the in-flight task and replans (preempt).
**Alternatives:**
- **Always abort** — simpler logic; always replan from scratch on any new message. Wastes work when the in-flight segment is still needed (e.g., minor `currentTime` advance).
- **Always complete** — never abort in-flight work. Causes stale segments to be appended after a quality switch; requires later cleanup.
**Rationale:** Continue/preempt minimizes wasted network bytes while ensuring the buffer always reflects the current intent. Most `currentTime` advances continue; track switches preempt.
---
### Init Segment Atomicity
**Decision:** Init segments use `minChunkSize: Infinity` — the full response body is accumulated before appending.
**Alternatives:**
- **Stream init segments** — possible in theory, but init data must be complete before media segments can be decoded. A partial init append would likely cause a decode error.
**Rationale:** Init segments are small (typically < 1 KB). Atomicity avoids ordering issues with no meaningful cost.
---
### Streaming Body for Media Segments {#streaming-body}
**Decision:** Video media segments are streamed incrementally via `ChunkedStreamIterable` (default 128 KB chunks). Audio media segments currently use `minChunkSize: Infinity` (atomic, equivalent to `arrayBuffer()`).
**Alternatives:**
- **Full `arrayBuffer()` for all segments** — was the original approach; simpler abort semantics, no partial-segment state needed. But delays bandwidth sampling until the entire segment downloads and prevents mid-download abort.
- **Streaming for both video and audio** — the intended final state. Audio segments are short; streaming them would complicate the code for minimal benefit, but it would enable consistent abort semantics.
**Status: in flux.** Audio streaming is pending. The current asymmetry (video streams, audio does not) is a migration step. See [Open Questions](#audio-streaming).
---
### Partial Segment Tracking
**Decision:** When the first chunk of a segment is appended, the actor marks it `partial: true` in its context. This flag is cleared when the final chunk appends.
**Alternatives:**
- **Optimistic completion** — treat the segment as "done" once queued. Risks calling `endOfStream` while a segment is still in-flight.
- **Track by bytes** — compare `totalBytes` to `ContentLength`. Requires reliable `Content-Length` headers (not guaranteed with HLS).
**Rationale:** `partial` is a simple boolean derived from the actor's own execution state — no external dependencies. `endOfStream` checks `!partial` for the last segment, preventing premature stream end.
---
## ABR
### EWMA Bandwidth Estimation
**Decision:** Bandwidth estimation uses a fast/slow EWMA pair. The exported estimate is the minimum of both.
**Alternatives:**
- **Simple moving average** — easy to compute but slow to react to drops and prone to noise.
- **Percentile-based** — more robust to outliers, but requires keeping a sample window in memory.
- **Single EWMA** — one decay factor. Choosing fast vs. slow is a trade-off; the dual approach hedges.
**Rationale:** Conservative minimum of fast/slow is the standard approach (used in hls.js, Shaka, etc.). The fast EMA reacts quickly to drops; the slow EMA prevents overreaction to spikes. Taking the minimum biases toward caution, reducing stalls.
### Zero-Factor Correction
**Decision:** Displayed bandwidth estimates apply `estimate / (1 - α^totalWeight)` to correct for EWMA initialization bias.
**Context:** A freshly created EWMA has a near-zero estimate even before any samples arrive, because the accumulated weight starts at zero. Raw values are misleading in the UI and can cause ABR to under-select on the first quality decision.
**Rationale:** This is a standard EWMA correction. Without it, the first quality selection is always the lowest rendition regardless of actual network conditions.
---
### Upgrade Throttle
**Decision:** Quality upgrades are gated by `minUpgradeInterval` (default 8 s). Downgrades are immediate.
**Alternatives:**
- **Symmetric throttle** — gate both upgrades and downgrades. But slow downgrades during network drops cause buffer stalls.
- **No throttle** — react to every bandwidth sample. Causes oscillation when bandwidth fluctuates around a rendition threshold.
**Rationale:** Asymmetry matches the asymmetry in consequence: a missed upgrade just means slightly lower quality; a missed downgrade can cause a rebuffer. Immediate downgrades prevent stalls; gated upgrades prevent thrashing.
---
### `abrDisabled` Flag
**Decision:** Setting `state.abrDisabled = true` prevents `switchQuality` from updating `selectedVideoTrackId`.
**Alternatives:**
- **Separate `manualVideoTrackId` field** — explicit field for user-selected quality, with ABR writing to a different field. Cleaner separation; the player can show which track is "manually" vs. "automatically" selected.
**Status: provisional.** `abrDisabled` is a blunt instrument. The long-term design separates `manualVideoTrackId` from `abrVideoTrackId` so both can be tracked independently. See [Open Questions](#abr-track-fields).
---
## MSE Coordination
### SourceBuffer Creation Order {#sourcebuffer-creation-order}
**Decision:** Both the video and audio `SourceBuffer` are created in the same synchronous execution context (step 5 in `PlaybackEngine`), even if only one is needed at first.
**Context:** Firefox has a bug where `mozHasAudio` remains `false` if the video `SourceBuffer` is created before the audio `SourceBuffer` in a different task. This causes Firefox to believe the stream has no audio and mute/skip it.
**Alternatives:**
- **Lazy creation** — create each buffer only when the first segment for that track is ready. Cleaner conceptually but triggers the Firefox bug.
**Rationale:** Creating both buffers together is a workaround for a browser bug. The cost (one extra `SourceBuffer` created slightly early) is negligible. The fix is permanent until Firefox patches the underlying bug.
---
### `endOfStream` Actor-Idle Gate
**Decision:** `endOfStream` waits for both `SourceBufferActor` instances to report `status: 'idle'` before calling `mediaSource.endOfStream()`.
**Context:** `MediaSource.endOfStream()` must not be called while any `SourceBuffer.updating` is `true` — it throws a `DOMException`. The actors expose their status synchronously via their snapshot.
**Alternatives:**
- **Poll `SourceBuffer.updating` directly** — bypasses the actor abstraction. Introduces a direct DOM dependency in a feature that otherwise reads only from actor snapshots.
- **setTimeout/rAF delay** — unreliable; race condition if the buffer update finishes after the timer fires.
**Rationale:** Actor idle is the correct signal: it means all queued tasks have completed, not just the currently executing one. Subscribing to actor snapshots is instantaneous — no polling.
---
### `remove()` Re-opens `MediaSource`
**Decision:** Code that could call `endOfStream` must guard against spurious re-entry when `SourceBuffer.remove()` is called near end-of-stream.
**Context:** Calling `SourceBuffer.remove()` (and `appendBuffer()`) automatically transitions a `'ended'` `MediaSource` back to `'open'`. If `endOfStream` is watching actor state and immediately re-fires when actors go idle, a `remove()` operation after `endOfStream()` creates an infinite loop.
**Implementation:** `endOfStream` checks `currentTime >= lastSegment.startTime` before firing. Back-buffer cleanup removes segments behind `currentTime`, so this guard ensures we don't re-end after cleaning up the back buffer near end-of-stream.
---
## State Management
### Batched `patch()` with Explicit `flush()`
**Decision:** `state.patch()` defers updates via `queueMicrotask`. `state.flush()` is provided for cases where subscribers must react synchronously.
**Alternatives:**
- **Synchronous `patch()`** — subscribers fire immediately on every patch. Risks re-entrant subscription loops and makes it impossible to batch multiple simultaneous updates.
- **Manual batching only** — no automatic deferral; callers always batch explicitly. More control but more boilerplate.
**Rationale:** Automatic batching eliminates most accidental re-entrancy. `flush()` is the escape hatch for the cases (ABR bandwidth sampling) where timing matters.
---
### Bandwidth Bridge {#bandwidth-bridge}
**Decision:** `loadSegments` maintains local `throughput` state per track and syncs it to `state.bandwidthState` after each sample.
**Context:** This is a migration artifact. The long-term design has ABR read directly from a reactive throughput source rather than going through the global state. The bridge exists to decouple the refactor from the feature work.
**Status: temporary.** Remove once ABR reads from `throughput` directly. See [Open Questions](#abr-throughput).
---
## Open Questions
### Audio Streaming {#audio-streaming}
Audio segments currently use `minChunkSize: Infinity` (full atomic download). This prevents mid-download abort and delays bandwidth sampling for audio fetches.
**Options:**
- Stream audio like video (consistent abort semantics, better sampling)
- Keep atomic (audio segments are short; streaming buys little)
**Open:** Streaming audio would eliminate the asymmetry. The main blocker is that streaming requires partial-segment tracking, which is already in place. Worth revisiting once the video streaming path stabilizes.
---
### ABR Track Fields {#abr-track-fields}
`abrDisabled` is a boolean that suppresses all ABR. The desired model separates:
- `abrVideoTrackId` — the track ABR would choose
- `manualVideoTrackId` — the track the user explicitly selected
This lets the UI show "currently manual at 720p, ABR would choose 1080p" without having two separate play modes.
**Open:** Needs a state shape decision and migration path from `abrDisabled`.
---
### ABR Throughput Direct Read {#abr-throughput}
The bandwidth bridge (`loadSegments``state.bandwidthState``switchQuality`) introduces a round-trip through global state. ABR should eventually read from a reactive throughput source owned by the network layer, removing the bridge.
**Open:** Requires defining the throughput API in `core/` and wiring it through `dom/`.
---
### `SegmentLoaderActor` / `LoadTask` Naming
The current naming (`LoadTask`, related internals) is provisional. Better candidates: `SegmentLoaderOp`, `LoadOp`, or `SegmentFetchTask`.
**Open:** Rename before public API stabilizes.
---
### `endOfStream` Subscription Structure
The `endOfStream` feature currently subscribes to both actor snapshots and state separately, leading to some duplication in the condition checks. A cleaner approach would combine actor + state into a single derived selector.
**Open:** Refactor once the actor snapshot API stabilizes.