refactor(spf): make relocation-pipelines a DOM-free primitive

Completes the loader-pipeline extraction: relocation-pipelines moves from
behaviors/dom to primitives/ root, fully DOM-free, so it's a genuine primitive
rather than a stray non-behavior parked under behaviors/.

Two decouplings make that honest:
- Cue: the text steps are now generic over `C extends Cue` instead of naming the
  DOM `VTTCue`. Relocation only mutates startTime/endTime, which the structural
  media/types `Cue` already covers — no new type needed.
- metadata resolver: resolveVttSegmentMetadata + TextSegmentMetadata move to a
  DOM-free media/text/resolve-vtt-metadata.ts (fetch + parseVttTimestampMap, no
  document). The document-based resolveVttSegment stays in media/dom/text.

primitives/dom is never needed. relocation's test moves to primitives/tests
(now a Node test, not Chromium) with its behavior-derive dependency replaced by
an inline no-op (only the discover steps are exercised).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-07-15 11:23:21 -07:00
co-authored by Claude Opus 4.8
parent 0b8efb34bc
commit d7a76fbf9a
8 changed files with 86 additions and 83 deletions
@@ -5,7 +5,7 @@
* the browser's optimized VTT parsing. Returns parsed VTTCue objects.
*/
import { parseVttTimestampMap, type TimestampMap } from '../../text/parse-vtt-timestamp-map';
import { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../text/resolve-vtt-metadata';
// Singleton dummy video (reused across all parsing)
let dummyVideo: HTMLVideoElement | null = null;
@@ -67,14 +67,6 @@ export function destroyVttResolver(): void {
dummyVideo = null;
}
/**
* Header-level metadata for a text segment, surfaced alongside its cues. Each
* field is present only when the segment declared it.
*/
export interface TextSegmentMetadata {
timestampMap?: TimestampMap;
}
/**
* A resolved VTT segment paired with its header metadata — the shape used when a
* caller needs the `X-TIMESTAMP-MAP` correlation (e.g. non-zero-PTS sources),
@@ -85,19 +77,6 @@ export interface ResolvedVttSegment {
metadata: TextSegmentMetadata;
}
/**
* Fetch a VTT segment and scrape only its header metadata (no cue parsing).
*
* The native `<track>` parser used by {@link resolveVttSegment} discards
* `X-TIMESTAMP-MAP`, so reading it requires the raw bytes. This is a separate,
* caller-controlled fetch — the caller decides *when* metadata is needed (e.g.
* once per source) rather than paying for it on every segment.
*/
export async function resolveVttSegmentMetadata(url: string): Promise<TextSegmentMetadata> {
const text = await fetch(url).then((response) => response.text());
return { timestampMap: parseVttTimestampMap(text) };
}
/**
* Resolve a VTT segment's cues and header metadata together. Cues still come
* from the browser's native parser ({@link resolveVttSegment}); the header is
@@ -1,10 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
destroyVttResolver,
resolveVttSegment,
resolveVttSegmentMetadata,
resolveVttSegmentWithMetadata,
} from '../resolve-vtt-segment';
import { resolveVttSegmentMetadata } from '../../../text/resolve-vtt-metadata';
import { destroyVttResolver, resolveVttSegment, resolveVttSegmentWithMetadata } from '../resolve-vtt-segment';
describe('resolveVttSegment', () => {
beforeEach(() => {
@@ -0,0 +1,28 @@
/**
* Header-level text-segment metadata — the `X-TIMESTAMP-MAP` correlation scraped from
* a VTT segment's raw bytes. DOM-free (a plain fetch + regex parse): the native
* `<track>` parser in `media/dom/text` (`resolveVttSegment`) discards this header, so a
* caller that needs it (e.g. non-zero-PTS relocation) fetches the bytes itself.
*/
import { parseVttTimestampMap, type TimestampMap } from './parse-vtt-timestamp-map';
/**
* Header-level metadata for a text segment, surfaced alongside its cues. Each
* field is present only when the segment declared it.
*/
export interface TextSegmentMetadata {
timestampMap?: TimestampMap;
}
/**
* Fetch a VTT segment and scrape only its header metadata (no cue parsing).
*
* The native `<track>` parser (`media/dom/text`'s `resolveVttSegment`) discards
* `X-TIMESTAMP-MAP`, so reading it requires the raw bytes. This is a separate,
* caller-controlled fetch — the caller decides *when* metadata is needed (e.g.
* once per source) rather than paying for it on every segment.
*/
export async function resolveVttSegmentMetadata(url: string): Promise<TextSegmentMetadata> {
const text = await fetch(url).then((response) => response.text());
return { timestampMap: parseVttTimestampMap(text) };
}
@@ -11,7 +11,7 @@
* {@link DeriveStartMediaTime} seam over it in `monitoring`, and stamps the settled
* `startMediaTime` onto the model — the coordinate *consume* — until `established`.
* The byte-level discover/stamp steps that fill the slot are a separate,
* DOM-scoped config `messagePipelines` array (`behaviors/dom/relocation-pipelines`);
* config `messagePipelines` array (`primitives/relocation-pipelines`);
* the two coordinate only through the shared `state.mediaContainerData` slot, never
* by import. See `internal/design/spf/presentation-timeline-model.md`.
*/
@@ -22,7 +22,6 @@ import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
import { endOfStream } from '../../behaviors/dom/end-of-stream';
import { loadAudioSegments } from '../../behaviors/dom/load-segments';
import { recoverEndStall } from '../../behaviors/dom/recover-end-stall';
import { relocationPipelinesFor } from '../../behaviors/dom/relocation-pipelines';
import { setupAudioBufferActors } from '../../behaviors/dom/setup-buffer-actors';
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
@@ -41,6 +40,7 @@ import { resolveAudioTrack } from '../../behaviors/resolve-track';
import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
import { syncPreload } from '../../behaviors/sync-preload';
import { switchAudioTrack } from '../../behaviors/track-switching';
import { relocationPipelinesFor } from '../../primitives/relocation-pipelines';
// ============================================================================
// Audio-Only HLS Engine State & Context
@@ -39,7 +39,6 @@ import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
import { endOfStream } from '../../behaviors/dom/end-of-stream';
import { loadAudioSegments, loadTextTrackSegments, loadVideoSegments } from '../../behaviors/dom/load-segments';
import { recoverEndStall } from '../../behaviors/dom/recover-end-stall';
import { relocatingTextPipelines, relocationPipelinesFor } from '../../behaviors/dom/relocation-pipelines';
import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
import { setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors';
@@ -61,6 +60,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 { relocatingTextPipelines, relocationPipelinesFor } from '../../primitives/relocation-pipelines';
import type { TextTrackSegmentResolver } from '../../primitives/text-segment-load-pipeline';
// ============================================================================
@@ -1,57 +1,49 @@
/**
* Non-zero-PTS relocation pipelines the config-supplied, loader-facing half of the
* `establishStartMediaTime` behavior (`../establish-start-media-time`). The reactor
* there owns the lifecycle and the `derive` coordination seam; this file supplies the
* `messagePipelines` the segment loader runs to *fill and act on* that behavior's
* state. It's the relocation analog of `track-switching`'s config-supplied
* constraint/rule chain same shape (pluggable strategy handed to the machinery via
* config, not applied inline), but supplied to the loader rather than applied by the
* behavior, so the two coordinate through the shared `mediaContainerData` /
* `startMediaTime` slots alone, never by import.
* `establishStartMediaTime` behavior (`../behaviors/establish-start-media-time`). The
* reactor there owns the lifecycle and the `derive` coordination seam; this file supplies
* the `messagePipelines` the segment/text loaders run to *fill and act on* that behavior's
* state. It's the relocation analog of `track-switching`'s config-supplied constraint/rule
* chain a pluggable strategy handed to the machinery via config, not applied inline
* supplied to the loaders rather than applied by the behavior, so the two coordinate
* through the shared `mediaContainerData` / `startMediaTime` slots alone, never by import.
*
* It lives in `behaviors/dom` rather than beside the reactor because it's the DOM arm
* of that behavior: it references the loader's base steps + `StepDeps` (which carry the
* `SourceBuffer`-backed actor), reads container bytes, and shifts `VTTCue`s none of
* which the DOM-free reactor may touch. The A/V pipeline is a plain `messagePipelines`
* array:
* - `discover` init `track_id` + `mdhd` timescale for the buffered media track,
* then that same track's `tfdt` baseMediaDecodeTime, matched by `track_id` so a
* muxed segment reads the media track's origin, not the first `traf` writes
* DOM-free: it composes the loader vocabularies (`segment-load-pipeline`,
* `text-segment-load-pipeline`) through their structural sink seams and shifts cues via
* the structural `Cue` type, so it names no `SourceBuffer`, `TextTracksActor`, or
* `VTTCue`. The A/V pipeline is a plain `messagePipelines` array:
* - `discover` init `track_id` + `mdhd` timescale for the buffered media track, then
* that same track's `tfdt` baseMediaDecodeTime, matched by `track_id` so a muxed
* segment reads the media track's origin, not the first `traf` writes
* `state.mediaContainerData`.
* - `stamp` reads that track's derived origin back and relocates via
* `timestampOffset = startMediaTime`.
* Steps read composition `state` from their call-time `deps` (no closures, no context).
*
* The text half (`relocatingTextPipelines`) is the same idea for the text-segment
* loader: a `resolveWithMetadata → relocateCues → dispatchCues` pipeline that shifts
* VTT cues onto the same 0-based timeline, reading the primary A/V track's
* `startMediaTime` (the reactor's consumed value) via `deps`.
* The text half (`relocatingTextPipelines`) is the same idea for the text-segment loader:
* a `resolveWithMetadata → relocateCues → dispatchCues` pipeline that shifts cues onto the
* same 0-based timeline, reading the primary A/V track's `startMediaTime` (the reactor's
* consumed value) via `deps`.
*/
import type { StateSignals } from '../../../core/composition/create-composition';
import { effect } from '../../../core/signals/effect';
import { peek, type Signal, update } from '../../../core/signals/primitives';
import { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../../media/dom/text/resolve-vtt-segment';
import { findMediaTrack, type MediaHandlerType, readBaseMediaDecodeTime } from '../../../media/mp4/timestamp-origin';
import type { MaybeResolvedPresentation, MediaContainerData } from '../../../media/types';
import { findTrackById } from '../../../media/utils/tracks';
import { peekHead } from '../../primitives/head-peek';
import {
dispatchStep,
fetchStep,
type LoadStep,
type MessagePipelines,
type StepDeps,
} from '../../primitives/segment-load-pipeline';
import type { StateSignals } from '../../core/composition/create-composition';
import { effect } from '../../core/signals/effect';
import { peek, type Signal, update } from '../../core/signals/primitives';
import { findMediaTrack, type MediaHandlerType, readBaseMediaDecodeTime } from '../../media/mp4/timestamp-origin';
import { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../media/text/resolve-vtt-metadata';
import type { Cue, MaybeResolvedPresentation, MediaContainerData } from '../../media/types';
import { findTrackById } from '../../media/utils/tracks';
import { peekHead } from './head-peek';
import { dispatchStep, fetchStep, type LoadStep, type MessagePipelines, type StepDeps } from './segment-load-pipeline';
import {
dispatchCuesStep,
type TextFrame,
type TextLoadStep,
type TextMessagePipelines,
type TextStepDeps,
textStepWiring,
} from '../../primitives/text-segment-load-pipeline';
} from './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
// `establishStartMediaTime` behavior's own `DeriveStartMediaTime` and state shape — the
// Declared locally so this module carries no `behaviors` import. Structurally identical to
// the `establishStartMediaTime` behavior's own `DeriveStartMediaTime` and state shape — the
// engine feeds one resolved `derive` to both sides, so the duplication is type-only and
// folds away once these land in a shared home.
export interface DeriveStartMediaTimeContext {
@@ -215,9 +207,13 @@ export function relocationPipelinesFor(trackType: 'video' | 'audio', derive: Der
* base `resolveCuesStep` (which fetches cues only) text's native `<track>` parser
* discards the header, so the map needs its own raw-bytes fetch.
*/
const resolveWithMetadataStep: TextLoadStep<VTTCue> = async (frame, signal, deps) => {
const resolveWithMetadataStep = async <C extends Cue>(
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps
): Promise<void> => {
const [cues, metadata] = await Promise.all([
textStepWiring<VTTCue>(deps).resolveSegment(frame.op.segment.url),
textStepWiring<C>(deps).resolveSegment(frame.op.segment.url),
resolveVttSegmentMetadata(frame.op.segment.url),
]);
if (signal.aborted) return;
@@ -226,7 +222,7 @@ const resolveWithMetadataStep: TextLoadStep<VTTCue> = async (frame, signal, deps
};
/**
* Relocate step shifts each VTT cue onto the 0-based presentation timeline:
* Relocate step shifts each cue onto the 0-based presentation timeline:
* `cueFinal = cueNative startMediaTime`, where `startMediaTime` is the primary
* A/V track's origin (selected **video**, else **audio** the single-anchor rule,
* and defensive like the reactor's optional selection) and `cueNative` folds in the
@@ -235,7 +231,11 @@ const resolveWithMetadataStep: TextLoadStep<VTTCue> = async (frame, signal, deps
* establishes, so the origin is awaited; fMP4 always establishes it (0-PTS 0),
* and a text-only source (no A/V selected) simply gets offset 0.
*/
const relocateCuesStep: TextLoadStep<VTTCue> = async (frame, signal, deps) => {
const relocateCuesStep = async <C extends Cue>(
frame: TextFrame<C>,
signal: AbortSignal,
deps: TextStepDeps
): Promise<void> => {
if (!frame.cues?.length) return;
const state = deps.state as unknown as StateSignals<RelocationSlots>;
const startMediaTime = await awaitDefined(() => {
@@ -261,7 +261,7 @@ const relocateCuesStep: TextLoadStep<VTTCue> = async (frame, signal, deps) => {
* `resolveWithMetadata` (cues + `X-TIMESTAMP-MAP`) `relocateCues` (shift by the
* primary A/V origin) `dispatchCues`.
*/
export const relocatingTextPipelines: TextMessagePipelines<VTTCue> = () => [
export const relocatingTextPipelines = <C extends Cue>(): TextLoadStep<C>[] => [
resolveWithMetadataStep,
relocateCuesStep,
dispatchCuesStep,
@@ -1,10 +1,9 @@
import { describe, expect, it } from 'vitest';
import { signal } from '../../../../core/signals/primitives';
import { initSegment, mediaSegment, trak } from '../../../../media/mp4/tests/synthetic-boxes';
import type { MediaContainerData } from '../../../../media/types';
import type { Frame, StepDeps } from '../../../primitives/segment-load-pipeline';
import { deriveSharedMinStartMediaTime } from '../../establish-start-media-time';
import { signal } from '../../../core/signals/primitives';
import { initSegment, mediaSegment, trak } from '../../../media/mp4/tests/synthetic-boxes';
import type { MediaContainerData } from '../../../media/types';
import { relocationPipelinesFor } from '../relocation-pipelines';
import type { Frame, StepDeps } from '../segment-load-pipeline';
// A caption-first muxing: the `clcp` traf precedes the media traf, so a
// first-`traf` read would pair the media track's timescale with the caption
@@ -31,9 +30,10 @@ function makeDeps(): {
return { deps: { state: { mediaContainerData: slot }, context: {}, config: {} }, slot };
}
/** The discover steps at their pipeline positions: `[fetch, discover, dispatch]`. */
/** The discover steps at their pipeline positions: `[fetch, discover, dispatch]`. The
* derive is irrelevant here (only the stamp step consumes it), so a no-op suffices. */
function discoverSteps(trackType: 'video' | 'audio') {
const pipelines = relocationPipelinesFor(trackType, deriveSharedMinStartMediaTime)();
const pipelines = relocationPipelinesFor(trackType, () => ({}))();
return { readInitTrackInfo: pipelines['append-init'][1]!, readSegmentOrigin: pipelines['append-segment'][1]! };
}