diff --git a/packages/spf/src/playback/actors/dom/segment-loader.ts b/packages/spf/src/playback/actors/dom/segment-loader.ts index a6c22b6c..5e7d18e7 100644 --- a/packages/spf/src/playback/actors/dom/segment-loader.ts +++ b/packages/spf/src/playback/actors/dom/segment-loader.ts @@ -13,7 +13,6 @@ import { type ForwardBufferConfig, getSegmentsToLoad, } from '../../../media/buffer/forward-buffer'; -import { readFirstBaseMediaDecodeTime, readFirstMediaTimescale } from '../../../media/mp4/timestamp-origin'; import { type AddressableObject, type AudioTrack, @@ -22,9 +21,9 @@ import { type VideoTrack, } from '../../../media/types'; import type { - AppendData, AppendInitMessage, AppendSegmentMessage, + IndividualSourceBufferMessage, RemoveMessage, SourceBufferActor, } from './source-buffer'; @@ -109,18 +108,51 @@ export interface SegmentLoaderActorContext { inFlightInitTrackId: string | null; /** Segment ID currently being fetched/appended, or null. */ inFlightSegmentId: string | null; - /** - * Relocation offset in seconds (`−baseMediaDecodeTime / timescale`), or null - * until established / when relocation is off (spike). Exposed on the snapshot - * so the text path can consume the same offset to rebase cues — Mux cues carry - * no `X-TIMESTAMP-MAP`, so text can't self-derive the origin and reads the - * A/V-established one instead. - */ - relocationOffset: number | null; } 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 — only a *parameterized or stateful* step + * (relocation's `tapOrigin`) needs to be a factory. + */ +export type LoadStep = (frame: Frame, signal: AbortSignal, deps: StepDeps) => void | Promise; + +/** Per-actor runtime dependencies, passed to each {@link LoadStep} on every call. */ +export interface StepDeps { + sourceBufferActor: SourceBufferActor; + fetchBytes: 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 `createRelocation`), 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 @@ -129,23 +161,8 @@ export type SegmentLoaderActor = MessageActor; backBuffer?: Partial; - /** - * Non-zero-PTS relocation (spike). When true, this loader reads its track's - * decode-time origin (`tfdt`/`mdhd`) from the init + first media segment and - * relocates the buffer to 0-based via `timestampOffset`. Per-track single-origin - * (Tier-1); off by default. - */ - relocateTimestampOrigin?: boolean; -} - -/** Per-track relocation working state (spike). Established once, on first media segment. */ -interface RelocationState { - readonly enabled: boolean; - /** `mdhd` timescale from the init segment. */ - timescale?: number; - /** `−(baseMediaDecodeTime / timescale)` — the SourceBuffer timestampOffset. */ - offset?: number; - established: boolean; + /** Per-message-type step pipelines. Defaults to {@link DEFAULT_MESSAGE_PIPELINES} (`fetch → dispatch`). */ + messagePipelines?: MessagePipelines; } // ============================================================================ @@ -200,27 +217,46 @@ function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSigna }); } -/** Drain an async byte-iterable into one contiguous buffer (relocation spike). */ -async function collect(iterable: AsyncIterable): Promise { - const chunks: Uint8Array[] = []; - let total = 0; - for await (const chunk of iterable) { - chunks.push(chunk); - total += chunk.length; +// ============================================================================ +// 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 }; } - const out = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.length; - } - return out; } -/** A collected buffer spans its backing ArrayBuffer exactly — hand it to MSE as one append. */ -function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { - return bytes.buffer as ArrayBuffer; -} +/** + * 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 + frame.data = await deps.fetchBytes(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) => { + deps.sourceBufferActor.send(toMessage(frame)); + await waitForIdle(deps.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 @@ -229,88 +265,36 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { interface LoadTaskOptions { getContext: () => SegmentLoaderActorContext; setContext: (ctx: SegmentLoaderActorContext) => void; - fetchBytes: FetchBytes; - sourceBufferActor: SourceBufferActor; - relocation: RelocationState; + 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, relocation }: 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 body = await fetchBytes(op, { signal: taskSignal, minChunkSize: Infinity }); - // Relocation (spike): read this track's mdhd timescale from the init — - // half of the decode-time origin (paired with the first segment's tfdt). - let data: AppendData = body; - if (relocation.enabled) { - const bytes = await collect(body); - relocation.timescale = readFirstMediaTimescale(bytes); - data = toArrayBuffer(bytes); - } - 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 { - // Relocation (spike): on the FIRST media segment, fetch the whole body, - // read its tfdt baseMediaDecodeTime, pair with the init timescale to get - // this track's decode-time origin, and carry `timestampOffset = −origin` - // in the meta so the actor sets it before appending. Established once; - // subsequent segments stream as usual (the offset persists on the buffer). - if (relocation.enabled && !relocation.established) { - const bytes = await collect(await fetchBytes(op, { signal: taskSignal, minChunkSize: Infinity })); - const baseMediaDecodeTime = readFirstBaseMediaDecodeTime(bytes); - if (baseMediaDecodeTime !== undefined && relocation.timescale) { - relocation.offset = -(baseMediaDecodeTime / relocation.timescale); - // Publish on the actor snapshot so the text path can read the same - // offset for cue rebasing (see SegmentLoaderActorContext.relocationOffset). - setContext({ ...getContext(), relocationOffset: relocation.offset }); - } - relocation.established = true; - if (!taskSignal.aborted) { - const meta = relocation.offset !== undefined ? { ...op.meta, timestampOffset: relocation.offset } : op.meta; - sourceBufferActor.send({ type: 'append-segment', data: toArrayBuffer(bytes), meta }); - await waitForIdle(sourceBufferActor.snapshot, taskSignal); - } - } else { - 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 }); } }); } @@ -348,8 +332,9 @@ export function createSegmentLoaderActor( const forwardBufferConfig: ForwardBufferConfig = { ...DEFAULT_FORWARD_BUFFER_CONFIG, ...config.forwardBuffer }; const backBufferConfig: BackBufferConfig = { ...DEFAULT_BACK_BUFFER_CONFIG, ...config.backBuffer }; - // Per-track relocation state (spike), established once on the first media segment. - const relocation: RelocationState = { enabled: config.relocateTimestampOrigin ?? false, established: false }; + const deps: StepDeps = { sourceBufferActor, 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 @@ -527,22 +512,20 @@ export function createSegmentLoaderActor( const scheduleAll = (tasks: LoadTask[], { getContext, setContext, runner }: Ctx): void => { tasks.forEach((op) => { - runner - .schedule(makeLoadTask(op, { getContext, setContext, fetchBytes, sourceBufferActor, relocation })) - .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(); + }); }); }; return createMachineActor SerialRunner>({ runner: () => new SerialRunner(), initial: 'idle', - context: { inFlightInitTrackId: null, inFlightSegmentId: null, relocationOffset: null }, + context: { inFlightInitTrackId: null, inFlightSegmentId: null }, states: { idle: { on: { diff --git a/packages/spf/src/playback/actors/dom/source-buffer.ts b/packages/spf/src/playback/actors/dom/source-buffer.ts index e0658ff6..83a20e28 100644 --- a/packages/spf/src/playback/actors/dom/source-buffer.ts +++ b/packages/spf/src/playback/actors/dom/source-buffer.ts @@ -18,10 +18,10 @@ export type AppendSegmentMeta = Pick & /** Declared track bandwidth in bps (from playlist BANDWIDTH attribute). */ trackBandwidth?: number; /** - * Non-zero-PTS relocation (spike): when present, set as - * `SourceBuffer.timestampOffset` before this append so native PTS is relocated - * onto a 0-based presentation timeline. Constant per source; the loader - * includes it once (on the first media segment). Absent = no relocation. + * 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; }; @@ -158,10 +158,10 @@ function appendSegmentTask( }); } - // Relocation (non-zero-PTS spike): set the offset before the coded frames - // are appended. The SerialRunner guarantees the buffer is idle here, so the - // assignment is safe; it's constant per source (loader sends it once). - if (meta.timestampOffset != null) { + // 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); 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 d1f4bc7b..ddd85af0 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, relocateTimestampOrigin } = config; + const { type, selectedKey, actorKey, loaderKey, fetch, forwardBuffer, backBuffer, messagePipelines } = config; const derivedStateSignal = computed(() => { if (!context.mediaSource.get()) return 'preconditions-unmet'; const selection: TrackSelectionState = { @@ -175,7 +176,7 @@ function setupBufferActors; }; context: BufferActorsContextMap<'videoBufferActor', 'videoSegmentLoaderActor'>; - config?: SegmentLoaderActorConfig & { getCdnId?: GetCdnId }; + config?: SegmentLoaderActorConfig & { + getCdnId?: GetCdnId; + /** Optional non-zero-PTS relocation pipelines (Tier-1); inert when absent. */ + videoMessagePipelines?: MessagePipelines; + }; }) => { // Bandwidth-sampling fetch. The factory accumulates EWMA state // internally; the callback bridges samples to engine state for ABR. @@ -250,7 +255,11 @@ export const setupVideoBufferActors = defineBehavior({ return setupBufferActors({ state, context, - config: { ...typeConfig, fetch: failoverFetch(trackedFetch, state, typeConfig) }, + config: { + ...typeConfig, + fetch: failoverFetch(trackedFetch, state, typeConfig), + messagePipelines: config.videoMessagePipelines, + }, }); }, }); @@ -283,14 +292,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); inert 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, + fetch: failoverFetch(fetchStream, state, typeConfig), + messagePipelines: config.audioMessagePipelines, + }, }); }, }); 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 20b6550b..b56c1ed2 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 @@ -20,9 +20,7 @@ */ import { defineBehavior } from '../../../core/composition/create-composition'; import { effect } from '../../../core/signals/effect'; -import { peek, type ReadonlySignal, type Signal } from '../../../core/signals/primitives'; -import { resolveVttSegmentWithMetadata } from '../../../media/dom/text/resolve-vtt-segment'; -import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; +import type { ReadonlySignal, Signal } from '../../../core/signals/primitives'; import { createTextTracksActor } from '../../actors/dom/text-tracks'; import { createTextTrackSegmentLoaderActor, @@ -36,75 +34,10 @@ export interface TextTrackActorsContext { mediaElement?: HTMLMediaElement | undefined; textTracksActor?: TextTracksActor | undefined; textTrackSegmentLoaderActor?: TextTrackSegmentLoaderActor | undefined; - /** - * Read-only, for cue rebasing under non-zero-PTS relocation (spike). The video - * loader establishes the shared decode-time offset and publishes it on its - * snapshot; the relocating resolver below reads it so text cues land on the - * same 0-based timeline as the relocated A/V. - */ - videoSegmentLoaderActor?: SegmentLoaderActor | undefined; } export interface TextTrackActorsConfig extends TextTrackSegmentLoaderActorConfig { resolveTextTrackSegment: TextTrackSegmentResolver; - /** - * Non-zero-PTS relocation (spike). When on, cues are rebased onto the relocated - * 0-based presentation timeline: `cueFinal = cueNative + timestampOffset`, where - * `cueNative = LOCAL + MPEGTS/90000` (`X-TIMESTAMP-MAP`) or the absolute cue time - * (no map), and `timestampOffset` is the video loader's established relocation - * offset. Off → the injected resolver is used unchanged (Tier 0). - */ - relocateTimestampOrigin?: boolean; -} - -/** - * Resolve when the video loader has published its relocation offset (spike). Text - * cues can't self-derive the origin for map-less sources (Mux), so the first text - * segment must wait for the A/V ground truth rather than land ~60s off. - */ -function awaitRelocationOffset(videoLoader: ReadonlySignal): Promise { - const read = (): number | null => { - const actor = peek(videoLoader); - return actor ? peek(actor.snapshot).context.relocationOffset : null; - }; - const current = read(); - if (current !== null) return Promise.resolve(current); - return new Promise((resolve) => { - let stop: (() => void) | undefined; - stop = effect(() => { - const actor = videoLoader.get(); - const offset = actor ? actor.snapshot.get().context.relocationOffset : null; - if (offset !== null) { - stop?.(); - resolve(offset); - } - }); - }); -} - -/** - * Wrap a cue resolver so cues are rebased onto the relocated 0-based timeline - * (spike). `X-TIMESTAMP-MAP` (Apple) puts cues at LOCAL time and the map's - * `mpegts/90000 − local` corrects them to the media timeline; absolute cues (Mux) - * need no correction. Both then shift by the video loader's relocation offset. - */ -function makeRelocatingResolver( - videoLoader: ReadonlySignal -): TextTrackSegmentResolver { - return async (url) => { - const { cues, metadata } = await resolveVttSegmentWithMetadata(url); - const offset = await awaitRelocationOffset(videoLoader); - const map = metadata.timestampMap; - const mapCorrection = map?.local !== undefined ? map.mpegts / 90000 - map.local : 0; - const delta = mapCorrection + offset; - if (delta !== 0) { - for (const cue of cues) { - cue.startTime += delta; - cue.endTime += delta; - } - } - return cues; - }; } function setupTextTrackActorsSetup({ @@ -115,7 +48,6 @@ function setupTextTrackActorsSetup({ mediaElement: ReadonlySignal; textTracksActor: Signal; textTrackSegmentLoaderActor: Signal; - videoSegmentLoaderActor: ReadonlySignal; }; config: TextTrackActorsConfig; }): () => void { @@ -123,14 +55,12 @@ function setupTextTrackActorsSetup({ const mediaElement = context.mediaElement.get(); if (!mediaElement) return; - const resolveTextTrackSegment = config.relocateTimestampOrigin - ? makeRelocatingResolver(context.videoSegmentLoaderActor) - : config.resolveTextTrackSegment; - const textTracksActor = createTextTracksActor(mediaElement); - const textTrackSegmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor, resolveTextTrackSegment, { - forwardBuffer: config.forwardBuffer, - }); + const textTrackSegmentLoaderActor = createTextTrackSegmentLoaderActor( + textTracksActor, + config.resolveTextTrackSegment, + { forwardBuffer: config.forwardBuffer } + ); context.textTracksActor.set(textTracksActor); context.textTrackSegmentLoaderActor.set(textTrackSegmentLoaderActor); @@ -145,6 +75,6 @@ function setupTextTrackActorsSetup({ export const setupTextTrackActors = defineBehavior({ stateKeys: [], - contextKeys: ['mediaElement', 'textTracksActor', 'textTrackSegmentLoaderActor', 'videoSegmentLoaderActor'], + 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 c3e43980..93e2c194 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 @@ -10,7 +10,6 @@ import type { Segment, TextTrack, } from '../../../../media/types'; -import type { SegmentLoaderActor } from '../../../actors/dom/segment-loader'; import type { TextTrackSegmentLoaderActor } from '../../../actors/text-track-segment-loader'; import type { TextTracksActor } from '../../../actors/text-tracks'; import { loadTextTrackSegments } from '../load-segments'; @@ -45,9 +44,6 @@ vi.mock('../../../../media/dom/text/resolve-vtt-segment', () => ({ } return Promise.resolve([new VTTCue(0, 5, `Subtitle from ${url}`)]); }), - resolveVttSegmentWithMetadata: vi.fn((url: string) => - Promise.resolve({ cues: [new VTTCue(0, 5, `Subtitle from ${url}`)], metadata: {} }) - ), destroyVttResolver: vi.fn(), })); @@ -70,7 +66,6 @@ function makeContext(initial: ComposedContext = {}): ContextSignals | undefined ) as ContextSignals['textTracksActor'], textTrackSegmentLoaderActor: signal(initial.textTrackSegmentLoaderActor), - videoSegmentLoaderActor: signal(initial.videoSegmentLoaderActor), }; } diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index 768f3a1d..67bbfc89 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -30,7 +30,7 @@ import type { import type { GetCdnId } from '../../../media/utils/cdn'; import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection'; import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator'; -import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; +import type { MessagePipelines, SegmentLoaderActor } from '../../actors/dom/segment-loader'; import type { SourceBufferActor } from '../../actors/dom/source-buffer'; import type { TextTracksActor } from '../../actors/dom/text-tracks'; import type { TextTrackSegmentLoaderActor, TextTrackSegmentResolver } from '../../actors/text-track-segment-loader'; @@ -275,15 +275,15 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig void): LoadStep { + const discover = createOriginDiscoverer(publish); + return async (frame) => { + if (frame.data) frame.data = await discover(frame.data); + }; +} + +/** + * Stamp the established offset onto the append meta (no-op until it resolves). + * Synchronous: `tapOrigin` runs earlier in the same segment's pipeline, so the + * offset is published by now. A Tier-2 shared-`min` variant would await the + * cross-track reduce here instead. + */ +function stampOffset(offset: ReadonlySignal): LoadStep { + return (frame) => { + const timestampOffset = peek(offset); + if (timestampOffset != null && frame.meta) frame.meta = { ...frame.meta, timestampOffset }; + }; +} + +/** Resolve once `source` holds a number — so the first text cue waits for the A/V ground truth. */ +function awaitDefined(source: ReadonlySignal): Promise { + const current = peek(source); + if (current !== undefined) return Promise.resolve(current); + return new Promise((resolve) => { + let stop: (() => void) | undefined; + stop = effect(() => { + const value = source.get(); + if (value !== undefined) { + stop?.(); + resolve(value); + } + }); + }); +} + +function createRelocatingTextResolver(offset: ReadonlySignal): TextTrackSegmentResolver { + return async (url) => { + const { cues, metadata } = await resolveVttSegmentWithMetadata(url); + const relocationOffset = await awaitDefined(offset); + const map = metadata.timestampMap; + // The LOCAL→native correction is `mpegts/90000 − local`; absent map → 0. + const mapCorrection = map?.local !== undefined ? map.mpegts / 90000 - map.local : 0; + const delta = mapCorrection + relocationOffset; + if (delta !== 0) { + for (const cue of cues) { + cue.startTime += delta; + cue.endTime += delta; + } + } + return cues; + }; +} + +/** Build a track's pipelines around its own offset signal. `tapOrigin`'s discoverer is created once per actor (shared across init + segments). */ +function relocatingPipelines(offset: Signal): MessagePipelines { + const publish = (offsetSeconds: number) => offset.set(offsetSeconds); + return () => { + // One discoverer per actor: the init establishes the timescale, the first + // media segment establishes `tfdt` + publishes — so the same `tapOrigin` + // step must be shared across both pipelines. + const tap = tapOrigin(publish); + return { + remove: [dispatchStep], + 'append-init': [fetchStep, tap, dispatchStep], + 'append-segment': [fetchStep, tap, stampOffset(offset), dispatchStep], + }; + }; +} + +/** Seam values a composition injects to enable non-zero-PTS relocation. */ +export interface Relocation { + videoMessagePipelines: MessagePipelines; + audioMessagePipelines: MessagePipelines; + resolveTextTrackSegment: TextTrackSegmentResolver; +} + +/** + * Build the relocation seam bundle. Spread into a `SimpleHlsEngineConfig` to + * compose a non-zero-PTS engine: + * + * ```ts + * createSimpleHlsEngine({ ...config, ...createRelocation() }); + * ``` + */ +export function createRelocation(): Relocation { + const videoOffset = signal(undefined); + const audioOffset = signal(undefined); + return { + videoMessagePipelines: relocatingPipelines(videoOffset), + audioMessagePipelines: relocatingPipelines(audioOffset), + resolveTextTrackSegment: createRelocatingTextResolver(videoOffset), + }; +} diff --git a/packages/spf/src/playback/primitives/origin-discoverer.ts b/packages/spf/src/playback/primitives/origin-discoverer.ts new file mode 100644 index 00000000..eb180ab1 --- /dev/null +++ b/packages/spf/src/playback/primitives/origin-discoverer.ts @@ -0,0 +1,65 @@ +/** + * Non-zero-PTS **discover**: a slim, eager head-peek decoration on a segment's + * byte stream that reads the decode-time origin and publishes the relocation + * offset — the content-inspection half of relocation, kept out of the fetch + * (transport) abstraction. + * + * Tier-1-only — the always-present segment loader never imports this; a + * relocating composition injects it as the loader's `discover` seam, so the mp4 + * parser tree-shakes out of a Tier-0 build. + * + * Unlike a throughput/failover fetch tap (which observes chunks *post-hoc*, as + * the appender pulls them), discovery feeds the *same* segment's append — + * `SourceBuffer.timestampOffset` must be set before the frames append — so it + * reads the head **eagerly**: pull chunks only until the boxes parse (usually + * one), publish, then re-emit the pulled head followed by the untouched tail, so + * the append still **streams**. The init segment carries `mdhd` timescale, a + * media segment carries `tfdt` baseMediaDecodeTime (both readers return + * `undefined` when their box is absent, so one discoverer handles both and + * self-discriminates). Once established it's a pure pass-through. + */ +import { readFirstBaseMediaDecodeTime, readFirstMediaTimescale } from '../../media/mp4/timestamp-origin'; + +function concat(chunks: Uint8Array[]): Uint8Array { + if (chunks.length === 1) return chunks[0]!; + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +/** Re-emit the eagerly-pulled head chunks, then stream the untouched tail. */ +async function* reassemble(head: Uint8Array[], tail: AsyncIterator): AsyncIterable { + yield* head; + for (let next = await tail.next(); !next.done; next = await tail.next()) yield next.value; +} + +export function createOriginDiscoverer( + publish: (offsetSeconds: number) => void +): (data: AsyncIterable) => Promise> { + let timescale: number | undefined; + let established = false; + return async (data) => { + if (established) return data; + const iterator = data[Symbol.asyncIterator](); + const head: Uint8Array[] = []; + for (let next = await iterator.next(); !next.done; next = await iterator.next()) { + head.push(next.value); + const bytes = concat(head); + timescale ??= readFirstMediaTimescale(bytes); + const baseMediaDecodeTime = readFirstBaseMediaDecodeTime(bytes); + if (baseMediaDecodeTime !== undefined && timescale !== undefined) { + publish(-(baseMediaDecodeTime / timescale)); + established = true; + break; + } + // Init data (timescale but no `moof`) — nothing more to peek here. + if (timescale !== undefined) break; + } + return reassemble(head, iterator); + }; +}