feat(packages): add mux media with src parsing, structured source, and storyboards (#1850)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Wesley Luyten
2026-07-27 16:55:24 -07:00
committed by GitHub
co-authored by Claude
parent fd4d2662ea
commit 409e7ef225
29 changed files with 1519 additions and 41 deletions
@@ -90,7 +90,11 @@ export interface MediaHost extends EventTarget {
}
type CustomMediaConstructor<T extends Constructor<MediaHost>> = Constructor<
HTMLElement & InstanceType<T> & { readonly host: InstanceType<T> }
HTMLElement &
InstanceType<T> & {
readonly host: InstanceType<T>;
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
}
> & {
properties: Record<string, { type: any; attribute?: string; empty?: unknown }>;
getTemplateHTML: (attrs: Record<string, string>) => string;
+88
View File
@@ -1 +1,89 @@
import { HlsJsMedia } from '../hls-js';
import {
createMuxStoryboardURL,
createMuxThumbnailURL,
createMuxVideoURL,
isSameMuxSource,
type MuxSource,
parseMuxVideoURL,
} from './utils';
export { MuxData, type MuxDataProps } from './mux-data';
export * from './utils';
export interface MuxMediaProps {
src: string;
source: MuxSource | null;
thumbnail: string;
storyboard: string;
}
export const muxMediaDefaultProps: MuxMediaProps = {
src: '',
source: null,
thumbnail: '',
storyboard: '',
};
/**
* @fires sourcechange - Fired when `source` changes, either directly or by parsing a new `src`. Read `source` for the new value.
*/
export class MuxMedia extends HlsJsMedia implements MuxMediaProps {
#source: MuxSource | null = muxMediaDefaultProps.source;
#thumbnail = muxMediaDefaultProps.thumbnail;
#storyboard = muxMediaDefaultProps.storyboard;
/**
* Media source URL. Setting a Mux stream URL
* (`https://stream.mux.com/<playback-id>.m3u8?...`) extracts the playback ID
* and query params into `source`; other URLs pass through unchanged.
*/
get src(): string {
return super.src;
}
set src(value: string) {
if (super.src === value) return;
const source = parseMuxVideoURL(value) ?? null;
const changed = !isSameMuxSource(this.#source, source);
this.#source = source;
super.src = value;
if (changed) this.dispatchEvent(new Event('sourcechange'));
}
/**
* Structured Mux source. Setting it derives `src` from the playback ID,
* custom domain, and `playback` params (appended as `snake_case` query
* params). A `playback.token` replaces all other params — signed URLs bake
* them into the token.
*/
get source(): MuxSource | null {
return this.#source;
}
set source(value: MuxSource | null) {
if (isSameMuxSource(this.#source, value)) return;
this.#source = value;
const src = createMuxVideoURL(value) ?? '';
if (super.src !== src) super.src = src;
this.dispatchEvent(new Event('sourcechange'));
}
/** Thumbnail image URL. Falls back to one derived from `source`. */
get thumbnail(): string {
return this.#thumbnail || (createMuxThumbnailURL(this.#source) ?? '');
}
set thumbnail(value: string) {
this.#thumbnail = value;
}
/** Storyboard (thumbnail sprite) VTT URL. Falls back to one derived from `source`. */
get storyboard(): string {
return this.#storyboard || (createMuxStoryboardURL(this.#source) ?? '');
}
set storyboard(value: string) {
this.#storyboard = value;
}
}
@@ -0,0 +1,222 @@
import { describe, expect, it, vi } from 'vitest';
import { HlsJsMedia } from '../../hls-js';
import { MuxMedia } from '..';
describe('MuxMedia', () => {
it('extends HlsJsMedia', () => {
expect(new MuxMedia()).toBeInstanceOf(HlsJsMedia);
});
it('defaults source to null', () => {
expect(new MuxMedia().source).toBeNull();
});
it('derives src from source.playbackId', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123' };
expect(media.src).toBe('https://stream.mux.com/abc123.m3u8');
});
it('clears src when source is cleared', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123' };
media.source = null;
expect(media.src).toBe('');
});
it('derives src using the custom domain', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123', customDomain: 'example.com' };
expect(media.src).toBe('https://stream.example.com/abc123.m3u8');
});
it('appends playback params as snake_case query params', () => {
const media = new MuxMedia();
media.source = {
playbackId: 'abc123',
playback: {
maxResolution: '1080p',
minResolution: '480p',
renditionOrder: 'desc',
assetStartTime: 3,
assetEndTime: 4,
customParam: 'x',
},
};
const url = new URL(media.src);
expect(url.searchParams.get('max_resolution')).toBe('1080p');
expect(url.searchParams.get('min_resolution')).toBe('480p');
expect(url.searchParams.get('rendition_order')).toBe('desc');
expect(url.searchParams.get('asset_start_time')).toBe('3');
expect(url.searchParams.get('asset_end_time')).toBe('4');
expect(url.searchParams.get('custom_param')).toBe('x');
});
it('applies a playback token and drops all other playback params', () => {
const media = new MuxMedia();
media.source = {
playbackId: 'abc123',
playback: { token: 'jwt', maxResolution: '1080p', assetStartTime: 3 },
};
const url = new URL(media.src);
expect(url.searchParams.get('token')).toBe('jwt');
expect(url.searchParams.has('max_resolution')).toBe(false);
expect(url.searchParams.has('asset_start_time')).toBe(false);
});
it('parses source from a Mux stream src', () => {
const media = new MuxMedia();
media.src = 'https://stream.mux.com/abc123.m3u8';
expect(media.src).toBe('https://stream.mux.com/abc123.m3u8');
expect(media.source).toEqual({ playbackId: 'abc123' });
});
it('parses the custom domain and playback params from a Mux stream src', () => {
const media = new MuxMedia();
media.src = 'https://stream.example.com/abc123.m3u8?token=jwt';
expect(media.source).toEqual({
playbackId: 'abc123',
customDomain: 'example.com',
playback: { token: 'jwt' },
});
});
it('passes non-Mux src through with a null source', () => {
const media = new MuxMedia();
media.src = 'https://example.com/custom.m3u8';
expect(media.src).toBe('https://example.com/custom.m3u8');
expect(media.source).toBeNull();
});
it('derives the thumbnail URL from source', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123', thumbnail: { time: 5, ext: 'jpg' } };
expect(media.thumbnail).toBe('https://image.mux.com/abc123/thumbnail.jpg?time=5');
});
it('uses the first entry when source.thumbnail is an array', () => {
const media = new MuxMedia();
media.source = {
playbackId: 'abc123',
thumbnail: [
{ time: 5, ext: 'webp' },
{ time: 5, ext: 'jpg' },
],
};
expect(media.thumbnail).toBe('https://image.mux.com/abc123/thumbnail.webp?time=5');
});
it('prefers an explicitly set thumbnail URL', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123' };
media.thumbnail = 'https://image.mux.com/other/thumbnail.webp';
expect(media.thumbnail).toBe('https://image.mux.com/other/thumbnail.webp');
});
it('derives the storyboard URL from source', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123' };
expect(media.storyboard).toBe('https://image.mux.com/abc123/storyboard.vtt?format=webp');
});
it('prefers an explicitly set storyboard URL', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123' };
media.storyboard = 'https://image.mux.com/other/storyboard.vtt';
expect(media.storyboard).toBe('https://image.mux.com/other/storyboard.vtt');
});
it('returns no storyboard for signed playback without a storyboard token', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123', playback: { token: 'jwt' } };
expect(media.storyboard).toBe('');
});
it('fires sourcechange when source is set', () => {
const media = new MuxMedia();
const onSourceChange = vi.fn(() => media.source);
media.addEventListener('sourcechange', onSourceChange);
media.source = { playbackId: 'abc123' };
expect(onSourceChange).toHaveBeenCalledTimes(1);
// The new source is readable when the event fires.
expect(onSourceChange).toHaveReturnedWith({ playbackId: 'abc123' });
media.source = null;
expect(onSourceChange).toHaveBeenCalledTimes(2);
});
it('does not fire sourcechange for the same source reference', () => {
const media = new MuxMedia();
const source = { playbackId: 'abc123' };
media.source = source;
const onSourceChange = vi.fn();
media.addEventListener('sourcechange', onSourceChange);
media.source = source;
expect(onSourceChange).not.toHaveBeenCalled();
});
it('does not fire sourcechange for a structurally equal source', () => {
const media = new MuxMedia();
media.source = { playbackId: 'abc123', playback: { maxResolution: '1080p' } };
const onSourceChange = vi.fn();
media.addEventListener('sourcechange', onSourceChange);
media.source = { playbackId: 'abc123', playback: { maxResolution: '1080p' } };
expect(onSourceChange).not.toHaveBeenCalled();
media.source = { playbackId: 'abc123', playback: { maxResolution: '720p' } };
expect(onSourceChange).toHaveBeenCalledTimes(1);
});
it('parses typed playback params from a Mux stream src', () => {
const media = new MuxMedia();
media.src = 'https://stream.mux.com/abc123.m3u8?asset_start_time=3&redundant_streams=true';
expect(media.source).toEqual({
playbackId: 'abc123',
playback: { assetStartTime: 3, redundantStreams: true },
});
});
it('fires sourcechange when a Mux stream src is parsed', () => {
const media = new MuxMedia();
const onSourceChange = vi.fn();
media.addEventListener('sourcechange', onSourceChange);
media.src = 'https://stream.mux.com/abc123.m3u8';
expect(onSourceChange).toHaveBeenCalledTimes(1);
});
it('does not fire sourcechange when a non-Mux src replaces another', () => {
const media = new MuxMedia();
media.src = 'https://example.com/a.m3u8';
const onSourceChange = vi.fn();
media.addEventListener('sourcechange', onSourceChange);
media.src = 'https://example.com/b.m3u8';
expect(onSourceChange).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,302 @@
import { describe, expect, it, vi } from 'vitest';
import {
createMuxQuery,
createMuxStoryboardURL,
createMuxThumbnailURL,
createMuxVideoURL,
isSameMuxSource,
parseMuxVideoURL,
} from '../utils';
// Header `{"alg":"HS256"}`, body sets `aud`, empty signature.
function fakeJwt(payload: Record<string, unknown>): string {
const encode = (obj: unknown) => btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_');
return `${encode({ alg: 'HS256' })}.${encode(payload)}.`;
}
describe('createMuxVideoURL', () => {
it('returns undefined without a playbackId', () => {
expect(createMuxVideoURL()).toBeUndefined();
expect(createMuxVideoURL(null)).toBeUndefined();
expect(createMuxVideoURL({ playbackId: '' })).toBeUndefined();
});
it('builds a stream URL from a playbackId', () => {
expect(createMuxVideoURL({ playbackId: 'abc123' })).toBe('https://stream.mux.com/abc123.m3u8');
});
it('uses the custom domain', () => {
expect(createMuxVideoURL({ playbackId: 'abc123', customDomain: 'example.com' })).toBe(
'https://stream.example.com/abc123.m3u8'
);
});
it('appends playback params as snake_case query params', () => {
const url = new URL(
createMuxVideoURL({
playbackId: 'abc123',
playback: { maxResolution: '1080p', renditionOrder: 'desc', extraParam: 'x', skip: undefined },
})!
);
expect(url.searchParams.get('max_resolution')).toBe('1080p');
expect(url.searchParams.get('rendition_order')).toBe('desc');
expect(url.searchParams.get('extra_param')).toBe('x');
expect(url.searchParams.has('skip')).toBe(false);
});
it('appends manifest modifiers as snake_case query params', () => {
const url = new URL(
createMuxVideoURL({
playbackId: 'abc123',
playback: {
redundantStreams: true,
rokuTrickPlay: true,
defaultSubtitlesLang: 'en-US',
programStartTime: 1700000000,
programEndTime: 1700000060,
assetStartTime: 3,
assetEndTime: 4,
excludePdt: true,
},
})!
);
expect(url.searchParams.get('redundant_streams')).toBe('true');
expect(url.searchParams.get('roku_trick_play')).toBe('true');
expect(url.searchParams.get('default_subtitles_lang')).toBe('en-US');
expect(url.searchParams.get('program_start_time')).toBe('1700000000');
expect(url.searchParams.get('program_end_time')).toBe('1700000060');
expect(url.searchParams.get('asset_start_time')).toBe('3');
expect(url.searchParams.get('asset_end_time')).toBe('4');
expect(url.searchParams.get('exclude_pdt')).toBe('true');
});
it('drops all params except the token for signed playback', () => {
const url = new URL(
createMuxVideoURL({ playbackId: 'abc123', playback: { token: 'jwt', maxResolution: '1080p' } })!
);
expect(url.searchParams.get('token')).toBe('jwt');
expect(url.searchParams.has('max_resolution')).toBe(false);
});
it('warns when minResolution exceeds maxResolution', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
createMuxVideoURL({ playbackId: 'abc123', playback: { minResolution: '1080p', maxResolution: '720p' } });
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
describe('parseMuxVideoURL', () => {
it('extracts the playbackId', () => {
expect(parseMuxVideoURL('https://stream.mux.com/abc123.m3u8')).toEqual({ playbackId: 'abc123' });
});
it('extracts a custom domain', () => {
expect(parseMuxVideoURL('https://stream.example.com/abc123.m3u8')).toEqual({
playbackId: 'abc123',
customDomain: 'example.com',
});
});
it('maps snake_case query params to camelCase playback params', () => {
expect(parseMuxVideoURL('https://stream.mux.com/abc123.m3u8?max_resolution=1080p&token=jwt')).toEqual({
playbackId: 'abc123',
playback: { maxResolution: '1080p', token: 'jwt' },
});
});
it('coerces numeric and boolean params to their declared types', () => {
expect(
parseMuxVideoURL(
'https://stream.mux.com/abc123.m3u8?asset_start_time=0&program_end_time=1700000060&redundant_streams=false&exclude_pdt=true'
)
).toEqual({
playbackId: 'abc123',
playback: { assetStartTime: 0, programEndTime: 1700000060, redundantStreams: false, excludePdt: true },
});
});
it('keeps non-numeric strings as strings', () => {
expect(
parseMuxVideoURL('https://stream.mux.com/abc123.m3u8?max_resolution=1080p&default_subtitles_lang=en')
).toEqual({
playbackId: 'abc123',
playback: { maxResolution: '1080p', defaultSubtitlesLang: 'en' },
});
});
it('keeps the token as a string', () => {
expect(parseMuxVideoURL('https://stream.mux.com/abc123.m3u8?token=123')).toEqual({
playbackId: 'abc123',
playback: { token: '123' },
});
});
it('returns undefined for non-Mux URLs', () => {
expect(parseMuxVideoURL('')).toBeUndefined();
expect(parseMuxVideoURL('not a url')).toBeUndefined();
expect(parseMuxVideoURL('https://example.com/video.m3u8')).toBeUndefined();
expect(parseMuxVideoURL('https://stream.mux.com/abc123/highest.mp4')).toBeUndefined();
});
it('round-trips through createMuxVideoURL', () => {
const src = 'https://stream.example.com/abc123.m3u8?asset_start_time=3&max_resolution=1080p';
expect(createMuxVideoURL(parseMuxVideoURL(src))).toBe(src);
});
});
describe('isSameMuxSource', () => {
it('treats nullish sources as equal', () => {
expect(isSameMuxSource(null, undefined)).toBe(true);
expect(isSameMuxSource(null, { playbackId: 'abc123' })).toBe(false);
});
it('compares sources structurally', () => {
expect(isSameMuxSource({ playbackId: 'abc123' }, { playbackId: 'abc123' })).toBe(true);
expect(isSameMuxSource({ playbackId: 'abc123' }, { playbackId: 'other' })).toBe(false);
});
it('compares nested params', () => {
const a = { playbackId: 'abc123', playback: { maxResolution: '1080p' as const }, thumbnail: [{ time: 5 }] };
expect(isSameMuxSource(a, { ...a, playback: { maxResolution: '1080p' }, thumbnail: [{ time: 5 }] })).toBe(true);
expect(isSameMuxSource(a, { ...a, playback: { maxResolution: '720p' } })).toBe(false);
expect(isSameMuxSource(a, { ...a, thumbnail: [{ time: 6 }] })).toBe(false);
});
it('treats keys set to undefined as absent', () => {
expect(isSameMuxSource({ playbackId: 'abc123', customDomain: undefined }, { playbackId: 'abc123' })).toBe(true);
});
});
describe('createMuxQuery', () => {
it('maps camelCase keys to snake_case and skips nullish values', () => {
expect(createMuxQuery({ assetStartTime: 1, b: undefined, c: null, d: 'x' })).toBe('?asset_start_time=1&d=x');
});
it('returns an empty string when there are no params', () => {
expect(createMuxQuery({ a: undefined })).toBe('');
expect(createMuxQuery()).toBe('');
});
it('keeps only the token when one is set', () => {
expect(createMuxQuery({ token: 'jwt', assetStartTime: 1 })).toBe('?token=jwt');
});
});
describe('createMuxThumbnailURL', () => {
it('builds a thumbnail URL with params', () => {
expect(createMuxThumbnailURL({ playbackId: 'abc123', thumbnail: { time: 5, ext: 'jpg' } })).toBe(
'https://image.mux.com/abc123/thumbnail.jpg?time=5'
);
});
it('defaults the extension to webp', () => {
expect(createMuxThumbnailURL({ playbackId: 'abc123' })).toBe('https://image.mux.com/abc123/thumbnail.webp');
});
it('uses the first entry of a thumbnail array', () => {
expect(createMuxThumbnailURL({ playbackId: 'abc123', thumbnail: [{ ext: 'webp' }, { ext: 'jpg' }] })).toBe(
'https://image.mux.com/abc123/thumbnail.webp'
);
});
it('appends transformation modifiers as snake_case query params', () => {
const url = new URL(
createMuxThumbnailURL({
playbackId: 'abc123',
thumbnail: {
time: 5,
width: 640,
height: 360,
rotate: 90,
fitMode: 'smartcrop',
flipV: true,
flipH: true,
programTime: 1700000000,
latest: true,
},
})!
);
expect(url.searchParams.get('time')).toBe('5');
expect(url.searchParams.get('width')).toBe('640');
expect(url.searchParams.get('height')).toBe('360');
expect(url.searchParams.get('rotate')).toBe('90');
expect(url.searchParams.get('fit_mode')).toBe('smartcrop');
expect(url.searchParams.get('flip_v')).toBe('true');
expect(url.searchParams.get('flip_h')).toBe('true');
expect(url.searchParams.get('program_time')).toBe('1700000000');
expect(url.searchParams.get('latest')).toBe('true');
});
it('uses explicit params over the source thumbnail', () => {
expect(createMuxThumbnailURL({ playbackId: 'abc123', thumbnail: { ext: 'webp' } }, { ext: 'jpg', time: 2 })).toBe(
'https://image.mux.com/abc123/thumbnail.jpg?time=2'
);
});
it('keeps only the token when one is set', () => {
const token = fakeJwt({ aud: 't' });
const url = new URL(createMuxThumbnailURL({ playbackId: 'abc123', thumbnail: { token, time: 5 } })!);
expect(url.pathname).toBe('/abc123/thumbnail.webp');
expect(url.searchParams.get('token')).toBe(token);
expect(url.searchParams.has('time')).toBe(false);
});
it('returns undefined for a token with the wrong audience', () => {
expect(
createMuxThumbnailURL({ playbackId: 'abc123', thumbnail: { token: fakeJwt({ aud: 's' }) } })
).toBeUndefined();
});
it('returns undefined for signed playback without a thumbnail token', () => {
expect(createMuxThumbnailURL({ playbackId: 'abc123', playback: { token: 'jwt' } })).toBeUndefined();
});
it('returns undefined without a playbackId', () => {
expect(createMuxThumbnailURL()).toBeUndefined();
expect(createMuxThumbnailURL({ playbackId: '' })).toBeUndefined();
});
});
describe('createMuxStoryboardURL', () => {
it('builds a storyboard URL', () => {
expect(createMuxStoryboardURL({ playbackId: 'abc123' })).toBe(
'https://image.mux.com/abc123/storyboard.vtt?format=webp'
);
});
it('uses the custom domain', () => {
expect(createMuxStoryboardURL({ playbackId: 'abc123', customDomain: 'example.com' })).toBe(
'https://image.example.com/abc123/storyboard.vtt?format=webp'
);
});
it('overrides the default format', () => {
expect(createMuxStoryboardURL({ playbackId: 'abc123', storyboard: { format: 'jpg' } })).toBe(
'https://image.mux.com/abc123/storyboard.vtt?format=jpg'
);
});
it('keeps only the token when one is set', () => {
const token = fakeJwt({ aud: 's' });
const url = new URL(createMuxStoryboardURL({ playbackId: 'abc123', storyboard: { token } })!);
expect(url.pathname).toBe('/abc123/storyboard.vtt');
expect(url.searchParams.get('token')).toBe(token);
expect(url.searchParams.has('format')).toBe(false);
});
it('returns undefined without a playbackId', () => {
expect(createMuxStoryboardURL()).toBeUndefined();
expect(createMuxStoryboardURL({ playbackId: '' })).toBeUndefined();
});
it('returns undefined for a token with the wrong audience', () => {
expect(
createMuxStoryboardURL({ playbackId: 'abc123', storyboard: { token: fakeJwt({ aud: 't' }) } })
).toBeUndefined();
});
it('returns undefined for signed playback without a storyboard token', () => {
expect(createMuxStoryboardURL({ playbackId: 'abc123', playback: { token: 'jwt' } })).toBeUndefined();
});
});
+210
View File
@@ -0,0 +1,210 @@
import { parseJwt } from '@videojs/utils/jwt';
import { deepEqual } from '@videojs/utils/object';
import { isNil } from '@videojs/utils/predicate';
import { camelCase, snakeCase } from '@videojs/utils/string';
export const MUX_VIDEO_DOMAIN = 'mux.com';
export type MuxResolution = '270p' | '360p' | '480p' | '540p' | '720p' | '1080p' | '1440p' | '2160p';
export type MuxRenditionOrder = 'desc';
export type MuxThumbnailExt = 'webp' | 'jpg' | 'png';
export type MuxThumbnailFitMode = 'preserve' | 'stretch' | 'crop' | 'smartcrop' | 'pad';
/**
* Playback modifiers appended to the stream URL as `snake_case` query params
* (e.g. `assetStartTime` `asset_start_time`). A signed playback `token`
* replaces every other param they must be baked into the signing token.
*/
export interface MuxPlaybackParams {
token?: string | undefined;
/** Maximum resolution of renditions included in the manifest. */
maxResolution?: MuxResolution | undefined;
/** Minimum resolution of renditions included in the manifest. */
minResolution?: MuxResolution | undefined;
/** Logic to order renditions in the HLS manifest. */
renditionOrder?: MuxRenditionOrder | undefined;
/** Start time for instant-clipping assets, as an epoch integer compared to the stream's program date time. */
programStartTime?: number | undefined;
/** End time for instant-clipping assets, as an epoch integer compared to the stream's program date time. */
programEndTime?: number | undefined;
/** Relative start time of the asset (in seconds) when using the instant clipping feature. */
assetStartTime?: number | undefined;
/** Relative end time of the asset (in seconds) when using the instant clipping feature. */
assetEndTime?: number | undefined;
/** Include HLS redundant streams in the manifest. */
redundantStreams?: boolean | undefined;
/** Add support for timeline hover previews on Roku devices. */
rokuTrickPlay?: boolean | undefined;
/** Default subtitles/captions language (BCP 47 compliant language code). */
defaultSubtitlesLang?: string | undefined;
/** Omit `EXT-X-PROGRAM-DATE-TIME` tags from HLS manifests for assets from live streams. */
excludePdt?: boolean | undefined;
[param: string]: string | number | boolean | undefined;
}
export interface MuxThumbnailParams {
token?: string | undefined;
/** Image format used in the URL path (`thumbnail.<ext>`). Defaults to `webp`. */
ext?: MuxThumbnailExt | undefined;
/** Video time (in seconds) the image is pulled from. Defaults to the middle of the video. */
time?: number | undefined;
/** Width of the thumbnail (in pixels). Defaults to the width of the original video. */
width?: number | undefined;
/** Height of the thumbnail (in pixels). Defaults to the height of the original video. */
height?: number | undefined;
/** Rotate the image clockwise by the given number of degrees. */
rotate?: number | undefined;
/** How to fit the thumbnail within the specified width + height. */
fitMode?: MuxThumbnailFitMode | undefined;
/** Flip the image top-bottom after performing all other transformations. */
flipV?: boolean | undefined;
/** Flip the image left-right after performing all other transformations. */
flipH?: boolean | undefined;
/** Thumbnail time for instant-clipping assets, as an epoch integer compared to the stream's program date time. */
programTime?: number | undefined;
/** Pull the latest thumbnail from an ongoing live stream. */
latest?: boolean | undefined;
[param: string]: string | number | boolean | undefined;
}
export interface MuxStoryboardParams {
token?: string | undefined;
/** Image format of the storyboard tiles referenced by the VTT. Defaults to `webp`. */
format?: MuxThumbnailExt | undefined;
[param: string]: string | number | undefined;
}
export interface MuxDrmParams {
token?: string | undefined;
}
export interface MuxSource {
playbackId: string;
customDomain?: string | undefined;
playback?: MuxPlaybackParams | undefined;
thumbnail?: MuxThumbnailParams | MuxThumbnailParams[] | undefined;
storyboard?: MuxStoryboardParams | undefined;
drm?: MuxDrmParams | undefined;
}
/**
* Serialize params to a query string (`?a=1&b=2`), mapping camelCase keys to
* `snake_case` and skipping nullish values. A `token` replaces every other
* param signed URLs bake all modifiers into the token itself.
*/
export function createMuxQuery(params: Record<string, unknown> = {}): string {
const { token, ...rest } = params;
if (token) return `?${new URLSearchParams({ token: String(token) })}`;
const search = new URLSearchParams();
for (const [key, value] of Object.entries(rest)) {
if (!isNil(value)) search.set(snakeCase(key), String(value));
}
const query = search.toString();
return query ? `?${query}` : '';
}
/** Build the Mux HLS stream URL for a source. */
export function createMuxVideoURL(source?: MuxSource | null): string | undefined {
if (!source?.playbackId) return undefined;
const { playbackId, customDomain = MUX_VIDEO_DOMAIN, playback } = source;
if (__DEV__ && playback?.minResolution && playback?.maxResolution) {
if (Number.parseInt(playback.maxResolution, 10) < Number.parseInt(playback.minResolution, 10)) {
console.warn(
`[vjs-mux] minResolution (${playback.minResolution}) must be <= maxResolution (${playback.maxResolution})`
);
}
}
return `https://stream.${customDomain}/${playbackId}.m3u8${createMuxQuery(playback)}`;
}
/**
* Parse a Mux stream URL (`https://stream.<domain>/<playback-id>.m3u8?...`)
* into a `MuxSource`, mapping `snake_case` query params back to camelCase
* playback params. Returns `undefined` for non-Mux URLs.
*/
export function parseMuxVideoURL(src: string): MuxSource | undefined {
if (!src) return undefined;
let url: URL;
try {
url = new URL(src);
} catch {
return undefined;
}
const [, domain] = url.hostname.match(/^stream\.(.+)$/) ?? [];
const [, playbackId] = url.pathname.match(/^\/([^/]+)\.m3u8$/) ?? [];
if (!domain || !playbackId) return undefined;
const source: MuxSource = { playbackId };
if (domain !== MUX_VIDEO_DOMAIN) source.customDomain = domain;
const playback: MuxPlaybackParams = {};
for (const [key, value] of url.searchParams) {
playback[camelCase(key)] = key === 'token' ? value : parseMuxParamValue(value);
}
if (Object.keys(playback).length > 0) source.playback = playback;
return source;
}
/**
* Structural equality for Mux sources. Compares nested playback / thumbnail /
* storyboard / drm params, treating keys explicitly set to `undefined` as absent.
*/
export function isSameMuxSource(a?: MuxSource | null, b?: MuxSource | null): boolean {
return deepEqual(a ?? null, b ?? null);
}
/**
* Coerce a query param string back to the boolean/number types declared on
* `MuxPlaybackParams`. Numbers only convert when the string round-trips exactly
* (so `1080p`, `007`, and JWTs stay strings).
*/
function parseMuxParamValue(value: string): string | number | boolean {
if (value === 'true') return true;
if (value === 'false') return false;
if (value !== '' && String(Number(value)) === value) return Number(value);
return value;
}
/**
* Build the thumbnail image URL for a source. Uses the first entry when
* `source.thumbnail` is an array, unless explicit `params` are given.
*/
export function createMuxThumbnailURL(source?: MuxSource | null, params?: MuxThumbnailParams): string | undefined {
if (!source?.playbackId) return undefined;
const { playbackId, customDomain = MUX_VIDEO_DOMAIN, thumbnail, playback } = source;
const { ext = 'webp', token, ...query } = params ?? (Array.isArray(thumbnail) ? thumbnail[0] : thumbnail) ?? {};
// Thumbnail tokens must carry the image (`t`) audience.
if (token && parseJwt<MuxJWT>(token)?.aud !== 't') return undefined;
// Signed playback requires a matching thumbnail token; an unsigned URL would be rejected.
if (!token && playback?.token) return undefined;
return `https://image.${customDomain}/${playbackId}/thumbnail.${ext}${createMuxQuery({ token, ...query })}`;
}
/** Build the storyboard (thumbnail sprite) VTT URL for a source. */
export function createMuxStoryboardURL(source?: MuxSource | null): string | undefined {
if (!source?.playbackId) return undefined;
const { playbackId, customDomain = MUX_VIDEO_DOMAIN, storyboard, playback } = source;
const { token, ...query } = storyboard ?? {};
// Storyboard tokens must carry the storyboard (`s`) audience.
if (token && parseJwt<MuxJWT>(token)?.aud !== 's') return undefined;
// Signed playback requires a matching storyboard token; an unsigned URL would be rejected.
if (!token && playback?.token) return undefined;
return `https://image.${customDomain}/${playbackId}/storyboard.vtt${createMuxQuery({ token, format: 'webp', ...query })}`;
}
export type MuxJWT = {
sub: string;
aud: 'v' | 't' | 'g' | 's' | 'd';
exp: number;
};