feat(spf): relocate non-zero-PTS origin to a 0-based timeline (spike)

Opt-in relocateTimestampOrigin on the HLS engine. The video loader reads
each track's decode-time origin (tfdt baseMediaDecodeTime / mdhd timescale)
from the init + first media segment, then relocates the buffer to 0-based
via SourceBuffer.timestampOffset = -origin, so currentTime/seekable/duration
stay 0-based with no adapter translation. Off by default (zero-PTS VOD pays
nothing).

The established offset is published on the segment-loader snapshot so the
text path can rebase cues onto the same timeline: cueFinal = cueNative +
timestampOffset, where cueNative applies the X-TIMESTAMP-MAP correction
(Apple) or the absolute cue time (map-less Mux, which can't self-derive the
origin and reads the A/V-established one).

Checkpoint before the composition-driven refactor.
This commit is contained in:
Christian Pillsbury
2026-07-08 10:13:20 -07:00
parent 555f9fdacc
commit 6a20bade9b
6 changed files with 208 additions and 18 deletions
@@ -13,6 +13,7 @@ import {
type ForwardBufferConfig,
getSegmentsToLoad,
} from '../../../media/buffer/forward-buffer';
import { readFirstBaseMediaDecodeTime, readFirstMediaTimescale } from '../../../media/mp4/timestamp-origin';
import {
type AddressableObject,
type AudioTrack,
@@ -20,7 +21,13 @@ import {
type Segment,
type VideoTrack,
} from '../../../media/types';
import type { AppendInitMessage, AppendSegmentMessage, RemoveMessage, SourceBufferActor } from './source-buffer';
import type {
AppendData,
AppendInitMessage,
AppendSegmentMessage,
RemoveMessage,
SourceBufferActor,
} from './source-buffer';
// ============================================================================
// BUFFER STATE TYPES
@@ -102,6 +109,14 @@ 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<SegmentLoaderActorState, SegmentLoaderActorContext, SegmentLoaderMessage>;
@@ -114,6 +129,23 @@ export type SegmentLoaderActor = MessageActor<SegmentLoaderActorState, SegmentLo
export interface SegmentLoaderActorConfig {
forwardBuffer?: Partial<ForwardBufferConfig>;
backBuffer?: Partial<BackBufferConfig>;
/**
* 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;
}
// ============================================================================
@@ -168,6 +200,28 @@ function waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSigna
});
}
/** Drain an async byte-iterable into one contiguous buffer (relocation spike). */
async function collect(iterable: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
const chunks: Uint8Array[] = [];
let total = 0;
for await (const chunk of iterable) {
chunks.push(chunk);
total += chunk.length;
}
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;
}
// ============================================================================
// LOAD TASK FACTORY
// ============================================================================
@@ -177,6 +231,7 @@ interface LoadTaskOptions {
setContext: (ctx: SegmentLoaderActorContext) => void;
fetchBytes: FetchBytes;
sourceBufferActor: SourceBufferActor;
relocation: RelocationState;
}
/**
@@ -187,7 +242,7 @@ interface LoadTaskOptions {
*/
function makeLoadTask(
op: LoadTask,
{ getContext, setContext, fetchBytes, sourceBufferActor }: LoadTaskOptions
{ getContext, setContext, fetchBytes, sourceBufferActor, relocation }: LoadTaskOptions
): Task<void> {
return new Task(async (taskSignal) => {
if (taskSignal.aborted) return;
@@ -203,7 +258,15 @@ function makeLoadTask(
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 });
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);
@@ -219,10 +282,32 @@ function makeLoadTask(
// directly to the actor so chunks are appended as they arrive.
setContext({ ...getContext(), inFlightSegmentId: op.meta.id });
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);
// 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);
}
}
} finally {
setContext({ ...getContext(), inFlightSegmentId: null });
@@ -263,6 +348,8 @@ 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 getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => {
// Exclude partial segments — they are still being streamed and must not be
@@ -441,7 +528,7 @@ export function createSegmentLoaderActor(
const scheduleAll = (tasks: LoadTask[], { getContext, setContext, runner }: Ctx): void => {
tasks.forEach((op) => {
runner
.schedule(makeLoadTask(op, { getContext, setContext, fetchBytes, sourceBufferActor }))
.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
@@ -455,7 +542,7 @@ export function createSegmentLoaderActor(
return createMachineActor<UserState, SegmentLoaderActorContext, SegmentLoaderMessage, () => SerialRunner>({
runner: () => new SerialRunner(),
initial: 'idle',
context: { inFlightInitTrackId: null, inFlightSegmentId: null },
context: { inFlightInitTrackId: null, inFlightSegmentId: null, relocationOffset: null },
states: {
idle: {
on: {
@@ -17,6 +17,13 @@ export type AppendSegmentMeta = Pick<Segment, 'id' | 'startTime' | 'duration'> &
trackId: Track['id'];
/** 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.
*/
timestampOffset?: number;
};
export type { AppendData };
@@ -151,6 +158,12 @@ 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) {
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.
@@ -146,7 +146,7 @@ function setupBufferActors<K extends SelectedTrackKey, A extends BufferActorKey,
fetch: FetchBytes;
} & SegmentLoaderActorConfig;
}): Reactor<BufferActorsFsmState | 'destroying' | 'destroyed'> {
const { type, selectedKey, actorKey, loaderKey, fetch, forwardBuffer, backBuffer } = config;
const { type, selectedKey, actorKey, loaderKey, fetch, forwardBuffer, backBuffer, relocateTimestampOrigin } = config;
const derivedStateSignal = computed<BufferActorsFsmState>(() => {
if (!context.mediaSource.get()) return 'preconditions-unmet';
const selection: TrackSelectionState = {
@@ -172,7 +172,11 @@ function setupBufferActors<K extends SelectedTrackKey, A extends BufferActorKey,
const track = getSelectedTrack(selection, type) as PartiallyResolvedTrack;
const buffer = createSourceBuffer(mediaSource, buildMimeCodec(track));
const bufferActor = createSourceBufferActor(buffer);
const segmentLoader = createSegmentLoaderActor(bufferActor, fetch, { forwardBuffer, backBuffer });
const segmentLoader = createSegmentLoaderActor(bufferActor, fetch, {
forwardBuffer,
backBuffer,
relocateTimestampOrigin,
});
// Synchronous slot writes — load-bearing for the Firefox
// `mozHasAudio` invariant (see file-level JSDoc). Both per-type
@@ -20,7 +20,9 @@
*/
import { defineBehavior } from '../../../core/composition/create-composition';
import { effect } from '../../../core/signals/effect';
import type { ReadonlySignal, Signal } from '../../../core/signals/primitives';
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 { createTextTracksActor } from '../../actors/dom/text-tracks';
import {
createTextTrackSegmentLoaderActor,
@@ -34,10 +36,75 @@ export interface TextTrackActorsContext {
mediaElement?: HTMLMediaElement | undefined;
textTracksActor?: TextTracksActor<VTTCue> | 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<VTTCue>;
/**
* 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<SegmentLoaderActor | undefined>): Promise<number> {
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<SegmentLoaderActor | undefined>
): TextTrackSegmentResolver<VTTCue> {
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({
@@ -48,6 +115,7 @@ function setupTextTrackActorsSetup({
mediaElement: ReadonlySignal<TextTrackActorsContext['mediaElement']>;
textTracksActor: Signal<TextTrackActorsContext['textTracksActor']>;
textTrackSegmentLoaderActor: Signal<TextTrackActorsContext['textTrackSegmentLoaderActor']>;
videoSegmentLoaderActor: ReadonlySignal<TextTrackActorsContext['videoSegmentLoaderActor']>;
};
config: TextTrackActorsConfig;
}): () => void {
@@ -55,12 +123,14 @@ 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,
config.resolveTextTrackSegment,
{ forwardBuffer: config.forwardBuffer }
);
const textTrackSegmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor, resolveTextTrackSegment, {
forwardBuffer: config.forwardBuffer,
});
context.textTracksActor.set(textTracksActor);
context.textTrackSegmentLoaderActor.set(textTrackSegmentLoaderActor);
@@ -75,6 +145,6 @@ function setupTextTrackActorsSetup({
export const setupTextTrackActors = defineBehavior({
stateKeys: [],
contextKeys: ['mediaElement', 'textTracksActor', 'textTrackSegmentLoaderActor'],
contextKeys: ['mediaElement', 'textTracksActor', 'textTrackSegmentLoaderActor', 'videoSegmentLoaderActor'],
setup: setupTextTrackActorsSetup,
});
@@ -10,6 +10,7 @@ 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';
@@ -44,6 +45,9 @@ 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(),
}));
@@ -66,6 +70,7 @@ function makeContext(initial: ComposedContext = {}): ContextSignals<ComposedCont
initial.textTracksActor as TextTracksActor<VTTCue & Cue> | undefined
) as ContextSignals<ComposedContext>['textTracksActor'],
textTrackSegmentLoaderActor: signal<TextTrackSegmentLoaderActor | undefined>(initial.textTrackSegmentLoaderActor),
videoSegmentLoaderActor: signal<SegmentLoaderActor | undefined>(initial.videoSegmentLoaderActor),
};
}
@@ -274,6 +274,16 @@ export interface SimpleHlsEngineConfig extends ShareSignalsConfig<SimpleHlsEngin
* key on something else (e.g. Mux's `cdn=` query param).
*/
getCdnId?: GetCdnId;
/**
* Non-zero-PTS relocation (spike). When on, the engine reads each source's
* decode-time origin (`tfdt` baseMediaDecodeTime ÷ `mdhd` timescale, via
* `media/mp4`) and relocates the buffer onto a 0-based presentation timeline
* with `SourceBuffer.timestampOffset = sharedOrigin`, so `currentTime` /
* `seekable` / `duration` stay 0-based with no adapter translation. **Off by
* default** zero-PTS VOD needs none of this and pays nothing (Tier 0). See
* `internal/design/spf/presentation-timeline-model.md`.
*/
relocateTimestampOrigin?: boolean;
}
// ============================================================================
@@ -342,6 +352,7 @@ export function createSimpleHlsEngine(
addSubtitlesTracksToMedia: config.addSubtitlesTracksToMedia ?? addSubtitlesTracksToMedia,
getShowingSubtitlesTrackFromMedia: config.getShowingSubtitlesTrackFromMedia ?? getShowingSubtitlesTrackFromMedia,
removeAllSubtitlesTracksFromMedia: config.removeAllSubtitlesTracksFromMedia ?? removeAllSubtitlesTracksFromMedia,
relocateTimestampOrigin: config.relocateTimestampOrigin ?? false,
};
const composition = createComposition(