|
| 1 | +# SPF — Stream Processing Framework |
| 2 | + |
| 3 | +SPF is a general-purpose framework for building streaming playback engines. It provides a small set of composable primitives — reactive state, actors, reactors, and tasks — that let you assemble a playback engine from independent, decoupled features. |
| 4 | + |
| 5 | +SPF keeps its opinions as localized as possible. The framework doesn't know about HLS, DASH, or any specific protocol. It provides the composition model; you provide the features. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## The Engine |
| 10 | + |
| 11 | +A playback engine is a composition of **features** — functions that each handle one concern (manifest resolution, segment loading, ABR, etc.). The engine wires them together with shared reactive state and runs them. |
| 12 | + |
| 13 | +```ts |
| 14 | +import { createPlaybackEngine } from '@videojs/spf'; |
| 15 | + |
| 16 | +const engine = createPlaybackEngine([ |
| 17 | + resolvePresentation, |
| 18 | + selectVideoTrack, |
| 19 | + loadVideoSegments, |
| 20 | + endOfStream, |
| 21 | +]); |
| 22 | +``` |
| 23 | + |
| 24 | +`createPlaybackEngine` is generic. It: |
| 25 | + |
| 26 | +1. Creates shared **state** and **owners** signals |
| 27 | +2. Passes them (along with **config**) to each feature |
| 28 | +3. Returns the engine interface: `{ state, owners, destroy() }` |
| 29 | + |
| 30 | +```ts |
| 31 | +function createPlaybackEngine( |
| 32 | + features: Feature[], |
| 33 | + options?: { |
| 34 | + config?: C; |
| 35 | + initialState?: S; |
| 36 | + initialOwners?: O; |
| 37 | + } |
| 38 | +): PlaybackEngine; |
| 39 | +``` |
| 40 | + |
| 41 | +All options are optional. The simplest engine is just `createPlaybackEngine([myFeature])`. |
| 42 | + |
| 43 | +### State, Owners, and Config |
| 44 | + |
| 45 | +An engine has three shared channels: |
| 46 | + |
| 47 | +| Channel | What it holds | Lifecycle | |
| 48 | +|---------|--------------|-----------| |
| 49 | +| **state** | Application state (selected tracks, bandwidth estimates, current time, etc.) | Reactive signal — read and written throughout the engine's lifetime | |
| 50 | +| **owners** | Platform objects (media elements, SourceBuffers, actors) | Reactive signal — populated by features as resources are created | |
| 51 | +| **config** | Static configuration (initial bandwidth, preferred languages, etc.) | Passed once at creation — not reactive | |
| 52 | + |
| 53 | +None of these have a fixed shape. Their types are determined by the features in the composition — each feature declares what state, owners, and config fields it needs, and the engine's types are the intersection of those requirements. |
| 54 | + |
| 55 | +### Features |
| 56 | + |
| 57 | +A feature is a function that receives `{ state, owners, config }` and optionally returns a cleanup handle: |
| 58 | + |
| 59 | +```ts |
| 60 | +type Feature = (deps: { state, owners, config }) => void | (() => void) | { destroy(): void }; |
| 61 | +``` |
| 62 | + |
| 63 | +Features are independent. They don't know about each other — they communicate through shared state. A feature might: |
| 64 | + |
| 65 | +- Observe state changes and react (a **reactor**) |
| 66 | +- Own a stateful worker that processes messages (an **actor**) |
| 67 | +- Run a one-time side effect on setup |
| 68 | +- Do nothing but return a cleanup function |
| 69 | + |
| 70 | +The engine doesn't care which. It calls the feature, collects any cleanup, and moves on. |
| 71 | + |
| 72 | +--- |
| 73 | + |
| 74 | +## An HLS Engine |
| 75 | + |
| 76 | +An HLS playback engine is one specific composition of features. There's nothing special about it from SPF's perspective — it's just a list of features and some config. |
| 77 | + |
| 78 | +```ts |
| 79 | +import { createPlaybackEngine } from '@videojs/spf'; |
| 80 | + |
| 81 | +function createSimpleHlsEngine(config = {}) { |
| 82 | + return createPlaybackEngine( |
| 83 | + [ |
| 84 | + // Preload and playback tracking |
| 85 | + syncPreloadAttribute, |
| 86 | + trackPlaybackInitiated, |
| 87 | + |
| 88 | + // Manifest resolution |
| 89 | + resolvePresentation, |
| 90 | + |
| 91 | + // Track selection (reads config for initial preferences) |
| 92 | + selectVideoTrackFromConfig, |
| 93 | + selectAudioTrackFromConfig, |
| 94 | + selectTextTrackFromConfig, |
| 95 | + |
| 96 | + // Resolve selected tracks (fetch media playlists) |
| 97 | + resolveVideoTrack, |
| 98 | + resolveAudioTrack, |
| 99 | + resolveTextTrack, |
| 100 | + |
| 101 | + // Presentation duration |
| 102 | + calculatePresentationDuration, |
| 103 | + |
| 104 | + // MSE setup |
| 105 | + setupMediaSource, |
| 106 | + updateDuration, |
| 107 | + setupSourceBuffers, |
| 108 | + |
| 109 | + // Playback tracking and ABR |
| 110 | + trackCurrentTime, |
| 111 | + switchQualityFromConfig, |
| 112 | + |
| 113 | + // Segment loading |
| 114 | + loadVideoSegments, |
| 115 | + loadAudioSegments, |
| 116 | + |
| 117 | + // End of stream coordination |
| 118 | + endOfStream, |
| 119 | + |
| 120 | + // Text tracks |
| 121 | + syncTextTracks, |
| 122 | + loadTextTrackCues, |
| 123 | + ], |
| 124 | + { config, initialState: { bandwidthState: initialBandwidthState() } } |
| 125 | + ); |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +Reading top to bottom, the engine tells a story: resolve a manifest, pick tracks, set up MSE, load segments, coordinate end-of-stream. Each line is a feature. To build a different engine — fewer features, different features, different protocol — you change the list. |
| 130 | + |
| 131 | +### Config threading |
| 132 | + |
| 133 | +Features that need configuration read it from `deps.config`. The engine passes config to every feature; each one destructures what it needs: |
| 134 | + |
| 135 | +```ts |
| 136 | +// A feature that reads config |
| 137 | +const selectVideoTrackFromConfig = ({ config, ...deps }) => |
| 138 | + selectVideoTrack(deps, { |
| 139 | + type: 'video', |
| 140 | + ...(config.initialBandwidth !== undefined && { initialBandwidth: config.initialBandwidth }), |
| 141 | + }); |
| 142 | + |
| 143 | +// A feature that doesn't need config — just ignores it |
| 144 | +function resolvePresentation({ state }) { |
| 145 | + // ... |
| 146 | +} |
| 147 | +``` |
| 148 | + |
| 149 | +### Media-type wrappers |
| 150 | + |
| 151 | +Some features are parameterized by media type (video, audio, text). Rather than passing `{ type: 'video' }` as inline config, we define thin wrappers that close over the type: |
| 152 | + |
| 153 | +```ts |
| 154 | +const loadVideoSegments = (deps) => loadSegments(deps, { type: 'video' }); |
| 155 | +const loadAudioSegments = (deps) => loadSegments(deps, { type: 'audio' }); |
| 156 | +``` |
| 157 | + |
| 158 | +This keeps the engine composition readable — a flat list of named features. |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## The Three Layers |
| 163 | + |
| 164 | +Features are built from three layers of primitives. Data flows in one direction: |
| 165 | + |
| 166 | +``` |
| 167 | + Reactive State (signals) |
| 168 | + │ observed by |
| 169 | + ▼ |
| 170 | + Reactors (thin subscribers) |
| 171 | + │ send messages to |
| 172 | + ▼ |
| 173 | + Actors (stateful workers) |
| 174 | + │ execute |
| 175 | + ▼ |
| 176 | + Tasks (async work units) |
| 177 | +``` |
| 178 | + |
| 179 | +### Reactive State — Signals |
| 180 | + |
| 181 | +All shared state in SPF is built on [TC39 Signals](https://github.com/tc39/proposal-signals). A signal is a reactive value: it always has a current reading, and computations that read it are automatically notified when it changes. |
| 182 | + |
| 183 | +```ts |
| 184 | +import { signal, computed, effect } from '@videojs/spf'; |
| 185 | + |
| 186 | +const count = signal(0); |
| 187 | +const doubled = computed(() => count.get() * 2); |
| 188 | + |
| 189 | +effect(() => { |
| 190 | + console.log(doubled.get()); // re-runs when count changes |
| 191 | +}); |
| 192 | + |
| 193 | +count.set(5); // logs: 10 |
| 194 | +``` |
| 195 | + |
| 196 | +SPF chose signals over observables because all SPF state is **state-over-time** — current track, buffer contents, bandwidth estimate. These are naturally modeled as "a value that changes" rather than "a stream of events." Signals give you automatic dependency tracking for derived state, synchronous reads, and a lower barrier to entry than observable pipelines. |
| 197 | + |
| 198 | +### Reactors |
| 199 | + |
| 200 | +A reactor observes reactive state and responds to changes. It has its own finite state machine and can run per-state effects. Reactors don't receive messages — they're driven entirely by signal observation. |
| 201 | + |
| 202 | +```ts |
| 203 | +const reactor = createMachineReactor({ |
| 204 | + initial: 'preconditions-unmet', |
| 205 | + monitor: () => canResolve(state.get()) ? 'resolving' : 'preconditions-unmet', |
| 206 | + states: { |
| 207 | + 'preconditions-unmet': {}, |
| 208 | + resolving: { |
| 209 | + entry: () => { |
| 210 | + // Run once on state entry (automatically untracked) |
| 211 | + const ac = new AbortController(); |
| 212 | + fetchAndParse(state, ac.signal); |
| 213 | + return ac; // cleanup on state exit |
| 214 | + }, |
| 215 | + }, |
| 216 | + }, |
| 217 | +}); |
| 218 | +``` |
| 219 | + |
| 220 | +Key concepts: |
| 221 | +- **`monitor`** — derives the target state from signals. The framework drives the transition. |
| 222 | +- **`entry`** — runs once on state entry, automatically untracked. Return a cleanup. |
| 223 | +- **`effects`** — re-run when tracked signals change. For reactive sync. |
| 224 | + |
| 225 | +### Actors |
| 226 | + |
| 227 | +An actor is a long-lived stateful worker that processes messages serially. It owns a reactive snapshot (status + context) and uses tasks and runners to execute work. |
| 228 | + |
| 229 | +```ts |
| 230 | +const actor = createMachineActor({ |
| 231 | + runner: () => new SerialRunner(), |
| 232 | + initial: 'idle', |
| 233 | + context: { segments: [], initTrackId: undefined }, |
| 234 | + states: { |
| 235 | + idle: { |
| 236 | + on: { |
| 237 | + 'append-segment': (msg, { transition, runner, setContext }) => { |
| 238 | + transition('updating'); |
| 239 | + const task = makeAppendTask(msg); |
| 240 | + runner.schedule(task).then(setContext); |
| 241 | + }, |
| 242 | + }, |
| 243 | + }, |
| 244 | + updating: { |
| 245 | + onSettled: 'idle', // auto-return when runner settles |
| 246 | + }, |
| 247 | + }, |
| 248 | +}); |
| 249 | +``` |
| 250 | + |
| 251 | +Key concepts: |
| 252 | +- **`send(message)`** — the only way to communicate with an actor |
| 253 | +- **`snapshot`** — reactive read-only state, observable by reactors and other consumers |
| 254 | +- **`runner`** — schedules and executes tasks (serial, concurrent, etc.) |
| 255 | +- **`onSettled`** — automatic transition when all tasks complete |
| 256 | + |
| 257 | +### How they connect |
| 258 | + |
| 259 | +A reactor observes state, decides something needs to happen, and sends a message to an actor. The actor does the work and updates its snapshot. Other reactors (or the engine) observe the actor's snapshot and react. |
| 260 | + |
| 261 | +``` |
| 262 | +state changes → reactor observes → actor.send({ type: 'load', track }) |
| 263 | + ↓ |
| 264 | + actor executes tasks |
| 265 | + ↓ |
| 266 | + actor.snapshot updates |
| 267 | + ↓ |
| 268 | + other reactors observe snapshot |
| 269 | +``` |
| 270 | + |
| 271 | +This separation means: |
| 272 | +- **Actors don't know about external state.** They receive messages and produce state changes. |
| 273 | +- **Reactors don't do work.** They observe and coordinate. |
| 274 | +- **State is the single source of truth.** Features communicate through it, not through each other. |
| 275 | + |
| 276 | +--- |
| 277 | + |
| 278 | +## Tasks |
| 279 | + |
| 280 | +A task is an ephemeral unit of async work with lifecycle tracking. Unlike a Promise, a task: |
| 281 | + |
| 282 | +- Starts in a **pending** state before it runs — it can be inspected, queued, or aborted before any work begins |
| 283 | +- Can be **aborted** from outside at any point |
| 284 | +- Exposes its **status synchronously** (`pending`, `running`, `done`, `error`) |
| 285 | +- Carries a typed **value** and **error**, readable once settled |
| 286 | + |
| 287 | +Tasks are the unit of work inside actors. They're not exposed externally — actors plan and execute tasks, and the task's status may or may not surface in the actor's snapshot. |
| 288 | + |
| 289 | +**Task runners** control scheduling: |
| 290 | +- **SerialRunner** — one task at a time (used for SourceBuffer operations, which are inherently serial) |
| 291 | +- **ConcurrentRunner** — parallel with deduplication by ID |
| 292 | + |
| 293 | +--- |
| 294 | + |
| 295 | +## Open Questions |
| 296 | + |
| 297 | +> These are areas where the design direction is clear but implementation details are still being worked through. |
| 298 | +
|
| 299 | +- **`presentation?: any`** — The engine-level state type uses `any` for the presentation field because it transitions from unresolved (`{ url }`) to resolved (`Presentation`) at runtime. Individual features narrow the type themselves. Signal invariance prevents using a union type here. A cleaner pattern is needed. |
| 300 | +- **Feature state interfaces and Signal invariance** — More broadly, how should features declare their state requirements given that `Signal<T>` is invariant? The current pattern (features use generic constraints, engine state uses `any` where types conflict) works but has rough edges. |
| 301 | +- **Task `aborted` as a distinct terminal state** — Currently abort lands in `error`. Should be first-class: `pending → running → done | error | aborted`. |
| 302 | +- **Effect scheduling semantics** — Effects are deferred via `queueMicrotask`. The exact behavior under compound state changes (multiple signal writes in one turn) is behavioral, not formally specified. |
| 303 | +- **Reactor lifecycle ownership** — Currently the engine explicitly creates and destroys everything. With signals, reactors could self-scope to a reactive context and auto-dispose. |
0 commit comments