diff --git a/packages/spf/src/media/presentation-anchor.ts b/packages/spf/src/media/presentation-anchor.ts new file mode 100644 index 00000000..eebeaa46 --- /dev/null +++ b/packages/spf/src/media/presentation-anchor.ts @@ -0,0 +1,98 @@ +import { isUndefined } from '@videojs/utils/predicate'; +import { getMediaPlaylistMetadata, type Track } from './types'; + +/** + * The presentation's live timeline anchor: the wall-clock (PDT, epoch seconds) + * that corresponds to **media-time 0**. One such value is shared across every + * track — establish it from one track and any other track positions itself by + * its own per-segment PDT. See + * [live-presentation-anchor](../../../internal/decisions/live-presentation-anchor.md). + * + * It is exactly the `Track.startDate` concept (wall clock at the timeline + * origin), promoted from per-track to presentation-level. + */ +export type PresentationAnchor = number; + +/** + * Derive the presentation anchor from a buffer pin — the authoritative source. + * Given a segment present in the track and where it *actually* landed in the + * SourceBuffer (`actualStart`, native PTS), the wall clock at media-time 0 is + * `segment.startDate − actualStart` (both along the linear timeline). `undefined` + * when the segment isn't present or carries no PDT. + */ +export function presentationAnchorFromBuffer( + track: Track, + segmentId: string, + actualStart: number +): PresentationAnchor | undefined { + const segment = track.segments.find((s) => s.id === segmentId); + if (!segment || isUndefined(segment.startDate)) return undefined; + return segment.startDate - actualStart; +} + +export interface PresentationAnchorEstimateOptions { + /** + * Sequence number assumed to be the stream's origin (time 0). Defaults to 0. + * See {@link presentationAnchorEstimate}. + */ + presumedStartSequence?: number; +} + +/** + * Estimate the presentation anchor from the manifest alone — the pre-buffer + * bootstrap, superseded by {@link presentationAnchorFromBuffer} once ground + * truth exists. A mid-join live playlist omits earlier segments, so the unseen + * origin's distance is estimated from the observed segments' **average** + * duration (more reliable than the target duration, a spec ceiling). The wall + * clock at media-time 0 is the anchor segment's PDT minus its estimated + * stream-origin offset, `(sequence − presumedStartSequence) × averageDuration`. + * + * ROUGH and provisional — assumes roughly-uniform durations and no + * discontinuities in the unseen past; error grows with the sequence gap. + * `undefined` when no segment carries PDT. + * + * TODO: the `mediaSequence` read is the remaining HLS coupling (a segment's + * ordinal-from-origin); neutralize it behind a format-neutral segment-position + * abstraction so DASH can supply its own (tracked separately). + */ +export function presentationAnchorEstimate( + track: Track, + { presumedStartSequence = 0 }: PresentationAnchorEstimateOptions = {} +): PresentationAnchor | undefined { + const { segments } = track; + const anchorIndex = segments.findIndex((segment) => !isUndefined(segment.startDate)); + const anchor = segments[anchorIndex]; + if (!anchor || isUndefined(anchor.startDate)) return undefined; + + const mediaSequence = getMediaPlaylistMetadata(track)?.mediaSequence ?? 0; + const anchorSequence = mediaSequence + anchorIndex; + const averageDuration = segments.reduce((sum, segment) => sum + segment.duration, 0) / segments.length; + const originOffset = (anchorSequence - presumedStartSequence) * averageDuration; + return anchor.startDate - originOffset; +} + +/** + * Re-origin a track's timeline so media-time 0 coincides with the shared + * presentation `anchor` (PDT at the origin). The track shifts by + * `track.startDate − anchor` — its own per-segment PDT carries it onto the + * shared timeline, so one anchor positions every track without a second buffer + * read. (Under the no-inter-track-skew assumption all tracks share the PTS + * clock; see the decision doc.) + * + * Segment `startDate` (intrinsic PDT) stays put; only timeline positions move. + * No-op when the track has no PDT origin (`startDate` undefined) or is already + * on the anchor — so callers can apply it unconditionally. + */ +export function positionTrackToAnchor(track: Tracks, anchor: PresentationAnchor): Tracks { + if (isUndefined(track.startDate)) return track; + + const shift = track.startDate - anchor; + if (shift === 0) return track; + + return { + ...track, + startTime: track.startTime + shift, + startDate: anchor, + segments: track.segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift })), + }; +} diff --git a/packages/spf/src/media/tests/presentation-anchor.test.ts b/packages/spf/src/media/tests/presentation-anchor.test.ts new file mode 100644 index 00000000..c266aad6 --- /dev/null +++ b/packages/spf/src/media/tests/presentation-anchor.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { anchorTrackToBufferedSegment } from '../anchor-track-to-buffered-segment'; +import { + positionTrackToAnchor, + presentationAnchorEstimate, + presentationAnchorFromBuffer, +} from '../presentation-anchor'; +import { MEDIA_PLAYLIST_METADATA_KEY, type Track } from '../types'; + +/** Minimal live track: a window starting at `startTime`, 2s segments, PDT from `startDate`. */ +function track( + opts: { + startTime?: number; + startDate?: number | undefined; + mediaSequence?: number; + segmentStartDates?: (number | undefined)[]; + } = {} +): Track { + const startTime = opts.startTime ?? 100; + const startDate = 'startDate' in opts ? opts.startDate : 1000; + return { + type: 'video', + id: 'v-1', + startTime, + startDate, + segments: [0, 2, 4, 6, 8].map((offset, i) => ({ + id: `segment-${(opts.mediaSequence ?? 50) + i}`, + url: `${(opts.mediaSequence ?? 50) + i}.m4s`, + duration: 2, + startTime: startTime + offset, + // Each segment's PDT = the track origin (startDate) + the segment's own + // media-time (startTime + offset), so track.startDate stays the PDT at time 0. + startDate: opts.segmentStartDates + ? opts.segmentStartDates[i] + : startDate === undefined + ? undefined + : startDate + startTime + offset, + })), + metadata: { + [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: opts.mediaSequence ?? 50, targetDuration: 2, endList: false }, + }, + } as unknown as Track; +} + +describe('presentationAnchorFromBuffer', () => { + it('is the pinned segment PDT minus where it actually landed (PDT at media-time 0)', () => { + // segment-50 has PDT 1100 and actually landed at native PTS 480 → anchor 620. + expect(presentationAnchorFromBuffer(track(), 'segment-50', 480)).toBe(620); + }); + + it('is undefined when the segment is absent or carries no PDT', () => { + expect(presentationAnchorFromBuffer(track(), 'missing', 480)).toBeUndefined(); + expect( + presentationAnchorFromBuffer(track({ segmentStartDates: [undefined, 1, 2, 3, 4] }), 'segment-50', 480) + ).toBeUndefined(); + }); +}); + +describe('presentationAnchorEstimate', () => { + it('estimates PDT at media-time 0 from sequence × average duration', () => { + // anchor segment-50 PDT 1100; originOffset = (50 − 0) × 2 = 100 → anchor 1000. + expect(presentationAnchorEstimate(track())).toBe(1000); + }); + + it('honors presumedStartSequence', () => { + // originOffset = (50 − 10) × 2 = 80 → 1100 − 80 = 1020. + expect(presentationAnchorEstimate(track(), { presumedStartSequence: 10 })).toBe(1020); + }); + + it('is undefined when no segment carries PDT', () => { + expect(presentationAnchorEstimate(track({ startDate: undefined }))).toBeUndefined(); + }); +}); + +describe('positionTrackToAnchor', () => { + it('shifts the track so startDate coincides with the anchor', () => { + // track.startDate 1000, anchor 900 → shift +100 (startTime 100 → 200). + const positioned = positionTrackToAnchor(track(), 900); + expect(positioned.startDate).toBe(900); + expect(positioned.startTime).toBe(200); + expect(positioned.segments.map((s) => s.startTime)).toEqual([200, 202, 204, 206, 208]); + // Segment PDTs are intrinsic — they don't move. + expect(positioned.segments.map((s) => s.startDate)).toEqual([1100, 1102, 1104, 1106, 1108]); + }); + + it('is a no-op when already on the anchor or the track has no PDT origin', () => { + const t = track(); + expect(positionTrackToAnchor(t, 1000)).toBe(t); // startDate already 1000 + const noPdt = track({ startDate: undefined }); + expect(positionTrackToAnchor(noPdt, 900)).toBe(noPdt); + }); +}); + +describe('generalizes anchorTrackToBufferedSegment', () => { + it('positioning to the buffer-derived anchor equals the per-track buffer pin', () => { + const t = track(); + const anchor = presentationAnchorFromBuffer(t, 'segment-50', 480)!; + const viaAnchor = positionTrackToAnchor(t, anchor); + const viaPin = anchorTrackToBufferedSegment(t, 'segment-50', 480); + expect(viaAnchor.startTime).toBe(viaPin.startTime); + expect(viaAnchor.startDate).toBe(viaPin.startDate); + expect(viaAnchor.segments.map((s) => s.startTime)).toEqual(viaPin.segments.map((s) => s.startTime)); + }); +});