From bfe12eedf9656b14fec492946551be2d5be49996 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Wed, 24 Jun 2026 10:58:18 -0700 Subject: [PATCH] refactor(spf): extract resolveLiveLatency out of the engine closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HLS live-latency bridge was defined inside createSimpleHlsEngine's closure despite capturing nothing — a fresh allocation per engine, and pure HLS logic stranded where it can't be tested. Move it to media/hls/reload-policy.ts as a pure resolveLiveLatency(presentation, trackId) next to liveLatencyFor; the engine injects the import. Adds direct coverage for the bridge (previously only stubbed via seek-to-live-edge's injected resolver). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spf/src/media/hls/reload-policy.ts | 24 +++++++++++++- .../src/media/hls/tests/reload-policy.test.ts | 30 ++++++++++++++++-- .../spf/src/playback/engines/hls/engine.ts | 31 ++++++------------- 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/packages/spf/src/media/hls/reload-policy.ts b/packages/spf/src/media/hls/reload-policy.ts index 4f05a856..7ac6444f 100644 --- a/packages/spf/src/media/hls/reload-policy.ts +++ b/packages/spf/src/media/hls/reload-policy.ts @@ -1,4 +1,11 @@ -import { getMediaPlaylistMetadata, type ResolvedTrack } from '../types'; +import { + getMediaPlaylistMetadata, + isResolvedPresentation, + isResolvedTrack, + type MaybeResolvedPresentation, + type ResolvedTrack, +} from '../types'; +import { findTrackById } from '../utils/tracks'; /** Reload cadence when a playlist carries no usable target duration. */ const FALLBACK_TARGET_DURATION = 6; @@ -56,3 +63,18 @@ export function mediaPlaylistReloadDelay(current: ResolvedTrack, previous: Resol export function liveLatencyFor(track: ResolvedTrack): number { return HOLD_BACK_TARGET_MULTIPLIER * targetDurationOf(track); } + +/** + * Resolve the target live latency for a presentation's timeline-bearing track — + * the HLS engine injects this as `seekToLiveEdge`'s format-neutral + * `resolveLiveLatency` seam. `0` when there is no resolved track to read (the + * behavior then seeks straight to the edge). + */ +export function resolveLiveLatency( + presentation: MaybeResolvedPresentation | undefined, + trackId: string | undefined +): number { + if (!isResolvedPresentation(presentation) || !trackId) return 0; + const track = findTrackById(presentation, trackId); + return track && isResolvedTrack(track) ? liveLatencyFor(track) : 0; +} diff --git a/packages/spf/src/media/hls/tests/reload-policy.test.ts b/packages/spf/src/media/hls/tests/reload-policy.test.ts index 6562a36f..f344b413 100644 --- a/packages/spf/src/media/hls/tests/reload-policy.test.ts +++ b/packages/spf/src/media/hls/tests/reload-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { MEDIA_PLAYLIST_METADATA_KEY, type ResolvedTrack } from '../../types'; -import { liveLatencyFor, mediaPlaylistReloadDelay } from '../reload-policy'; +import { type MaybeResolvedPresentation, MEDIA_PLAYLIST_METADATA_KEY, type ResolvedTrack } from '../../types'; +import { liveLatencyFor, mediaPlaylistReloadDelay, resolveLiveLatency } from '../reload-policy'; /** Minimal resolved-track stand-in carrying only what the policy reads. */ function track(opts: { @@ -58,3 +58,29 @@ describe('liveLatencyFor', () => { expect(liveLatencyFor(track({ targetDuration: 0 }))).toBe(18); }); }); + +describe('resolveLiveLatency', () => { + /** Minimal presentation wrapping one video track, enough for findTrackById + liveLatencyFor. */ + function presentation(targetDuration: number): MaybeResolvedPresentation { + const video = { + id: 'v-1', + segments: [{ id: 's0', url: 's0.m4s', duration: 2, startTime: 0 }], + metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { targetDuration, mediaSequence: 0, endList: false } }, + }; + return { + id: 'p-1', + url: 'master.m3u8', + selectionSets: [{ switchingSets: [{ tracks: [video] }] }], + } as unknown as MaybeResolvedPresentation; + } + + it('returns the timeline-bearing track latency (3× target duration)', () => { + expect(resolveLiveLatency(presentation(2), 'v-1')).toBe(6); + }); + + it('returns 0 when the presentation is unresolved, or the track id is absent / unknown', () => { + expect(resolveLiveLatency(undefined, 'v-1')).toBe(0); + expect(resolveLiveLatency(presentation(2), undefined)).toBe(0); + expect(resolveLiveLatency(presentation(2), 'missing')).toBe(0); + }); +}); diff --git a/packages/spf/src/playback/engines/hls/engine.ts b/packages/spf/src/playback/engines/hls/engine.ts index bd959c00..f4b88e8e 100644 --- a/packages/spf/src/playback/engines/hls/engine.ts +++ b/packages/spf/src/playback/engines/hls/engine.ts @@ -20,20 +20,17 @@ import { removeAllSubtitlesTracksFromMedia, } from '../../../media/dom/text/text-track-slots'; import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant'; -import { liveLatencyFor, mediaPlaylistReloadDelay } from '../../../media/hls/reload-policy'; -import { - type AudioTrack, - type CanPlayTrack, - isResolvedPresentation, - isResolvedTrack, - type MaybeResolvedPresentation, - type ResolvedTrack, - type TextTrack, - type VideoTrack, +import { mediaPlaylistReloadDelay, resolveLiveLatency } from '../../../media/hls/reload-policy'; +import type { + AudioTrack, + CanPlayTrack, + MaybeResolvedPresentation, + ResolvedTrack, + TextTrack, + VideoTrack, } from '../../../media/types'; import type { GetCdnId } from '../../../media/utils/cdn'; import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection'; -import { findTrackById } from '../../../media/utils/tracks'; import type { BandwidthConfig, BandwidthState } from '../../../network/bandwidth-estimator'; import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; import type { SourceBufferActor } from '../../actors/dom/source-buffer'; @@ -341,19 +338,11 @@ export function createSimpleHlsEngine( return bufferedAnchorFor(context.segments, context.bufferedRanges); }); - // Format-specific live latency for `seekToLiveEdge`: look up the - // timeline-bearing track and apply the HLS HOLD-BACK rule. The behavior - // consumes this as a neutral `resolveLiveLatency` seam (a DASH engine would - // inject `suggestedPresentationDelay`). - const resolveLiveLatency = (presentation: MaybeResolvedPresentation | undefined, trackId: string | undefined) => { - if (!isResolvedPresentation(presentation) || !trackId) return 0; - const track = findTrackById(presentation, trackId); - return track && isResolvedTrack(track) ? liveLatencyFor(track) : 0; - }; - const finalConfig = { ...config, resolveBufferedAnchor, + // Format-neutral live-latency seam for `seekToLiveEdge` — the HLS resolver + // (HOLD-BACK); a DASH engine would inject `suggestedPresentationDelay`. resolveLiveLatency, canPlayTrack: config.canPlayTrack ?? canPlayTrack, // The resolve* loaders' RecurringRunner re-runs on this `reschedule`: the pure