From d7a76fbf9a183b798a61784108d78c2ddd18b0a6 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 15 Jul 2026 11:23:21 -0700 Subject: [PATCH] refactor(spf): make relocation-pipelines a DOM-free primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/media/dom/text/resolve-vtt-segment.ts | 23 +---- .../text/tests/resolve-vtt-segment.test.ts | 8 +- .../src/media/text/resolve-vtt-metadata.ts | 28 ++++++ .../behaviors/establish-start-media-time.ts | 2 +- .../playback/engines/hls/engine-audio-only.ts | 2 +- .../spf/src/playback/engines/hls/engine.ts | 2 +- .../relocation-pipelines.ts | 90 +++++++++---------- .../tests/relocation-pipelines.test.ts | 14 +-- 8 files changed, 86 insertions(+), 83 deletions(-) create mode 100644 packages/spf/src/media/text/resolve-vtt-metadata.ts rename packages/spf/src/playback/{behaviors/dom => primitives}/relocation-pipelines.ts (79%) rename packages/spf/src/playback/{behaviors/dom => primitives}/tests/relocation-pipelines.test.ts (88%) diff --git a/packages/spf/src/media/dom/text/resolve-vtt-segment.ts b/packages/spf/src/media/dom/text/resolve-vtt-segment.ts index 4cbe3ea2..8d0a2e9e 100644 --- a/packages/spf/src/media/dom/text/resolve-vtt-segment.ts +++ b/packages/spf/src/media/dom/text/resolve-vtt-segment.ts @@ -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 `` 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 { - 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 diff --git a/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts b/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts index 3dc27729..c3926148 100644 --- a/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts +++ b/packages/spf/src/media/dom/text/tests/resolve-vtt-segment.test.ts @@ -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(() => { diff --git a/packages/spf/src/media/text/resolve-vtt-metadata.ts b/packages/spf/src/media/text/resolve-vtt-metadata.ts new file mode 100644 index 00000000..c7a10a3c --- /dev/null +++ b/packages/spf/src/media/text/resolve-vtt-metadata.ts @@ -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 + * `` 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 `` 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 { + const text = await fetch(url).then((response) => response.text()); + return { timestampMap: parseVttTimestampMap(text) }; +} diff --git a/packages/spf/src/playback/behaviors/establish-start-media-time.ts b/packages/spf/src/playback/behaviors/establish-start-media-time.ts index 826cb39d..7174955c 100644 --- a/packages/spf/src/playback/behaviors/establish-start-media-time.ts +++ b/packages/spf/src/playback/behaviors/establish-start-media-time.ts @@ -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`. */ diff --git a/packages/spf/src/playback/engines/hls/engine-audio-only.ts b/packages/spf/src/playback/engines/hls/engine-audio-only.ts index 15fddc94..29b212d1 100644 --- a/packages/spf/src/playback/engines/hls/engine-audio-only.ts +++ b/packages/spf/src/playback/engines/hls/engine-audio-only.ts @@ -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 diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 9e9f6fdd..5ced7dc3 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -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'; // ============================================================================ diff --git a/packages/spf/src/playback/behaviors/dom/relocation-pipelines.ts b/packages/spf/src/playback/primitives/relocation-pipelines.ts similarity index 79% rename from packages/spf/src/playback/behaviors/dom/relocation-pipelines.ts rename to packages/spf/src/playback/primitives/relocation-pipelines.ts index 25c059b2..bfcb49cd 100644 --- a/packages/spf/src/playback/behaviors/dom/relocation-pipelines.ts +++ b/packages/spf/src/playback/primitives/relocation-pipelines.ts @@ -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 `` parser * discards the header, so the map needs its own raw-bytes fetch. */ -const resolveWithMetadataStep: TextLoadStep = async (frame, signal, deps) => { +const resolveWithMetadataStep = async ( + frame: TextFrame, + signal: AbortSignal, + deps: TextStepDeps +): Promise => { const [cues, metadata] = await Promise.all([ - textStepWiring(deps).resolveSegment(frame.op.segment.url), + textStepWiring(deps).resolveSegment(frame.op.segment.url), resolveVttSegmentMetadata(frame.op.segment.url), ]); if (signal.aborted) return; @@ -226,7 +222,7 @@ const resolveWithMetadataStep: TextLoadStep = 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 = 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 = async (frame, signal, deps) => { +const relocateCuesStep = async ( + frame: TextFrame, + signal: AbortSignal, + deps: TextStepDeps +): Promise => { if (!frame.cues?.length) return; const state = deps.state as unknown as StateSignals; const startMediaTime = await awaitDefined(() => { @@ -261,7 +261,7 @@ const relocateCuesStep: TextLoadStep = async (frame, signal, deps) => { * `resolveWithMetadata` (cues + `X-TIMESTAMP-MAP`) → `relocateCues` (shift by the * primary A/V origin) → `dispatchCues`. */ -export const relocatingTextPipelines: TextMessagePipelines = () => [ +export const relocatingTextPipelines = (): TextLoadStep[] => [ resolveWithMetadataStep, relocateCuesStep, dispatchCuesStep, diff --git a/packages/spf/src/playback/behaviors/dom/tests/relocation-pipelines.test.ts b/packages/spf/src/playback/primitives/tests/relocation-pipelines.test.ts similarity index 88% rename from packages/spf/src/playback/behaviors/dom/tests/relocation-pipelines.test.ts rename to packages/spf/src/playback/primitives/tests/relocation-pipelines.test.ts index 3b8e495d..017b6d67 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/relocation-pipelines.test.ts +++ b/packages/spf/src/playback/primitives/tests/relocation-pipelines.test.ts @@ -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]! }; }