feat(spf): expose media tracks on the SPF media adapter (#1826)

This commit is contained in:
Santiago Puppo
2026-07-30 16:13:30 -03:00
committed by GitHub
parent b16f28fb74
commit c83b044986
8 changed files with 986 additions and 1 deletions
@@ -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';
@@ -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<K extends object>(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<T, K extends object>({
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<T extends VideoDedupeKey>(rendition?: T): Partial<VideoTrack> | 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<T extends AudioDedupeKey>(track?: T): Partial<AudioTrack> | 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);
};
@@ -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<VideoTrack>[],
audioTracks: Partial<AudioTrack>[] = []
): 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<VideoTrack>): Partial<VideoTrack> => ({
type: 'video',
url: 'https://cdn-a.example.com/v.m3u8',
bandwidth: 1_000_000,
codecs: ['avc1.640028'],
...over,
});
const audio = (over: Partial<AudioTrack>): Partial<AudioTrack> => ({
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);
});
});