diff --git a/apps/sandbox/app/shared/mux.ts b/apps/sandbox/app/shared/mux.ts index 6ebc75f8..14169bc3 100644 --- a/apps/sandbox/app/shared/mux.ts +++ b/apps/sandbox/app/shared/mux.ts @@ -1,5 +1,8 @@ +import { parseMuxVideoURL } from '@videojs/core/dom/media/mux'; import { SOURCES, type SourceId } from './sources'; export function getMuxAssetId(source: SourceId): string | undefined { - return SOURCES[source].url.match(/stream\.mux\.com\/([a-zA-Z0-9]+)/)?.[1]; + const { url } = SOURCES[source]; + // MP4 renditions (`https://stream.mux.com//.mp4`) aren't stream URLs. + return parseMuxVideoURL(url)?.playbackId ?? url.match(/stream\.mux\.com\/([\w-]+)\/[\w-]+\.mp4/)?.[1]; } diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts index dcbcd416..82b8ad2a 100644 --- a/apps/sandbox/templates/html-mux-video/main.ts +++ b/apps/sandbox/templates/html-mux-video/main.ts @@ -4,7 +4,6 @@ import '@videojs/html/video/player'; import '@videojs/html/media/mux-video'; import { createHtmlSandboxState, createLatestLoader, renderMediaAttrs } from '@app/shared/html/sandbox-state'; import { loadVideoSkinTag } from '@app/shared/html/skins'; -import { renderStoryboard } from '@app/shared/html/storyboard'; import { onAutoplayChange, onLoopChange, @@ -13,7 +12,7 @@ import { onSkinChange, onSourceChange, } from '@app/shared/sandbox-listener'; -import { getPlaceholderSrc, getPosterSrc, getStoryboardSrc, isLiveSource, SOURCES } from '@app/shared/sources'; +import { getPlaceholderSrc, getPosterSrc, isLiveSource, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -27,7 +26,6 @@ async function render() { const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling, { live })); if (!tag) return; - const storyboard = getStoryboardSrc(state.source); const poster = getPosterSrc(state.source); const placeholder = getPlaceholderSrc(state.source); const mediaAttrs = renderMediaAttrs(state); @@ -36,9 +34,8 @@ async function render() { document.getElementById('root')!.innerHTML = wrapSandboxHtmlI18n(html` <${playerTag}> <${tag} class="aspect-video max-w-4xl mx-auto"${placeholder ? ` placeholdersrc="${placeholder}"` : ''}> - - ${renderStoryboard(storyboard)} - + + ${poster ? html`Video poster` : ''} diff --git a/apps/sandbox/templates/react-mux-video/main.tsx b/apps/sandbox/templates/react-mux-video/main.tsx index 6062c171..65f82844 100644 --- a/apps/sandbox/templates/react-mux-video/main.tsx +++ b/apps/sandbox/templates/react-mux-video/main.tsx @@ -2,7 +2,6 @@ import '@app/styles.css'; import { LiveVideoProvider, VideoProvider } from '@app/shared/react/providers'; import { SandboxI18nProvider } from '@app/shared/react/sandbox-i18n'; import { VideoSkinComponent } from '@app/shared/react/skins'; -import { Storyboard } from '@app/shared/react/storyboard'; import { useAutoplay } from '@app/shared/react/use-autoplay'; import { useLoop } from '@app/shared/react/use-loop'; import { useMuted } from '@app/shared/react/use-muted'; @@ -11,7 +10,6 @@ import { usePoster } from '@app/shared/react/use-poster'; import { usePreload } from '@app/shared/react/use-preload'; import { useSkin } from '@app/shared/react/use-skin'; import { useSource } from '@app/shared/react/use-source'; -import { useStoryboard } from '@app/shared/react/use-storyboard'; import { isLiveSource, SOURCES } from '@app/shared/sources'; import type { Styling } from '@app/types'; import { MuxVideo } from '@videojs/react/media/mux-video'; @@ -28,7 +26,6 @@ function App() { const styling = useMemo(readStyling, []); const poster = usePoster(); const placeholder = usePlaceholder(); - const storyboard = useStoryboard(); const live = isLiveSource(source); const autoplay = useAutoplay(); const muted = useMuted(); @@ -47,6 +44,7 @@ function App() { live={live} className="aspect-video max-w-4xl mx-auto" > + {/* The storyboard track is derived automatically from the Mux src. */} - - + /> diff --git a/packages/core/src/dom/media/custom-media-element/index.ts b/packages/core/src/dom/media/custom-media-element/index.ts index f3fccc89..c8bdce0b 100644 --- a/packages/core/src/dom/media/custom-media-element/index.ts +++ b/packages/core/src/dom/media/custom-media-element/index.ts @@ -90,7 +90,11 @@ export interface MediaHost extends EventTarget { } type CustomMediaConstructor> = Constructor< - HTMLElement & InstanceType & { readonly host: InstanceType } + HTMLElement & + InstanceType & { + readonly host: InstanceType; + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void; + } > & { properties: Record; getTemplateHTML: (attrs: Record) => string; diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/index.ts index a3f7b235..00f385de 100644 --- a/packages/core/src/dom/media/mux/index.ts +++ b/packages/core/src/dom/media/mux/index.ts @@ -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/.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; + } +} diff --git a/packages/core/src/dom/media/mux/tests/mux-media.test.ts b/packages/core/src/dom/media/mux/tests/mux-media.test.ts new file mode 100644 index 00000000..773db022 --- /dev/null +++ b/packages/core/src/dom/media/mux/tests/mux-media.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/dom/media/mux/tests/utils.test.ts b/packages/core/src/dom/media/mux/tests/utils.test.ts new file mode 100644 index 00000000..97eaaa8f --- /dev/null +++ b/packages/core/src/dom/media/mux/tests/utils.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/core/src/dom/media/mux/utils.ts b/packages/core/src/dom/media/mux/utils.ts new file mode 100644 index 00000000..40f906e4 --- /dev/null +++ b/packages/core/src/dom/media/mux/utils.ts @@ -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.`). 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 { + 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./.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(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(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; +}; diff --git a/packages/html/src/media/mux-audio/index.ts b/packages/html/src/media/mux-audio/index.ts index 7bbab861..f4333723 100644 --- a/packages/html/src/media/mux-audio/index.ts +++ b/packages/html/src/media/mux-audio/index.ts @@ -1,14 +1,26 @@ import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; -import { HlsJsMedia } from '@videojs/core/dom/media/hls-js'; import { addComponent } from '@videojs/core/dom/media/media-host'; -import { MuxData } from '@videojs/core/dom/media/mux'; +import { MuxData, MuxMedia } from '@videojs/core/dom/media/mux'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -export class MuxAudio extends MediaAttachMixin(CustomMediaElement('audio', HlsJsMedia)) { +const MuxAudioBase = MediaAttachMixin(CustomMediaElement('audio', MuxMedia)); + +export class MuxAudio extends MuxAudioBase { constructor() { super(); addComponent(this.host, new MuxData({ playerSoftwareName: 'mux-audio' })); addComponent(this.host, new GoogleCast()); + this.host.addEventListener('sourcechange', () => this.#reflectSrc()); + } + + // Mirrors the host `src` to the `src` attribute so it matches the active playback URL. + #reflectSrc() { + const src = this.host.src; + if (src) { + if (this.getAttribute('src') !== src) this.setAttribute('src', src); + } else if (this.hasAttribute('src')) { + this.removeAttribute('src'); + } } } diff --git a/packages/html/src/media/mux-video/index.ts b/packages/html/src/media/mux-video/index.ts index 4988a115..c237979a 100644 --- a/packages/html/src/media/mux-video/index.ts +++ b/packages/html/src/media/mux-video/index.ts @@ -1,14 +1,68 @@ import { CustomMediaElement } from '@videojs/core/dom/media/custom-media-element'; import { GoogleCast } from '@videojs/core/dom/media/google-cast'; -import { HlsJsMedia } from '@videojs/core/dom/media/hls-js'; +import { StreamTypes } from '@videojs/core/dom/media/hls-js'; import { addComponent } from '@videojs/core/dom/media/media-host'; -import { MuxData } from '@videojs/core/dom/media/mux'; +import { MuxData, MuxMedia } from '@videojs/core/dom/media/mux'; import { MediaAttachMixin } from '../../store/media-attach-mixin'; -export class MuxVideo extends MediaAttachMixin(CustomMediaElement('video', HlsJsMedia)) { +const MuxVideoBase = MediaAttachMixin(CustomMediaElement('video', MuxMedia)); + +export class MuxVideo extends MuxVideoBase { + static properties = { + ...MuxVideoBase.properties, + thumbnail: { type: String, empty: '' }, + storyboard: { type: String, empty: '' }, + }; + constructor() { super(); addComponent(this.host, new MuxData({ playerSoftwareName: 'mux-video' })); addComponent(this.host, new GoogleCast()); + // Storyboards aren't generated for live streams; re-evaluate when the type is detected. + this.host.addEventListener('streamtypechange', () => this.#syncStoryboard()); + // Covers both the `src` attribute and the `source` property (JS-only). + this.host.addEventListener('sourcechange', () => { + this.#reflectSrc(); + this.#syncStoryboard(); + }); + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + super.attributeChangedCallback(name, oldValue, newValue); + if (name === 'storyboard') this.#syncStoryboard(); + } + + // Mirrors the host `src` to the `src` attribute so it matches the active playback URL. + #reflectSrc() { + const src = this.host.src; + if (src) { + if (this.getAttribute('src') !== src) this.setAttribute('src', src); + } else if (this.hasAttribute('src')) { + this.removeAttribute('src'); + } + } + + // Keeps a storyboard track child in sync, from the `storyboard` attribute or derived from `source`. + #syncStoryboard() { + // Live streams have no storyboard; skip until the type is known to be otherwise. + const src = this.host.streamType === StreamTypes.LIVE ? undefined : this.host.storyboard || undefined; + + let track = this.querySelector('track[data-storyboard]'); + + if (!src) { + track?.remove(); + return; + } + + if (!track) { + track = document.createElement('track'); + track.kind = 'metadata'; + track.label = 'thumbnails'; + track.default = true; + track.setAttribute('data-storyboard', ''); + } + + if (track.getAttribute('src') !== src) track.setAttribute('src', src); + if (track.parentNode !== this) this.append(track); } } diff --git a/packages/html/src/media/tests/mux-video.test.ts b/packages/html/src/media/tests/mux-video.test.ts index fe003b48..95977bdf 100644 --- a/packages/html/src/media/tests/mux-video.test.ts +++ b/packages/html/src/media/tests/mux-video.test.ts @@ -46,4 +46,138 @@ describe('MuxVideo', () => { expect(el.config.muxData?.envKey).toBe('test-key'); expect(el.hasAttribute('config')).toBe(false); }); + + it('parses the host source from the src attribute', () => { + const el = createMuxVideo(); + + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + + expect(el.host.src).toBe('https://stream.mux.com/abc123.m3u8'); + expect(el.host.source).toEqual({ playbackId: 'abc123' }); + }); + + it('derives the host src from the source property', () => { + const el = createMuxVideo(); + + el.source = { playbackId: 'abc123' }; + + expect(el.host.src).toBe('https://stream.mux.com/abc123.m3u8'); + expect(el.source).toEqual({ playbackId: 'abc123' }); + }); + + it('applies the customDomain and playback params from the source property', () => { + const el = createMuxVideo(); + + el.source = { playbackId: 'abc123', customDomain: 'example.com', playback: { maxResolution: '1080p' } }; + + const url = new URL(el.host.src); + expect(url.host).toBe('stream.example.com'); + expect(url.searchParams.get('max_resolution')).toBe('1080p'); + }); + + it('adds a storyboard track inferred from the src attribute', () => { + const el = createMuxVideo(); + + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + + const track = el.querySelector('track'); + expect(track?.kind).toBe('metadata'); + expect(track?.getAttribute('src')).toBe('https://image.mux.com/abc123/storyboard.vtt?format=webp'); + }); + + it('adds a storyboard track inferred from the source property', () => { + const el = createMuxVideo(); + + el.source = { playbackId: 'abc123', customDomain: 'example.com' }; + + expect(el.querySelector('track')?.getAttribute('src')).toBe( + 'https://image.example.com/abc123/storyboard.vtt?format=webp' + ); + }); + + it('prefers the storyboard attribute over the derived URL', () => { + const el = createMuxVideo(); + + el.setAttribute('storyboard', 'https://image.mux.com/other/storyboard.vtt?token=jwt'); + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + + expect(el.querySelector('track')?.getAttribute('src')).toBe('https://image.mux.com/other/storyboard.vtt?token=jwt'); + }); + + it('removes the storyboard track when the src is cleared', () => { + const el = createMuxVideo(); + + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + expect(el.querySelector('track')).not.toBeNull(); + + el.removeAttribute('src'); + expect(el.querySelector('track')).toBeNull(); + }); + + it('does not add a storyboard track for live streams', () => { + const el = createMuxVideo(); + + el.host.streamType = 'live'; + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + + expect(el.querySelector('track')).toBeNull(); + }); + + it('removes the storyboard track when the stream becomes live', () => { + const el = createMuxVideo(); + + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + expect(el.querySelector('track')).not.toBeNull(); + + el.host.streamType = 'live'; + expect(el.querySelector('track')).toBeNull(); + }); + + it('keeps a single storyboard track across source changes', () => { + const el = createMuxVideo(); + + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + el.setAttribute('src', 'https://stream.mux.com/xyz789.m3u8'); + + const tracks = el.querySelectorAll('track'); + expect(tracks.length).toBe(1); + expect(tracks[0]?.getAttribute('src')).toBe('https://image.mux.com/xyz789/storyboard.vtt?format=webp'); + }); + + it('reflects the derived src to the src attribute when source is set', () => { + const el = createMuxVideo(); + + el.source = { playbackId: 'abc123' }; + + expect(el.getAttribute('src')).toBe('https://stream.mux.com/abc123.m3u8'); + }); + + it('updates a stale src attribute when source replaces it', () => { + const el = createMuxVideo(); + + el.setAttribute('src', 'https://stream.mux.com/abc123.m3u8'); + el.source = { playbackId: 'xyz789' }; + + expect(el.getAttribute('src')).toBe('https://stream.mux.com/xyz789.m3u8'); + expect(el.host.src).toBe('https://stream.mux.com/xyz789.m3u8'); + }); + + it('removes the src attribute when the source is cleared', () => { + const el = createMuxVideo(); + + el.source = { playbackId: 'abc123' }; + el.source = null; + + expect(el.hasAttribute('src')).toBe(false); + expect(el.host.src).toBe(''); + }); + + it('exposes the effective thumbnail URL without touching the media poster', () => { + const el = createMuxVideo(); + + el.source = { playbackId: 'abc123', thumbnail: { time: 5, ext: 'webp' } }; + + expect(el.thumbnail).toBe('https://image.mux.com/abc123/thumbnail.webp?time=5'); + expect(el.shadowRoot!.querySelector('video')?.getAttribute('poster')).toBeNull(); + }); }); diff --git a/packages/react/src/media/mux-audio/index.tsx b/packages/react/src/media/mux-audio/index.tsx index 72e2a8ff..633e9ab3 100644 --- a/packages/react/src/media/mux-audio/index.tsx +++ b/packages/react/src/media/mux-audio/index.tsx @@ -2,9 +2,10 @@ import { GoogleCast } from '@videojs/core/dom/media/google-cast'; import type { HlsMediaProps } from '@videojs/core/dom/media/hls-js'; -import { HlsJsMedia, hlsMediaDefaultProps } from '@videojs/core/dom/media/hls-js'; +import { hlsMediaDefaultProps } from '@videojs/core/dom/media/hls-js'; import { addComponent } from '@videojs/core/dom/media/media-host'; -import { MuxData } from '@videojs/core/dom/media/mux'; +import type { MuxMediaProps } from '@videojs/core/dom/media/mux'; +import { MuxData, MuxMedia, muxMediaDefaultProps } from '@videojs/core/dom/media/mux'; import type { AudioHTMLAttributes, ReactNode } from 'react'; import { forwardRef } from 'react'; import { useAttachMedia } from '../../utils/use-attach-media'; @@ -13,19 +14,22 @@ import { useMediaInstance } from '../../utils/use-media-instance'; import { useSyncProps } from '../../utils/use-sync-props'; export interface MuxAudioProps - extends Omit, keyof HlsMediaProps>, - Partial { + extends Omit, keyof HlsMediaProps | keyof MuxMediaProps>, + Partial, + Partial { children?: ReactNode; } +const muxAudioDefaultProps: HlsMediaProps & MuxMediaProps = { ...hlsMediaDefaultProps, ...muxMediaDefaultProps }; + export const MuxAudio = forwardRef(function MuxAudio({ children, ...props }, ref) { - const media = useMediaInstance(HlsJsMedia, (media) => { + const media = useMediaInstance(MuxMedia, (media) => { addComponent(media, new MuxData({ playerSoftwareName: 'mux-audio' })); addComponent(media, new GoogleCast()); }); const attachRef = useAttachMedia(media); const composedRef = useComposedRefs(attachRef, ref); - const htmlProps = useSyncProps(media, props, hlsMediaDefaultProps); + const htmlProps = useSyncProps(media, props, muxAudioDefaultProps); return (