mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(spf): keep the live anchor sticky per source
The anchor reactor's monitor re-derived buffer ground truth on every reload, so a transient loss of it — a buffer underrun, flush, or seek that momentarily empties both SourceBuffers — flipped the reactor back to `unanchored` and cleared `liveAnchor`. The anchor value itself is idempotent, but `seekToLiveEdge` gates its `live` state on `liveAnchor` being set: clearing it drove `live → inactive → live` and re-fired the one-time live-edge seek, jumping the playhead forward. Make the anchor sticky per source: once `liveAnchor` is published, stay `anchored` while the presentation stays resolved; only a source change (the presentation reset to an unresolved value) reverts it. This matches the "established once per source" intent in live-presentation-anchor.md. 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
3983195661
commit
6bbbc23961
@@ -125,24 +125,32 @@ function anchorLiveTracksSetup<Context extends object>({
|
||||
|
||||
return createMachineReactor<AnchorFsmState>({
|
||||
initial: 'unanchored',
|
||||
// Re-checks buffer availability on each reload (the resolver is read untracked
|
||||
// by the engine, so reloads — not buffer ticks — drive the transition). An
|
||||
// unresolved presentation drops back to idle.
|
||||
// Checks buffer availability on each reload until anchored (the resolver is
|
||||
// read untracked by the engine, so reloads — not buffer ticks — drive the
|
||||
// transition). An unresolved presentation drops back to idle.
|
||||
monitor: () => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) return 'unanchored';
|
||||
// Sticky per source: once the anchor is published, stay `anchored` for the
|
||||
// lifetime of this resolved presentation. Only a source change — which
|
||||
// resets the presentation to an unresolved value (handled above) — reverts.
|
||||
// A *transient* loss of buffer ground truth (underrun, flush, seek) must NOT
|
||||
// drop the established anchor: doing so re-opens the seekToLiveEdge gate and
|
||||
// re-fires its one-time live-edge seek, jumping the playhead. "Pin-once"
|
||||
// means pin once per source — see live-presentation-anchor.md.
|
||||
if (state.liveAnchor.get() !== undefined) return 'anchored';
|
||||
return isUndefined(deriveBufferAnchor(presentation)) ? 'unanchored' : 'anchored';
|
||||
},
|
||||
states: {
|
||||
// Reset the published anchor per source so a new source re-gates the seek.
|
||||
unanchored: { entry: () => state.liveAnchor.set(undefined) },
|
||||
anchored: {
|
||||
// Establish the shared anchor once and stamp it onto every track. Runs
|
||||
// once per entry; a source change exits to `unanchored`, so the next
|
||||
// source re-establishes. Re-deriving the same buffer anchor is idempotent
|
||||
// (segment PDT and native-PTS start are stable), and
|
||||
// `positionAllTracksToAnchor` writes no new reference when nothing moved —
|
||||
// so a transient re-entry is a no-op.
|
||||
// Establish the shared anchor once and stamp it onto every track. The
|
||||
// sticky monitor keeps us `anchored` for the source once `liveAnchor` is
|
||||
// published, so this runs exactly once per source; only a source change
|
||||
// (exit to `unanchored`) re-arms it. (Were it to re-enter, re-deriving the
|
||||
// same buffer anchor is idempotent and `positionAllTracksToAnchor` writes
|
||||
// no new reference when nothing moved — so it would still be a no-op.)
|
||||
entry: () => {
|
||||
const presentation = state.presentation.get();
|
||||
if (!isResolvedPresentation(presentation)) return;
|
||||
|
||||
@@ -148,6 +148,62 @@ describe('anchorLiveTracks', () => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps the established anchor through a transient loss of buffer ground truth (sticky per source)', async () => {
|
||||
let hasBuffer = true;
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
config: {
|
||||
resolveBufferedAnchor: () =>
|
||||
hasBuffer ? { trackId: 'v-1', segmentId: 'segment-85', actualStart: 500 } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.liveAnchor.get()).toBe(500);
|
||||
|
||||
// Buffer ground truth momentarily vanishes (underrun / flush / seek), then a
|
||||
// reload fires. The established anchor must persist — dropping it re-opens the
|
||||
// seekToLiveEdge gate and re-fires its one-time live-edge seek. Only a source
|
||||
// change (unresolved presentation) reverts.
|
||||
hasBuffer = false;
|
||||
const carried = makeVideoTrack();
|
||||
carried.startTime = 500;
|
||||
carried.startDate = 500;
|
||||
carried.segments = [{ ...carried.segments[0]!, startTime: 500, startDate: 1000 }];
|
||||
state.presentation.set(makePresentation([carried]));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(state.liveAnchor.get()).toBe(500);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('reverts the anchor on a source change, then re-establishes for the new source', async () => {
|
||||
let actualStart = 500;
|
||||
const { cleanup, state } = run({
|
||||
presentation: makePresentation([makeVideoTrack()]),
|
||||
config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart }) },
|
||||
});
|
||||
|
||||
expect(state.liveAnchor.get()).toBe(500);
|
||||
|
||||
// Source change → presentation reset to an unresolved value: the anchor clears
|
||||
// so the new source re-gates the seek.
|
||||
state.presentation.set({ url: 'https://example.com/new.m3u8' });
|
||||
await flush();
|
||||
await flush();
|
||||
expect(state.liveAnchor.get()).toBeUndefined();
|
||||
|
||||
// New source resolves with its own buffer truth → re-establishes.
|
||||
actualStart = 700;
|
||||
state.presentation.set(makePresentation([makeVideoTrack()]));
|
||||
await flush();
|
||||
await flush();
|
||||
expect(state.liveAnchor.get()).toBe(300); // video seg PDT 1000 − actualStart 700
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('establishes once — a later reload is left to the parser (no re-establish even if buffer drifts)', async () => {
|
||||
let actualStart = 500;
|
||||
const { cleanup, state } = run({
|
||||
|
||||
Reference in New Issue
Block a user