diff --git a/packages/spf/src/media/anchor-track-to-sequence-origin.ts b/packages/spf/src/media/anchor-track-to-sequence-origin.ts new file mode 100644 index 00000000..537693e7 --- /dev/null +++ b/packages/spf/src/media/anchor-track-to-sequence-origin.ts @@ -0,0 +1,65 @@ +import { isUndefined } from '@videojs/utils/predicate'; +import { getMediaPlaylistMetadata, type Track } from './types'; + +export interface AnchorToSequenceOriginOptions { + /** + * Sequence number assumed to be the stream's origin (time 0). Defaults to 0 + * — the spec default when `EXT-X-MEDIA-SEQUENCE` is absent, and the common + * encoder convention. Override when the true origin sequence is known. + */ + startSequence?: number; +} + +/** + * Re-origin a track's timeline to an estimated stream start (the segment at + * `startSequence`, default 0), so `startTime` reads as elapsed-since-stream-start + * and `startDate` becomes the wall clock at that origin — the stream-absolute + * convention, from the manifest alone. + * + * A mid-join live playlist omits the earlier segments, so their total duration + * is estimated from the observed segments' **average duration** — more reliable + * than `EXT-X-TARGETDURATION` (a spec ceiling that systematically + * over-estimates). The origin offset of the first PDT-bearing segment is + * `(its sequence − startSequence) × averageDuration`; present segments keep + * their actual relative spacing, only the offset to the unseen origin is + * estimated. + * + * ROUGH and provisional: assumes `startSequence` is the true origin (often but + * not always correct — configurable), roughly uniform durations, and no + * discontinuities in the unseen past; error grows with the sequence gap. + * Refined later from the buffer (`buffered`/`tfdt`), which is authoritative. + * + * Per-track: each track estimates independently, so two tracks' results can + * differ by their accumulated average-duration difference (e.g. AAC audio vs + * video). Exact cross-track A/V alignment comes from `alignTrackTimelines` + * (PDT) and ultimately the buffer, not from these estimates. + * + * No-op when there are no segments or none carries `programDateTime`. + */ +export function anchorTrackToSequenceOrigin( + track: Tracks, + { startSequence = 0 }: AnchorToSequenceOriginOptions = {} +): Tracks { + const { segments } = track; + const anchorIndex = segments.findIndex((segment) => !isUndefined(segment.programDateTime)); + const anchor = segments[anchorIndex]; + if (!anchor || isUndefined(anchor.programDateTime)) { + return track; + } + + 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 - startSequence) * averageDuration; + const shift = originOffset - anchor.startTime; + if (shift === 0) { + return track; + } + + return { + ...track, + startTime: track.startTime + shift, + startDate: anchor.programDateTime - originOffset, + segments: segments.map((segment) => ({ ...segment, startTime: segment.startTime + shift })), + }; +} diff --git a/packages/spf/src/media/tests/anchor-track-to-sequence-origin.test.ts b/packages/spf/src/media/tests/anchor-track-to-sequence-origin.test.ts new file mode 100644 index 00000000..d3eaa58c --- /dev/null +++ b/packages/spf/src/media/tests/anchor-track-to-sequence-origin.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { anchorTrackToSequenceOrigin } from '../anchor-track-to-sequence-origin'; +import { MEDIA_PLAYLIST_METADATA_KEY, type Segment, type Track } from '../types'; + +function makeTrack( + mediaSequence: number, + segments: Array<{ startTime: number; duration: number; pdt?: number }> +): Track { + return { + type: 'video', + id: 'track', + url: 'https://example.com/playlist.m3u8', + mimeType: 'video/mp4', + bandwidth: 0, + duration: Number.POSITIVE_INFINITY, + startTime: segments[0]?.startTime ?? 0, + segments: segments.map( + (s, i): Segment => ({ + id: `segment-${mediaSequence + i}`, + url: `${mediaSequence + i}.m4s`, + duration: s.duration, + startTime: s.startTime, + ...(s.pdt === undefined ? {} : { programDateTime: s.pdt }), + }) + ), + metadata: { + [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence, targetDuration: 5, endList: false }, + }, + }; +} + +describe('anchorTrackToSequenceOrigin', () => { + it('re-bases startTime to elapsed-since-origin and startDate to the seq-0 wall clock', () => { + // Mid-join window starting at sequence 85, 4s segments, join-relative startTimes. + const track = makeTrack(85, [ + { startTime: 0, duration: 4, pdt: 1000 }, + { startTime: 4, duration: 4, pdt: 1004 }, + ]); + + const anchored = anchorTrackToSequenceOrigin(track); + + // origin offset = (85 − 0) × 4 = 340; first segment moves from 0 → 340. + expect(anchored.segments.map((s) => s.startTime)).toEqual([340, 344]); + expect(anchored.startTime).toBe(340); + // startDate = PDT(first) − originOffset = 1000 − 340 = 660 (wall clock at seq 0). + expect(anchored.startDate).toBe(660); + }); + + it('uses observed average duration, not EXT-X-TARGETDURATION', () => { + // avg of [3,5,4] = 4; targetDuration is 5 — the estimate must use 4. + const track = makeTrack(10, [ + { startTime: 0, duration: 3, pdt: 1000 }, + { startTime: 3, duration: 5, pdt: 1003 }, + { startTime: 8, duration: 4, pdt: 1008 }, + ]); + + const anchored = anchorTrackToSequenceOrigin(track); + + // originOffset = (10 − 0) × 4 = 40 (not 10 × 5 = 50). + expect(anchored.segments[0]?.startTime).toBe(40); + }); + + it('honors a configured startSequence (no shift when it equals the window start)', () => { + const track = makeTrack(85, [{ startTime: 0, duration: 4, pdt: 1000 }]); + // startSequence = 85 → originOffset 0 → already at origin → unchanged identity. + expect(anchorTrackToSequenceOrigin(track, { startSequence: 85 })).toBe(track); + }); + + it('preserves present segments’ actual spacing (only the origin offset is estimated)', () => { + const track = makeTrack(10, [ + { startTime: 0, duration: 1.9, pdt: 1000 }, + { startTime: 1.9, duration: 2.1, pdt: 1001.9 }, + ]); + const anchored = anchorTrackToSequenceOrigin(track); + // Inter-segment gap stays the real 1.9s; both shift by the same offset. + const [a, b] = anchored.segments; + expect((b?.startTime ?? 0) - (a?.startTime ?? 0)).toBeCloseTo(1.9, 6); + }); + + it('is a no-op when no segment carries programDateTime', () => { + const track = makeTrack(85, [ + { startTime: 0, duration: 4 }, + { startTime: 4, duration: 4 }, + ]); + expect(anchorTrackToSequenceOrigin(track)).toBe(track); + }); + + it('is a no-op for an empty track', () => { + const track = makeTrack(0, []); + expect(anchorTrackToSequenceOrigin(track)).toBe(track); + }); +});