Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
6ca18e8
docs(spf): WIP HLS engine composition explainer
cjpillsbury Apr 23, 2026
fa13a48
refactor(spf): sort features/ into behaviors, actors, primitives
cjpillsbury Apr 23, 2026
b80647f
refactor(spf): enforce DOM-freedom on src/core via subtree tsconfig
cjpillsbury Apr 23, 2026
c0af416
refactor(spf): make core type-tests DOM-free; drop tests exclude
cjpillsbury Apr 23, 2026
9856c3c
refactor(spf): move dom/network to top-level src/network
cjpillsbury Apr 23, 2026
ab484b5
refactor(spf): enforce DOM-freedom on src/media and src/network
cjpillsbury Apr 23, 2026
a204a42
refactor(spf): extract text-track actor types to media/actors
cjpillsbury Apr 24, 2026
aab1959
refactor(spf): move text-track-segment-loader factory to media/actors
cjpillsbury Apr 24, 2026
8dff20e
refactor(spf): split text-track loader and host provider
cjpillsbury Apr 24, 2026
e17d7da
refactor(spf): inject VTT parser via provider config, drop dom wrapper
cjpillsbury Apr 24, 2026
1126dea
refactor(spf): fold provideTextTrackActors teardown into effect cleanup
cjpillsbury Apr 24, 2026
325287a
refactor(spf): rename parseVttSegment to resolveVttSegment and relate…
cjpillsbury Apr 24, 2026
e3cc1f8
refactor(spf)!: move HLS engine and SpfMedia to src/playback-engines/…
cjpillsbury Apr 24, 2026
441345e
refactor(spf)!: rename HLS exports to simple-hls naming
cjpillsbury Apr 24, 2026
a1e9c73
refactor(spf): promote SPF behaviors and actor types to top-level
cjpillsbury Apr 24, 2026
bbda124
refactor(spf)!: eliminate src/dom/, redistribute by role
cjpillsbury Apr 24, 2026
708d084
refactor(spf): consolidate behaviors, actors, and HLS engine under pl…
cjpillsbury Apr 27, 2026
3478bbd
refactor(spf): make onMediaSourceReadyStateChange a callback-based pr…
cjpillsbury Apr 27, 2026
7aa0d83
refactor(spf): move select-tracks orchestrations to playback/behaviors/
cjpillsbury Apr 27, 2026
1048cbe
refactor(utils,spf): move generateId to @videojs/utils/string
cjpillsbury Apr 27, 2026
d461751
refactor(spf): drop core/network references from media tsconfigs
cjpillsbury Apr 27, 2026
62c7aae
docs(spf): add src/CLAUDE.md capturing dependency boundaries and layout
cjpillsbury Apr 27, 2026
0c2b285
refactor(spf): rename provideTextTrackActors to setupTextTrackActors
cjpillsbury Apr 27, 2026
a6fe5e1
chore(spf): delete stale committed .d.ts/.d.ts.map artifacts in src/
cjpillsbury Apr 27, 2026
53b1b06
docs(spf): start hls-engine.md walkthrough (intro through stage 1)
cjpillsbury Apr 27, 2026
ef84359
refactor(spf): move mediaSourceReadyState onto state, drop initial-va…
cjpillsbury Apr 27, 2026
bbd0f5f
refactor(spf): drop the no-op destroyVttResolver line from HLS compos…
cjpillsbury Apr 27, 2026
c8bab06
refactor(spf): split state.presentation into presentationUrl + presen…
cjpillsbury Apr 27, 2026
3c71527
feat(spf): add explicit-typed overload to createComposition
cjpillsbury Apr 27, 2026
939b411
docs(spf): add stages 2 and 3 to hls-engine.md
cjpillsbury Apr 27, 2026
95bb8b6
docs(spf): add stages 4 and 5 to hls-engine.md
cjpillsbury Apr 27, 2026
0e0a648
docs(spf): add stages 6 and 7 to hls-engine.md
cjpillsbury Apr 27, 2026
2271436
docs(spf): add stages 8 and 9 plus the createComposition appendix
cjpillsbury Apr 27, 2026
4f29d25
refactor(spf): drop *FromConfig suffix on engine wrappers; shadow imp…
cjpillsbury Apr 27, 2026
b1d0648
refactor(spf): collapse presentationUrl back into single presentation…
cjpillsbury May 5, 2026
174052e
fix(spf): require both id and selectionSets in isResolvedPresentation
cjpillsbury May 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/sandbox/templates/spf-segment-loading/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import '@app/styles.css';
// preload=auto|metadata|none Initial preload mode

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

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

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

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

let engine: ReturnType<typeof createHlsPlaybackEngine>;
let engine: ReturnType<typeof createSimpleHlsEngine>;
let cleanupEffects: () => void = () => {};

function startEngine(src: string) {
cleanupEffects();
if (engine) engine.destroy();

engine = createHlsPlaybackEngine({ initialBandwidth: 1_000_000 });
engine = createSimpleHlsEngine({ initialBandwidth: 1_000_000 });
(window as any).engine = engine;
(window as any).state = () => engine.state.get();
(window as any).owners = () => engine.owners.get();
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/dom/media/simple-hls/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SpfMediaMixin } from '@videojs/spf/dom';
import { SimpleHlsMediaMixin } from '@videojs/spf/hls';
import { HTMLVideoElementHost } from '../video-host';

export class SimpleHlsMedia extends SpfMediaMixin(HTMLVideoElementHost) {}
export class SimpleHlsMedia extends SimpleHlsMediaMixin(HTMLVideoElementHost) {}
10 changes: 5 additions & 5 deletions packages/react/src/media/simple-hls-video/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

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

export interface SimpleHlsVideoProps
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, keyof SpfMediaProps>,
Partial<SpfMediaProps> {
extends Omit<VideoHTMLAttributes<HTMLVideoElement>, keyof SimpleHlsMediaProps>,
Partial<SimpleHlsMediaProps> {
children?: ReactNode;
}

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

return (
<video ref={composedRef} {...htmlProps}>
Expand Down
303 changes: 303 additions & 0 deletions packages/spf/docs/explainer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,303 @@
# SPF — Stream Processing Framework

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.

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.

---

## The Engine

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.

```ts
import { createPlaybackEngine } from '@videojs/spf';

const engine = createPlaybackEngine([
resolvePresentation,
selectVideoTrack,
loadVideoSegments,
endOfStream,
]);
```

`createPlaybackEngine` is generic. It:

1. Creates shared **state** and **owners** signals
2. Passes them (along with **config**) to each feature
3. Returns the engine interface: `{ state, owners, destroy() }`

```ts
function createPlaybackEngine(
features: Feature[],
options?: {
config?: C;
initialState?: S;
initialOwners?: O;
}
): PlaybackEngine;
```

All options are optional. The simplest engine is just `createPlaybackEngine([myFeature])`.

### State, Owners, and Config

An engine has three shared channels:

| Channel | What it holds | Lifecycle |
|---------|--------------|-----------|
| **state** | Application state (selected tracks, bandwidth estimates, current time, etc.) | Reactive signal — read and written throughout the engine's lifetime |
| **owners** | Platform objects (media elements, SourceBuffers, actors) | Reactive signal — populated by features as resources are created |
| **config** | Static configuration (initial bandwidth, preferred languages, etc.) | Passed once at creation — not reactive |

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.

### Features

A feature is a function that receives `{ state, owners, config }` and optionally returns a cleanup handle:

```ts
type Feature = (deps: { state, owners, config }) => void | (() => void) | { destroy(): void };
```

Features are independent. They don't know about each other — they communicate through shared state. A feature might:

- Observe state changes and react (a **reactor**)
- Own a stateful worker that processes messages (an **actor**)
- Run a one-time side effect on setup
- Do nothing but return a cleanup function

The engine doesn't care which. It calls the feature, collects any cleanup, and moves on.

---

## An HLS Engine

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.

```ts
import { createPlaybackEngine } from '@videojs/spf';

function createSimpleHlsEngine(config = {}) {
return createPlaybackEngine(
[
// Preload and playback tracking
syncPreloadAttribute,
trackPlaybackInitiated,

// Manifest resolution
resolvePresentation,

// Track selection (reads config for initial preferences)
selectVideoTrackFromConfig,
selectAudioTrackFromConfig,
selectTextTrackFromConfig,

// Resolve selected tracks (fetch media playlists)
resolveVideoTrack,
resolveAudioTrack,
resolveTextTrack,

// Presentation duration
calculatePresentationDuration,

// MSE setup
setupMediaSource,
updateDuration,
setupSourceBuffers,

// Playback tracking and ABR
trackCurrentTime,
switchQualityFromConfig,

// Segment loading
loadVideoSegments,
loadAudioSegments,

// End of stream coordination
endOfStream,

// Text tracks
syncTextTracks,
loadTextTrackCues,
],
{ config, initialState: { bandwidthState: initialBandwidthState() } }
);
}
```

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.

### Config threading

Features that need configuration read it from `deps.config`. The engine passes config to every feature; each one destructures what it needs:

```ts
// A feature that reads config
const selectVideoTrackFromConfig = ({ config, ...deps }) =>
selectVideoTrack(deps, {
type: 'video',
...(config.initialBandwidth !== undefined && { initialBandwidth: config.initialBandwidth }),
});

// A feature that doesn't need config — just ignores it
function resolvePresentation({ state }) {
// ...
}
```

### Media-type wrappers

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:

```ts
const loadVideoSegments = (deps) => loadSegments(deps, { type: 'video' });
const loadAudioSegments = (deps) => loadSegments(deps, { type: 'audio' });
```

This keeps the engine composition readable — a flat list of named features.

---

## The Three Layers

Features are built from three layers of primitives. Data flows in one direction:

```
Reactive State (signals)
│ observed by
Reactors (thin subscribers)
│ send messages to
Actors (stateful workers)
│ execute
Tasks (async work units)
```

### Reactive State — Signals

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.

```ts
import { signal, computed, effect } from '@videojs/spf';

const count = signal(0);
const doubled = computed(() => count.get() * 2);

effect(() => {
console.log(doubled.get()); // re-runs when count changes
});

count.set(5); // logs: 10
```

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.

### Reactors

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.

```ts
const reactor = createMachineReactor({
initial: 'preconditions-unmet',
monitor: () => canResolve(state.get()) ? 'resolving' : 'preconditions-unmet',
states: {
'preconditions-unmet': {},
resolving: {
entry: () => {
// Run once on state entry (automatically untracked)
const ac = new AbortController();
fetchAndParse(state, ac.signal);
return ac; // cleanup on state exit
},
},
},
});
```

Key concepts:
- **`monitor`** — derives the target state from signals. The framework drives the transition.
- **`entry`** — runs once on state entry, automatically untracked. Return a cleanup.
- **`effects`** — re-run when tracked signals change. For reactive sync.

### Actors

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.

```ts
const actor = createMachineActor({
runner: () => new SerialRunner(),
initial: 'idle',
context: { segments: [], initTrackId: undefined },
states: {
idle: {
on: {
'append-segment': (msg, { transition, runner, setContext }) => {
transition('updating');
const task = makeAppendTask(msg);
runner.schedule(task).then(setContext);
},
},
},
updating: {
onSettled: 'idle', // auto-return when runner settles
},
},
});
```

Key concepts:
- **`send(message)`** — the only way to communicate with an actor
- **`snapshot`** — reactive read-only state, observable by reactors and other consumers
- **`runner`** — schedules and executes tasks (serial, concurrent, etc.)
- **`onSettled`** — automatic transition when all tasks complete

### How they connect

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.

```
state changes → reactor observes → actor.send({ type: 'load', track })
actor executes tasks
actor.snapshot updates
other reactors observe snapshot
```

This separation means:
- **Actors don't know about external state.** They receive messages and produce state changes.
- **Reactors don't do work.** They observe and coordinate.
- **State is the single source of truth.** Features communicate through it, not through each other.

---

## Tasks

A task is an ephemeral unit of async work with lifecycle tracking. Unlike a Promise, a task:

- Starts in a **pending** state before it runs — it can be inspected, queued, or aborted before any work begins
- Can be **aborted** from outside at any point
- Exposes its **status synchronously** (`pending`, `running`, `done`, `error`)
- Carries a typed **value** and **error**, readable once settled

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.

**Task runners** control scheduling:
- **SerialRunner** — one task at a time (used for SourceBuffer operations, which are inherently serial)
- **ConcurrentRunner** — parallel with deduplication by ID

---

## Open Questions

> These are areas where the design direction is clear but implementation details are still being worked through.

- **`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.
- **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.
- **Task `aborted` as a distinct terminal state** — Currently abort lands in `error`. Should be first-class: `pending → running → done | error | aborted`.
- **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.
- **Reactor lifecycle ownership** — Currently the engine explicitly creates and destroys everything. With signals, reactors could self-scope to a reactive context and auto-dispose.
Loading
Loading