mirror of
https://github.com/zoriya/v10.git
synced 2026-08-15 18:34:22 +00:00
347 lines
17 KiB
Markdown
347 lines
17 KiB
Markdown
---
|
||
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.
|