diff --git a/packages/media/src/dom/simple-hls/media-tracks.ts b/packages/media/src/dom/simple-hls/media-tracks.ts new file mode 100644 index 00000000..46c1842f --- /dev/null +++ b/packages/media/src/dom/simple-hls/media-tracks.ts @@ -0,0 +1,219 @@ +import { type Composition, computed, effect, untrack } from '@videojs/spf'; +import type { SimpleHlsEngineContext, SimpleHlsEngineState } from '@videojs/spf/hls'; +import { + type AudioTrack, + dedupedAudioTracks, + dedupedVideoTracks, + findAudioTrackById, + findVideoTrackById, + frameRateToNumber, + isSameAudioTrack, + isSameVideoTrack, + toUserAudioTrackSelection, + toUserVideoTrackSelection, + type VideoTrack, +} from '@videojs/spf/media-tracks'; +import type { Constructor } from '@videojs/utils/types'; +import type { + AudioTrackLike, + MediaAudioTrackCapability, + MediaVideoRenditionCapability, + MediaVideoTrackCapability, + VideoRenditionLike, +} from '../../core/types'; + +// Translate a DOM rendition/track into the SPF dedupe-key shape +const toVideoKey = (rendition: VideoRenditionLike) => ({ + width: rendition.width, + height: rendition.height, + bandwidth: rendition.bitrate!, +}); + +const toAudioKey = (track: AudioTrackLike) => ({ language: track.language, name: track.label }); + +type SimpleHlsEngineHost = { + readonly engine: Composition; + destroy?(): void; +}; + +type MediaTracksHost = SimpleHlsEngineHost & + MediaVideoTrackCapability & + MediaAudioTrackCapability & + MediaVideoRenditionCapability; + +/** Two track lists carry the same set when their id sequences match. */ +const sameIds = (a: { id: string }[], b: { id: string }[]): boolean => + a.length === b.length && a.every((item, i) => item.id === b[i]!.id); + +/** + * Projects the SPF engine's presentation onto the media element's + * `videoRenditions` / `audioTracks` lists, and wires user selection back to the + * engine's `userVideoTrackSelection` / `userAudioTrackSelection` signals. + * + * Requires the media-tracks mixin (track-list infrastructure) earlier in the + * chain so the host exposes `addVideoTrack`, `videoRenditions`, and friends. + */ +export function SimpleHlsMediaMediaTracksMixin>(BaseClass: Base) { + class SimpleHlsMediaMediaTracks extends (BaseClass as Constructor) { + #abort = new AbortController(); + #destroyed = false; + // Memoized SPF model — the last `computed` result per type — so a DOM + // selection maps back to the engine's match criteria by id via the SPF + // selection helpers (the DOM lists carry only DOM-shape props). + #renditions: VideoTrack[] = []; + #audioTracks: AudioTrack[] = []; + + constructor(...args: any[]) { + super(...args); + + const { state } = this.engine; + const { signal } = this.#abort; + + const renditionsSignal = computed(() => dedupedVideoTracks(state.presentation.get()), { equals: sameIds }); + const audioTracksSignal = computed(() => dedupedAudioTracks(state.presentation.get()), { equals: sameIds }); + + // Rebuild video renditions when the set changes; seed `active` from the + // current resolved selection (read untracked — `active` deltas are the + // reflection effect's job). + const reflectRenditions = () => { + const renditions = renditionsSignal.get(); + this.#renditions = renditions; + this.#removeVideoTracks(); + if (!renditions.length) return; + + // "video track" here is the Media UI Extensions concept, which maps onto + // the SPF model differently than the name suggests: + // MUE track -> SPF selection set + // MUE rendition -> SPF track + // @see https://github.com/video-dev/media-ui-extensions/blob/main/proposals/0011-renditions-list.md + // + // Every rendition hangs off a single "main" video track. Multi-track + // video (multiple camera angles, CMAF multi-track) isn't supported yet, + // so exactly one video track is always exposed; when it lands, this + // becomes one track (selection set) per angle. + const videoTrack = this.addVideoTrack('main'); + videoTrack.selected = true; + + const resolved = untrack(() => findVideoTrackById(state.presentation.get(), state.selectedVideoTrackId.get())); + for (const rendition of renditions) { + const domRendition = videoTrack.addRendition( + '', + rendition.width, + rendition.height, + rendition.codecs.join(','), + rendition.bandwidth, + rendition.frameRate ? frameRateToNumber(rendition.frameRate) : undefined + ); + domRendition.id = rendition.id; + domRendition.active = isSameVideoTrack(toVideoKey(domRendition), resolved); + } + }; + + const reflectSelectedVideo = () => { + const resolved = findVideoTrackById(state.presentation.get(), state.selectedVideoTrackId.get()); + for (const rendition of this.videoRenditions) { + rendition.active = isSameVideoTrack(toVideoKey(rendition), resolved); + } + }; + + // Rebuild audio tracks when the set changes; seed `enabled` from the + // current resolved selection. + const reflectAudioTracks = () => { + const tracks = audioTracksSignal.get(); + this.#audioTracks = tracks; + this.#removeAudioTracks(); + if (!tracks.length) return; + + const resolved = untrack(() => findAudioTrackById(state.presentation.get(), state.selectedAudioTrackId.get())); + for (const track of tracks) { + const domTrack = this.addAudioTrack(track.default ? 'main' : 'alternative', track.name, track.language ?? ''); + domTrack.id = track.id; + domTrack.enabled = isSameAudioTrack(toAudioKey(domTrack), resolved); + } + }; + + const reflectSelectedAudio = () => { + const resolved = findAudioTrackById(state.presentation.get(), state.selectedAudioTrackId.get()); + for (const track of this.audioTracks) { + track.enabled = isSameAudioTrack(toAudioKey(track), resolved); + } + }; + + // A source change (new manifest URL) invalidates any pinned selection: + // rendition identity (width/height/bandwidth) and audio identity + // (language/name) don't reliably carry across sources, so drop both pins + // and let the engine re-pick for the new source. + const sourceUrl = computed(() => state.presentation.get()?.url); + const resetSelectionOnSourceChange = () => { + sourceUrl.get(); + state.userVideoTrackSelection.set(undefined); + state.userAudioTrackSelection.set(undefined); + }; + + const effectCleanups = [ + effect(reflectRenditions), + effect(reflectSelectedVideo), + effect(reflectAudioTracks), + effect(reflectSelectedAudio), + effect(resetSelectionOnSourceChange), + ]; + + this.videoRenditions.addEventListener('change', this.#selectRendition, { signal }); + this.audioTracks.addEventListener('change', this.#selectAudio, { signal }); + signal.addEventListener('abort', () => effectCleanups.forEach((cleanup) => cleanup()), { once: true }); + } + + destroy(): void { + if (this.#destroyed) return; + this.#destroyed = true; + this.#abort.abort(); + this.#removeVideoTracks(); + this.#removeAudioTracks(); + super.destroy?.(); + } + + // Manual quality pin → the chosen rendition's width/height/bandwidth as the + // match criteria (the properties renditions were deduped on). + // `selectedIndex === -1` (Auto) clears the pin, resuming ABR. + #selectRendition = () => { + const { userVideoTrackSelection } = this.engine.state; + const index = this.videoRenditions.selectedIndex; + const domRendition = index < 0 ? undefined : this.videoRenditions[index]; + const rendition = this.#renditions.find((candidate) => candidate.id === domRendition?.id); + userVideoTrackSelection.set(toUserVideoTrackSelection(rendition)); + }; + + #selectAudio = () => { + const { presentation, selectedAudioTrackId, userAudioTrackSelection } = this.engine.state; + const resolved = findAudioTrackById(presentation.get(), selectedAudioTrackId.get()); + const current = [...this.audioTracks].find((track) => isSameAudioTrack(toAudioKey(track), resolved)); + + // `enabled` is not exclusive like video `selected`, so prefer a newly + // enabled track over the one that is already playing. + const enabled = [...this.audioTracks].filter((track) => track.enabled); + const target = enabled.find((track) => track !== current) ?? enabled[0]; + if (!target) return; + + // Disable the rest so future change events resolve unambiguously. + for (const track of enabled) { + if (track !== target) track.enabled = false; + } + // Skip the write when the enabled track is already the resolved one + // that's this projection's own reflection (or a no-op re-affirm), not a user switch. + if (target === current) return; + + const audioTrack = this.#audioTracks.find((candidate) => candidate.id === target.id); + userAudioTrackSelection.set(toUserAudioTrackSelection(audioTrack)); + }; + + #removeVideoTracks() { + for (const videoTrack of [...this.videoTracks]) this.removeVideoTrack(videoTrack); + } + + #removeAudioTracks() { + for (const audioTrack of [...this.audioTracks]) this.removeAudioTrack(audioTrack); + } + } + + return SimpleHlsMediaMediaTracks as unknown as Base; +} diff --git a/packages/media/src/dom/simple-hls/media.ts b/packages/media/src/dom/simple-hls/media.ts index fed44799..0f33800b 100644 --- a/packages/media/src/dom/simple-hls/media.ts +++ b/packages/media/src/dom/simple-hls/media.ts @@ -1,6 +1,8 @@ import { SimpleHlsMediaMixin } from '@videojs/spf/hls'; +import { MediaTracksMixin } from '../../core/media-tracks'; import { HTMLVideoElementHost } from '../video-host'; +import { SimpleHlsMediaMediaTracksMixin } from './media-tracks'; -const SimpleHlsMediaBase = SimpleHlsMediaMixin(HTMLVideoElementHost); +const SimpleHlsMediaBase = SimpleHlsMediaMediaTracksMixin(MediaTracksMixin(SimpleHlsMediaMixin(HTMLVideoElementHost))); export class SimpleHlsMedia extends SimpleHlsMediaBase {} diff --git a/packages/media/src/dom/simple-hls/tests/media-tracks.test.ts b/packages/media/src/dom/simple-hls/tests/media-tracks.test.ts new file mode 100644 index 00000000..277808fc --- /dev/null +++ b/packages/media/src/dom/simple-hls/tests/media-tracks.test.ts @@ -0,0 +1,347 @@ +import { effect, signal } from '@videojs/spf'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { MediaTracksMixin } from '../../../core/media-tracks'; +import { HTMLVideoElementHost } from '../../video-host'; +import { SimpleHlsMediaMediaTracksMixin } from '../media-tracks'; + +// The projection only touches these five engine-state signals; a fake engine +// backed by real signals is enough to drive the effects. +function createEngine() { + return { + state: { + presentation: signal(undefined), + selectedVideoTrackId: signal(undefined), + selectedAudioTrackId: signal(undefined), + userVideoTrackSelection: signal(undefined), + userAudioTrackSelection: signal(undefined), + }, + }; +} + +type Engine = ReturnType; + +class FakeHost extends HTMLVideoElementHost { + engine: Engine; + constructor(engine: Engine) { + super(); + this.engine = engine; + } +} + +const SimpleHlsMediaMediaTracks = SimpleHlsMediaMediaTracksMixin(MediaTracksMixin(FakeHost as any)); + +const presentation = (video: any[], audio: any[] = [], url = 'https://example.com/master.m3u8') => ({ + id: 'pres-1', + url, + selectionSets: [ + { id: 'v', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: video }] }, + { id: 'a', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: audio }] }, + ], +}); + +const vTrack = (over: any) => ({ + type: 'video', + url: 'https://cdn-a/v.m3u8', + bandwidth: 1_000_000, + codecs: ['avc1.640028'], + ...over, +}); + +const aTrack = (over: any) => ({ + type: 'audio', + url: 'https://cdn-a/a.m3u8', + bandwidth: 128_000, + codecs: ['mp4a.40.2'], + name: 'Audio', + ...over, +}); + +// Flush by riding the same scheduler the projection uses: bump a throwaway +// signal and resolve once its effect re-runs. That re-run lands after the SPF +// effect queue and the microtask-queued track/rendition `change` events have +// drained, so the flush inherits the runtime's timing instead of hard-coding a +// micro/macrotask. +const flush = () => + new Promise((resolve) => { + const sig = signal(0); + const stop = effect(() => { + if (sig.get() === 0) return; // skip the synchronous initial run + stop(); + resolve(); + }); + sig.set(1); + }); + +describe('SimpleHlsMediaMediaTracksMixin', () => { + let engine: Engine; + let host: any; + + beforeEach(() => { + engine = createEngine(); + host = new SimpleHlsMediaMediaTracks(engine) as any; + }); + + afterEach(() => { + host.destroy(); + }); + + it('projects video renditions into a selected main track, deduped by w/h/bandwidth', async () => { + engine.state.presentation.set( + presentation([ + vTrack({ id: 'a-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://a/v.m3u8' }), + vTrack({ id: 'b-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://b/v.m3u8' }), + vTrack({ id: 'a-720', width: 1280, height: 720, bandwidth: 3_000_000 }), + ]) + ); + await flush(); + + expect(host.videoTracks.length).toBe(1); + expect(host.videoTracks[0].selected).toBe(true); + expect([...host.videoRenditions].map((r: any) => r.id)).toEqual(['a-1080', 'a-720']); + expect([...host.videoRenditions].map((r: any) => r.height)).toEqual([1080, 720]); + expect(host.videoRenditions[0].codec).toBe('avc1.640028'); + }); + + it('maps a rational frame rate onto the DOM rendition as a number', async () => { + engine.state.presentation.set( + presentation([ + vTrack({ + id: 'v1', + width: 1920, + height: 1080, + frameRate: { frameRateNumerator: 30000, frameRateDenominator: 1001 }, + }), + vTrack({ id: 'v2', width: 640, height: 360, bandwidth: 800_000 }), + ]) + ); + await flush(); + + expect(host.videoRenditions[0].frameRate).toBeCloseTo(29.97, 2); + // No FRAME-RATE on the source → undefined, not coerced to 0. + expect(host.videoRenditions[1].frameRate).toBeUndefined(); + }); + + it('projects audio tracks by language and enables the resolved one', async () => { + engine.state.selectedAudioTrackId.set('en'); + engine.state.presentation.set( + presentation( + [], + [ + aTrack({ id: 'en', language: 'en', name: 'English', default: true }), + aTrack({ id: 'es', language: 'es', name: 'Spanish' }), + ] + ) + ); + await flush(); + + expect([...host.audioTracks].map((t: any) => t.language)).toEqual(['en', 'es']); + expect([...host.audioTracks].map((t: any) => t.label)).toEqual(['English', 'Spanish']); + expect(host.audioTracks[0].enabled).toBe(true); + expect(host.audioTracks[1].enabled).toBe(false); + }); + + it('reflects the resolved video selection as active', async () => { + engine.state.presentation.set( + presentation([ + vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }), + vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }), + ]) + ); + await flush(); + + engine.state.selectedVideoTrackId.set('lo'); + await flush(); + + expect([...host.videoRenditions].map((r: any) => r.active)).toEqual([false, true]); + }); + + it('reflects active by properties, so a non-primary CDN resolved id still lights up its rendition', async () => { + engine.state.presentation.set( + presentation([ + vTrack({ id: 'a-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://a/v.m3u8' }), + vTrack({ id: 'b-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://b/v.m3u8' }), + vTrack({ id: 'a-720', width: 1280, height: 720, bandwidth: 3_000_000 }), + ]) + ); + await flush(); + + // The DOM rendition kept the first copy's id ('a-1080'); the engine resolved + // the second-CDN copy (as on failover). Property-based reflection still marks + // the collapsed rendition active. + engine.state.selectedVideoTrackId.set('b-1080'); + await flush(); + + expect([...host.videoRenditions].map((r: any) => r.id)).toEqual(['a-1080', 'a-720']); + expect([...host.videoRenditions].map((r: any) => r.active)).toEqual([true, false]); + }); + + it('reflects audio enabled by properties across per-CDN copies', async () => { + engine.state.presentation.set( + presentation( + [], + [ + aTrack({ id: 'en-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }), + aTrack({ id: 'en-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }), + aTrack({ id: 'es-a', language: 'es', name: 'Spanish' }), + ] + ) + ); + await flush(); + + engine.state.selectedAudioTrackId.set('en-b'); + await flush(); + + expect([...host.audioTracks].map((t: any) => t.id)).toEqual(['en-a', 'es-a']); + expect([...host.audioTracks].map((t: any) => t.enabled)).toEqual([true, false]); + }); + + it('pins a rendition selection as width/height/bandwidth criteria; Auto clears it', async () => { + engine.state.presentation.set( + presentation([ + vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }), + vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }), + ]) + ); + await flush(); + + host.videoRenditions.selectedIndex = 1; + await flush(); + expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 }); + + host.videoRenditions.selectedIndex = -1; + await flush(); + expect(engine.state.userVideoTrackSelection.get()).toBeUndefined(); + }); + + it('pins an audio selection as a language criterion when the user selects another track', async () => { + engine.state.selectedAudioTrackId.set('en'); + engine.state.presentation.set( + presentation( + [], + [ + aTrack({ id: 'en', language: 'en', name: 'English', default: true }), + aTrack({ id: 'es', language: 'es', name: 'Spanish' }), + ] + ) + ); + await flush(); + + // Exclusive selection, as the audio-track store feature drives it. + host.audioTracks[0].enabled = false; + host.audioTracks[1].enabled = true; + await flush(); + + expect(engine.state.userAudioTrackSelection.get()).toEqual({ language: 'es', name: 'Spanish' }); + }); + + it('does not write a selection when reflection enables the resolved track', async () => { + // The host (built in beforeEach) is wired for side effects; asserted via engine state below. + engine.state.selectedAudioTrackId.set('en'); + engine.state.presentation.set( + presentation([], [aTrack({ id: 'en', language: 'en', default: true }), aTrack({ id: 'es', language: 'es' })]) + ); + await flush(); + + expect(engine.state.userAudioTrackSelection.get()).toBeUndefined(); + }); + + it('preserves renditions across a live-reload presentation swap with the same set', async () => { + const tracks = [ + vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }), + vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }), + ]; + engine.state.presentation.set(presentation(tracks)); + await flush(); + engine.state.selectedVideoTrackId.set('lo'); + await flush(); + + // New presentation object, identical rendition set (as a live refresh does). + engine.state.presentation.set(presentation(tracks.map((t) => ({ ...t })))); + await flush(); + + // Set-equality gate means no rebuild; the reflected active state survives. + expect([...host.videoRenditions].map((r: any) => r.active)).toEqual([false, true]); + }); + + it('clears the pinned rendition and audio selection when the source changes', async () => { + engine.state.selectedAudioTrackId.set('en'); + engine.state.presentation.set( + presentation( + [ + vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }), + vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }), + ], + [ + aTrack({ id: 'en', language: 'en', name: 'English', default: true }), + aTrack({ id: 'es', language: 'es', name: 'Spanish' }), + ] + ) + ); + await flush(); + + // User pins a rendition and switches to an alternate audio track. + host.videoRenditions.selectedIndex = 1; + host.audioTracks[0].enabled = false; + host.audioTracks[1].enabled = true; + await flush(); + expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 }); + expect(engine.state.userAudioTrackSelection.get()).toEqual({ language: 'es', name: 'Spanish' }); + + // A different source (new manifest URL) drops both pins so the engine re-picks. + engine.state.presentation.set( + presentation( + [vTrack({ id: 'hi2', width: 1920, height: 1080, bandwidth: 5_000_000 })], + [aTrack({ id: 'en2', language: 'en', name: 'English', default: true })], + 'https://example.com/other.m3u8' + ) + ); + await flush(); + + expect(engine.state.userVideoTrackSelection.get()).toBeUndefined(); + expect(engine.state.userAudioTrackSelection.get()).toBeUndefined(); + }); + + it('keeps the pinned rendition across a same-URL live reload', async () => { + const tracks = [ + vTrack({ id: 'hi', width: 1920, height: 1080, bandwidth: 5_000_000 }), + vTrack({ id: 'lo', width: 640, height: 360, bandwidth: 800_000 }), + ]; + engine.state.presentation.set(presentation(tracks)); + await flush(); + + host.videoRenditions.selectedIndex = 1; + await flush(); + expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 }); + + // Live reload: same URL, new presentation object — the pin must survive. + engine.state.presentation.set(presentation(tracks.map((t) => ({ ...t })))); + await flush(); + expect(engine.state.userVideoTrackSelection.get()).toEqual({ width: 640, height: 360, bandwidth: 800_000 }); + }); + + it('clears tracks when the source is unset', async () => { + engine.state.presentation.set(presentation([vTrack({ id: 'hi', width: 1920, height: 1080 })])); + await flush(); + expect(host.videoRenditions.length).toBe(1); + + engine.state.presentation.set(undefined); + await flush(); + expect(host.videoTracks.length).toBe(0); + expect(host.videoRenditions.length).toBe(0); + }); + + it('disposes effects and clears tracks on destroy', async () => { + engine.state.presentation.set(presentation([vTrack({ id: 'hi' })], [aTrack({ id: 'en', language: 'en' })])); + await flush(); + expect(host.videoTracks.length).toBe(1); + + host.destroy(); + expect(host.videoTracks.length).toBe(0); + expect(host.audioTracks.length).toBe(0); + + // Effects are torn down: further presentation changes are ignored. + engine.state.presentation.set(presentation([vTrack({ id: 'other' })])); + await flush(); + expect(host.videoTracks.length).toBe(0); + }); +}); diff --git a/packages/spf/package.json b/packages/spf/package.json index 89fe3d9e..a84dbe91 100644 --- a/packages/spf/package.json +++ b/packages/spf/package.json @@ -33,6 +33,11 @@ "development": "./dist/dev/hls.js", "default": "./dist/default/hls.js" }, + "./media-tracks": { + "types": "./dist/dev/media-tracks.d.ts", + "development": "./dist/dev/media-tracks.js", + "default": "./dist/default/media-tracks.js" + }, "./background-video": { "types": "./dist/dev/background-video.d.ts", "development": "./dist/dev/background-video.js", diff --git a/packages/spf/src/media/media-tracks/index.ts b/packages/spf/src/media/media-tracks/index.ts new file mode 100644 index 00000000..c3c2fe50 --- /dev/null +++ b/packages/spf/src/media/media-tracks/index.ts @@ -0,0 +1,20 @@ +/** + * Media-track translation utilities — pure transforms from SPF's track model + * onto the deduped video-rendition / audio-track lists a media-element adapter + * exposes, plus the selection-criteria builders. + * + * @packageDocumentation + */ + +export type { AudioDedupeKey, AudioTrack, VideoDedupeKey, VideoTrack } from './media-tracks'; +export { + dedupedAudioTracks, + dedupedVideoTracks, + findAudioTrackById, + findVideoTrackById, + frameRateToNumber, + isSameAudioTrack, + isSameVideoTrack, + toUserAudioTrackSelection, + toUserVideoTrackSelection, +} from './media-tracks'; diff --git a/packages/spf/src/media/media-tracks/media-tracks.ts b/packages/spf/src/media/media-tracks/media-tracks.ts new file mode 100644 index 00000000..f1131cac --- /dev/null +++ b/packages/spf/src/media/media-tracks/media-tracks.ts @@ -0,0 +1,153 @@ +/** + * Media-track translation utilities. + * + * Pure, DOM-free transforms from SPF's CMAF-HAM track model onto the deduped + * lists a media-element adapter exposes (video renditions, audio tracks), plus + * the selection-criteria builders that turn a chosen rendition/track back into a + * `user*TrackSelection` partial the engine's track-switching reads. + * + * These return SPF *model vocabulary* (`bandwidth`, `codecs: string[]`, + * `frameRate` as a rational `FrameRate`); the consuming adapter owns the + * mapping (e.g. for DOM `bandwidth` -> `bitrate`, `codecs.join(',')` -> `codec`, + * `name` -> `label`). + * + * Deduplication is by *properties*, never URL, so a multi-CDN source that lists + * the same rendition on several hosts collapses to one entry: video renditions + * by `width` + `height` + `bandwidth`, audio tracks by `language` + `name`. + * The selection builders emit those same properties as the match criteria, so + * selecting a collapsed entry re-selects, for example, every underlying per-CDN track. + */ + +import type { AudioTrack, FrameRate, MaybeResolvedPresentation, VideoTrack } from '../types'; +import { findTrackById, getTracksByType } from '../utils/tracks'; + +export type { AudioTrack, VideoTrack }; + +/** Properties that identify a distinct video rendition (multi-CDN copies share them). */ +export interface VideoDedupeKey { + width?: VideoTrack['width']; + height?: VideoTrack['height']; + bandwidth?: VideoTrack['bandwidth']; +} + +/** Properties that identify a distinct audio track (multi-CDN copies share them). */ +export interface AudioDedupeKey { + language?: AudioTrack['language']; + name?: AudioTrack['name']; +} + +/** + * The distinct video tracks of a presentation, deduped by {@link VideoDedupeKey} (first occurrence wins). + * + * Returns `[]` when the presentation is unresolved or has no video tracks. + */ +export function dedupedVideoTracks(presentation: MaybeResolvedPresentation | undefined): VideoTrack[] { + if (!presentation) return []; + + return dedupe({ + tracks: getTracksByType(presentation, 'video') as readonly VideoTrack[], + keyFn: toUserVideoTrackSelection, + }); +} + +/** + * The distinct audio tracks of a presentation, deduped by `language` + `name` + * (first occurrence wins). Returns `[]` when the presentation is unresolved or has no audio tracks. + */ +export function dedupedAudioTracks(presentation: MaybeResolvedPresentation | undefined): AudioTrack[] { + if (!presentation) return []; + + return dedupe({ + tracks: getTracksByType(presentation, 'audio') as readonly AudioTrack[], + keyFn: toUserAudioTrackSelection, + }); +} + +/** + * Find a video track by id, searching the same candidate set the engine resolves + * against ({@link dedupedVideoTracks}'s pre-dedupe source). Returns `undefined` + * when absent. Maps the engine's resolved `selectedVideoTrackId` back to its + * properties for `active` reflection — the resolved id may be a per-CDN copy that + * isn't the representative {@link dedupedVideoTracks} kept. + */ +export function findVideoTrackById( + presentation: MaybeResolvedPresentation | undefined, + id: string | undefined +): VideoTrack | undefined { + if (!presentation || !id) return undefined; + const track = findTrackById(presentation, id); + return track?.type === 'video' ? (track as VideoTrack) : undefined; +} + +/** Audio counterpart of {@link findVideoTrackById}, for `enabled` reflection. */ +export function findAudioTrackById( + presentation: MaybeResolvedPresentation | undefined, + id: string | undefined +): AudioTrack | undefined { + if (!presentation || !id) return undefined; + const track = findTrackById(presentation, id); + return track?.type === 'audio' ? (track as AudioTrack) : undefined; +} + +/** + * Shallow-equal two key objects by their own properties. Both come from the same + * key builder, so they carry the same keys — a one-directional scan suffices. + */ +function sameKey(a: K, b: K): boolean { + for (const attr in a) { + if (a[attr] !== b[attr]) return false; + } + return true; +} + +/** + * Dedupe tracks by a key function, keeping the first occurrence of each key. + * Keys are compared field-by-field ({@link sameKey}). + */ +function dedupe({ + tracks, + keyFn, +}: { + tracks: readonly T[]; + keyFn: (track: T) => K | undefined; +}): T[] { + const seen: K[] = []; + const kept: T[] = []; + for (const track of tracks) { + const key = keyFn(track); + if (!key || seen.some((other) => sameKey(other, key))) continue; + seen.push(key); + kept.push(track); + } + + return kept; +} + +/** + * Build a partial video track that can be used as `userVideoTrackSelection`. + */ +export function toUserVideoTrackSelection(rendition?: T): Partial | undefined { + return rendition ? { width: rendition.width, height: rendition.height, bandwidth: rendition.bandwidth } : undefined; +} + +/** + * Build a partial audio track that can be used as a `userAudioTrackSelection`. + */ +export function toUserAudioTrackSelection(track?: T): Partial | undefined { + return track ? { language: track.language, name: track.name } : undefined; +} + +/** Whether two video tracks are the same by dedupe key */ +export function isSameVideoTrack(a: VideoDedupeKey, b: VideoDedupeKey | undefined): boolean { + return !!b && a.width === b.width && a.height === b.height && a.bandwidth === b.bandwidth; +} + +/** Whether two audio tracks are the same by dedupe key */ +export function isSameAudioTrack(a: AudioDedupeKey, b: AudioDedupeKey | undefined): boolean { + return !!b && (a.language ?? '') === (b.language ?? '') && a.name === b.name; +} + +/** Collapse a rational frame rate (numerator/denominator) to frames per second. */ +export const frameRateToNumber = (frameRate: FrameRate) => { + return frameRate.frameRateNumerator / (frameRate.frameRateDenominator ?? 1); +}; diff --git a/packages/spf/src/media/media-tracks/tests/media-tracks.test.ts b/packages/spf/src/media/media-tracks/tests/media-tracks.test.ts new file mode 100644 index 00000000..49fbff09 --- /dev/null +++ b/packages/spf/src/media/media-tracks/tests/media-tracks.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { matchesPartialTrack } from '../../primitives/select-tracks'; +import type { AudioTrack, MaybeResolvedPresentation, VideoTrack } from '../../types'; +import { + dedupedAudioTracks, + dedupedVideoTracks, + findAudioTrackById, + findVideoTrackById, + isSameAudioTrack, + isSameVideoTrack, + toUserAudioTrackSelection, + toUserVideoTrackSelection, +} from '../media-tracks'; + +// Minimal presentation builders. Tracks carry only the fields the transforms +// read — the `as unknown as` cast keeps the fixtures terse (matching the +// sibling track-util tests). +const presentationWith = ( + videoTracks: Partial[], + audioTracks: Partial[] = [] +): MaybeResolvedPresentation => + ({ + id: 'pres-1', + url: 'https://example.com/master.m3u8', + selectionSets: [ + { id: 'v', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: videoTracks }] }, + { id: 'a', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: audioTracks }] }, + ], + }) as unknown as MaybeResolvedPresentation; + +const video = (over: Partial): Partial => ({ + type: 'video', + url: 'https://cdn-a.example.com/v.m3u8', + bandwidth: 1_000_000, + codecs: ['avc1.640028'], + ...over, +}); + +const audio = (over: Partial): Partial => ({ + type: 'audio', + url: 'https://cdn-a.example.com/a.m3u8', + bandwidth: 128_000, + codecs: ['mp4a.40.2'], + name: 'Audio', + ...over, +}); + +describe('dedupedVideoTracks', () => { + it('returns the model video tracks in order', () => { + const renditions = dedupedVideoTracks( + presentationWith([ + video({ id: 'v1', width: 1920, height: 1080, bandwidth: 5_000_000 }), + video({ id: 'v2', width: 1280, height: 720, bandwidth: 3_000_000 }), + ]) + ); + + expect(renditions.map((r) => r.id)).toEqual(['v1', 'v2']); + expect(renditions[0]).toMatchObject({ width: 1920, height: 1080, bandwidth: 5_000_000, codecs: ['avc1.640028'] }); + }); + + it('dedups by width + height + bandwidth, collapsing multi-CDN copies (first wins)', () => { + const renditions = dedupedVideoTracks( + presentationWith([ + video({ id: 'cdn-a-720', width: 1280, height: 720, bandwidth: 3_000_000, url: 'https://a/v.m3u8' }), + video({ id: 'cdn-b-720', width: 1280, height: 720, bandwidth: 3_000_000, url: 'https://b/v.m3u8' }), + video({ id: 'cdn-a-1080', width: 1920, height: 1080, bandwidth: 5_000_000, url: 'https://a/v.m3u8' }), + ]) + ); + + expect(renditions.map((r) => r.id)).toEqual(['cdn-a-720', 'cdn-a-1080']); + }); + + it('keeps renditions with the same bandwidth but different resolution distinct', () => { + const renditions = dedupedVideoTracks( + presentationWith([ + video({ id: 'a', width: 1280, height: 720, bandwidth: 3_000_000 }), + video({ id: 'b', width: 1920, height: 1080, bandwidth: 3_000_000 }), + ]) + ); + expect(renditions).toHaveLength(2); + }); + + it('returns [] for an unresolved presentation, no video tracks, or undefined', () => { + expect(dedupedVideoTracks({ url: 'https://example.com/x.m3u8' })).toEqual([]); + expect(dedupedVideoTracks(presentationWith([]))).toEqual([]); + expect(dedupedVideoTracks(undefined)).toEqual([]); + }); +}); + +describe('dedupedAudioTracks', () => { + it('returns the model audio tracks in order', () => { + const tracks = dedupedAudioTracks( + presentationWith([], [audio({ id: 'a-en', language: 'en', name: 'English', default: true })]) + ); + expect(tracks.map((t) => t.id)).toEqual(['a-en']); + expect(tracks[0]).toMatchObject({ language: 'en', name: 'English', default: true }); + }); + + it('dedups by language + name, collapsing multi-CDN copies (first wins)', () => { + const tracks = dedupedAudioTracks( + presentationWith( + [], + [ + audio({ id: 'en-cdn-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }), + audio({ id: 'en-cdn-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }), + audio({ id: 'es-cdn-a', language: 'es', name: 'Spanish', url: 'https://a/a.m3u8' }), + ] + ) + ); + expect(tracks.map((t) => t.id)).toEqual(['en-cdn-a', 'es-cdn-a']); + }); + + it('keeps same-language tracks with different names distinct (e.g. commentary)', () => { + const tracks = dedupedAudioTracks( + presentationWith( + [], + [ + audio({ id: 'en', language: 'en', name: 'English' }), + audio({ id: 'en-commentary', language: 'en', name: 'English (Commentary)' }), + ] + ) + ); + expect(tracks.map((t) => t.id)).toEqual(['en', 'en-commentary']); + }); + + it('returns [] when there are no audio tracks or the presentation is undefined', () => { + expect(dedupedAudioTracks(presentationWith([]))).toEqual([]); + expect(dedupedAudioTracks(undefined)).toEqual([]); + }); +}); + +describe('toUserVideoTrackSelection', () => { + it('emits the width/height/bandwidth match criteria', () => { + const [rendition] = dedupedVideoTracks( + presentationWith([video({ id: 'v1', width: 1280, height: 720, bandwidth: 3_000_000 })]) + ); + expect(toUserVideoTrackSelection(rendition!)).toEqual({ width: 1280, height: 720, bandwidth: 3_000_000 }); + }); + + it('matches every underlying track sharing those properties (multi-CDN)', () => { + const criteria = toUserVideoTrackSelection({ width: 1280, height: 720, bandwidth: 3_000_000 })!; + const cdnA = video({ id: 'cdn-a', width: 1280, height: 720, bandwidth: 3_000_000 }) as VideoTrack; + const cdnB = video({ id: 'cdn-b', width: 1280, height: 720, bandwidth: 3_000_000 }) as VideoTrack; + const other = video({ id: 'other', width: 1920, height: 1080, bandwidth: 5_000_000 }) as VideoTrack; + + expect(matchesPartialTrack(cdnA, criteria)).toBe(true); + expect(matchesPartialTrack(cdnB, criteria)).toBe(true); + expect(matchesPartialTrack(other, criteria)).toBe(false); + }); +}); + +describe('toUserAudioTrackSelection', () => { + it('emits the language + name match criteria', () => { + const [track] = dedupedAudioTracks(presentationWith([], [audio({ id: 'a-es', language: 'es', name: 'Spanish' })])); + expect(toUserAudioTrackSelection(track!)).toEqual({ language: 'es', name: 'Spanish' }); + }); + + it('matches same-language same-name tracks (multi-CDN) but not a different role', () => { + const criteria = toUserAudioTrackSelection({ language: 'en', name: 'English' })!; + const enA = audio({ id: 'en-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }) as AudioTrack; + const enB = audio({ id: 'en-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }) as AudioTrack; + const commentary = audio({ id: 'en-c', language: 'en', name: 'English (Commentary)' }) as AudioTrack; + + expect(matchesPartialTrack(enA, criteria)).toBe(true); + expect(matchesPartialTrack(enB, criteria)).toBe(true); + expect(matchesPartialTrack(commentary, criteria)).toBe(false); + }); +}); + +describe('findVideoTrackById', () => { + const pres = presentationWith([ + video({ id: 'cdn-a-1080', width: 1920, height: 1080, url: 'https://a/v.m3u8' }), + video({ id: 'cdn-b-1080', width: 1920, height: 1080, url: 'https://b/v.m3u8' }), + ]); + + it('finds a track by id, including a non-first per-CDN copy the dedup drops', () => { + // 'cdn-b-1080' is collapsed out of dedupedVideoTracks but still resolvable. + expect(findVideoTrackById(pres, 'cdn-b-1080')?.id).toBe('cdn-b-1080'); + }); + + it('returns undefined for a missing id or absent presentation', () => { + expect(findVideoTrackById(pres, 'nope')).toBeUndefined(); + expect(findVideoTrackById(undefined, 'cdn-a-1080')).toBeUndefined(); + expect(findVideoTrackById(pres, undefined)).toBeUndefined(); + }); +}); + +describe('findAudioTrackById', () => { + const pres = presentationWith( + [], + [ + audio({ id: 'en-a', language: 'en', name: 'English', url: 'https://a/a.m3u8' }), + audio({ id: 'en-b', language: 'en', name: 'English', url: 'https://b/a.m3u8' }), + ] + ); + + it('finds a track by id, including a non-first per-CDN copy', () => { + expect(findAudioTrackById(pres, 'en-b')?.id).toBe('en-b'); + }); + + it('returns undefined for a missing id or absent presentation', () => { + expect(findAudioTrackById(pres, 'nope')).toBeUndefined(); + expect(findAudioTrackById(undefined, 'en-a')).toBeUndefined(); + }); +}); + +describe('isSameVideoTrack', () => { + it('matches by width + height + bandwidth regardless of id/url', () => { + const a = { width: 1280, height: 720, bandwidth: 3_000_000 }; + const b = video({ id: 'other-cdn', width: 1280, height: 720, bandwidth: 3_000_000 }) as VideoTrack; + expect(isSameVideoTrack(a, b)).toBe(true); + }); + + it('does not match a different quality, and is false when the track is undefined', () => { + const a = { width: 1280, height: 720, bandwidth: 3_000_000 }; + expect(isSameVideoTrack(a, video({ width: 1920, height: 1080, bandwidth: 5_000_000 }) as VideoTrack)).toBe(false); + expect(isSameVideoTrack(a, undefined)).toBe(false); + }); +}); + +describe('isSameAudioTrack', () => { + it('matches by language + name, treating empty and absent language alike', () => { + expect( + isSameAudioTrack({ language: 'en', name: 'English' }, audio({ language: 'en', name: 'English' }) as AudioTrack) + ).toBe(true); + // DOM coerces a missing language to '' — must still match the model's `undefined`. + expect( + isSameAudioTrack({ language: '', name: 'Audio' }, audio({ language: undefined, name: 'Audio' }) as AudioTrack) + ).toBe(true); + }); + + it('does not match a different role, and is false when the track is undefined', () => { + expect( + isSameAudioTrack({ language: 'en', name: 'English' }, audio({ language: 'en', name: 'Commentary' }) as AudioTrack) + ).toBe(false); + expect(isSameAudioTrack({ language: 'en', name: 'English' }, undefined)).toBe(false); + }); +}); diff --git a/packages/spf/tsdown.config.ts b/packages/spf/tsdown.config.ts index 7cfa55b9..ba48b405 100644 --- a/packages/spf/tsdown.config.ts +++ b/packages/spf/tsdown.config.ts @@ -8,6 +8,7 @@ const createConfig = (mode: PackageBuildMode): UserConfig => ({ index: 'src/index.ts', dom: 'src/dom.ts', hls: 'src/playback/engines/hls/index.ts', + 'media-tracks': 'src/media/media-tracks/index.ts', 'background-video': 'src/playback/engines/background-video/index.ts', }, });