diff --git a/examples/vanilla-ts-esm/public/index.html b/examples/vanilla-ts-esm/public/index.html index f33441c2f..59b797110 100644 --- a/examples/vanilla-ts-esm/public/index.html +++ b/examples/vanilla-ts-esm/public/index.html @@ -40,6 +40,7 @@

Elements

  • <mux-player> (audio tracks)
  • <mux-player> (microvideo theme)
  • <mux-player> (classic theme)
  • +
  • <mux-player> (min-bandwidth-sample-duration-ms test)
  • <mux-uploader>
  • <mux-uploader> (full-page)
  • diff --git a/examples/vanilla-ts-esm/public/min-bandwidth-sample-duration-ms-test.html b/examples/vanilla-ts-esm/public/min-bandwidth-sample-duration-ms-test.html new file mode 100644 index 000000000..7d227113f --- /dev/null +++ b/examples/vanilla-ts-esm/public/min-bandwidth-sample-duration-ms-test.html @@ -0,0 +1,800 @@ + + + + + min-bandwidth-sample-duration-ms Test + + + + + + + + +
    +
    + +

    Elements

    +
    +
    + +
    +
    + +

    min-bandwidth-sample-duration-ms test

    +

    + Clamps hls.js's EWMA bandwidth samples to a minimum duration (default 50 ms). + Try 5 / 0 / 200 to see the estimate and rendition pick change. Blank = default. +

    + +
    +
    +
    +
    +
    + + + + ms. Default: 50 +
    + +
    +
    +
    + + + +
    +
    + +
    +
    +
    Configured minDelayMs_
    +
    --
    +
    +
    +
    Current bandwidth estimate
    +
    --
    +
    +
    +
    First segment rendition
    +
    --
    +
    +
    +
    Current rendition
    +
    --
    +
    +
    +
    Segments loaded
    +
    0
    +
    +
    +
    Samples clamped by floor
    +
    0 / 0
    +
    +
    + +

    Available renditions

    +

    + Blue = active, + green outline = first-segment pick, + red = lowest rendition ever played, + struck-through / hatched = excluded by CapLevelController. +

    +
    + +

    Per-segment samples

    +

    + Orange: this segment's real load time was below + the configured floor, so the EWMA estimator used the floor instead. The + bandwidth reported is computed from the clamped duration. +

    +
    +
    + seg # + res + bytes + real ms + effective ms + sample bps + EWMA est +
    +
    No segments loaded yet.
    +
    +
    +
    + +
    + Browse Elements + + + + diff --git a/packages/mux-audio-react/src/index.tsx b/packages/mux-audio-react/src/index.tsx index 6ea09af12..2c602f8cd 100644 --- a/packages/mux-audio-react/src/index.tsx +++ b/packages/mux-audio-react/src/index.tsx @@ -98,6 +98,7 @@ MuxAudio.propTypes = { maxResolution: PropTypes.oneOf(['720p', '1080p', '1440p', '2160p']), metadata: PropTypes.any, minResolution: PropTypes.oneOf(['480p', '540p', '720p', '1080p', '1440p', '2160p']), + minBandwidthSampleDurationMs: PropTypes.number, playbackId: PropTypes.string, playbackToken: PropTypes.string, playerInitTime: PropTypes.number, diff --git a/packages/mux-audio/src/index.ts b/packages/mux-audio/src/index.ts index a57c0bf93..5868c07a2 100644 --- a/packages/mux-audio/src/index.ts +++ b/packages/mux-audio/src/index.ts @@ -46,6 +46,7 @@ export const Attributes = { TYPE: 'type', STREAM_TYPE: 'stream-type', START_TIME: 'start-time', + MIN_BANDWIDTH_SAMPLE_DURATION_MS: 'min-bandwidth-sample-duration-ms', } as const; const AttributeNameValues = Object.values(Attributes); @@ -460,6 +461,23 @@ class MuxAudioElement extends CustomAudioElement implements Partial', () => { assert.equal(player.metadata.video_id, playbackId); }); + describe('min-bandwidth-sample-duration-ms', () => { + it('reflects the min-bandwidth-sample-duration-ms attribute as a number prop', async function () { + const player = await fixture(``); + + assert.equal(player.minBandwidthSampleDurationMs, 12); + assert.equal(player.getAttribute('min-bandwidth-sample-duration-ms'), '12'); + }); + + it('returns undefined when the attribute is unset', async function () { + const player = await fixture(``); + + assert.equal(player.minBandwidthSampleDurationMs, undefined); + }); + + it('supports setting and clearing via the property', async function () { + const player = await fixture(``); + + player.minBandwidthSampleDurationMs = 5; + assert.equal(player.minBandwidthSampleDurationMs, 5); + assert.equal(player.getAttribute('min-bandwidth-sample-duration-ms'), '5'); + + player.minBandwidthSampleDurationMs = 0; + assert.equal(player.minBandwidthSampleDurationMs, 0); + assert.equal(player.getAttribute('min-bandwidth-sample-duration-ms'), '0'); + + player.minBandwidthSampleDurationMs = undefined; + assert.equal(player.minBandwidthSampleDurationMs, undefined); + assert.isFalse(player.hasAttribute('min-bandwidth-sample-duration-ms')); + }); + }); + // Test is failing for audio... it.skip('currentPdt and getStartDate work as expected', async function () { this.timeout(5000); diff --git a/packages/mux-player-astro/REFERENCE.md b/packages/mux-player-astro/REFERENCE.md index c44fb798c..d11bb1a23 100644 --- a/packages/mux-player-astro/REFERENCE.md +++ b/packages/mux-player-astro/REFERENCE.md @@ -28,6 +28,7 @@ | `tokens` | `object` | Signed tokens for private videos | `undefined` | | `proudlyDisplayMuxBadge` | `boolean` | Show Mux badge | `false` | | `theme` | `string` | Player theme component | `undefined` | +| `minBandwidthSampleDurationMs` | `number` (ms) | Override the hls.js EWMA bandwidth estimator minimum sample-duration floor. Defaults to the upstream hls.js value (`50`). | `50` | Available themes: diff --git a/packages/mux-player-astro/src/types.ts b/packages/mux-player-astro/src/types.ts index 3d9f3f804..f8b34d9bb 100644 --- a/packages/mux-player-astro/src/types.ts +++ b/packages/mux-player-astro/src/types.ts @@ -32,6 +32,7 @@ export type MuxPlayerProps = { backwardSeekOffset?: number; maxResolution?: MaxResolutionValue; minResolution?: MinResolutionValue; + minBandwidthSampleDurationMs?: number; renditionOrder?: RenditionOrderValue; programStartTime?: number; programEndTime?: number; diff --git a/packages/mux-player-react/REFERENCE.md b/packages/mux-player-react/REFERENCE.md index b7d2e7eb5..ca694a8df 100644 --- a/packages/mux-player-react/REFERENCE.md +++ b/packages/mux-player-react/REFERENCE.md @@ -48,6 +48,7 @@ | `maxAutoResolution` | `string` (`"720p"`, `"1080p"`, `"1440p"`, or `"2160p"`) | Caps automatic resolution selection to align with [Mux Video pricing tiers](https://www.mux.com/docs/pricing/video#resolution-based-pricing). The player won't automatically select resolutions above this cap. Unlike `maxResolution`, all renditions remain available. Unlike `capRenditionToPlayerSize`, this is independent of player dimensions. | N/A | | `renditionOrder` | `"desc"` | Change the order in which renditions are provided in the src playlist. Can impact initial segment loads. Currently only support `"desc"` for descending order | N/A | | `capRenditionToPlayerSize` | `boolean` | Caps video resolution based on the player's display dimensions to avoid downloading video larger than can be displayed. When `undefined` (default), caps to player size with a 720p minimum floor. Set to `true` to cap strictly to player dimensions (may use lower resolutions for small players). Set to `false` to disable dimension-based capping. Independent of `maxResolution` (server-side) and `maxAutoResolution` (pricing cap). | `undefined` (Mux optimized) | +| `minBandwidthSampleDurationMs` | `number` (ms) | Overrides the hls.js EWMA bandwidth estimator's minimum sample-duration floor (`minDelayMs_`). Lowering it lets very short transfers (small segments on fast networks) contribute to the estimate; raising it better defends against cache-hit / timer-resolution noise. Set to `0` to disable the clamp entirely. Defaults to the upstream hls.js value (`50`). | `50` | | `programStartTime` | `number` | Apply PDT-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the beginning of the media stream. | N/A | | `programEndTime` | `number` | Apply PDT-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the end of the media stream. | N/A | | `assetStartTime` | `number` | Apply media timeline-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the beginning of the media stream. | N/A | diff --git a/packages/mux-player-react/src/types.ts b/packages/mux-player-react/src/types.ts index 0430fd95a..55ee1660d 100644 --- a/packages/mux-player-react/src/types.ts +++ b/packages/mux-player-react/src/types.ts @@ -90,6 +90,7 @@ export type MuxPlayerProps = { maxResolution?: MaxResolutionValue; minResolution?: MinResolutionValue; maxAutoResolution?: MaxAutoResolutionValue; + minBandwidthSampleDurationMs?: number; renditionOrder?: RenditionOrderValue; programStartTime?: number; programEndTime?: number; diff --git a/packages/mux-player/REFERENCE.md b/packages/mux-player/REFERENCE.md index 7460615ca..a4b9f0b92 100644 --- a/packages/mux-player/REFERENCE.md +++ b/packages/mux-player/REFERENCE.md @@ -23,6 +23,7 @@ | `max-auto-resolution` | `string` (`"720p"`, `"1080p"`, `"1440p"`, or `"2160p"`) | Caps automatic resolution selection to align with [Mux Video pricing tiers](https://www.mux.com/docs/pricing/video#resolution-based-pricing). The player won't automatically select resolutions above this cap. Unlike `max-resolution`, all renditions remain available. Unlike `cap-rendition-to-player-size`, this is independent of player dimensions. | N/A | | `rendition-order` | `"desc"` | Change the order in which renditions are provided in the src playlist. Can impact initial segment loads. Currently only support `"desc"` for descending order | N/A | | `cap-rendition-to-player-size` | `boolean` | Caps video resolution based on the player's display dimensions to avoid downloading video larger than can be displayed. When unset (default), caps to player size with a 720p minimum floor. Set to `true` to cap strictly to player dimensions (may use lower resolutions for small players). Set to `false` to disable dimension-based capping. Independent of `max-resolution` (server-side) and `max-auto-resolution` (pricing cap). | `undefined` (Mux optimized) | +| `min-bandwidth-sample-duration-ms` | `number` (ms) | Overrides the hls.js EWMA bandwidth estimator's minimum sample-duration floor (`minDelayMs_`). Lowering it lets very short transfers (small segments on fast networks) contribute to the estimate; raising it better defends against cache-hit / timer-resolution noise. Set to `0` to disable the clamp entirely. Defaults to the upstream hls.js value (`50`). | `50` | | `program-start-time` | `number` | Apply PDT-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the beginning of the media stream. | N/A | | `program-end-time` | `number` | Apply PDT-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the end of the media stream. | N/A | | `asset-start-time` | `number` | Apply media timeline-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the beginning of the media stream. | N/A | @@ -149,6 +150,7 @@ | `minResolution` | `"480p" \| "540p" \| "720p" \| "1080p" \| "1440p" \| "2160p"` | Limits the lowest resolution rendition requested from the server. Renditions below this are excluded from the playlist entirely. | N/A | | `renditionOrder` | `"desc"` | Change the order in which renditions are provided in the src playlist. Can impact initial segment loads. Currently only support `"desc"` for descending order | N/A | | `capRenditionToPlayerSize` | `boolean` | Caps video resolution based on the player's display dimensions to avoid downloading video larger than can be displayed. When `undefined` (default), caps to player size with a 720p minimum floor. Set to `true` to cap strictly to player dimensions (may use lower resolutions for small players). Set to `false` to disable dimension-based capping. Independent of `maxResolution` (server-side) and `maxAutoResolution` (pricing cap). | `undefined` (Mux optimized) | +| `minBandwidthSampleDurationMs` | `number` (ms) | Overrides the hls.js EWMA bandwidth estimator's minimum sample-duration floor (`minDelayMs_`). Lowering it lets very short transfers (small segments on fast networks) contribute to the estimate; raising it better defends against cache-hit / timer-resolution noise. Set to `0` to disable the clamp entirely. Defaults to the upstream hls.js value (`50`). | `50` | | `programStartTime` | `number` | Apply PDT-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the beginning of the media stream. | N/A | | `programEndTime` | `number` | Apply PDT-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the end of the media stream. | N/A | | `assetStartTime` | `number` | Apply media timeline-based [instant clips](https://docs.mux.com/guides/create-instant-clips) to the beginning of the media stream. | N/A | diff --git a/packages/mux-player/src/base.ts b/packages/mux-player/src/base.ts index e709a87b6..64b19f0e9 100644 --- a/packages/mux-player/src/base.ts +++ b/packages/mux-player/src/base.ts @@ -155,6 +155,7 @@ function getProps(el: MuxPlayerElement, state?: any): MuxTemplateProps { maxResolution: el.maxResolution, minResolution: el.minResolution, maxAutoResolution: el.maxAutoResolution, + minBandwidthSampleDurationMs: el.minBandwidthSampleDurationMs, programStartTime: el.programStartTime, programEndTime: el.programEndTime, assetStartTime: el.assetStartTime, @@ -1399,6 +1400,23 @@ class MuxPlayerElement extends VideoApiElement implements IMuxPlayerElement { } } + get minBandwidthSampleDurationMs(): number | undefined { + const val = this.getAttribute(MuxVideoAttributes.MIN_BANDWIDTH_SAMPLE_DURATION_MS); + if (val == null) return undefined; + const num = +val; + return Number.isFinite(num) ? num : undefined; + } + + set minBandwidthSampleDurationMs(val: number | undefined) { + if (val === this.minBandwidthSampleDurationMs) return; + + if (val == null) { + this.removeAttribute(MuxVideoAttributes.MIN_BANDWIDTH_SAMPLE_DURATION_MS); + } else { + this.setAttribute(MuxVideoAttributes.MIN_BANDWIDTH_SAMPLE_DURATION_MS, `${+val}`); + } + } + get renditionOrder() { return (this.getAttribute(MuxVideoAttributes.RENDITION_ORDER) as RenditionOrderValue) ?? undefined; } diff --git a/packages/mux-player/src/template.ts b/packages/mux-player/src/template.ts index 87f94a54b..a396d3879 100644 --- a/packages/mux-player/src/template.ts +++ b/packages/mux-player/src/template.ts @@ -142,6 +142,9 @@ export const content = (props: MuxTemplateProps) => html` exportparts="video" disable-pseudo-ended="${props.disablePseudoEnded ?? false}" max-auto-resolution="${props.maxAutoResolution ?? false}" + min-bandwidth-sample-duration-ms="${props.minBandwidthSampleDurationMs != null + ? props.minBandwidthSampleDurationMs + : false}" cap-rendition-to-player-size="${props.capRenditionToPlayerSize ?? false}" > ${props.storyboard diff --git a/packages/mux-player/src/types.ts b/packages/mux-player/src/types.ts index 8df3697a0..243b8c62f 100644 --- a/packages/mux-player/src/types.ts +++ b/packages/mux-player/src/types.ts @@ -47,6 +47,7 @@ export type MuxTemplateProps = Partial & { maxResolution?: MaxResolutionValue; minResolution?: MinResolutionValue; maxAutoResolution?: MaxAutoResolutionValue; + minBandwidthSampleDurationMs?: number; renditionOrder?: RenditionOrderValue; extraSourceParams?: Record; tokens: { diff --git a/packages/mux-player/test/player.test.js b/packages/mux-player/test/player.test.js index d3e619ad7..c72b6a7bc 100644 --- a/packages/mux-player/test/player.test.js +++ b/packages/mux-player/test/player.test.js @@ -592,6 +592,48 @@ describe('', () => { assert.equal(player.maxResolution, '720p'); }); + describe('min-bandwidth-sample-duration-ms', () => { + it('reflects the min-bandwidth-sample-duration-ms attribute as a number prop', async function () { + const player = await fixture(``); + + assert.equal(player.minBandwidthSampleDurationMs, 12); + assert.equal(player.getAttribute('min-bandwidth-sample-duration-ms'), '12'); + }); + + it('forwards min-bandwidth-sample-duration-ms to the inner mux-video element', async function () { + const player = await fixture(``); + + assert.equal(player.media?.getAttribute('min-bandwidth-sample-duration-ms'), '7'); + assert.equal(player.media?.minBandwidthSampleDurationMs, 7); + }); + + it('removes the attribute when cleared via the property', async function () { + const player = await fixture(``); + + assert.equal(player.minBandwidthSampleDurationMs, 5); + + player.minBandwidthSampleDurationMs = undefined; + assert.isFalse(player.hasAttribute('min-bandwidth-sample-duration-ms')); + assert.equal(player.minBandwidthSampleDurationMs, undefined); + + player.minBandwidthSampleDurationMs = 0; + assert.equal(player.getAttribute('min-bandwidth-sample-duration-ms'), '0'); + assert.equal(player.minBandwidthSampleDurationMs, 0); + }); + }); + it('should apply extra-playlist-params as arbitrary search params on src', async function () { const player = await fixture(`', () => { assert.equal(player.maxResolution, '720p'); }); + describe('min-bandwidth-sample-duration-ms', () => { + it('reflects the min-bandwidth-sample-duration-ms attribute as a number prop', async function () { + const player = await fixture(``); + + assert.equal(player.minBandwidthSampleDurationMs, 12); + assert.equal(player.getAttribute('min-bandwidth-sample-duration-ms'), '12'); + }); + + it('returns undefined when the attribute is unset', async function () { + const player = await fixture(``); + + assert.equal(player.minBandwidthSampleDurationMs, undefined); + }); + + it('removes the attribute when set to undefined or null', async function () { + const player = await fixture(``); + + assert.equal(player.minBandwidthSampleDurationMs, 5); + player.minBandwidthSampleDurationMs = undefined; + assert.isFalse(player.hasAttribute('min-bandwidth-sample-duration-ms')); + assert.equal(player.minBandwidthSampleDurationMs, undefined); + + player.minBandwidthSampleDurationMs = 0; + assert.equal(player.getAttribute('min-bandwidth-sample-duration-ms'), '0'); + assert.equal(player.minBandwidthSampleDurationMs, 0); + + player.minBandwidthSampleDurationMs = null; + assert.isFalse(player.hasAttribute('min-bandwidth-sample-duration-ms')); + }); + + it('propagates min-bandwidth-sample-duration-ms to the hls.js EWMA bandwidth estimator', async function () { + const player = await fixture(``); + + assert.equal(player._hls?.abrController.bwEstimator.minDelayMs_, 7); + }); + + it('uses the default 50ms floor when min-bandwidth-sample-duration-ms is unset', async function () { + const player = await fixture(``); + + assert.equal(player._hls?.abrController.bwEstimator.minDelayMs_, 50); + }); + }); + it('maps arbitrary metadata-* attrs to the metadata prop and populates video_id if not provided', async function () { const playbackId = '23s11nz72DsoN657h4314PjKKjsF2JG33eBQQt6B95I'; const player = await fixture(` >, mediaEl: HTMLMediaElement @@ -809,6 +811,7 @@ export const setupHls = ( preferCmcd, _hlsConfig = {}, maxAutoResolution, + minBandwidthSampleDurationMs, } = props; const type = getType(props); const hlsType = type === ExtensionMimeTypeMap.M3U8; @@ -821,6 +824,11 @@ export const setupHls = ( renderTextTracksNatively: false, liveDurationInfinity: true, capLevelOnFPSDrop: true, + testBandwidth: true, + abrController: + typeof minBandwidthSampleDurationMs === 'number' + ? createSaneAbrController(minBandwidthSampleDurationMs) + : SaneAbrController, }; const streamTypeConfig = getStreamTypeConfig(streamType); const drmConfig = getDRMConfig(props); diff --git a/packages/playback-core/src/sane-abr-controller.ts b/packages/playback-core/src/sane-abr-controller.ts new file mode 100644 index 000000000..14f773b40 --- /dev/null +++ b/packages/playback-core/src/sane-abr-controller.ts @@ -0,0 +1,85 @@ +import Hls from './hls'; +import type { HlsInterface } from './hls'; + +// The hls.js commonJS module doesn't re-export AbrController as a value, so get it from the default config. +const AbrController = Hls.DefaultConfig.abrController; + +/** + * Default minimum sample duration (ms) used by the EWMA bandwidth estimator. + * Matches the hls.js / Shaka historical default. See the long-form rationale + * on {@link createSaneAbrController} for why callers may want to lower this. + */ +export const DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS = 50; + +const patchEstimator = (controller: InstanceType, minDelayMs: number) => { + // `minDelayMs_` is a private member of EwmaBandWidthEstimator; access via `any`. + const estimator = (controller as any).bwEstimator; + if (estimator) { + estimator.minDelayMs_ = minDelayMs; + } +}; + +/** + * Create a custom hls.js AbrController class that exposes the EWMA bandwidth + * estimator's minimum sample-duration floor (`minDelayMs_`) as a configurable + * constructor argument. + * + * Background + * ---------- + * `EwmaBandWidthEstimator#sample()` clamps `durationMs` to `minDelayMs_` + * before computing `bandwidth = 8000 * numBytes / durationMs`. hls.js + * inherited `minDelayMs_ = 50` from Shaka in 2016 as a defensive clamp + * against three failure modes: + * 1. HTTP cache hits producing sub-millisecond "downloads" + * (e.g. 5 MB / 0.2ms = 200 Gbps samples poisoning the fast EWMA). + * 2. `performance.now()` timer resolution (historically ~1ms, post-Spectre + * clamped to 100µs–2ms depending on the browser) producing + * duration = 0 and bandwidth = Infinity. + * 3. Statistical noise on very short transfers where OS scheduling / + * TCP ACK timing dominate over actual network capacity. + * + * The 50ms value is a conservative round number Shaka picked; it is not + * derived from any measurement of real fragment sizes or bandwidth. We + * preserve it as the default here so behavior matches upstream hls.js, but + * expose this factory so callers can tune it. + * + * Why a caller might lower it + * --------------------------- + * Some of the original assumptions behind 50ms have weakened over the years: + * - Fragment bitrates are much higher. A 100 Mbps+ connection can truly + * finish a 50KB fragment in ~4ms, so the 50ms floor will systematically + * undercount fast transfers of small segments on modern 4K/HDR ladders. + * - hls.js now guards against cache-hit samples elsewhere + * (init-segment / reload filtering), partly handling the original + * motivation for the floor. + * - Worst-case timer resolution is now ~2ms (Spectre clamping), so values + * as low as ~5ms are still comfortably above divide-by-near-zero + * territory. + * + * Callers that want to experiment with lower floors (e.g. 5, or 0 to + * disable the clamp entirely) can pass their own `minDelayMs`. + */ +export const createSaneAbrController = ( + minDelayMs: number = DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS +): typeof AbrController => { + return class SaneAbrController extends AbrController { + constructor(hls: HlsInterface) { + super(hls); + patchEstimator(this, minDelayMs); + } + + public resetEstimator(abrEwmaDefaultEstimate?: number) { + super.resetEstimator(abrEwmaDefaultEstimate); + // `this.bwEstimator` is replaced with a fresh instance on reset; re-poke it. + patchEstimator(this, minDelayMs); + } + }; +}; + +/** + * Default `SaneAbrController` class wired up with the default + * {@link DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS} floor. Preserved for code that + * imported the class directly (e.g. `new SaneAbrController(hls)`). + */ +const SaneAbrController = createSaneAbrController(); +export default SaneAbrController; diff --git a/packages/playback-core/src/types.ts b/packages/playback-core/src/types.ts index fda351c60..8df97d077 100644 --- a/packages/playback-core/src/types.ts +++ b/packages/playback-core/src/types.ts @@ -174,6 +174,14 @@ export type MuxMediaPropTypes = { _hlsConfig?: Partial; autoPlay?: Autoplay; autoplay?: Autoplay; + /** + * Minimum sample duration (in ms) for hls.js's EWMA bandwidth estimator. + * Overrides the `minDelayMs_` floor used by `SaneAbrController`. Lower + * values allow very short transfers (small fragments on fast connections) + * to contribute to the bandwidth estimate; higher values better defend + * against cache-hit / timer-resolution noise. + */ + minBandwidthSampleDurationMs?: number; /** Based on `cap-rendition-to-player-size` attribute and `_hlsConfig.capLevelToPlayerSize` */ capRenditionToPlayerSize?: boolean; beaconCollectionDomain: Options['beaconCollectionDomain']; diff --git a/packages/playback-core/test/index.test.js b/packages/playback-core/test/index.test.js index de8f09b3d..0612dfe89 100644 --- a/packages/playback-core/test/index.test.js +++ b/packages/playback-core/test/index.test.js @@ -14,6 +14,11 @@ import { toPlaybackIdFromSrc, getCapLevelControllerConfig, } from '../src/index.ts'; +import SaneAbrController, { + createSaneAbrController, + DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS, +} from '../src/sane-abr-controller.ts'; +import Hls from '../src/hls.ts'; describe('playback core', function () { let video; @@ -384,4 +389,77 @@ describe('playback core', function () { assert.equal(config.capLevelToPlayerSize, false, 'should keep hls.js capLevelToPlayerSize as false'); }); }); + + describe('SaneAbrController / minBandwidthSampleDurationMs', () => { + // hls.js instantiates the AbrController internally; we need a real Hls instance to inspect + // the configured estimator floor without relying on internal construction order. + const getMinDelay = (AbrControllerClass) => { + const hls = new Hls({ abrController: AbrControllerClass }); + try { + return hls.abrController.bwEstimator.minDelayMs_; + } finally { + hls.destroy(); + } + }; + + it('defaults to DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS (50) to match upstream hls.js', () => { + assert.equal(DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS, 50); + assert.equal(getMinDelay(SaneAbrController), DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS); + assert.equal(getMinDelay(createSaneAbrController()), DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS); + }); + + it('honors the configured minDelayMs on the underlying bwEstimator', () => { + assert.equal(getMinDelay(createSaneAbrController(5)), 5); + assert.equal(getMinDelay(createSaneAbrController(0)), 0); + assert.equal(getMinDelay(createSaneAbrController(200)), 200); + }); + + it('re-applies minDelayMs after resetEstimator (estimator is replaced on reset)', () => { + const ControllerClass = createSaneAbrController(7); + const hls = new Hls({ abrController: ControllerClass }); + try { + assert.equal(hls.abrController.bwEstimator.minDelayMs_, 7); + // resetEstimator swaps out `this.bwEstimator` for a fresh instance; our override should re-poke it. + hls.abrController.resetEstimator(); + assert.equal(hls.abrController.bwEstimator.minDelayMs_, 7); + } finally { + hls.destroy(); + } + }); + + it('threads minBandwidthSampleDurationMs through initialize() into the hls.js abrController', () => { + const core = initialize( + { + src: 'https://stream.mux.com/23s11nz72DsoN657h4314PjKKjsF2JG33eBQQt6B95I.m3u8', + preferPlayback: 'mse', + minBandwidthSampleDurationMs: 12, + }, + video + ); + try { + assert.equal( + core.engine?.abrController.bwEstimator.minDelayMs_, + 12, + 'minBandwidthSampleDurationMs prop should be propagated to the EWMA estimator' + ); + } finally { + teardown(video, core); + } + }); + + it('uses the default (50) when minBandwidthSampleDurationMs is omitted', () => { + const core = initialize( + { + src: 'https://stream.mux.com/23s11nz72DsoN657h4314PjKKjsF2JG33eBQQt6B95I.m3u8', + preferPlayback: 'mse', + }, + video + ); + try { + assert.equal(core.engine?.abrController.bwEstimator.minDelayMs_, DEFAULT_MIN_BANDWIDTH_SAMPLE_DURATION_MS); + } finally { + teardown(video, core); + } + }); + }); });