mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user