From 2733cba05275521cef7280cee5d39daa8b197e05 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 14 Jul 2026 12:00:15 -0700 Subject: [PATCH] feat(spf): relocate non-zero-PTS sources to a 0-based timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assets whose A/V encode starts at a non-zero native PTS (Mux instant clips, Apple bipbop @10s) are relocated onto a 0-based presentation timeline via `SourceBuffer.timestampOffset`, so buffer / model / `currentTime` / `seekable` stay 0-based and the adapter is untouched. - `establishStartMediaTime` reactor establishes each track's media-timeline origin once per source (the DOM-free half), running an injected `deriveStartMediaTime` seam over discovered container data. - Default `deriveSharedMinStartMediaTime`: relocate every track by the `min` across selected A/V origins — preserves real A/V skew and keeps every DTS >= 0. Origins below NEAR_ZERO_ORIGIN_THRESHOLD (1s) are left native (ordinary ~0-PTS VOD isn't perturbed). - `relocation-steps` message pipelines discover the origin (mdhd/tfdt head-peek) and stamp `timestampOffset`; text cues rebase by X-TIMESTAMP-MAP − origin. - Wired into both the default and audio-only HLS engines. See internal/design/spf/presentation-timeline-model.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spf/src/media/types/index.ts | 32 +++ .../src/playback/actors/dom/segment-loader.ts | 207 +++++++++++---- .../src/playback/actors/dom/source-buffer.ts | 13 + .../actors/text-track-segment-loader.ts | 159 +++++++++-- .../behaviors/dom/relocation-steps.ts | 225 ++++++++++++++++ .../behaviors/dom/setup-buffer-actors.ts | 36 ++- .../behaviors/dom/setup-text-track-actors.ts | 35 ++- .../tests/load-text-track-segments.test.ts | 6 +- .../behaviors/establish-start-media-time.ts | 247 ++++++++++++++++++ .../tests/establish-start-media-time.test.ts | 159 +++++++++++ .../playback/engines/hls/engine-audio-only.ts | 33 ++- .../spf/src/playback/engines/hls/engine.ts | 66 ++++- .../spf/src/playback/engines/hls/index.ts | 8 + 13 files changed, 1143 insertions(+), 83 deletions(-) create mode 100644 packages/spf/src/playback/behaviors/dom/relocation-steps.ts create mode 100644 packages/spf/src/playback/behaviors/establish-start-media-time.ts create mode 100644 packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts diff --git a/packages/spf/src/media/types/index.ts b/packages/spf/src/media/types/index.ts index 7aecbbf2..a9b9de1c 100644 --- a/packages/spf/src/media/types/index.ts +++ b/packages/spf/src/media/types/index.ts @@ -131,8 +131,40 @@ export type Track = Ham & bandwidth: number; initialization?: AddressableObject; segments: Segment[]; + /** + * Media-timeline (decode/encode) coordinate of the track's timeline origin + * (`startTime`) — the media-time base value of the coordinate model, peer to + * `startTime` (presentation). Derived from the container + * (`tfdt.baseMediaDecodeTime ÷ mdhd.timescale`); the relocation offset is + * `startTime − startMediaTime`, never stored. + * + * Optional: absent until established (0-PTS sources never set it — their + * origin is already 0). Established once per source by the + * `establishStartMediaTime` reactor. See + * `internal/design/spf/presentation-timeline-model.md`. + */ + startMediaTime?: number; }; +/** + * Per-track-type origin-establishment data, accumulated across appends (`mdhd` + * timescale from the init, `tfdt` baseMediaDecodeTime from the first media + * segment) — hence optional. The transient input the `establishStartMediaTime` + * reactor reduces into `Track.startMediaTime`. + * + * `segmentStartTime` is the 0-based presentation start of the segment + * `baseMediaDecodeTime` was read from — *not* a container value (it's the playlist + * position), but co-located because the origin is `baseMediaDecodeTime/timescale − + * segmentStartTime`: the first *loaded* segment isn't necessarily the 0th (a + * non-zero initial `currentTime`, or live/DVR), so the decode time alone isn't the + * stream origin. + */ +export interface MediaContainerData { + timescale?: number; + baseMediaDecodeTime?: number; + segmentStartTime?: number; +} + /** * Resolved video track with segments. */ diff --git a/packages/spf/src/playback/actors/dom/segment-loader.ts b/packages/spf/src/playback/actors/dom/segment-loader.ts index a69ffe1f..e123e0c6 100644 --- a/packages/spf/src/playback/actors/dom/segment-loader.ts +++ b/packages/spf/src/playback/actors/dom/segment-loader.ts @@ -1,4 +1,5 @@ import { createMachineActor, type HandlerContext, type MessageActor } from '../../../core/actors/create-machine-actor'; +import type { AnySlotMap } from '../../../core/composition/create-composition'; import { effect } from '../../../core/signals/effect'; import { peek } from '../../../core/signals/primitives'; import { SerialRunner, Task } from '../../../core/tasks/task'; @@ -20,7 +21,13 @@ import { type Segment, type VideoTrack, } from '../../../media/types'; -import type { AppendInitMessage, AppendSegmentMessage, RemoveMessage, SourceBufferActor } from './source-buffer'; +import type { + AppendInitMessage, + AppendSegmentMessage, + IndividualSourceBufferMessage, + RemoveMessage, + SourceBufferActor, +} from './source-buffer'; // ============================================================================ // BUFFER STATE TYPES @@ -106,6 +113,67 @@ export interface SegmentLoaderActorContext { export type SegmentLoaderActor = MessageActor; +/** + * A {@link LoadTask} mid-reassembly into its append message. `LoadTask` is a + * message with `data` omitted and a URL added; the pipeline reverses that — + * `fetchStep` produces `data`, `dispatchStep` reassembles the message. `data` is + * the omitted payload (kept as the fetched stream — the loader never produces the + * `ArrayBuffer` arm of `AppendData` — widened back at dispatch). `meta` overrides + * `op.meta` for `append-segment` (e.g. a stamped `timestampOffset`); an + * `append-init` dispatches `op.meta` directly. + */ +export interface Frame { + readonly op: LoadTask; + data?: AsyncIterable; + meta?: AppendSegmentMessage['meta']; +} + +/** + * One stage of a message pipeline. Mutates the {@link Frame} in place and may be + * async; the runner checks `signal.aborted` before each step and passes the + * actor's {@link StepDeps} on every call, so a stateless step (e.g. + * {@link fetchStep}) is a plain value, and a step that needs composition signals + * (relocation's discover/stamp) reads them from `deps` at call time. + */ +export type LoadStep = (frame: Frame, signal: AbortSignal, deps: StepDeps) => void | Promise; + +/** + * The uniform passthrough handed to every {@link LoadStep} on every call — the + * composition triple, nothing more. `state`/`context` are the full composition signal + * maps; `config` is the threaded config with the loader's own wiring folded in (see + * {@link stepWiring} + `createSegmentLoaderActor`). All three are typed loose: the + * loader is a conduit and never reads them; a step asserts the slots it knows are + * present (composition steps read `state`; base steps read the folded wiring off + * `config`). + */ +export interface StepDeps { + state: AnySlotMap; + context: AnySlotMap; + config: object; +} + +/** + * Base-step view of the loader's own wiring. `createSegmentLoaderActor` folds its + * `sourceBufferActor` + `fetch` into the threaded `config` so base steps read them + * from the uniform passthrough — present whether the loader runs inside a composition + * or standalone. `config` is loose (`object`), so assert the shape here (one cast, like + * relocation's `containerSlot`). + */ +function stepWiring(deps: StepDeps): { sourceBufferActor: SourceBufferActor; fetch: FetchBytes } { + return deps.config as { sourceBufferActor: SourceBufferActor; fetch: FetchBytes }; +} + +/** + * Builds the ordered step list for each message type. Called **once per actor** + * — its role is per-actor instantiation, so stateful steps (relocation's origin + * discoverer) get fresh state per source reset; deps arrive at step-call time, + * not here. The default ({@link DEFAULT_MESSAGE_PIPELINES}) is `fetch → dispatch`; + * a non-zero-PTS composition returns a map that inserts its own discover/stamp + * steps between them (see `establishStartMediaTime`), so the loader stays oblivious + * to relocation and the Tier 0 pipeline carries no relocation vocabulary at all. + */ +export type MessagePipelines = () => Record; + /** * Configuration for `createSegmentLoaderActor`. Each sub-config is * spread over the corresponding `DEFAULT_*_CONFIG` so callers can @@ -114,6 +182,8 @@ export type SegmentLoaderActor = MessageActor; backBuffer?: Partial; + /** Per-message-type step pipelines. Defaults to {@link DEFAULT_MESSAGE_PIPELINES} (`fetch → dispatch`). */ + messagePipelines?: MessagePipelines; } // ============================================================================ @@ -168,6 +238,49 @@ function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSigna }); } +// ============================================================================ +// STEPS +// ============================================================================ + +/** Build the SourceBuffer message a completed frame dispatches. `fetchStep` always precedes `dispatchStep` in append pipelines, so `data` is set by now. */ +function toMessage({ op, data, meta }: Frame): IndividualSourceBufferMessage { + switch (op.type) { + case 'remove': + return op; + case 'append-init': + return { type: 'append-init', data: data!, meta: op.meta }; + case 'append-segment': + return { type: 'append-segment', data: data!, meta: meta ?? op.meta }; + } +} + +/** + * Fetch this op's bytes into the frame. Init segments need the full body + * (`minChunkSize: Infinity`) before appending; media segments stream so chunks + * append as they arrive. Awaiting headers eagerly also starts the HTTP + * connection (and records the fetch in observers like tests). + */ +export const fetchStep: LoadStep = async (frame, signal, deps) => { + const { op } = frame; + if (op.type === 'remove') return; // fetchStep only appears in append pipelines + const { fetch } = stepWiring(deps); + frame.data = await fetch(op, op.type === 'append-init' ? { signal, minChunkSize: Infinity } : { signal }); +}; + +/** Dispatch the frame's message to the SourceBufferActor and await its return to idle. */ +export const dispatchStep: LoadStep = async (frame, signal, deps) => { + const { sourceBufferActor } = stepWiring(deps); + sourceBufferActor.send(toMessage(frame)); + await waitForIdle(sourceBufferActor.snapshot, signal); +}; + +/** Tier 0 default: fetch (for ops that carry bytes) then dispatch. No relocation vocabulary. */ +const DEFAULT_MESSAGE_PIPELINES: MessagePipelines = () => ({ + remove: [dispatchStep], + 'append-init': [fetchStep, dispatchStep], + 'append-segment': [fetchStep, dispatchStep], +}); + // ============================================================================ // LOAD TASK FACTORY // ============================================================================ @@ -175,57 +288,36 @@ function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSigna interface LoadTaskOptions { getContext: () => SegmentLoaderActorContext; setContext: (ctx: SegmentLoaderActorContext) => void; - fetchBytes: FetchBytes; - sourceBufferActor: SourceBufferActor; + pipelines: Record; + deps: StepDeps; } /** - * Wraps a LoadTask descriptor into a Task that fetches (if needed) and - * forwards to SourceBufferActor. Updates in-flight context around async - * operations so the loading handler can make accurate continue/preempt - * decisions at any point. + * Wraps a LoadTask descriptor into a Task that runs the op's message pipeline + * (fetch/discover/stamp/dispatch, per the composition's `messagePipelines`). + * Updates in-flight context around the async region so the loading handler can + * make accurate continue/preempt decisions at any point, and checks the abort + * signal before each step. */ -function makeLoadTask( - op: LoadTask, - { getContext, setContext, fetchBytes, sourceBufferActor }: LoadTaskOptions -): Task { +function makeLoadTask(op: LoadTask, { getContext, setContext, pipelines, deps }: LoadTaskOptions): Task { return new Task(async (taskSignal) => { if (taskSignal.aborted) return; - if (op.type === 'remove') { - sourceBufferActor.send(op); - await waitForIdle(sourceBufferActor.snapshot, taskSignal); - return; - } + const frame: Frame = op.type === 'append-segment' ? { op, meta: op.meta } : { op }; - if (op.type === 'append-init') { - setContext({ ...getContext(), inFlightInitTrackId: op.meta.trackId }); - try { - // Init segments are small and need the full body before appending. - // minChunkSize: Infinity accumulates all chunks into one before yielding. - const data = await fetchBytes(op, { signal: taskSignal, minChunkSize: Infinity }); - if (!taskSignal.aborted) { - sourceBufferActor.send({ type: 'append-init', data, meta: op.meta }); - await waitForIdle(sourceBufferActor.snapshot, taskSignal); - } - } finally { - setContext({ ...getContext(), inFlightInitTrackId: null }); - } - return; - } - - // append-segment: await headers eagerly (starts the HTTP connection and - // records the fetch in observers like tests), then pass the body stream - // directly to the actor so chunks are appended as they arrive. - setContext({ ...getContext(), inFlightSegmentId: op.meta.id }); + // In-flight bookkeeping brackets the async region; the `finally` resets it + // even if a step aborts or throws mid-pipeline. Only append ops track it. try { - const stream = await fetchBytes(op, { signal: taskSignal }); - if (!taskSignal.aborted) { - sourceBufferActor.send({ type: 'append-segment', data: stream, meta: op.meta }); - await waitForIdle(sourceBufferActor.snapshot, taskSignal); + if (op.type === 'append-init') setContext({ ...getContext(), inFlightInitTrackId: op.meta.trackId }); + else if (op.type === 'append-segment') setContext({ ...getContext(), inFlightSegmentId: op.meta.id }); + + for (const step of pipelines[op.type]) { + if (taskSignal.aborted) return; + await step(frame, taskSignal, deps); } } finally { - setContext({ ...getContext(), inFlightSegmentId: null }); + if (op.type === 'append-init') setContext({ ...getContext(), inFlightInitTrackId: null }); + else if (op.type === 'append-segment') setContext({ ...getContext(), inFlightSegmentId: null }); } }); } @@ -252,17 +344,32 @@ function makeLoadTask( * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments). * Accepts an optional `minChunkSize` in options; init segments pass `Infinity` * so the entire body accumulates as one chunk before appending. + * @param compositionDeps - The composition's `state`/`context`/`config`, threaded + * opaquely into each step's {@link StepDeps} (the loader never reads them). Lets + * injected steps (relocation) read composition signals at call time. Defaults to + * empty for standalone / base-pipeline use. */ export function createSegmentLoaderActor( sourceBufferActor: SourceBufferActor, fetchBytes: FetchBytes, - config: SegmentLoaderActorConfig = {} + config: SegmentLoaderActorConfig = {}, + compositionDeps: StepDeps = { state: {}, context: {}, config: {} } ): SegmentLoaderActor { type UserState = Exclude; type Ctx = HandlerContext SerialRunner>; const forwardBufferConfig: ForwardBufferConfig = { ...DEFAULT_FORWARD_BUFFER_CONFIG, ...config.forwardBuffer }; const backBufferConfig: BackBufferConfig = { ...DEFAULT_BACK_BUFFER_CONFIG, ...config.backBuffer }; + // Fold the loader's own wiring into the passthrough `config` (see `stepWiring`) so + // base steps read it from the uniform `{state,context,config}` — present in both + // composition and standalone use. + const deps: StepDeps = { + state: compositionDeps.state, + context: compositionDeps.context, + config: { ...compositionDeps.config, sourceBufferActor, fetch: fetchBytes }, + }; + // Built once per actor (fresh stateful steps per source); default is `fetch → dispatch`. + const pipelines = (config.messagePipelines ?? DEFAULT_MESSAGE_PIPELINES)(); const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => { // Exclude partial segments — they are still being streamed and must not be @@ -440,15 +547,13 @@ export function createSegmentLoaderActor( const scheduleAll = (tasks: LoadTask[], { getContext, setContext, runner }: Ctx): void => { tasks.forEach((op) => { - runner - .schedule(makeLoadTask(op, { getContext, setContext, fetchBytes, sourceBufferActor })) - .then(undefined, (e: unknown) => { - if (e instanceof Error && e.name === 'AbortError') return; - // On unexpected fetch/append errors, abort remaining tasks so a failed - // init doesn't cause segment fetches to proceed with no init segment. - console.error('Unexpected error in segment loader:', e); - runner.abortPending(); - }); + runner.schedule(makeLoadTask(op, { getContext, setContext, pipelines, deps })).then(undefined, (e: unknown) => { + if (e instanceof Error && e.name === 'AbortError') return; + // On unexpected fetch/append errors, abort remaining tasks so a failed + // init doesn't cause segment fetches to proceed with no init segment. + console.error('Unexpected error in segment loader:', e); + runner.abortPending(); + }); }); }; diff --git a/packages/spf/src/playback/actors/dom/source-buffer.ts b/packages/spf/src/playback/actors/dom/source-buffer.ts index 59dcd976..83a20e28 100644 --- a/packages/spf/src/playback/actors/dom/source-buffer.ts +++ b/packages/spf/src/playback/actors/dom/source-buffer.ts @@ -17,6 +17,13 @@ export type AppendSegmentMeta = Pick & trackId: Track['id']; /** Declared track bandwidth in bps (from playlist BANDWIDTH attribute). */ trackBandwidth?: number; + /** + * Non-zero-PTS relocation: when present, applied as `SourceBuffer.timestampOffset` + * before this append so native PTS is relocated onto a 0-based presentation + * timeline. A relocating composition stamps it (constant per source) onto each + * media segment's meta; the apply is idempotent-guarded. Absent = no relocation. + */ + timestampOffset?: number; }; export type { AppendData }; @@ -151,6 +158,12 @@ function appendSegmentTask( }); } + // Relocation: set the offset before the coded frames are appended. The + // SerialRunner guarantees the buffer is idle here, so the assignment is safe. + // Guarded so re-stamping the (constant) offset on later appends is a no-op. + if (meta.timestampOffset != null && sourceBuffer.timestampOffset !== meta.timestampOffset) { + sourceBuffer.timestampOffset = meta.timestampOffset; + } await appendSegment(sourceBuffer, message.data, taskSignal); // No abort check here: the physical SourceBuffer has been modified, so // the model must be updated to match regardless of signal state. 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 592330b9..a3b82d98 100644 --- a/packages/spf/src/playback/actors/text-track-segment-loader.ts +++ b/packages/spf/src/playback/actors/text-track-segment-loader.ts @@ -1,4 +1,5 @@ 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 { @@ -57,10 +58,75 @@ export type TextTrackSegmentLoaderActor = MessageActor< * "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. + * 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 @@ -68,8 +134,10 @@ export type TextTrackSegmentResolver = (url: string) => Pro * concern — cues evict by their playhead-relative window at runtime — * so no `backBuffer` config field. */ -export interface TextTrackSegmentLoaderActorConfig { +export interface TextTrackSegmentLoaderActorConfig { forwardBuffer?: Partial; + /** Ordered step pipeline. Defaults to {@link DEFAULT_TEXT_MESSAGE_PIPELINES} (`resolveCues → dispatchCues`). */ + messagePipelines?: TextMessagePipelines; } // ============================================================================= @@ -82,6 +150,48 @@ interface TextLoadTask { 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 @@ -112,12 +222,26 @@ interface TextLoadTask { export function createTextTrackSegmentLoaderActor( textTracksActor: TextTracksActor, resolveSegment: TextTrackSegmentResolver, - config: TextTrackSegmentLoaderActorConfig = {} + config: TextTrackSegmentLoaderActorConfig = {}, + // Composition deps threaded opaquely into each step's `TextStepDeps` (relocation + // reads composition state). The loader never reads them. Defaults empty for + // standalone / base-pipeline use. + compositionDeps: TextStepDeps = { state: {}, context: {}, config: {} } ): TextTrackSegmentLoaderActor { type UserState = Exclude; type Ctx = HandlerContext SerialRunner>; const forwardBufferConfig: ForwardBufferConfig = { ...DEFAULT_FORWARD_BUFFER_CONFIG, ...config.forwardBuffer }; + // Fold the loader's wiring into the passthrough `config` (see `textStepWiring`) so + // base steps read it from the uniform `{state,context,config}` — present in both + // composition and standalone use. + const deps: TextStepDeps = { + state: compositionDeps.state, + context: compositionDeps.context, + config: { ...compositionDeps.config, textTracksActor, resolveSegment }, + }; + // Built once per actor; default is `resolveCues → dispatchCues`. + const pipeline = (config.messagePipelines ?? DEFAULT_TEXT_MESSAGE_PIPELINES)(); /** * Translate a load message into an ordered TextLoadTask list based on @@ -148,27 +272,26 @@ export function createTextTrackSegmentLoaderActor( }; /** - * Wraps a TextLoadTask into a Task that fetches + dispatches `add-cues`. - * Updates `inFlightSegmentId` around the fetch so the load handler can - * make accurate continue/preempt decisions. + * Wraps a TextLoadTask into a Task that runs the op's step pipeline + * (resolve/relocate/dispatch, per the composition's `messagePipelines`). + * Updates `inFlightSegmentId` around the async region so the load handler can + * make accurate continue/preempt decisions, and checks the abort signal before + * each step. + * + * Text degrades gracefully: a step throwing (e.g. a failed segment fetch) is + * logged and swallowed so the runner continues to the next segment — unlike the + * v/a loader, where a failed init must abort the remaining tasks. */ const makeLoadTask = (op: TextLoadTask, { getContext, setContext }: Ctx): Task => { return new Task(async (signal) => { if (signal.aborted) return; + const frame: TextFrame = { op }; setContext({ ...getContext(), inFlightTrackId: op.trackId, inFlightSegmentId: op.segment.id }); try { - const cues = await resolveSegment(op.segment.url); - if (signal.aborted) return; - textTracksActor.send({ - type: 'add-cues', - meta: { - trackId: op.trackId, - id: op.segment.id, - startTime: op.segment.startTime, - duration: op.segment.duration, - }, - cues, - }); + for (const step of pipeline) { + if (signal.aborted) return; + await step(frame, signal, deps); + } } catch (error) { // Graceful degradation: log and continue to the next segment. console.error('Failed to load text-track segment:', error); diff --git a/packages/spf/src/playback/behaviors/dom/relocation-steps.ts b/packages/spf/src/playback/behaviors/dom/relocation-steps.ts new file mode 100644 index 00000000..bf65d7a2 --- /dev/null +++ b/packages/spf/src/playback/behaviors/dom/relocation-steps.ts @@ -0,0 +1,225 @@ +/** + * Non-zero-PTS relocation loader steps — the DOM-scoped, byte-level half that pairs + * with the `establishStartMediaTime` reactor (`../establish-start-media-time`). A + * plain config `messagePipelines` array: `discover` (init `mdhd` timescale, media + * `tfdt` baseMediaDecodeTime) writes `state.mediaContainerData`; `stamp` reads that + * track's origin back and relocates via `timestampOffset = −startMediaTime`. Steps + * read composition `state` from their call-time `deps` (no closures, no context); + * the reactor reads the same slot to derive/consume. They coordinate only through + * that slot — no import between the two. + * + * DOM-scoped only because it references the loader's base steps + `StepDeps` (which + * carry the `SourceBuffer`-backed actor); the relocation logic itself is byte/signal + * work. Tier 1: `stamp` computes the offset straight from the discovered origin, so + * it's independent of the reactor's derive and works for late tracks. + * + * 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`. + */ +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 { readFirstBaseMediaDecodeTime, readFirstMediaTimescale } from '../../../media/mp4/timestamp-origin'; +import type { MediaContainerData } from '../../../media/types'; +import { findTrackById } from '../../../media/utils/tracks'; +import { + dispatchStep, + fetchStep, + type LoadStep, + type MessagePipelines, + type StepDeps, +} from '../../actors/dom/segment-loader'; +import { + dispatchCuesStep, + type TextLoadStep, + type TextMessagePipelines, + textStepWiring, +} from '../../actors/text-track-segment-loader'; +import { peekHead } from '../../primitives/head-peek'; +import type { DeriveStartMediaTime, EstablishStartMediaTimeState } from '../establish-start-media-time'; + +type ContainerSlot = Signal | undefined>; + +/** Assert the relocation state view from the opaque step deps — this module knows the slots the composition provides. */ +function relocationState(deps: StepDeps): StateSignals { + return deps.state as unknown as StateSignals; +} + +function containerSlot(deps: StepDeps): ContainerSlot { + return relocationState(deps).mediaContainerData; +} + +/** Synchronous RMW of the per-type entry — disjoint keys across producers, so no lost update. */ +function writeContainer(slot: ContainerSlot, trackType: string, patch: Partial): void { + update(slot, (current) => ({ ...current, [trackType]: { ...current?.[trackType], ...patch } })); +} + +/** + * Resolve once `read()` returns a number. Shared by the A/V stamp (waits for the + * `derive`d origin — immediate for per-type, the shared-`min` barrier for coordinated) + * and the text step (waits for the primary A/V origin). No bound: for fMP4 the origin + * always establishes. + */ +function awaitDefined(read: () => number | undefined): Promise { + return new Promise((resolve) => { + let stop: (() => void) | undefined; + stop = effect(() => { + const value = read(); + if (value !== undefined) { + stop?.(); + resolve(value); + } + }); + }); +} + +/** + * Relocation pipelines for one track type — a plain config `messagePipelines`. + * Keyed by **track type** (`'video'` / `'audio'`), so ABR rungs of a type share the + * origin (discover skips once the type's value is present). The steps read/write + * `state.mediaContainerData[trackType]` via their call-time `deps`; the stamp applies + * the same `derive` seam the reactor uses (pass the composition's resolved + * `deriveStartMediaTime` so the buffer offset and the model's `startMediaTime` agree). + */ +export function relocationPipelinesFor(trackType: 'video' | 'audio', derive: DeriveStartMediaTime): MessagePipelines { + /** Init step: head-peek the `mdhd` timescale into `mediaContainerData[trackType]`. */ + const readInitTimescale: LoadStep = async (frame, _signal, deps) => { + const { op } = frame; + if (op.type !== 'append-init' || !frame.data) return; + const slot = containerSlot(deps); + if (peek(slot)?.[trackType]?.timescale !== undefined) return; // already have it (any rung of this type) + frame.data = await peekHead(frame.data, (bytes) => { + const timescale = readFirstMediaTimescale(bytes); + if (timescale === undefined) return false; + writeContainer(slot, trackType, { timescale }); + return true; + }); + }; + + /** + * Media-segment step: head-peek the `tfdt` baseMediaDecodeTime, recording the + * segment's 0-based `startTime` with it — the origin is `bmdt/ts − segmentStartTime`, + * so the first *loaded* segment need not be the 0th. + */ + const readSegmentOrigin: LoadStep = async (frame, _signal, deps) => { + const { op } = frame; + if (op.type !== 'append-segment' || !frame.data) return; + const slot = containerSlot(deps); + if (peek(slot)?.[trackType]?.baseMediaDecodeTime !== undefined) return; // established + const segmentStartTime = op.meta.startTime; + frame.data = await peekHead(frame.data, (bytes) => { + const baseMediaDecodeTime = readFirstBaseMediaDecodeTime(bytes); + if (baseMediaDecodeTime === undefined) return false; + writeContainer(slot, trackType, { baseMediaDecodeTime, segmentStartTime }); + return true; + }); + }; + + /** + * Stamp step — tier-agnostic apply. Relocate by the `derive`d `startMediaTime` for + * this type (`offset = −startMediaTime`). Applies the **same** `derive` the reactor + * uses, over the shared `mediaContainerData` slot — so the buffer offset matches the + * model's stamped `startMediaTime`, and it's robust to `established` + late tracks + * (the slot persists; the model value may not be re-stamped after the reactor goes + * sticky). Awaited: per-type resolves at once (own origin discovered earlier in this + * pipeline); shared-`min` waits until every selected A/V origin is in — the barrier, + * filled by the other type's discover step. A derived `0` (0-PTS / below threshold) + * leaves the append native — setting `timestampOffset` at all can ripple. + */ + const stampStartMediaTime: LoadStep = async (frame, signal, deps) => { + if (frame.op.type !== 'append-segment') return; + const state = relocationState(deps); + // Liveness guard: if THIS type's own origin wasn't discovered — `readSegmentOrigin` + // ran earlier in this pipeline and found no `tfdt` (mock / TS / containerless) — the + // source isn't relocatable, so leave the append native and DON'T wait. Only a + // discoverable type awaits the derived origin (which for shared-`min` legitimately + // blocks on the other selected type — the barrier). + const own = peek(state.mediaContainerData)?.[trackType]; + if (own?.timescale === undefined || own.baseMediaDecodeTime === undefined || own.segmentStartTime === undefined) { + return; + } + const startMediaTime = await awaitDefined(() => { + const containerData = state.mediaContainerData.get(); + if (!containerData) return undefined; + return derive(containerData, { + selectedVideoTrackId: state.selectedVideoTrackId?.get(), + selectedAudioTrackId: state.selectedAudioTrackId?.get(), + })[trackType]; + }); + if (signal.aborted || startMediaTime === 0) return; + frame.meta = { ...(frame.meta ?? frame.op.meta), timestampOffset: -startMediaTime }; + }; + + return () => ({ + remove: [dispatchStep], + 'append-init': [fetchStep, readInitTimescale, dispatchStep], + 'append-segment': [fetchStep, readSegmentOrigin, stampStartMediaTime, dispatchStep], + }); +} + +// ============================================================================ +// TEXT (cue relocation) +// ============================================================================ + +/** + * Resolve step for the relocation text pipeline. Reuses the injected host resolver + * (the loader's folded `resolveSegment`, via `textStepWiring`) for cues and fetches + * the `X-TIMESTAMP-MAP` header in parallel, stashing it on `frame.metadata` for + * `relocateCuesStep`. Replaces the + * 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 [cues, metadata] = await Promise.all([ + textStepWiring(deps).resolveSegment(frame.op.segment.url), + resolveVttSegmentMetadata(frame.op.segment.url), + ]); + if (signal.aborted) return; + frame.cues = cues; + frame.metadata = metadata; +}; + +/** + * Relocate step — shifts each VTT 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 + * `X-TIMESTAMP-MAP` correction (`mpegts/90000 − local`) for map-bearing VTT (Apple) + * or is the absolute cue time (no map, e.g. Mux). Text can resolve before A/V + * 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) => { + if (!frame.cues?.length) return; + const state = deps.state as unknown as StateSignals; + const startMediaTime = await awaitDefined(() => { + const primaryId = state.selectedVideoTrackId.get() ?? state.selectedAudioTrackId.get(); + if (primaryId === undefined) return 0; + const presentation = state.presentation.get(); + return presentation ? findTrackById(presentation, primaryId)?.startMediaTime : undefined; + }); + if (signal.aborted) return; + const { timestampMap } = (frame.metadata as TextSegmentMetadata | undefined) ?? {}; + const mapCorrection = timestampMap ? timestampMap.mpegts / 90000 - timestampMap.local : 0; + const delta = mapCorrection - startMediaTime; + if (delta !== 0) { + for (const cue of frame.cues) { + cue.startTime += delta; + cue.endTime += delta; + } + } +}; + +/** + * Relocation text pipeline — the text analog of `relocationPipelinesFor(type)`. + * `resolveWithMetadata` (cues + `X-TIMESTAMP-MAP`) → `relocateCues` (shift by the + * primary A/V origin) → `dispatchCues`. + */ +export const relocatingTextPipelines: TextMessagePipelines = () => [ + resolveWithMetadataStep, + relocateCuesStep, + dispatchCuesStep, +]; diff --git a/packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts b/packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts index c536d318..13716421 100644 --- a/packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts +++ b/packages/spf/src/playback/behaviors/dom/setup-buffer-actors.ts @@ -72,6 +72,7 @@ import type { BandwidthState } from '../../../network/bandwidth-estimator'; import { createTrackedFetch, type FetchBytes, fetchStream } from '../../../network/fetch'; import { createSegmentLoaderActor, + type MessagePipelines, type SegmentLoaderActor, type SegmentLoaderActorConfig, } from '../../actors/dom/segment-loader'; @@ -146,7 +147,7 @@ function setupBufferActors { - const { type, selectedKey, actorKey, loaderKey, fetch, forwardBuffer, backBuffer } = config; + const { type, selectedKey, actorKey, loaderKey, fetch, forwardBuffer, backBuffer, messagePipelines } = config; const derivedStateSignal = computed(() => { if (!context.mediaSource.get()) return 'preconditions-unmet'; const selection: TrackSelectionState = { @@ -172,7 +173,14 @@ function setupBufferActors; }; context: BufferActorsContextMap<'videoBufferActor', 'videoSegmentLoaderActor'>; - config?: SegmentLoaderActorConfig & { getCdnId?: GetCdnId }; + config?: SegmentLoaderActorConfig & { + getCdnId?: GetCdnId; + /** Optional non-zero-PTS relocation pipelines (Tier-1); the loader uses its Tier-0 default when absent. */ + videoMessagePipelines?: MessagePipelines; + }; }) => { // Bandwidth-sampling fetch. The factory accumulates EWMA state // internally; the callback bridges samples to engine state for ABR. @@ -246,7 +258,11 @@ export const setupVideoBufferActors = defineBehavior({ return setupBufferActors({ state, context, - config: { ...typeConfig, fetch: failoverFetch(trackedFetch, state, typeConfig) }, + config: { + ...typeConfig, + messagePipelines: config.videoMessagePipelines, + fetch: failoverFetch(trackedFetch, state, typeConfig), + }, }); }, }); @@ -279,14 +295,22 @@ export const setupAudioBufferActors = defineBehavior({ }: { state: BufferActorsStateMap<'selectedAudioTrackId'>; context: BufferActorsContextMap<'audioBufferActor', 'audioSegmentLoaderActor'>; - config?: SegmentLoaderActorConfig & { getCdnId?: GetCdnId }; + config?: SegmentLoaderActorConfig & { + getCdnId?: GetCdnId; + /** Optional non-zero-PTS relocation pipelines (Tier-1); the loader uses its Tier-0 default when absent. */ + audioMessagePipelines?: MessagePipelines; + }; }) => { // Key order mirrors setupVideoBufferActors. const typeConfig = { ...AUDIO_TYPE_CONFIG, ...config }; return setupBufferActors({ state, context, - config: { ...typeConfig, fetch: failoverFetch(fetchStream, state, typeConfig) }, + config: { + ...typeConfig, + messagePipelines: config.audioMessagePipelines, + fetch: failoverFetch(fetchStream, state, typeConfig), + }, }); }, }); 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 b56c1ed2..32d43ddf 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 @@ -18,12 +18,13 @@ * `config` so this behavior owns the DOM-bound part of the text-track * pipeline. */ -import { defineBehavior } from '../../../core/composition/create-composition'; +import type { AnySlotMap, Behavior } from '../../../core/composition/create-composition'; import { effect } from '../../../core/signals/effect'; 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, @@ -36,14 +37,24 @@ export interface TextTrackActorsContext { textTrackSegmentLoaderActor?: TextTrackSegmentLoaderActor | undefined; } -export interface TextTrackActorsConfig extends TextTrackSegmentLoaderActorConfig { +export interface TextTrackActorsConfig extends Pick, 'forwardBuffer'> { resolveTextTrackSegment: TextTrackSegmentResolver; + /** + * Ordered text step pipeline, mapped to the loader's `messagePipelines`. Named + * with the `text` domain prefix to mirror the v/a `video`/`audioMessagePipelines` + * composition-config slots. Defaults (in the loader) to `resolveCues → dispatchCues`. + */ + textMessagePipelines?: TextMessagePipelines; } function setupTextTrackActorsSetup({ + state, context, config, }: { + // Forwarded opaquely into the loader's steps (relocation reads composition state); + // this behavior owns no state of its own (stateKeys: []). + state: AnySlotMap; context: { mediaElement: ReadonlySignal; textTracksActor: Signal; @@ -59,7 +70,9 @@ function setupTextTrackActorsSetup({ const textTrackSegmentLoaderActor = createTextTrackSegmentLoaderActor( textTracksActor, config.resolveTextTrackSegment, - { forwardBuffer: config.forwardBuffer } + { forwardBuffer: config.forwardBuffer, messagePipelines: config.textMessagePipelines }, + // Composition deps forwarded into each step (relocation reads the primary A/V origin). + { state, context, config } ); context.textTracksActor.set(textTracksActor); context.textTrackSegmentLoaderActor.set(textTrackSegmentLoaderActor); @@ -73,8 +86,20 @@ function setupTextTrackActorsSetup({ }); } -export const setupTextTrackActors = defineBehavior({ +// Manual `Behavior` literal (like `end-of-stream`): no state of its own +// (`stateKeys: []`), but the setup forwards the composition `state` to the resolver +// opaquely. A literal (not `defineBehavior`) so `stateKeys: []` can coexist with a +// setup that reads `state`. +export const setupTextTrackActors: Behavior< + Record, + { + mediaElement: ReadonlySignal; + textTracksActor: Signal; + textTrackSegmentLoaderActor: Signal; + }, + TextTrackActorsConfig +> = { stateKeys: [], contextKeys: ['mediaElement', 'textTracksActor', 'textTrackSegmentLoaderActor'], setup: setupTextTrackActorsSetup, -}); +}; diff --git a/packages/spf/src/playback/behaviors/dom/tests/load-text-track-segments.test.ts b/packages/spf/src/playback/behaviors/dom/tests/load-text-track-segments.test.ts index 93e2c194..b2b7a755 100644 --- a/packages/spf/src/playback/behaviors/dom/tests/load-text-track-segments.test.ts +++ b/packages/spf/src/playback/behaviors/dom/tests/load-text-track-segments.test.ts @@ -112,7 +112,11 @@ function setupLoadTextTrackCues(initialState: TextTrackSegmentLoadingState, init // targeting dormant / activation behavior override this explicitly. const state = makeState({ preload: 'auto', ...initialState }); const context = makeContext(initialContext); - const setupCleanup = setupTextTrackActors.setup({ context, config: { resolveTextTrackSegment: resolveVttSegment } }); + const setupCleanup = setupTextTrackActors.setup({ + state, + context, + config: { resolveTextTrackSegment: resolveVttSegment }, + }) as () => void; const reactor = loadTextTrackSegments.setup({ state, context }); const cleanup = () => { reactor.destroy(); diff --git a/packages/spf/src/playback/behaviors/establish-start-media-time.ts b/packages/spf/src/playback/behaviors/establish-start-media-time.ts new file mode 100644 index 00000000..760d85c0 --- /dev/null +++ b/packages/spf/src/playback/behaviors/establish-start-media-time.ts @@ -0,0 +1,247 @@ +/** + * Establish each track's `startMediaTime` (its media-timeline origin) once per + * source, so a non-zero-PTS source relocates onto a 0-based presentation timeline. + * The VOD sibling of `anchor-presentation-timeline`: both are per-source + * establishment units that write a coordinate base value onto every track (the + * anchor writes `startDate`/`startTime` from wall clock; this writes + * `startMediaTime` from the container's decode-time origin). + * + * This is the **DOM-free reactor half**: it owns the transient `mediaContainerData` + * slot lifecycle (`inactive` clears it per source), runs the injected + * {@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-steps`); + * the two coordinate only through the shared `state.mediaContainerData` slot, never + * by import. See `internal/design/spf/presentation-timeline-model.md`. + */ +import type { Behavior } from '../../core/composition/create-composition'; +import { createMachineReactor, type Reactor } from '../../core/reactors/create-machine-reactor'; +import { type ReadonlySignal, type Signal, update } from '../../core/signals/primitives'; +import { + isResolvedPresentation, + type MaybeResolvedPresentation, + type MediaContainerData, + type Presentation, +} from '../../media/types'; +import { findTrackById } from '../../media/utils/tracks'; + +// ============================================================================ +// STATE / CONFIG +// ============================================================================ + +export interface EstablishStartMediaTimeState { + presentation?: MaybeResolvedPresentation; + /** + * Transient origin-establishment data, keyed by **track type** (`'video'` / + * `'audio'`) — per spec one init+media pair per type suffices, and ABR rungs of + * a type share the origin. Filled by the discover steps across appends, reset + * per source. Never the model — the churn stays here; only the settled + * `startMediaTime` reaches `Track`. + */ + mediaContainerData?: Record; + selectedVideoTrackId?: string; + selectedAudioTrackId?: string; +} + +export interface DeriveStartMediaTimeContext { + selectedVideoTrackId?: string; + selectedAudioTrackId?: string; +} + +/** + * Reduce the discovered container data (keyed by track type) into each type's + * `startMediaTime`. `undefined` means "not ready yet". Pure and injected — the single + * coordination seam. The default {@link deriveSharedMinStartMediaTime} relocates every + * track by one shared `min` origin (handles aligned + skewed A/V + single-type); + * {@link derivePerTypeStartMediaTime} is the barrier-free per-type alternative. + */ +export type DeriveStartMediaTime = ( + containerData: Record, + ctx: DeriveStartMediaTimeContext +) => Record; + +/** + * A single type's own media-timeline origin: + * `baseMediaDecodeTime/timescale − segmentStartTime` (the `segmentStartTime` term + * makes it the stream origin even when the first loaded segment isn't the 0th). + * `undefined` until timescale + baseMediaDecodeTime + segmentStartTime are all present. + */ +function ownOrigin(data: MediaContainerData | undefined): number | undefined { + const { timescale, baseMediaDecodeTime, segmentStartTime } = data ?? {}; + return timescale != null && baseMediaDecodeTime != null && segmentStartTime != null + ? baseMediaDecodeTime / timescale - segmentStartTime + : undefined; +} + +/** + * Origins below this magnitude (seconds) are treated as `0` — the derive returns `0`, so + * the presentation is left on its native (~0-based) timeline and no `timestampOffset` is + * set (the loader stamp no-ops on a derived `0`). Ordinary VOD carries a small nonzero + * encode origin (audio priming, first-frame CTS, edit lists); relocating by a sub-second + * amount is pointless, and setting `timestampOffset` at all can ripple edge segments. + * Relocation targets streams with an intentional large origin (instant clips, bipbop @10s). + * + * Absolute basis, not proportional: the DTS-below-zero / ripple risk scales with the + * origin's absolute size, not with the presentation's duration. Also snaps negatives to + * `0` — a negative origin would relocate *forward*, which is never the intent. + */ +export const NEAR_ZERO_ORIGIN_THRESHOLD = 1; + +/** Snap a below-threshold (incl. negative) origin to `0` so it isn't relocated. */ +function thresholdOrigin(origin: number): number { + return origin < NEAR_ZERO_ORIGIN_THRESHOLD ? 0 : origin; +} + +/** + * The **default** — relocate the whole presentation by one shared origin: the `min` + * across the *selected* A/V tracks' own origins, denormalized onto every type. This + * single reduce subsumes the "per-type" and "shared" tiers: + * - **aligned A/V** — `min` equals each origin (they're equal), so it matches per-type; + * - **skewed A/V** (e.g. Apple's 44ms audio-lead) — `min` keeps every track's earliest + * DTS ≥ 0 (relocating by ≤ each own origin never drives one negative) *and* preserves + * the real skew (per-type would flatten it, desyncing A/V); + * - **single type / muxed** — `min` of the one origin is that origin. + * + * Returns `undefined` for every type until all *selected* types have a complete origin + * (the shared-`min` barrier). Which types must contribute is read from `ctx` (the + * selected v/a ids); with no selection context it coordinates across whatever types + * have data. A shared origin below {@link NEAR_ZERO_ORIGIN_THRESHOLD} is returned as `0` + * (native — ordinary ~0-PTS VOD isn't relocated). + */ +export const deriveSharedMinStartMediaTime: DeriveStartMediaTime = (containerData, ctx) => { + const contributingTypes: string[] = []; + if (ctx.selectedVideoTrackId != null) contributingTypes.push('video'); + if (ctx.selectedAudioTrackId != null) contributingTypes.push('audio'); + const types = contributingTypes.length > 0 ? contributingTypes : Object.keys(containerData); + + const origins = types.map((type) => ownOrigin(containerData[type])); + // Barrier: not ready until every contributing type has a complete origin. + if (origins.length === 0 || origins.some((origin) => origin === undefined)) return {}; + + const shared = thresholdOrigin(Math.min(...(origins as number[]))); + const out: Record = {}; + for (const type of Object.keys(containerData)) out[type] = shared; + return out; +}; + +/** + * Coordination-axis *off* — each type relocates by its own origin, independently. Not + * the default: it flattens real A/V skew (see {@link deriveSharedMinStartMediaTime}). + * Kept as an opt-in for compositions that know their A/V is aligned and want to skip + * the shared-`min` barrier (each type stamps as soon as its own origin is discovered). + */ +export const derivePerTypeStartMediaTime: DeriveStartMediaTime = (containerData) => { + const out: Record = {}; + for (const [type, data] of Object.entries(containerData)) { + const origin = ownOrigin(data); + out[type] = origin === undefined ? undefined : thresholdOrigin(origin); + } + return out; +}; + +export interface EstablishStartMediaTimeConfig { + /** The reduce seam (coordination knob). Defaults to {@link deriveSharedMinStartMediaTime}. */ + deriveStartMediaTime?: DeriveStartMediaTime; +} + +// ============================================================================ +// REACTOR (owns the transient slot; derives + consumes onto the model) +// ============================================================================ + +/** Stamp the derived per-track `startMediaTime` onto the model (idempotent — same reference when nothing moved). */ +function stampTracks(presentation: Presentation, startMediaTimes: Record): Presentation { + let changed = false; + const selectionSets = presentation.selectionSets.map((selectionSet) => ({ + ...selectionSet, + switchingSets: selectionSet.switchingSets.map((switchingSet) => ({ + ...switchingSet, + tracks: switchingSet.tracks.map((track) => { + const startMediaTime = startMediaTimes[track.type]; + if (startMediaTime === undefined || track.startMediaTime === startMediaTime) return track; + changed = true; + return { ...track, startMediaTime }; + }), + })), + })); + return changed ? ({ ...presentation, selectionSets } as Presentation) : presentation; +} + +type EstablishFsmState = 'inactive' | 'monitoring' | 'established'; + +interface EstablishStartMediaTimeDeps { + state: { + presentation: Signal; + mediaContainerData: Signal; + // Optional so the one behavior composes across video-only / audio-only / both, + // like other cross-track-type behaviors (present at runtime iff a sibling owns it). + selectedVideoTrackId?: ReadonlySignal; + selectedAudioTrackId?: ReadonlySignal; + }; + config?: EstablishStartMediaTimeConfig; +} + +function establishStartMediaTimeSetup({ + state, + config = {}, +}: EstablishStartMediaTimeDeps): Reactor { + const derive = config.deriveStartMediaTime ?? deriveSharedMinStartMediaTime; + + const selectionContext = (): DeriveStartMediaTimeContext => ({ + selectedVideoTrackId: state.selectedVideoTrackId?.get(), + selectedAudioTrackId: state.selectedAudioTrackId?.get(), + }); + + /** Established once the selected A/V tracks (whichever exist) carry `startMediaTime`. */ + const established = (): boolean => { + const presentation = state.presentation.get(); + if (!isResolvedPresentation(presentation)) return false; + const ids = [state.selectedVideoTrackId?.get(), state.selectedAudioTrackId?.get()].filter( + (id): id is string => id !== undefined + ); + return ids.length > 0 && ids.every((id) => findTrackById(presentation, id)?.startMediaTime !== undefined); + }; + + return createMachineReactor({ + initial: 'inactive', + monitor: () => { + if (!isResolvedPresentation(state.presentation.get())) return 'inactive'; + return established() ? 'established' : 'monitoring'; + }, + states: { + // Fresh per source: clear the transient slot so a new source re-discovers. + inactive: { entry: () => state.mediaContainerData.set(undefined) }, + // Derive: reduce the accumulating container data into per-track startMediaTime + // and stamp it onto the model. Re-runs as discover fills the slot; the update + // reads presentation untracked, so no self-loop. Disabled on entry to + // `established` (establish-once, sticky per source). + monitoring: { + effects: () => { + const containerData = state.mediaContainerData.get(); + if (!containerData) return; + const startMediaTimes = derive(containerData, selectionContext()); + // `current` is always resolved here — the monitor gates `monitoring` on a + // resolved presentation and transitions before effects re-run — so we cast + // to `Presentation` rather than re-narrowing. + update(state.presentation as Signal, (current) => + stampTracks(current as Presentation, startMediaTimes) + ); + }, + }, + established: {}, + }, + }); +} + +export const establishStartMediaTime: Behavior< + { + presentation: Signal; + mediaContainerData: Signal; + }, + Record, + EstablishStartMediaTimeConfig +> = { + stateKeys: ['presentation', 'mediaContainerData'], + contextKeys: [], + setup: establishStartMediaTimeSetup, +}; diff --git a/packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts b/packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts new file mode 100644 index 00000000..b0287c09 --- /dev/null +++ b/packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { + derivePerTypeStartMediaTime, + deriveSharedMinStartMediaTime, + NEAR_ZERO_ORIGIN_THRESHOLD, +} from '../establish-start-media-time'; + +describe('deriveSharedMinStartMediaTime', () => { + const sel = { selectedVideoTrackId: 'v', selectedAudioTrackId: 'a' }; + + it('relocates every type by the shared min across selected A/V origins', () => { + // Apple bipbop: video origin 10.000 (ts 6000, tfdt 60000), audio origin 9.956 + // (ts 48000, tfdt 477888). Audio leads by 44ms → shared min = 9.956 for BOTH, + // so relocating preserves the skew (video lands at +0.044, audio at 0). + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 6000, baseMediaDecodeTime: 60000, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 477888, segmentStartTime: 0 }, + }, + sel + ) + ).toEqual({ video: 9.956, audio: 9.956 }); + }); + + it('matches per-type when A/V is aligned (min equals each equal origin)', () => { + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 90000 * 60, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 48000 * 60, segmentStartTime: 0 }, + }, + sel + ) + ).toEqual({ video: 60, audio: 60 }); + }); + + it('barriers: returns nothing until every selected type has a complete origin', () => { + // Audio is selected but not yet discovered → hold back both (no partial relocation). + expect( + deriveSharedMinStartMediaTime( + { video: { timescale: 6000, baseMediaDecodeTime: 60000, segmentStartTime: 0 } }, + sel + ) + ).toEqual({}); + }); + + it('subtracts a non-zero segmentStartTime so the origin is the stream origin, not the loaded segment', () => { + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 90000 * 160, segmentStartTime: 100 }, + audio: { timescale: 48000, baseMediaDecodeTime: 48000 * 160, segmentStartTime: 100 }, + }, + sel + ) + ).toEqual({ video: 60, audio: 60 }); + }); + + it('degenerates to the single type when only one is selected', () => { + expect( + deriveSharedMinStartMediaTime( + { video: { timescale: 6000, baseMediaDecodeTime: 60000, segmentStartTime: 0 } }, + { selectedVideoTrackId: 'v' } + ) + ).toEqual({ video: 10 }); + }); + + it('coordinates across whatever types have data when there is no selection context', () => { + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 6000, baseMediaDecodeTime: 60000, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 477888, segmentStartTime: 0 }, + }, + {} + ) + ).toEqual({ video: 9.956, audio: 9.956 }); + }); + + it('leaves ordinary ~0-PTS VOD native: a shared origin below the threshold returns 0', () => { + // Both types carry a sub-second (0.5s) encode offset → not relocated. + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 45000, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 24000, segmentStartTime: 0 }, + }, + sel + ) + ).toEqual({ video: 0, audio: 0 }); + }); + + it('relocates at/above the threshold (the boundary is exclusive)', () => { + const atThreshold = { + video: { timescale: 90000, baseMediaDecodeTime: 90000 * NEAR_ZERO_ORIGIN_THRESHOLD, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 48000 * NEAR_ZERO_ORIGIN_THRESHOLD, segmentStartTime: 0 }, + }; + expect(deriveSharedMinStartMediaTime(atThreshold, sel)).toEqual({ + video: NEAR_ZERO_ORIGIN_THRESHOLD, + audio: NEAR_ZERO_ORIGIN_THRESHOLD, + }); + }); + + it('snaps a negative shared origin to 0 (never relocates forward)', () => { + // segmentStartTime > bmdt/ts → negative own origin. + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 0, segmentStartTime: 5 }, + audio: { timescale: 48000, baseMediaDecodeTime: 0, segmentStartTime: 5 }, + }, + sel + ) + ).toEqual({ video: 0, audio: 0 }); + }); +}); + +describe('derivePerTypeStartMediaTime', () => { + it('resolves each track type by its own origin (bmdt/ts − segmentStartTime)', () => { + expect( + derivePerTypeStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 90000 * 60, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 48000 * 59.956, segmentStartTime: 0 }, + }, + {} + ) + ).toEqual({ video: 60, audio: 59.956 }); + }); + + it('subtracts a non-zero segmentStartTime so it yields the stream origin, not the loaded segment', () => { + expect( + derivePerTypeStartMediaTime( + { video: { timescale: 90000, baseMediaDecodeTime: 90000 * 160, segmentStartTime: 100 } }, + {} + ) + ).toEqual({ video: 60 }); + }); + + it('is undefined for a type until timescale + baseMediaDecodeTime + segmentStartTime are all present', () => { + expect(derivePerTypeStartMediaTime({ video: { timescale: 90000 } }, {})).toEqual({ video: undefined }); + expect(derivePerTypeStartMediaTime({ audio: { baseMediaDecodeTime: 100, segmentStartTime: 0 } }, {})).toEqual({ + audio: undefined, + }); + }); + + it('snaps a below-threshold origin to 0 independently per type', () => { + expect( + derivePerTypeStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 45000, segmentStartTime: 0 }, // 0.5s → 0 + audio: { timescale: 48000, baseMediaDecodeTime: 48000 * 60, segmentStartTime: 0 }, // 60s → 60 + }, + {} + ) + ).toEqual({ video: 0, audio: 60 }); + }); +}); 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 210c73a1..658c2e20 100644 --- a/packages/spf/src/playback/engines/hls/engine-audio-only.ts +++ b/packages/spf/src/playback/engines/hls/engine-audio-only.ts @@ -9,7 +9,7 @@ import type { BackBufferConfig } from '../../../media/buffer/back-buffer'; import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer'; import { canPlayTrack } from '../../../media/dom/capabilities'; import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant'; -import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation } from '../../../media/types'; +import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation, MediaContainerData } from '../../../media/types'; import type { GetCdnId } from '../../../media/utils/cdn'; import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection'; import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; @@ -21,11 +21,21 @@ import { 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-steps'; import { setupAudioBufferActors } from '../../behaviors/dom/setup-buffer-actors'; import { setupMediaSource } from '../../behaviors/dom/setup-mediasource'; import { trackCurrentTime } from '../../behaviors/dom/track-current-time'; import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers'; import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration'; +// Non-zero-PTS relocation (spike): remove this import, the composed reactor, the +// `audioMessagePipelines` finalConfig entry, the `mediaContainerData` state slot, +// and the `deriveStartMediaTime` config field to drop relocation from audio-only. +import { + type DeriveStartMediaTime, + deriveSharedMinStartMediaTime, + establishStartMediaTime, +} from '../../behaviors/establish-start-media-time'; import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation'; import { resolveAudioTrack } from '../../behaviors/resolve-track'; import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor'; @@ -47,6 +57,9 @@ export interface SimpleHlsAudioOnlyEngineState { presentation?: MaybeResolvedPresentation; preload?: 'auto' | 'metadata' | 'none'; selectedAudioTrackId?: string; + // Non-zero-PTS relocation (spike): transient per-track container data owned by + // `establishStartMediaTime`. Remove with the composed reactor. + mediaContainerData?: Record; /** * Consumer-driven constraint narrowing the audio candidate set. Sibling * of `userVideoTrackSelection` in the default engine. Partial-track @@ -119,6 +132,8 @@ export interface SimpleHlsAudioOnlyEngineConfig * Defaults to the URL origin; override to key on e.g. Mux's `cdn=` param. */ getCdnId?: GetCdnId; + /** Non-zero-PTS relocation (spike): the reduce seam (tier knob); defaults to per-track own. */ + deriveStartMediaTime?: DeriveStartMediaTime; } // ============================================================================ @@ -166,11 +181,17 @@ const shareSignals = makeShareSignals { + const deriveStartMediaTime = config.deriveStartMediaTime ?? deriveSharedMinStartMediaTime; const finalConfig = { ...config, + deriveStartMediaTime, canPlayTrack: config.canPlayTrack ?? canPlayTrack, resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration, parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist, + // Non-zero-PTS relocation (spike): pair the audio loader with the relocation steps + // `establishStartMediaTime` derives from; same `deriveStartMediaTime` seam. Remove + // with the reactor. + audioMessagePipelines: relocationPipelinesFor('audio', deriveStartMediaTime), }; return createComposition( @@ -210,6 +231,13 @@ export function createHlsAudioOnlyEngine( // so the Firefox `mozHasAudio` registration ordering is moot here. setupMediaSource, updateMediaSourceDuration, + + // Non-zero-PTS relocation (spike): establishes per-track startMediaTime; + // MUST precede setupAudioBufferActors. Remove this line + the import + the + // finalConfig/state entries to drop relocation. (Selection is optional in the + // reactor, so it works with only audio in scope.) + establishStartMediaTime, + setupAudioBufferActors, // Playback tracking @@ -223,6 +251,9 @@ export function createHlsAudioOnlyEngine( // `mediaSource.sourceBuffers` aggregately — composes unchanged with // only audio in scope. endOfStream, + // Force native `ended` if Chrome freezes the playhead short of the buffered end + // after `endOfStream`. Inert for a clean-ending single-track source. + recoverEndStall, // Adapter signal callback. shareSignals, diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 90da594f..f49728d0 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -16,7 +16,14 @@ import { removeAllSubtitlesTracksFromMedia, } from '../../../media/dom/text/text-track-slots'; import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant'; -import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation, TextTrack, VideoTrack } from '../../../media/types'; +import type { + AudioTrack, + CanPlayTrack, + MaybeResolvedPresentation, + MediaContainerData, + TextTrack, + VideoTrack, +} from '../../../media/types'; import type { GetCdnId } from '../../../media/utils/cdn'; import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection'; import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator'; @@ -31,6 +38,8 @@ import { 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-steps'; 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'; @@ -38,6 +47,15 @@ import { syncTextTracks } from '../../behaviors/dom/sync-text-tracks'; import { trackCurrentTime } from '../../behaviors/dom/track-current-time'; import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers'; import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration'; +// Non-zero-PTS relocation (spike): remove this import, the composed reactor, the +// `video/audio/textMessagePipelines` finalConfig entries, the `mediaContainerData` +// state slot, and the `deriveStartMediaTime` config field to drop relocation entirely +// (text then falls back to the plain `resolveVttSegment` resolver). +import { + type DeriveStartMediaTime, + deriveSharedMinStartMediaTime, + establishStartMediaTime, +} from '../../behaviors/establish-start-media-time'; import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation'; import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../behaviors/resolve-track'; import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor'; @@ -66,6 +84,9 @@ export interface SimpleHlsEngineState { selectedAudioTrackId?: string; selectedTextTrackId?: string; bandwidthState?: BandwidthState; + // Non-zero-PTS relocation (spike): transient per-track container data owned by + // `establishStartMediaTime`. Remove with the composed reactor. + mediaContainerData?: Record; userVideoTrackSelection?: Partial; /** * Consumer-driven constraint narrowing the audio candidate set. Sibling @@ -238,6 +259,22 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig { + // Non-zero-PTS relocation (spike): resolve the coordination seam once so the reactor + // (model `startMediaTime`) and the loader stamps (buffer `timestampOffset`) apply the + // SAME derive. Default is shared-`min` across selected A/V (subsumes per-type). + const deriveStartMediaTime = config.deriveStartMediaTime ?? deriveSharedMinStartMediaTime; const finalConfig = { ...config, + deriveStartMediaTime, canPlayTrack: config.canPlayTrack ?? canPlayTrack, resolveTextTrackSegment: config.resolveTextTrackSegment ?? resolveVttSegment, + // Non-zero-PTS relocation (spike): the text pipeline rebases cues onto the + // relocated 0-based timeline. Remove `textMessagePipelines` to drop text relocation. + textMessagePipelines: relocatingTextPipelines, resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration, parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist, addSubtitlesTracksToMedia: config.addSubtitlesTracksToMedia ?? addSubtitlesTracksToMedia, getShowingSubtitlesTrackFromMedia: config.getShowingSubtitlesTrackFromMedia ?? getShowingSubtitlesTrackFromMedia, removeAllSubtitlesTracksFromMedia: config.removeAllSubtitlesTracksFromMedia ?? removeAllSubtitlesTracksFromMedia, + // Non-zero-PTS relocation (spike): the discover/stamp steps `establishStartMediaTime` + // pairs with. They apply the same `deriveStartMediaTime` seam as the reactor. Remove + // these two lines with the reactor. + videoMessagePipelines: relocationPipelinesFor('video', deriveStartMediaTime), + audioMessagePipelines: relocationPipelinesFor('audio', deriveStartMediaTime), }; return createComposition( @@ -343,6 +393,17 @@ export function createSimpleHlsEngine( // in setup-buffer-actors.ts. setupMediaSource, updateMediaSourceDuration, + + // ── Non-zero-PTS relocation (spike) ────────────────────────────────── + // Establishes per-track `startMediaTime` and publishes the relocating + // segment-loader pipelines to context. MUST precede `setup*BufferActors` + // so the pipelines are published before the loaders read them. Remove this + // one line (+ the import, the `mediaContainerData`/`*MessagePipelines` + // slots including `textMessagePipelines`, and the `deriveStartMediaTime` + // config) to drop relocation and test the Tier-0 baseline / bundle size. + establishStartMediaTime, + // ───────────────────────────────────────────────────────────────────── + setupVideoBufferActors, setupAudioBufferActors, @@ -366,6 +427,9 @@ export function createSimpleHlsEngine( // End of stream coordination endOfStream, + // Force native `ended` when Chrome freezes the playhead a few frames short of a + // skewed-A/V end after `endOfStream` (audio-clock stall). Inert otherwise. + recoverEndStall, // Text tracks syncTextTracks, diff --git a/packages/spf/src/playback/engines/hls/index.ts b/packages/spf/src/playback/engines/hls/index.ts index 2fe3c6c2..95f4ccc0 100644 --- a/packages/spf/src/playback/engines/hls/index.ts +++ b/packages/spf/src/playback/engines/hls/index.ts @@ -1,3 +1,11 @@ +// Non-zero-PTS relocation (spike): the coordination seam type + the shared-`min` +// default and the per-type alternative, for a consumer swapping the policy via +// `config.deriveStartMediaTime`. +export { + type DeriveStartMediaTime, + derivePerTypeStartMediaTime, + deriveSharedMinStartMediaTime, +} from '../../behaviors/establish-start-media-time'; export type { SimpleHlsMediaAPI, SimpleHlsMediaProps } from './adapter'; export { SimpleHlsMediaElement, SimpleHlsMediaMixin, simpleHlsMediaDefaultProps } from './adapter'; export type { SimpleHlsAudioOnlyMediaAPI, SimpleHlsAudioOnlyMediaProps } from './adapter-audio-only';