Skip to content

Commit 0cfd3bb

Browse files
cjpillsburyclaude
andauthored
feat(spf): HLS engine composition walkthrough + doc-driven cleanups (#1512)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 17d44a5 commit 0cfd3bb

103 files changed

Lines changed: 2511 additions & 1279 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sandbox/templates/spf-segment-loading/main.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import '@app/styles.css';
99
// preload=auto|metadata|none Initial preload mode
1010

1111
import { effect } from '@videojs/spf';
12-
import type { HlsPlaybackEngineState } from '@videojs/spf/playback-engine';
13-
import { createHlsPlaybackEngine } from '@videojs/spf/playback-engine';
12+
import type { SimpleHlsEngineState } from '@videojs/spf/hls';
13+
import { createSimpleHlsEngine } from '@videojs/spf/hls';
1414

1515
// ── DOM refs ──────────────────────────────────────────────────────────────────
1616
const video = document.getElementById('video') as HTMLVideoElement;
@@ -58,7 +58,7 @@ function formatBandwidth(bps: number): string {
5858
return `${Math.round(bps / 1000)} Kbps`;
5959
}
6060

61-
function getVideoTracks(state: HlsPlaybackEngineState) {
61+
function getVideoTracks(state: SimpleHlsEngineState) {
6262
return state.presentation?.selectionSets?.find((s) => s.type === 'video')?.switchingSets[0]?.tracks ?? [];
6363
}
6464

@@ -265,14 +265,14 @@ function inspectState() {
265265
log('=== SPF Segment Loading POC Test ===');
266266
log(`Stream: ${INITIAL_SRC}`);
267267

268-
let engine: ReturnType<typeof createHlsPlaybackEngine>;
268+
let engine: ReturnType<typeof createSimpleHlsEngine>;
269269
let cleanupEffects: () => void = () => {};
270270

271271
function startEngine(src: string) {
272272
cleanupEffects();
273273
if (engine) engine.destroy();
274274

275-
engine = createHlsPlaybackEngine({ initialBandwidth: 1_000_000 });
275+
engine = createSimpleHlsEngine({ initialBandwidth: 1_000_000 });
276276
(window as any).engine = engine;
277277
(window as any).state = () => engine.state.get();
278278
(window as any).owners = () => engine.owners.get();
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { SpfMediaMixin } from '@videojs/spf/dom';
1+
import { SimpleHlsMediaMixin } from '@videojs/spf/hls';
22
import { HTMLVideoElementHost } from '../video-host';
33

4-
export class SimpleHlsMedia extends SpfMediaMixin(HTMLVideoElementHost) {}
4+
export class SimpleHlsMedia extends SimpleHlsMediaMixin(HTMLVideoElementHost) {}

packages/react/src/media/simple-hls-video/index.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22

33
import { SimpleHlsMedia } from '@videojs/core/dom/media/simple-hls';
4-
import type { SpfMediaProps } from '@videojs/spf/dom';
5-
import { spfMediaDefaultProps } from '@videojs/spf/dom';
4+
import type { SimpleHlsMediaProps } from '@videojs/spf/hls';
5+
import { simpleHlsMediaDefaultProps } from '@videojs/spf/hls';
66
import type { ReactNode, VideoHTMLAttributes } from 'react';
77
import { forwardRef } from 'react';
88
import { useAttachMedia } from '../../utils/use-attach-media';
@@ -11,8 +11,8 @@ import { useMediaInstance } from '../../utils/use-media-instance';
1111
import { useSyncProps } from '../../utils/use-sync-props';
1212

1313
export interface SimpleHlsVideoProps
14-
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, keyof SpfMediaProps>,
15-
Partial<SpfMediaProps> {
14+
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, keyof SimpleHlsMediaProps>,
15+
Partial<SimpleHlsMediaProps> {
1616
children?: ReactNode;
1717
}
1818

@@ -23,7 +23,7 @@ export const SimpleHlsVideo = forwardRef<HTMLVideoElement, SimpleHlsVideoProps>(
2323
const media = useMediaInstance(SimpleHlsMedia);
2424
const attachRef = useAttachMedia(media);
2525
const composedRef = useComposedRefs(attachRef, ref);
26-
const htmlProps = useSyncProps(media, props, spfMediaDefaultProps);
26+
const htmlProps = useSyncProps(media, props, simpleHlsMediaDefaultProps);
2727

2828
return (
2929
<video ref={composedRef} {...htmlProps}>

packages/spf/docs/explainer.md

Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
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

Comments
 (0)