diff --git a/packages/spf/src/playback/actors/dom/text-tracks.ts b/packages/spf/src/playback/actors/dom/text-tracks.ts index e3d632e1..d057957a 100644 --- a/packages/spf/src/playback/actors/dom/text-tracks.ts +++ b/packages/spf/src/playback/actors/dom/text-tracks.ts @@ -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. diff --git a/packages/spf/src/playback/actors/text-track-segment-loader.ts b/packages/spf/src/playback/actors/text-track-segment-loader.ts index a3b82d98..cf55d24e 100644 --- a/packages/spf/src/playback/actors/text-track-segment-loader.ts +++ b/packages/spf/src/playback/actors/text-track-segment-loader.ts @@ -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 = (url: string) => Promise; - -/** - * 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 { - 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 = ( - frame: TextFrame, - signal: AbortSignal, - deps: TextStepDeps -) => void | Promise; - -/** - * 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( - deps: TextStepDeps -): { textTracksActor: TextTracksActor; resolveSegment: TextTrackSegmentResolver } { - return deps.config as { textTracksActor: TextTracksActor; resolveSegment: TextTrackSegmentResolver }; -} - -/** - * 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 = () => TextLoadStep[]; - /** * Configuration for `createTextTrackSegmentLoaderActor`. Spread over * `DEFAULT_FORWARD_BUFFER_CONFIG` to override individual forward-window @@ -144,54 +76,6 @@ export interface TextTrackSegmentLoaderActorConfig { // 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` values): each touches `C` — `frame.cues: C[]` and the -// `C`-typed `textStepWiring` — so a `Cue`-typed const wouldn't slot into a -// `VTTCue` pipeline. A generic function assigns to any `TextLoadStep`. - -/** Resolve the op's cues (via the injected host primitive) into the frame. The text analog of `fetchStep`. */ -export const resolveCuesStep = async ( - frame: TextFrame, - signal: AbortSignal, - deps: TextStepDeps -): Promise => { - const cues = await textStepWiring(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 = ( - frame: TextFrame, - _signal: AbortSignal, - deps: TextStepDeps -): void => { - const { op } = frame; - textStepWiring(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 = (): TextLoadStep[] => [resolveCuesStep, dispatchCuesStep]; - /** * Loads text-track segments for a track and delegates cue management * to a TextTracksActor. Mirrors the v/a `SegmentLoaderActor` shape (FSM diff --git a/packages/spf/src/playback/actors/text-tracks.ts b/packages/spf/src/playback/actors/text-tracks.ts index f9670608..8d613990 100644 --- a/packages/spf/src/playback/actors/text-tracks.ts +++ b/packages/spf/src/playback/actors/text-tracks.ts @@ -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 & { 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>>; } -export interface AddCuesMessage { - 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 diff --git a/packages/spf/src/playback/actors/tsconfig.json b/packages/spf/src/playback/actors/tsconfig.json index fe864a4e..7b74f588 100644 --- a/packages/spf/src/playback/actors/tsconfig.json +++ b/packages/spf/src/playback/actors/tsconfig.json @@ -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/**"] } diff --git a/packages/spf/src/playback/behaviors/dom/relocation-pipelines.ts b/packages/spf/src/playback/behaviors/dom/relocation-pipelines.ts index 9af1e039..25c059b2 100644 --- a/packages/spf/src/playback/behaviors/dom/relocation-pipelines.ts +++ b/packages/spf/src/playback/behaviors/dom/relocation-pipelines.ts @@ -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 diff --git a/packages/spf/src/playback/behaviors/dom/setup-text-track-actors.ts b/packages/spf/src/playback/behaviors/dom/setup-text-track-actors.ts index 32d43ddf..591f9267 100644 --- a/packages/spf/src/playback/behaviors/dom/setup-text-track-actors.ts +++ b/packages/spf/src/playback/behaviors/dom/setup-text-track-actors.ts @@ -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; diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index ae2ee459..9e9f6fdd 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -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 diff --git a/packages/spf/src/playback/engines/hls/tsconfig.json b/packages/spf/src/playback/engines/hls/tsconfig.json index acb1ab38..9e83eff2 100644 --- a/packages/spf/src/playback/engines/hls/tsconfig.json +++ b/packages/spf/src/playback/engines/hls/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../../../network" }, { "path": "../../../media" }, { "path": "../../../media/dom" }, + { "path": "../../primitives" }, { "path": "../../behaviors" }, { "path": "../../behaviors/dom" }, { "path": "../../actors" }, diff --git a/packages/spf/src/playback/primitives/text-segment-load-pipeline.ts b/packages/spf/src/playback/primitives/text-segment-load-pipeline.ts new file mode 100644 index 00000000..c17ab7fd --- /dev/null +++ b/packages/spf/src/playback/primitives/text-segment-load-pipeline.ts @@ -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 { + send(message: AddCuesMessage): 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 = (url: string) => Promise; + +/** 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 { + 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 = ( + frame: TextFrame, + signal: AbortSignal, + deps: TextStepDeps +) => void | Promise; + +/** + * 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( + deps: TextStepDeps +): { textTracksActor: CueSink; resolveSegment: TextTrackSegmentResolver } { + return deps.config as { textTracksActor: CueSink; resolveSegment: TextTrackSegmentResolver }; +} + +/** + * 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 = () => TextLoadStep[]; + +// ============================================================================ +// BASE STEPS +// ============================================================================ + +// Base steps are generic over the cue type `C` (generic arrow consts, not +// `TextLoadStep` values): each touches `C` — `frame.cues: C[]` and the +// `C`-typed `textStepWiring` — so a `Cue`-typed const wouldn't slot into a +// `VTTCue` pipeline. A generic function assigns to any `TextLoadStep`. + +/** Resolve the op's cues (via the injected host primitive) into the frame. The text analog of `fetchStep`. */ +export const resolveCuesStep = async ( + frame: TextFrame, + signal: AbortSignal, + deps: TextStepDeps +): Promise => { + const cues = await textStepWiring(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 = ( + frame: TextFrame, + _signal: AbortSignal, + deps: TextStepDeps +): void => { + const { op } = frame; + textStepWiring(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 = (): TextLoadStep[] => [ + resolveCuesStep, + dispatchCuesStep, +]; diff --git a/packages/spf/src/playback/primitives/text-track-messages.ts b/packages/spf/src/playback/primitives/text-track-messages.ts new file mode 100644 index 00000000..6eecb329 --- /dev/null +++ b/packages/spf/src/playback/primitives/text-track-messages.ts @@ -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 & { trackId: string }; + +export interface AddCuesMessage { + type: 'add-cues'; + meta: CueSegmentMeta; + cues: C[]; +}