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,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