mirror of
https://github.com/zoriya/v10.git
synced 2026-08-14 09:59:44 +00:00
feat(spf): add presentation-anchor primitives for the shared live anchor
Introduce media/presentation-anchor.ts realizing the single presentation-level anchor decision (internal/decisions/live-presentation-anchor.md): presentationAnchorFromBuffer / presentationAnchorEstimate derive the shared (media-time ↔ PDT) anchor — buffer-pinned, or the pre-buffer estimate — and positionTrackToAnchor re-origins any track onto it by its own per-segment PDT. Tested to generalize anchorTrackToBufferedSegment. Consumed by the anchor-live-tracks reactor conversion (next), which replaces the per-track anchor primitives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f531e1055c
commit
3a9d1faca0
@@ -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<Tracks extends Track>(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 })),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user