refactor(spf): extract text-loader vocabulary to primitives behind a CueSink seam

Mirror of the v/a extraction for the text side. The text step vocabulary
(TextFrame/TextLoadStep/TextStepDeps/TextMessagePipelines/TextLoadTask/
TextTrackSegmentResolver + resolveCues/dispatchCues) and the cue-sink message
DTOs (AddCuesMessage/CueSegmentMeta) move out of the text loader / text-tracks
actor into primitives/. All DOM-free (generic over Cue), so they land in
primitives/ root.

The dispatch step reaches its sink through a structural CueSink seam instead of
the concrete TextTracksActor, so the pipeline names no actor; the loader keeps
only scheduling. actors/ (root) and engines/hls gain primitives references.

Net effect: relocation-pipelines now imports only core/media/primitives — zero
actors/behaviors. It's ready to move to primitives; only the DOM VTTCue in its
text step keeps it in behaviors/dom for now (slice 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-07-15 11:08:06 -07:00
co-authored by Claude Opus 4.8
parent a09473a372
commit 0b8efb34bc
10 changed files with 205 additions and 152 deletions
@@ -1,13 +1,7 @@
import { createTransitionActor } from '../../../core/actors/create-transition-actor';
import type { Cue } from '../../../media/types';
import type {
AddCuesMessage,
ClearMessage,
CueSegmentMeta,
TextTracksActor,
TextTracksActorContext,
TextTracksActorMessage,
} from '../text-tracks';
import type { AddCuesMessage, CueSegmentMeta } from '../../primitives/text-track-messages';
import type { ClearMessage, TextTracksActor, TextTracksActorContext, TextTracksActorMessage } from '../text-tracks';
// Re-export the host-agnostic types so existing dom-side consumers can keep
// importing from this module.
@@ -1,5 +1,4 @@
import { createMachineActor, type HandlerContext, type MessageActor } from '../../core/actors/create-machine-actor';
import type { AnySlotMap } from '../../core/composition/create-composition';
import { peek } from '../../core/signals/primitives';
import { SerialRunner, Task } from '../../core/tasks/task';
import {
@@ -7,7 +6,15 @@ import {
type ForwardBufferConfig,
getSegmentsToLoad,
} from '../../media/buffer/forward-buffer';
import type { Cue, Segment, TextTrack } from '../../media/types';
import type { Cue, TextTrack } from '../../media/types';
import {
DEFAULT_TEXT_MESSAGE_PIPELINES,
type TextFrame,
type TextLoadTask,
type TextMessagePipelines,
type TextStepDeps,
type TextTrackSegmentResolver,
} from '../primitives/text-segment-load-pipeline';
import type { TextTracksActor } from './text-tracks';
// =============================================================================
@@ -52,81 +59,6 @@ export type TextTrackSegmentLoaderActor = MessageActor<
TextTrackSegmentLoaderMessage
>;
/**
* Resolves a text-track segment URL into the array of cues it contains.
*
* "Resolve" because the fn covers both network fetch and parse into the
* domain model. Host-agnostic — the concrete resolver (e.g. the
* browser's native VTT resolver) is supplied at engine-assembly time,
* so this actor stays DOM-free. A pure `url → cues` primitive (the text
* analog of the v/a loader's `fetchBytes`); composition-awareness lives in
* the injected {@link TextLoadStep}s, not here.
*/
export type TextTrackSegmentResolver<C extends Cue = Cue> = (url: string) => Promise<C[]>;
/**
* A text load in mid-pipeline — the text analog of the v/a loader's `Frame`.
* `resolveCuesStep` fills `cues`; `dispatchCuesStep` sends them. `metadata` is
* opaque header metadata a resolve step may attach for a later step to read
* (e.g. relocation stashes the `X-TIMESTAMP-MAP` correlation here for its rebase
* step). Typed `unknown` so the generic loader stays host-agnostic — the step
* that reads it knows its concrete shape (mirrors `StepDeps.state`).
*/
export interface TextFrame<C extends Cue = Cue> {
readonly op: TextLoadTask;
cues?: C[];
metadata?: unknown;
}
/**
* One stage of a text message pipeline — the text analog of the v/a loader's
* `LoadStep`. Mutates the {@link TextFrame} in place and may be async; the runner
* checks `signal.aborted` before each step and passes the actor's
* {@link TextStepDeps} on every call, so a stateless step (`resolveCuesStep`) is a
* plain value and a step that needs composition signals (relocation's cue rebase)
* reads them from `deps` at call time.
*/
export type TextLoadStep<C extends Cue = Cue> = (
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps
) => void | Promise<void>;
/**
* The uniform passthrough handed to each {@link TextLoadStep} — the composition triple,
* the text analog of `StepDeps`. `state`/`context` are the composition signal maps;
* `config` is the threaded config with the loader's wiring folded in (see
* {@link textStepWiring} + `createTextTrackSegmentLoaderActor`). Typed loose: composition
* steps read `state`; base steps read the folded wiring off `config`.
*/
export interface TextStepDeps {
state: AnySlotMap;
context: AnySlotMap;
config: object;
}
/**
* Base-step view of the loader's wiring, folded into `config` by
* `createTextTrackSegmentLoaderActor` so base steps read it from the uniform passthrough
* — present in both composition and standalone use. `config` is loose (`object`), so
* assert the shape here (mirrors the v/a loader's `stepWiring`).
*/
export function textStepWiring<C extends Cue>(
deps: TextStepDeps
): { textTracksActor: TextTracksActor<C>; resolveSegment: TextTrackSegmentResolver<C> } {
return deps.config as { textTracksActor: TextTracksActor<C>; resolveSegment: TextTrackSegmentResolver<C> };
}
/**
* Builds the ordered step list, called **once per actor** (mirrors the v/a loader's
* `MessagePipelines`, but text has a single op type so it's a flat array, not a
* `Record`). The default ({@link DEFAULT_TEXT_MESSAGE_PIPELINES}) is
* `resolveCues → dispatchCues`; a non-zero-PTS composition returns a list that
* inserts a cue-rebase step (see `relocatingTextPipelines`), so the loader stays
* oblivious to relocation.
*/
export type TextMessagePipelines<C extends Cue = Cue> = () => TextLoadStep<C>[];
/**
* Configuration for `createTextTrackSegmentLoaderActor`. Spread over
* `DEFAULT_FORWARD_BUFFER_CONFIG` to override individual forward-window
@@ -144,54 +76,6 @@ export interface TextTrackSegmentLoaderActorConfig<C extends Cue = Cue> {
// Implementation
// =============================================================================
/** Internal load-task descriptor — one segment fetch + dispatch unit. */
interface TextLoadTask {
segment: Segment;
trackId: string;
}
// =============================================================================
// Steps
// =============================================================================
// Base steps are generic over the cue type `C` (generic arrow consts, not
// `TextLoadStep<Cue>` values): each touches `C` — `frame.cues: C[]` and the
// `C`-typed `textStepWiring<C>` — so a `Cue`-typed const wouldn't slot into a
// `VTTCue` pipeline. A generic function assigns to any `TextLoadStep<C>`.
/** Resolve the op's cues (via the injected host primitive) into the frame. The text analog of `fetchStep`. */
export const resolveCuesStep = async <C extends Cue>(
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps
): Promise<void> => {
const cues = await textStepWiring<C>(deps).resolveSegment(frame.op.segment.url);
if (signal.aborted) return;
frame.cues = cues;
};
/** Dispatch the frame's cues to the TextTracksActor as `add-cues`. The text analog of `dispatchStep`. */
export const dispatchCuesStep = <C extends Cue>(
frame: TextFrame<C>,
_signal: AbortSignal,
deps: TextStepDeps
): void => {
const { op } = frame;
textStepWiring<C>(deps).textTracksActor.send({
type: 'add-cues',
meta: {
trackId: op.trackId,
id: op.segment.id,
startTime: op.segment.startTime,
duration: op.segment.duration,
},
cues: frame.cues ?? [],
});
};
/** Tier 0 default: resolve then dispatch. No relocation vocabulary. */
const DEFAULT_TEXT_MESSAGE_PIPELINES = <C extends Cue>(): TextLoadStep<C>[] => [resolveCuesStep, dispatchCuesStep];
/**
* Loads text-track segments for a track and delegates cue management
* to a TextTracksActor. Mirrors the v/a `SegmentLoaderActor` shape (FSM
@@ -1,13 +1,11 @@
import type { TransitionActor } from '../../core/actors/create-transition-actor';
import type { Cue, Segment } from '../../media/types';
import type { AddCuesMessage } from '../primitives/text-track-messages';
// =============================================================================
// Message / context shapes
// =============================================================================
/** Segment identity and timing — mirrors AppendSegmentMeta without trackId (keyed separately). */
export type CueSegmentMeta = Pick<Segment, 'id' | 'startTime' | 'duration'> & { trackId: string };
/** Non-finite (extended) data managed by the actor — the XState "context". */
export interface TextTracksActorContext {
/** Cues added per track ID. Used for duplicate detection and snapshot observability. */
@@ -16,12 +14,6 @@ export interface TextTracksActorContext {
segments: Record<string, Array<Pick<Segment, 'id' | 'startTime' | 'duration'>>>;
}
export interface AddCuesMessage<C extends Cue = Cue> {
type: 'add-cues';
meta: CueSegmentMeta;
cues: C[];
}
/**
* Wipe the actor's `loaded` + `segments` context. Sent on source reset
* (typically by `syncTextTracks` on state exit) so a subsequent
@@ -6,7 +6,12 @@
"exactOptionalPropertyTypes": false,
"declarationDir": "../../../types/playback/actors"
},
"references": [{ "path": "../../../../utils" }, { "path": "../../core" }, { "path": "../../media" }],
"references": [
{ "path": "../../../../utils" },
{ "path": "../../core" },
{ "path": "../../media" },
{ "path": "../primitives" }
],
"include": ["./*.ts", "./tests/**/*.ts"],
"exclude": ["./dom/**"]
}
@@ -34,12 +34,6 @@ import { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../../me
import { findMediaTrack, type MediaHandlerType, readBaseMediaDecodeTime } from '../../../media/mp4/timestamp-origin';
import type { MaybeResolvedPresentation, MediaContainerData } from '../../../media/types';
import { findTrackById } from '../../../media/utils/tracks';
import {
dispatchCuesStep,
type TextLoadStep,
type TextMessagePipelines,
textStepWiring,
} from '../../actors/text-track-segment-loader';
import { peekHead } from '../../primitives/head-peek';
import {
dispatchStep,
@@ -48,6 +42,12 @@ import {
type MessagePipelines,
type StepDeps,
} from '../../primitives/segment-load-pipeline';
import {
dispatchCuesStep,
type TextLoadStep,
type TextMessagePipelines,
textStepWiring,
} from '../../primitives/text-segment-load-pipeline';
// Declared locally so this module carries no `behaviors` import (first step toward
// relocating it to `primitives/dom`). Structurally identical to the
@@ -24,12 +24,11 @@ import type { ReadonlySignal, Signal } from '../../../core/signals/primitives';
import { createTextTracksActor } from '../../actors/dom/text-tracks';
import {
createTextTrackSegmentLoaderActor,
type TextMessagePipelines,
type TextTrackSegmentLoaderActor,
type TextTrackSegmentLoaderActorConfig,
type TextTrackSegmentResolver,
} from '../../actors/text-track-segment-loader';
import type { TextTracksActor } from '../../actors/text-tracks';
import type { TextMessagePipelines, TextTrackSegmentResolver } from '../../primitives/text-segment-load-pipeline';
export interface TextTrackActorsContext {
mediaElement?: HTMLMediaElement | undefined;
@@ -30,7 +30,7 @@ import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth
import type { SegmentLoaderActor } from '../../actors/dom/segment-loader';
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
import type { TextTracksActor } from '../../actors/dom/text-tracks';
import type { TextTrackSegmentLoaderActor, TextTrackSegmentResolver } from '../../actors/text-track-segment-loader';
import type { TextTrackSegmentLoaderActor } from '../../actors/text-track-segment-loader';
import {
calculatePresentationDuration,
type PresentationDurationResolver,
@@ -61,6 +61,7 @@ import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../be
import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
import { syncPreload } from '../../behaviors/sync-preload';
import { switchAudioTrack, switchTextTrack, switchVideoTrack } from '../../behaviors/track-switching';
import type { TextTrackSegmentResolver } from '../../primitives/text-segment-load-pipeline';
// ============================================================================
// HLS Engine State & Context
@@ -12,6 +12,7 @@
{ "path": "../../../network" },
{ "path": "../../../media" },
{ "path": "../../../media/dom" },
{ "path": "../../primitives" },
{ "path": "../../behaviors" },
{ "path": "../../behaviors/dom" },
{ "path": "../../actors" },
@@ -0,0 +1,161 @@
/**
* The text-track **load pipeline** the step vocabulary and base steps a text-segment
* loader runs, the text mirror of `segment-load-pipeline`. Extracted from the loader
* actor so the "what" (the composable steps) is separable from the "how" (scheduling).
* A pipeline is a flat, ordered list of {@link TextLoadStep}s (text has a single op
* type, so no per-type `Record`); the default is `resolveCues → dispatchCues`, and a
* composition can insert its own steps (e.g. non-zero-PTS cue rebase) without the
* loader knowing.
*
* The dispatch step reaches its cue sink through the structural {@link CueSink} seam
* rather than the concrete `TextTracksActor` type the loader folds the real actor
* into `config`, and it satisfies the seam by structure so this module names no
* actor and stays DOM-free (generic over the cue type `C`).
*/
import type { AnySlotMap } from '../../core/composition/create-composition';
import type { Cue, Segment } from '../../media/types';
import type { AddCuesMessage } from './text-track-messages';
// ============================================================================
// SINK SEAM
// ============================================================================
/**
* The structural view of a cue sink the base steps use just the `send`
* {@link dispatchCuesStep} needs. The concrete `TextTracksActor` satisfies it by
* structure, so the pipeline names no actor.
*/
export interface CueSink<C extends Cue = Cue> {
send(message: AddCuesMessage<C>): void;
}
// ============================================================================
// STEP MODEL
// ============================================================================
/**
* Resolves a text-track segment URL into the array of cues it contains.
*
* "Resolve" because the fn covers both network fetch and parse into the
* domain model. Host-agnostic the concrete resolver (e.g. the
* browser's native VTT resolver) is supplied at engine-assembly time,
* so this stays DOM-free. A pure `url → cues` primitive (the text
* analog of the v/a loader's `fetchBytes`); composition-awareness lives in
* the injected {@link TextLoadStep}s, not here.
*/
export type TextTrackSegmentResolver<C extends Cue = Cue> = (url: string) => Promise<C[]>;
/** Internal load-task descriptor — one segment fetch + dispatch unit. */
export interface TextLoadTask {
segment: Segment;
trackId: string;
}
/**
* A text load in mid-pipeline the text analog of the v/a loader's `Frame`.
* `resolveCuesStep` fills `cues`; `dispatchCuesStep` sends them. `metadata` is
* opaque header metadata a resolve step may attach for a later step to read
* (e.g. relocation stashes the `X-TIMESTAMP-MAP` correlation here for its rebase
* step). Typed `unknown` so the generic loader stays host-agnostic the step
* that reads it knows its concrete shape (mirrors `StepDeps.state`).
*/
export interface TextFrame<C extends Cue = Cue> {
readonly op: TextLoadTask;
cues?: C[];
metadata?: unknown;
}
/**
* One stage of a text message pipeline the text analog of the v/a loader's
* `LoadStep`. Mutates the {@link TextFrame} in place and may be async; the runner
* checks `signal.aborted` before each step and passes the actor's
* {@link TextStepDeps} on every call, so a stateless step (`resolveCuesStep`) is a
* plain value and a step that needs composition signals (relocation's cue rebase)
* reads them from `deps` at call time.
*/
export type TextLoadStep<C extends Cue = Cue> = (
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps
) => void | Promise<void>;
/**
* The uniform passthrough handed to each {@link TextLoadStep} the composition triple,
* the text analog of `StepDeps`. `state`/`context` are the composition signal maps;
* `config` is the threaded config with the loader's wiring folded in (see
* {@link textStepWiring} + `createTextTrackSegmentLoaderActor`). Typed loose: composition
* steps read `state`; base steps read the folded wiring off `config`.
*/
export interface TextStepDeps {
state: AnySlotMap;
context: AnySlotMap;
config: object;
}
/**
* Base-step view of the loader's wiring, folded into `config` by
* `createTextTrackSegmentLoaderActor` so base steps read it from the uniform passthrough
* present in both composition and standalone use. `config` is loose (`object`), so
* assert the shape here (mirrors the v/a loader's `stepWiring`). The sink is the
* structural {@link CueSink}, not the concrete actor.
*/
export function textStepWiring<C extends Cue>(
deps: TextStepDeps
): { textTracksActor: CueSink<C>; resolveSegment: TextTrackSegmentResolver<C> } {
return deps.config as { textTracksActor: CueSink<C>; resolveSegment: TextTrackSegmentResolver<C> };
}
/**
* Builds the ordered step list, called **once per actor** (mirrors the v/a loader's
* `MessagePipelines`, but text has a single op type so it's a flat array, not a
* `Record`). The default ({@link DEFAULT_TEXT_MESSAGE_PIPELINES}) is
* `resolveCues → dispatchCues`; a non-zero-PTS composition returns a list that
* inserts a cue-rebase step (see `relocatingTextPipelines`), so the loader stays
* oblivious to relocation.
*/
export type TextMessagePipelines<C extends Cue = Cue> = () => TextLoadStep<C>[];
// ============================================================================
// BASE STEPS
// ============================================================================
// Base steps are generic over the cue type `C` (generic arrow consts, not
// `TextLoadStep<Cue>` values): each touches `C` — `frame.cues: C[]` and the
// `C`-typed `textStepWiring<C>` — so a `Cue`-typed const wouldn't slot into a
// `VTTCue` pipeline. A generic function assigns to any `TextLoadStep<C>`.
/** Resolve the op's cues (via the injected host primitive) into the frame. The text analog of `fetchStep`. */
export const resolveCuesStep = async <C extends Cue>(
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps
): Promise<void> => {
const cues = await textStepWiring<C>(deps).resolveSegment(frame.op.segment.url);
if (signal.aborted) return;
frame.cues = cues;
};
/** Dispatch the frame's cues to the TextTracksActor as `add-cues`. The text analog of `dispatchStep`. */
export const dispatchCuesStep = <C extends Cue>(
frame: TextFrame<C>,
_signal: AbortSignal,
deps: TextStepDeps
): void => {
const { op } = frame;
textStepWiring<C>(deps).textTracksActor.send({
type: 'add-cues',
meta: {
trackId: op.trackId,
id: op.segment.id,
startTime: op.segment.startTime,
duration: op.segment.duration,
},
cues: frame.cues ?? [],
});
};
/** Tier 0 default: resolve then dispatch. No relocation vocabulary. */
export const DEFAULT_TEXT_MESSAGE_PIPELINES = <C extends Cue>(): TextLoadStep<C>[] => [
resolveCuesStep,
dispatchCuesStep,
];
@@ -0,0 +1,16 @@
/**
* Cue-sink message protocol the `add-cues` operation a text-track sink accepts.
* Plain, transport-neutral data: the text load pipeline (`text-segment-load-pipeline`)
* produces it and the TextTracksActor consumes it, so it lives at the primitives layer
* both depend on. The text mirror of `source-buffer-messages`.
*/
import type { Cue, Segment } from '../../media/types';
/** Segment identity and timing — mirrors AppendSegmentMeta without trackId (keyed separately). */
export type CueSegmentMeta = Pick<Segment, 'id' | 'startTime' | 'duration'> & { trackId: string };
export interface AddCuesMessage<C extends Cue = Cue> {
type: 'add-cues';
meta: CueSegmentMeta;
cues: C[];
}