diff --git a/.claude/plans/mux-player-migration.md b/.claude/plans/mux-player-migration.md new file mode 100644 index 000000000..ce75b10ac --- /dev/null +++ b/.claude/plans/mux-player-migration.md @@ -0,0 +1,271 @@ +# Mux Player Migration Plan +# VJS v10 → `mux-video` / `mux-player` Functional Parity + +**Branch:** `feat/mux-player-hls.js-migration` +**Date:** 2026-03-24 +**Focus:** HlsVideo path only. SPF path is tracked in the Notion support matrix but is out of scope here. + +## Status + +| Phase | Description | hls.js | Native | +|-------|-------------|--------|--------| +| 1 | `MuxHlsMediaDelegate` + `MuxVideo` element | ✅ Done | ✅ Done (implicit MSE fallback; no explicit `prefer-playback` yet) | +| 2 | Stream type detection | ✅ Done | ⚠️ Not yet implemented. Requires independent `fetch()` + parse of the multivariant and media playlists (`#EXT-X-PLAYLIST-TYPE`, `#EXT-X-PART-INF`, `#EXT-X-TARGETDURATION`). Reference: `playback-core/src/index.ts:getStreamInfoFromSrcAndType()`. | +| 3 | Error handling | ✅ Done | ⚠️ Not yet implemented. Requires listening to native `error` events, then doing a follow-up `fetch(src)` to recover the HTTP status code for accurate error classification. Reference: `playback-core/src/index.ts:handleNativeError()`. | +| 4 | DRM | ✅ Done (Widevine, PlayReady, FairPlay via EME) | ⚠️ Not yet implemented. Requires two separate FairPlay code paths: modern EME (`eme-fairplay.ts`) and legacy WebKit (`webkit-fairplay.ts`, needed for AirPlay). Both fetch app cert + license from `license.mux.com`. | +| 5 | Mux Data integration | ✅ Done | ✅ Done — `mux.monitor()` called without `hlsjs` when `engine` is null. | +| 6 | Convenience API (`playbackId` → URL, tokens, `prefer-playback`) | ⏳ Not started | ⏳ Not started (`prefer-playback='native'` is gating item) | +| 7 | `MuxPlayer` UI | ⏳ Not started | ⏳ Not started | + +--- + +## Architecture + +The stack mirrors the elements repo structure, mapped onto VJS v10's delegate + mixin patterns: + +``` +elements repo → VJS v10 equivalent +───────────────────────────────────────────────────────────── +playback-core (hls.js config) → MuxHlsMediaDelegate +mux-video (custom element) → MuxVideo (html package) +mux-player (UI + media-chrome) → MuxPlayer (html package, createPlayer-based) +``` + +### Delegate Layer: `MuxHlsMediaDelegate` + +Extends the existing `HlsMediaDelegate` (which already handles text tracks). Adds: +- Mux-specific hls.js config (resolution cap, DRM, CMCD, redundant streams) +- Stream type detection from `LEVEL_LOADED` +- Error mapping to Mux error codes +- Pseudo-ended detection + +**Location:** `packages/core/src/dom/media/mux/` + +### Element Layer: `MuxVideo` + +Extends `MediaAttachMixin(MuxCustomMedia)` — same pattern as `HlsVideo`. Adds: +- `playbackId` attribute → `toMuxVideoURL()` → `this.src` +- `playback-token`, `drm-token` attributes → token object +- `env-key` attribute → Mux Data SDK init +- `stream-type`, `target-live-window` properties (populated after manifest load) +- `metadata` property → live heartbeat to Mux Data +- `max-resolution`, `rendition-order`, `cap-rendition-to-player-size` attributes + +**Location:** `packages/html/src/media/mux-video/` +**Tag name:** `mux-video` + +### Player Layer: `MuxPlayer` + +Built with `createPlayer({ features: videoFeatures })` + Mux-specific features. Adds: +- Poster: `https://image.mux.com/{playbackId}/thumbnail.webp?token=` +- Storyboard: `https://image.mux.com/{playbackId}/storyboard.vtt?token=` +- Error dialog UI +- Stream-type-aware control configuration (live indicator, DVR scrubbar) +- `thumbnail-token`, `storyboard-token` attribute handling + +**Location:** `packages/html/src/presets/mux.ts` and `packages/html/src/media/mux-video/` + +--- + +## Phases + +### Phase 1: `MuxHlsMediaDelegate` + `MuxVideo` element ✅ DONE + +**Goal:** A `` element backed by a Mux-tuned hls.js instance, with graceful native fallback when MSE is unavailable. No URL construction — callers compose the `src` externally. + +Deliverables: +1. `MuxHlsMediaDelegate` — extends `HlsMediaDelegate`, overrides hls.js config: + - `backBufferLength: 30` + - `liveDurationInfinity: true` + - `MinCapLevelController` wired by default (see below) + - **Native fallback:** if `!Hls.isSupported()`, skip hls.js entirely and set `target.src` directly. All downstream delegate behavior (stream type, error mapping, Mux Data) must be gated on whether hls.js is actually running. +2. `MinCapLevelController` — port from `playback-core/src/min-cap-level-controller.ts`: + - Caps ABR at player size, with 720p minimum floor + - Must be injected at hls.js construction time — can't be done from outside +3. `MuxCustomMedia` = `DelegateMixin(CustomMediaMixin(HTMLElement, {tag:'video'}), MuxHlsMediaDelegate)` +4. `MuxVideo` — extends `MediaAttachMixin(MuxCustomMedia)`, same pattern as `HlsVideo`: + - No `playbackId`, no URL construction, no token plumbing yet +5. `MuxVideoElement` define file + `safeDefine('mux-video', MuxVideoElement)` +6. Tests for `MinCapLevelController` behavior and native fallback path (`!Hls.isSupported()`) + +--- + +### Phase 2: Stream Type Detection ✅ DONE + +**Goal:** `streamType` and `targetLiveWindow` are observable after manifest load; live/DVR assets work correctly. + +Deliverables: +1. `updateStreamInfoFromLevelDetails(levelDetails)` — reads hls.js `LevelDetails.type` (VOD/EVENT/LIVE): + - `streamType: 'on-demand' | 'live' | 'unknown'` + - `targetLiveWindow: number` (Infinity for EVENT, 0 for LIVE, NaN for VOD) + - `liveEdgeOffset`: `partTarget * 2` for LL-HLS, `targetDuration * 3` for live +2. Wire into `MuxHlsMediaDelegate` on `Hls.Events.LEVEL_LOADED` +3. `streamtype-change`, `targetlivewindow-change` custom events dispatched from the media element +4. `streamType`, `targetLiveWindow`, `liveEdgeStart` read-only properties on `MuxVideo` (populated after manifest load) +5. `seekable` proxy for live: cap `seekable.end()` at `hls.liveSyncPosition` +6. Tests using fixture manifests (VOD, live, DVR/EVENT) + +--- + +### Phase 3: Error Handling ✅ DONE + +**Goal:** hls.js errors surface as structured Mux errors; transient failures retry correctly. + +Deliverables: +1. `MuxErrorCode` enum — port from `playback-core/src/errors.ts` +2. `MuxMediaError` — extends `MediaError`, adds `muxCode`, `errorCategory`, `context`, `fatal` +3. Error mapping in `MuxHlsMediaDelegate` on `Hls.Events.ERROR`: + - HTTP status classification: 4xx JWT errors (missing/malformed/expired/aud mismatch) + - NETWORK_NOT_READY (412) retry: 6 retries, first after 5s, subsequent after 60s + - Non-retriable 4xx → fatal, no retry +4. Pseudo-ended detection wired into `MuxHlsMediaDelegate`: + - Port heuristic from `playback-core` (TARGET-DURATION + last segment duration) + - Override `ended` getter +5. Tests + +--- + +### Phase 4: DRM ✅ DONE + +**Goal:** Widevine, PlayReady, and FairPlay assets decrypt and play. + +Deliverables: +1. `getDRMConfig(playbackId, drmToken)` — builds hls.js `drmSystems` config: + - FairPlay cert + license at `license.mux.com/appcert/fps/` and `license.mux.com/license/fps/` + - Widevine license at `license.mux.com/license/widevine/` + `HW_SECURE_ALL` robustness + - PlayReady license at `license.mux.com/license/playready/` +2. Wire into `MuxHlsMediaDelegate` when a `drmToken` is provided (passed as a delegate option) +3. WebKit FairPlay fallback for older Safari — **native path, DRM-triggered:** + - When EME FairPlay fails, re-initialize using `webkitGenerateKeyRequest` / `webkitAddKey` + - This forces native HLS playback (hls.js torn down, `target.src` set directly) + - Distinct from Phase 1 native fallback (which is MSE unavailable); this is intentional re-init after an EME failure +4. FairPlay over AirPlay workaround (iOS 26.1+) — port from `playback-core` 0.33.2 +5. `drm-token` attribute on `MuxVideo` — flows into the delegate; this is functional (not convenience), the delegate needs it to configure EME +6. Tests (mock EME / DRM fixtures) + +--- + +### Phase 5: Mux Data Integration ✅ DONE + +**Goal:** Playback analytics flow to Mux Data; requires hls.js instance access, so must be inside the delegate. + +Deliverables: +1. Add `@mux/mux-embed` as a dependency of `packages/html` +2. `setupMuxData(props, mediaEl, hlsInstance)` — calls `mux.monitor()`: + - Passes `hlsjs` and `Hls` constructor references (required by mux-embed for monitoring) + - `automaticErrorTracking: false` (manual error reporting to avoid double-tracking) + - Custom `errorTranslator` to suppress string-coded hls.js internal errors + - `view_session_id` and `video_id` injected per session +3. Wire into `MuxVideo` connect/disconnect (or `attach`/`detach`) lifecycle +4. `env-key` attribute — if absent but `src` is a `stream.mux.com` URL, infer env mode +5. `metadata` property → live heartbeat: `mux.emit('hb', metadata)` +6. Error reporting: `mux.emit('error', { player_error_code, player_error_message, player_error_context })` +7. DRM type heartbeat: `mux.emit('hb', { view_drm_type })` +8. Tests (mock `mux-embed`) + +--- + +### Phase 6: Convenience API (playbackId → URL, tokens) ← **Next** + +**Goal:** `` works end-to-end without callers constructing the URL. + +Deliverables: +1. `toMuxVideoURL(props)` utility — `playbackId` + options → `stream.mux.com` HLS URL: + - `?redundant_streams=true` appended by default + - `?max_resolution=`, `?min_resolution=`, `?rendition_order=` optional params + - When `playback-token` present: emit only `?token=`, strip all other params + - `customDomain` support +2. `playbackId` attribute/property on `MuxVideo` → calls `toMuxVideoURL()` → sets `this.src` +3. `playback-token` attribute — forwarded as `?token=` into the URL +4. `max-resolution`, `min-resolution`, `rendition-order`, `custom-domain`, `extra-source-params` attributes +5. `prefer-playback='native'` attribute — explicit opt-in to native HLS (AirPlay, user preference): + - Bypasses hls.js even when `Hls.isSupported()` is true + - Sets `target.src` directly, same codepath as the Phase 1 MSE-unavailable fallback + - `prefer-playback='mse'` forces hls.js even on Safari (override native preference) +6. Token validation utilities — check JWT `aud` claim for `thumbnail-token`, `storyboard-token`, `drm-token` +7. Tests for `toMuxVideoURL()`, attribute reflection, and `prefer-playback` behavior + +--- + +### Phase 7: MuxPlayer UI + +**Goal:** `` renders a full player with controls, poster, storyboard, and error UI. + +Deliverables: +1. `getPosterURLFromPlaybackId(playbackId, token?)` — `image.mux.com/{id}/thumbnail.webp[?token=]` +2. `getStoryboardURLFromPlaybackId(playbackId, token?)` — `image.mux.com/{id}/storyboard.vtt[?token=]` +3. `MuxPlayer` element built with `createPlayer({ features: videoFeatures })`: + - Shadow DOM contains `` as the media element + - Uses existing VJS v10 skin/UI system +4. All `MuxVideo` attributes forwarded through to the inner `` +5. `thumbnail-token`, `storyboard-token` attributes with JWT audience validation +6. Poster integration with existing VJS v10 poster UI feature +7. Storyboard integration (timeline preview) +8. Error dialog: maps `MuxMediaError` to human-readable title/message/link +9. Stream-type-aware UI: + - Live: live indicator, constrained scrubbar + - DVR: full scrubbar with live-edge indicator + - Audio-only: suppress video-specific controls +10. `mux-player` tag definition + CDN bundle entry + +--- + +## Key Utilities to Port (from playback-core) + +| Utility | Source | Notes | +|---|---|---| +| `toMuxVideoURL()` | `playback-core/src/index.ts:406` | URL construction | +| `MinCapLevelController` | `playback-core/src/min-cap-level-controller.ts` | Custom hls.js controller | +| `updateStreamInfoFromHlsjsLevelDetails()` | `playback-core/src/index.ts` | Stream type detection | +| `getDRMConfig()` | `playback-core/src/index.ts:848` | DRM configuration | +| `fallbackToWebkitFairplay()` | `playback-core/src/webkit-fairplay.ts` | Safari DRM fallback | +| `getErrorFromHlsErrorData()` | `playback-core/src/errors.ts` | Error mapping | +| `getErrorFromResponse()` | `playback-core/src/request-errors.ts` | JWT/HTTP error classification | +| `isPseudoEnded()` | `playback-core/src/index.ts` | Ended detection heuristic | +| `setupMux()` | `playback-core/src/index.ts:1057` | Mux Data init | +| `setupAutoplay()` | `playback-core/src/autoplay.ts` | Smart autoplay | + +--- + +## File Layout (Target) + +``` +packages/core/src/dom/media/mux/ + index.ts ← MuxHlsMediaDelegate, MuxCustomMedia, MuxMedia (React) + stream-info.ts ← updateStreamInfoFromLevelDetails, stream type types + url.ts ← toMuxVideoURL, toPlaybackIdParts + errors.ts ← MuxErrorCode, MuxMediaError, getErrorFromHlsErrorData + drm.ts ← getDRMConfig, fallbackToWebkitFairplay + cap-level-controller.ts ← MinCapLevelController + mux-data.ts ← setupMuxData + tests/ + url.test.ts + stream-info.test.ts + errors.test.ts + +packages/html/src/media/mux-video/ + index.ts ← MuxVideo class + +packages/html/src/define/media/ + mux-video.ts ← MuxVideoElement + safeDefine('mux-video', ...) + +packages/html/src/cdn/media/ + mux-video.ts ← CDN bundle entry + +packages/html/src/media/mux-player/ (Phase 7) + index.ts ← MuxPlayer class + +packages/html/src/define/ + mux-player.ts ← MuxPlayerElement + safeDefine('mux-player', ...) +``` + +--- + +## Cross-Cutting Concerns + +- **`redundant_streams=true`**: Always appended by default (matches `DEFAULT_EXTRA_PLAYLIST_PARAMS` in mux-player). Can be disabled via `extra-source-params`. +- **Native playback (Safari iOS)**: `preferPlayback='native'` skips hls.js entirely; `mediaEl.src` is set directly. The explicit opt-in is deferred to Phase 6. An implicit MSE-unavailable fallback is already wired (`Hls.isSupported() === false` → `target.src = src`). The native path requires four independent implementations that are not yet ported (see status table). The reference `playback-core` handles all four via explicit native-path code: manifest fetch+parse for stream type, native `error` event + follow-up `fetch` for error classification, `eme-fairplay.ts`/`webkit-fairplay.ts` for DRM, and `mux.monitor()` called without `hlsjs` for analytics. +- **Autoplay**: Smart autoplay (muted fallback, live-edge seeking) is deferred. VJS v10 has an existing autoplay feature; Mux-specific live-edge-seek behavior can be added as a feature slice. +- **Audio-only**: Requires UI suppression of video-specific controls. Tracked in the Notion matrix as ⚠️ for HlsVideo. Defer to Phase 7. +- **CMCD**: `preferCmcd` attribute. Deferred; hls.js supports it natively. +- **Multi-language audio tracks**: `AudioTrackList` API not yet wired in VJS v10. Deferred. diff --git a/packages/core/src/dom/media/mux/cap-level-controller.ts b/packages/core/src/dom/media/mux/cap-level-controller.ts new file mode 100644 index 000000000..4367b4963 --- /dev/null +++ b/packages/core/src/dom/media/mux/cap-level-controller.ts @@ -0,0 +1,83 @@ +import type Hls from 'hls.js'; +import type { Level } from 'hls.js'; +import { CapLevelController } from 'hls.js'; + +export type MaxResolutionValue = '720p' | '1080p' | '1440p' | '2160p'; + +/** Total pixel counts per Mux Video pricing tier: https://www.mux.com/docs/pricing/video#resolution-based-pricing */ +const RESOLUTION_PIXEL_LIMITS: Record = { + '720p': 921600, // 1280 × 720 + '1080p': 2073600, // 1920 × 1080 + '1440p': 4194304, // 2560 × 1440 + '2160p': 8294400, // 3840 × 2160 +}; + +// Keyed by hls instance so multiple players don't share state. +const maxAutoResolutionMap = new WeakMap(); + +/** + * hls.js CapLevelController that enforces a 720p minimum floor when capping + * to player size, and supports an explicit `maxAutoResolution` cap for Mux + * Video resolution-based billing. + */ +export class MuxCapLevelController extends CapLevelController { + /** Never auto-cap below this height (pixels). */ + static readonly minMaxResolutionHeight = 720; + + static setMaxAutoResolution(hls: Hls, value: MaxResolutionValue | undefined): void { + if (value !== undefined) { + maxAutoResolutionMap.set(hls, value); + } else { + maxAutoResolutionMap.delete(hls); + } + } + + #maxAutoResolution(): MaxResolutionValue | undefined { + // @ts-expect-error: hls is TS-private in CapLevelController + return maxAutoResolutionMap.get(this.hls); + } + + #validLevels(capLevelIndex: number): Level[] { + // @ts-expect-error: hls, isLevelAllowed are TS-private in CapLevelController + return ((this.hls.levels ?? []) as Level[]).filter( + // @ts-expect-error + (level: Level, index: number) => this.isLevelAllowed(level) && index <= capLevelIndex + ); + } + + #maxLevelWithinResolution(capLevelIndex: number, maxAutoResolution: MaxResolutionValue): number { + const validLevels = this.#validLevels(capLevelIndex); + const maxPixels = RESOLUTION_PIXEL_LIMITS[maxAutoResolution]; + + const withinCap = validLevels.filter((l) => l.width * l.height <= maxPixels); + if (withinCap.length === 0) return 0; + + // Prefer an exact tier match; otherwise take the highest that stays under the cap. + const exactIdx = withinCap.findIndex((l) => l.width * l.height === maxPixels); + const best = exactIdx !== -1 ? withinCap[exactIdx] : withinCap[withinCap.length - 1]; + + return validLevels.findIndex((l) => l === best); + } + + override getMaxLevel(capLevelIndex: number): number { + const maxAutoResolution = this.#maxAutoResolution(); + + if (maxAutoResolution !== undefined) { + return this.#maxLevelWithinResolution(capLevelIndex, maxAutoResolution); + } + + const baseMaxLevel = super.getMaxLevel(capLevelIndex); + const validLevels = this.#validLevels(capLevelIndex); + + // Out-of-bounds means no levels available yet or no capping needed — pass through. + if (!validLevels[baseMaxLevel]) return baseMaxLevel; + + const baseHeight = Math.min(validLevels[baseMaxLevel].width, validLevels[baseMaxLevel].height); + const minHeight = MuxCapLevelController.minMaxResolutionHeight; + + if (baseHeight >= minHeight) return baseMaxLevel; + + // Player size would cap below the floor — find the lowest level that meets it. + return CapLevelController.getMaxLevelByMediaSize(validLevels, minHeight * (16 / 9), minHeight); + } +} diff --git a/packages/core/src/dom/media/mux/drm.ts b/packages/core/src/dom/media/mux/drm.ts new file mode 100644 index 000000000..46acc574b --- /dev/null +++ b/packages/core/src/dom/media/mux/drm.ts @@ -0,0 +1,65 @@ +import type { HlsConfig } from 'hls.js'; + +const MUX_LICENSE_DOMAIN = 'mux.com'; + +/** Extract the playback ID from a Mux stream URL (`stream.mux.com/.m3u8`). */ +export function toPlaybackIdFromSrc(src: string): string | undefined { + if (!src.startsWith('https://stream.')) return undefined; + try { + const [playbackId] = new URL(src).pathname.slice(1).split(/\.m3u8|\//); + return playbackId || undefined; + } catch { + return undefined; + } +} + +function toLicenseUrl(playbackId: string, drmToken: string, scheme: 'fairplay' | 'widevine' | 'playready'): string { + return `https://license.${MUX_LICENSE_DOMAIN}/license/${scheme}/${playbackId}?token=${drmToken}`; +} + +function toAppCertUrl(playbackId: string, drmToken: string): string { + return `https://license.${MUX_LICENSE_DOMAIN}/appcert/fairplay/${playbackId}?token=${drmToken}`; +} + +/** + * Builds the hls.js DRM configuration for Widevine, PlayReady, and FairPlay. + * Requires a playback ID (to build license URLs) and a DRM token. + */ +export function getDRMConfig(playbackId: string, drmToken: string): Partial { + return { + emeEnabled: true, + drmSystems: { + 'com.apple.fps': { + licenseUrl: toLicenseUrl(playbackId, drmToken, 'fairplay'), + serverCertificateUrl: toAppCertUrl(playbackId, drmToken), + }, + 'com.widevine.alpha': { + licenseUrl: toLicenseUrl(playbackId, drmToken, 'widevine'), + }, + 'com.microsoft.playready': { + licenseUrl: toLicenseUrl(playbackId, drmToken, 'playready'), + }, + }, + // Prefer hardware-level Widevine (L1) security when available. + requestMediaKeySystemAccessFunc: (keySystem, supportedConfigurations) => { + if (keySystem === 'com.widevine.alpha') { + supportedConfigurations = [ + ...supportedConfigurations.flatMap((config) => { + if (!config.videoCapabilities) return []; + return [ + { + ...config, + videoCapabilities: config.videoCapabilities.map((cap) => ({ + ...cap, + robustness: 'HW_SECURE_ALL', + })), + }, + ]; + }), + ...supportedConfigurations, + ]; + } + return navigator.requestMediaKeySystemAccess(keySystem, supportedConfigurations); + }, + }; +} diff --git a/packages/core/src/dom/media/mux/errors.ts b/packages/core/src/dom/media/mux/errors.ts new file mode 100644 index 000000000..67fe1c7e1 --- /dev/null +++ b/packages/core/src/dom/media/mux/errors.ts @@ -0,0 +1,200 @@ +import type { ErrorData } from 'hls.js'; +import Hls from 'hls.js'; + +export const MuxErrorCategory = { + VIDEO: 'video', + DRM: 'drm', +} as const; + +export type MuxErrorCategoryValue = (typeof MuxErrorCategory)[keyof typeof MuxErrorCategory]; + +export const MuxErrorCode = { + NOT_AN_ERROR: 0, + NETWORK_OFFLINE: 2000002, + NETWORK_UNKNOWN_ERROR: 2000000, + NETWORK_NO_STATUS: 2000001, + NETWORK_INVALID_URL: 2400000, + NETWORK_NOT_FOUND: 2404000, + NETWORK_NOT_READY: 2412000, + NETWORK_GENERIC_SERVER_FAIL: 2500000, + NETWORK_TOKEN_MISSING: 2403201, + NETWORK_TOKEN_MALFORMED: 2412202, + NETWORK_TOKEN_EXPIRED: 2403210, + NETWORK_TOKEN_AUD_MISSING: 2403221, + NETWORK_TOKEN_AUD_MISMATCH: 2403222, + NETWORK_TOKEN_SUB_MISMATCH: 2403232, + ENCRYPTED_ERROR: 5000000, + ENCRYPTED_UNSUPPORTED_KEY_SYSTEM: 5000001, + ENCRYPTED_GENERATE_REQUEST_FAILED: 5000002, + ENCRYPTED_UPDATE_LICENSE_FAILED: 5000003, + ENCRYPTED_UPDATE_SERVER_CERT_FAILED: 5000004, + ENCRYPTED_CDM_ERROR: 5000005, + ENCRYPTED_OUTPUT_RESTRICTED: 5000006, + ENCRYPTED_MISSING_TOKEN: 5000002, +} as const; + +export type MuxErrorCodeValue = (typeof MuxErrorCode)[keyof typeof MuxErrorCode]; + +export class MuxMediaError extends Error { + static readonly MEDIA_ERR_ABORTED = 1; + static readonly MEDIA_ERR_NETWORK = 2; + static readonly MEDIA_ERR_DECODE = 3; + static readonly MEDIA_ERR_SRC_NOT_SUPPORTED = 4; + static readonly MEDIA_ERR_ENCRYPTED = 5; + + readonly code: number; + readonly fatal: boolean; + muxCode?: MuxErrorCodeValue; + errorCategory?: MuxErrorCategoryValue; + context?: string; + data?: unknown; + + constructor(message: string, code = MuxMediaError.MEDIA_ERR_NETWORK, fatal?: boolean, context?: string) { + super(message); + this.name = 'MuxMediaError'; + this.code = code; + this.fatal = fatal ?? (code >= MuxMediaError.MEDIA_ERR_NETWORK && code <= MuxMediaError.MEDIA_ERR_ENCRYPTED); + if (context !== undefined) this.context = context; + } +} + +// Maps a numeric HTTP status code to a MuxErrorCode without JWT inspection. +// Full JWT-aware classification is added in Phase 6 when playbackId is available. +function getMuxCodeFromStatus(status: number): MuxErrorCodeValue { + if (status === 412) return MuxErrorCode.NETWORK_NOT_READY; + if (status === 404) return MuxErrorCode.NETWORK_NOT_FOUND; + if (status === 403) return MuxErrorCode.NETWORK_TOKEN_MISSING; + if (status === 400) return MuxErrorCode.NETWORK_INVALID_URL; + if (status >= 500) return MuxErrorCode.NETWORK_GENERIC_SERVER_FAIL; + if (!status) return MuxErrorCode.NETWORK_NO_STATUS; + return MuxErrorCode.NETWORK_UNKNOWN_ERROR; +} + +export function getErrorFromHlsErrorData(data: ErrorData): MuxMediaError { + const { ErrorTypes, ErrorDetails } = Hls; + + const hlsCodeToMediaErrCode = (): number => { + // Some DRM license/cert failures come through as network errors in hls.js. + if ( + data.details === ErrorDetails.KEY_SYSTEM_LICENSE_REQUEST_FAILED || + data.details === ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_REQUEST_FAILED + ) { + return MuxMediaError.MEDIA_ERR_NETWORK; + } + if (data.type === ErrorTypes.NETWORK_ERROR) return MuxMediaError.MEDIA_ERR_NETWORK; + if (data.type === ErrorTypes.MEDIA_ERROR) return MuxMediaError.MEDIA_ERR_DECODE; + if (data.type === ErrorTypes.KEY_SYSTEM_ERROR) return MuxMediaError.MEDIA_ERR_ENCRYPTED; + return MuxMediaError.MEDIA_ERR_NETWORK; + }; + + const code = hlsCodeToMediaErrCode(); + const context = buildContext(data); + + // ── Network errors with an HTTP response ────────────────────────────────── + if (code === MuxMediaError.MEDIA_ERR_NETWORK && data.response) { + const status = data.response.code ?? 0; + const err = new MuxMediaError('', code, data.fatal, context); + err.muxCode = getMuxCodeFromStatus(status); + err.errorCategory = MuxErrorCategory.VIDEO; + err.data = data; + return err; + } + + // ── DRM / key-system errors ─────────────────────────────────────────────── + if (code === MuxMediaError.MEDIA_ERR_ENCRYPTED) { + const err = buildDrmError(data, context); + err.data = data; + return err; + } + + // ── Generic fallthrough ─────────────────────────────────────────────────── + const err = new MuxMediaError(data.error?.message ?? '', code, data.fatal, context); + err.data = data; + return err; +} + +function buildDrmError(data: ErrorData, context: string): MuxMediaError { + const { ErrorDetails } = Hls; + const code = MuxMediaError.MEDIA_ERR_ENCRYPTED; + + if (data.details === ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE) { + const err = new MuxMediaError( + 'Attempting to play DRM-protected content without providing a DRM token.', + code, + data.fatal, + context + ); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_MISSING_TOKEN; + return err; + } + + if (data.details === ErrorDetails.KEY_SYSTEM_NO_ACCESS) { + const err = new MuxMediaError( + 'Cannot play DRM-protected content with current security configuration. Try another browser.', + code, + data.fatal, + context + ); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_UNSUPPORTED_KEY_SYSTEM; + return err; + } + + if (data.details === ErrorDetails.KEY_SYSTEM_NO_SESSION) { + const err = new MuxMediaError( + 'Failed to generate a DRM license request.', + code, + true, // always fatal even though hls.js says non-fatal + context + ); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_GENERATE_REQUEST_FAILED; + return err; + } + + if (data.details === ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED) { + const err = new MuxMediaError('Failed to update DRM license.', code, data.fatal, context); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_LICENSE_FAILED; + return err; + } + + if (data.details === ErrorDetails.KEY_SYSTEM_SERVER_CERTIFICATE_UPDATE_FAILED) { + const err = new MuxMediaError('Failed to set server certificate.', code, data.fatal, context); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_UPDATE_SERVER_CERT_FAILED; + return err; + } + + if (data.details === ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR) { + const err = new MuxMediaError('The DRM CDM had an internal failure.', code, data.fatal, context); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_CDM_ERROR; + return err; + } + + if (data.details === ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED) { + const err = new MuxMediaError('DRM playback is restricted in this environment.', code, false, context); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_OUTPUT_RESTRICTED; + return err; + } + + const err = new MuxMediaError(data.error?.message ?? '', code, data.fatal, context); + err.errorCategory = MuxErrorCategory.DRM; + err.muxCode = MuxErrorCode.ENCRYPTED_ERROR; + return err; +} + +function buildContext(data: ErrorData): string { + return [ + data.url ? `url: ${data.url}` : '', + data.response ? `response: ${data.response.code}, ${data.response.text}` : '', + data.reason ? `failure reason: ${data.reason}` : '', + data.error ? `error: ${data.error}` : '', + data.err?.message ? `error message: ${data.err.message}` : '', + ] + .filter(Boolean) + .join('\n'); +} diff --git a/packages/core/src/dom/media/mux/index.ts b/packages/core/src/dom/media/mux/index.ts new file mode 100644 index 000000000..468c1b03c --- /dev/null +++ b/packages/core/src/dom/media/mux/index.ts @@ -0,0 +1,232 @@ +import type { ErrorData, LevelLoadedData } from 'hls.js'; +import Hls from 'hls.js'; + +import { type Delegate, DelegateMixin } from '../../../core/media/delegate'; +import { CustomMediaMixin } from '../custom-media-element'; +import { HlsMediaTextTracksMixin } from '../hls/text-tracks'; +import { MediaProxyMixin } from '../proxy'; +import { MuxCapLevelController } from './cap-level-controller'; +import { getDRMConfig, toPlaybackIdFromSrc } from './drm'; +import { getErrorFromHlsErrorData, MuxErrorCode, MuxMediaError } from './errors'; +export { MuxMediaError }; + +import { getStreamInfoFromLevelDetails, type StreamInfo } from './stream-info'; + +const muxHlsConfig = { + backBufferLength: 30, + renderTextTracksNatively: false, + liveDurationInfinity: true, + capLevelToPlayerSize: true, + capLevelOnFPSDrop: true, + capLevelController: MuxCapLevelController, +}; + +const UNKNOWN_STREAM_INFO: StreamInfo = { + streamType: 'unknown', + targetLiveWindow: NaN, + liveEdgeOffset: NaN, +}; + +// Retry limits for NETWORK_NOT_READY (412) errors. +const MAX_412_RETRIES = 6; +const FIRST_RETRY_DELAY_MS = 5_000; +const SUBSEQUENT_RETRY_DELAY_MS = 60_000; + +// Margin of error (seconds) for pseudo-ended detection. +const ENDED_MOE = 0.034; + +class MuxHlsMediaDelegateBase implements Delegate { + #engine = Hls.isSupported() ? new Hls(muxHlsConfig) : null; + #target: HTMLMediaElement | null = null; + #streamInfo: StreamInfo = UNKNOWN_STREAM_INFO; + #retryCount = 0; + #retryTimer: ReturnType | null = null; + #drmToken: string | null = null; + #playbackId: string | null = null; + + get engine(): Hls | null { + return this.#engine; + } + + get streamType(): StreamInfo['streamType'] { + return this.#streamInfo.streamType; + } + + get targetLiveWindow(): number { + return this.#streamInfo.targetLiveWindow; + } + + get liveEdgeOffset(): number { + return this.#streamInfo.liveEdgeOffset; + } + + get liveEdgeStart(): number { + const liveSyncPosition = this.#engine?.liveSyncPosition ?? null; + if (liveSyncPosition === null) return NaN; + return liveSyncPosition - this.#streamInfo.liveEdgeOffset; + } + + get drmToken(): string | null { + return this.#drmToken; + } + + set drmToken(token: string | null) { + this.#drmToken = token; + } + + get ended(): boolean { + const target = this.#target; + if (!target) return false; + // Trust the browser when it says ended. + if (target.ended || target.loop) return target.ended; + // Pseudo-ended: paused at (or past) the reported duration within a small margin. + return target.paused && target.currentTime >= target.duration - ENDED_MOE; + } + + attach(target: EventTarget): void { + this.#target = target as HTMLMediaElement; + this.#engine?.attachMedia(this.#target); + this.#connectStreamInfo(); + this.#connectErrors(); + } + + detach(): void { + this.#disconnectErrors(); + this.#disconnectStreamInfo(); + this.#engine?.detachMedia(); + this.#target = null; + } + + destroy(): void { + this.#disconnectErrors(); + this.#disconnectStreamInfo(); + this.#engine?.destroy(); + this.#engine = null; + } + + set src(src: string) { + this.#streamInfo = UNKNOWN_STREAM_INFO; + this.#clearRetry(); + this.#retryCount = 0; + this.#playbackId = toPlaybackIdFromSrc(src) ?? null; + + // When a DRM token is present, recreate the hls.js engine with DRM config + // so that emeEnabled and drmSystems are set before the source is loaded. + if (this.#drmToken && this.#playbackId && this.#engine) { + this.#disconnectErrors(); + this.#disconnectStreamInfo(); + this.#engine.destroy(); + this.#engine = new Hls({ ...muxHlsConfig, ...getDRMConfig(this.#playbackId, this.#drmToken) }); + if (this.#target) { + this.#engine.attachMedia(this.#target); + this.#connectStreamInfo(); + this.#connectErrors(); + } + } + + if (this.#engine) { + this.#engine.loadSource(src); + } else if (this.#target) { + // MSE not available — fall back to native HLS playback. + this.#target.src = src; + } + } + + get src(): string { + return this.#engine?.url ?? this.#target?.src ?? ''; + } + + // ── Stream info ──────────────────────────────────────────────────────────── + + #connectStreamInfo(): void { + const { engine } = this; + if (!engine) return; + engine.on(Hls.Events.LEVEL_LOADED, this.#onLevelLoaded); + } + + #disconnectStreamInfo(): void { + this.#engine?.off(Hls.Events.LEVEL_LOADED, this.#onLevelLoaded); + } + + #onLevelLoaded = (_event: string, data: LevelLoadedData): void => { + const streamInfo = getStreamInfoFromLevelDetails(data.details); + const prev = this.#streamInfo; + + this.#streamInfo = streamInfo; + + const target = this.#target; + if (!target) return; + + if (streamInfo.streamType !== prev.streamType) { + target.dispatchEvent(new CustomEvent('streamtypechange', { detail: streamInfo.streamType })); + } + + if (!Object.is(streamInfo.targetLiveWindow, prev.targetLiveWindow)) { + target.dispatchEvent(new CustomEvent('targetlivewindowchange', { detail: streamInfo.targetLiveWindow })); + } + }; + + // ── Error handling ───────────────────────────────────────────────────────── + + #connectErrors(): void { + const { engine } = this; + if (!engine) return; + engine.on(Hls.Events.ERROR, this.#onHlsError); + } + + #disconnectErrors(): void { + this.#clearRetry(); + this.#engine?.off(Hls.Events.ERROR, this.#onHlsError); + } + + #clearRetry(): void { + if (this.#retryTimer !== null) { + clearTimeout(this.#retryTimer); + this.#retryTimer = null; + } + } + + #onHlsError = (_event: string, data: ErrorData): void => { + // Non-fatal errors are informational only. + if (!data.fatal) return; + + const error = getErrorFromHlsErrorData(data); + this.#handleFatalError(error); + }; + + #handleFatalError(error: MuxMediaError): void { + const target = this.#target; + + // 412 retry: content not ready yet (live stream not started, asset still processing). + if (error.muxCode === MuxErrorCode.NETWORK_NOT_READY) { + const retryCount = this.#retryCount; + if (retryCount < MAX_412_RETRIES) { + const delay = retryCount === 0 ? FIRST_RETRY_DELAY_MS : SUBSEQUENT_RETRY_DELAY_MS; + this.#retryCount = retryCount + 1; + this.#retryTimer = setTimeout(() => { + this.#retryTimer = null; + const currentSrc = this.#engine?.url ?? ''; + if (currentSrc) this.#engine?.loadSource(currentSrc); + }, delay); + // Dispatch a non-fatal notification so UIs can show "retrying…" state. + target?.dispatchEvent(new CustomEvent('muxerror', { detail: error })); + return; + } + // Exhausted retries — fall through to fatal dispatch. + this.#retryCount = 0; + } + + target?.dispatchEvent(new CustomEvent('muxerror', { detail: error })); + } +} + +const MuxHlsMediaDelegate = HlsMediaTextTracksMixin(MuxHlsMediaDelegateBase); + +// Web component: needs to extend HTMLElement. +export class MuxCustomMedia extends DelegateMixin( + CustomMediaMixin(globalThis.HTMLElement ?? class {}, { tag: 'video' }), + MuxHlsMediaDelegate +) {} + +// React: proxies to an attached EventTarget, no HTMLElement extension needed. +export class MuxMedia extends DelegateMixin(MediaProxyMixin, MuxHlsMediaDelegate) {} diff --git a/packages/core/src/dom/media/mux/stream-info.ts b/packages/core/src/dom/media/mux/stream-info.ts new file mode 100644 index 000000000..a9265c752 --- /dev/null +++ b/packages/core/src/dom/media/mux/stream-info.ts @@ -0,0 +1,30 @@ +import type { LevelDetails } from 'hls.js'; + +export type StreamType = 'on-demand' | 'live' | 'unknown'; + +export interface StreamInfo { + streamType: StreamType; + /** NaN for on-demand; 0 for sliding-window live; Infinity for DVR/EVENT. */ + targetLiveWindow: number; + /** Seconds behind live edge at which playback should start. NaN for on-demand. */ + liveEdgeOffset: number; +} + +export function getStreamInfoFromLevelDetails(details: LevelDetails): StreamInfo { + if (!details.live) { + return { streamType: 'on-demand', targetLiveWindow: NaN, liveEdgeOffset: NaN }; + } + + const liveEdgeOffset = + details.partTarget > 0 + ? details.partTarget * 2 // LL-HLS: two part targets behind edge + : details.targetduration * 3; // regular live: three target durations behind edge + + // EVENT playlists accumulate segments — full window is seekable (DVR). + if (details.type === 'EVENT') { + return { streamType: 'live', targetLiveWindow: Infinity, liveEdgeOffset }; + } + + // LIVE or null — sliding window, no DVR. + return { streamType: 'live', targetLiveWindow: 0, liveEdgeOffset }; +} diff --git a/packages/core/src/dom/media/mux/tests/cap-level-controller.test.ts b/packages/core/src/dom/media/mux/tests/cap-level-controller.test.ts new file mode 100644 index 000000000..d9e2d9de9 --- /dev/null +++ b/packages/core/src/dom/media/mux/tests/cap-level-controller.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock hls.js before importing MuxCapLevelController so it picks up the fake +// CapLevelController. vi.mock is hoisted automatically by Vitest. +vi.mock('hls.js', () => { + class CapLevelController { + // Public here so MuxCapLevelController can access via @ts-expect-error pattern. + hls: { levels: any[] }; + + constructor(hls: { levels: any[] }) { + this.hls = hls; + } + + getMaxLevel(capLevelIndex: number): number { + // Default: return the cap index as-is (caller controls via spy). + return capLevelIndex; + } + + static getMaxLevelByMediaSize(levels: any[], _width: number, height: number): number { + // Find the lowest level index whose shorter dimension meets the height. + for (let i = 0; i < levels.length; i++) { + if (Math.min(levels[i].width, levels[i].height) >= height) return i; + } + return levels.length - 1; + } + + isLevelAllowed(_level: any): boolean { + return true; + } + } + + return { CapLevelController, default: { isSupported: () => true } }; +}); + +import { CapLevelController } from 'hls.js'; +import { MuxCapLevelController } from '../cap-level-controller'; + +function makeLevel(width: number, height: number) { + return { width, height }; +} + +// Standard four-rung ladder used across tests. +const LEVELS = [ + makeLevel(640, 360), // 0 — 360p + makeLevel(854, 480), // 1 — 480p + makeLevel(1280, 720), // 2 — 720p + makeLevel(1920, 1080), // 3 — 1080p +]; + +function createController(levels = LEVELS) { + const hls = { levels } as any; + return new MuxCapLevelController(hls); +} + +describe('MuxCapLevelController', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('getMaxLevel — 720p floor', () => { + it('passes through the base max level when it already meets the floor', () => { + const ctrl = createController(); + // Base returns 2 (720p) — meets the 720p floor exactly. + vi.spyOn(CapLevelController.prototype, 'getMaxLevel').mockReturnValue(2); + + expect(ctrl.getMaxLevel(3)).toBe(2); + }); + + it('passes through the base max level when it exceeds the floor', () => { + const ctrl = createController(); + // Base returns 3 (1080p) — exceeds the floor. + vi.spyOn(CapLevelController.prototype, 'getMaxLevel').mockReturnValue(3); + + expect(ctrl.getMaxLevel(3)).toBe(3); + }); + + it('floors to 720p when the base max level is below it', () => { + const ctrl = createController(); + // Base returns 0 (360p) — below the 720p floor. + vi.spyOn(CapLevelController.prototype, 'getMaxLevel').mockReturnValue(0); + + // getMaxLevelByMediaSize scans from index 0 upward for the first level + // whose shorter dimension >= 720; that's index 2 (1280×720). + expect(ctrl.getMaxLevel(3)).toBe(2); + }); + + it('passes through an out-of-bounds base max level unchanged', () => { + const ctrl = createController(); + // Out-of-bounds signals "no levels available yet" — pass through. + vi.spyOn(CapLevelController.prototype, 'getMaxLevel').mockReturnValue(99); + + expect(ctrl.getMaxLevel(3)).toBe(99); + }); + }); + + describe('getMaxLevel — maxAutoResolution cap', () => { + it('caps to the specified resolution tier', () => { + const ctrl = createController(); + const hls = (ctrl as any).hls; + MuxCapLevelController.setMaxAutoResolution(hls, '720p'); + + // maxAutoResolution is set — base level is irrelevant. + expect(ctrl.getMaxLevel(3)).toBe(2); // 720p is index 2 + }); + + it('selects the exact tier match when available', () => { + const ctrl = createController(); + const hls = (ctrl as any).hls; + MuxCapLevelController.setMaxAutoResolution(hls, '1080p'); + + expect(ctrl.getMaxLevel(3)).toBe(3); // 1080p is index 3 + }); + + it('picks the highest level under the cap when no exact match exists', () => { + // No 900p tier — levels jump from 720p to 1080p. + const ctrl = createController(); + const hls = (ctrl as any).hls; + // '1440p' has no matching level in our ladder — highest under cap is 1080p (index 3). + MuxCapLevelController.setMaxAutoResolution(hls, '1440p'); + + expect(ctrl.getMaxLevel(3)).toBe(3); + }); + + it('returns 0 when all levels exceed the cap', () => { + const ctrl = createController(); + const hls = (ctrl as any).hls; + // No level fits within 480p total pixels… wait, 480p IS in the ladder at index 1. + // Use a resolution below any level: fake a tiny cap. + // Patch the levels so nothing fits. + hls.levels = [makeLevel(1280, 720), makeLevel(1920, 1080)]; + MuxCapLevelController.setMaxAutoResolution(hls, '720p'); + + // Only 720p fits; index 0 in the new levels array. + expect(ctrl.getMaxLevel(1)).toBe(0); + }); + }); + + describe('setMaxAutoResolution', () => { + it('associates a resolution value with the hls instance', () => { + const ctrl = createController(); + const hls = (ctrl as any).hls; + MuxCapLevelController.setMaxAutoResolution(hls, '1080p'); + + // Verify via side-effect: controller now caps at 1080p (index 3). + expect(ctrl.getMaxLevel(3)).toBe(3); + }); + + it('clears the cap when set to undefined', () => { + const ctrl = createController(); + const hls = (ctrl as any).hls; + MuxCapLevelController.setMaxAutoResolution(hls, '720p'); + MuxCapLevelController.setMaxAutoResolution(hls, undefined); + + // Cap cleared — falls back to floor logic via super. + vi.spyOn(CapLevelController.prototype, 'getMaxLevel').mockReturnValue(3); + expect(ctrl.getMaxLevel(3)).toBe(3); + }); + + it('does not share state between different hls instances', () => { + const ctrl1 = createController(); + const ctrl2 = createController(); + const hls1 = (ctrl1 as any).hls; + + MuxCapLevelController.setMaxAutoResolution(hls1, '720p'); + + // ctrl2 has no cap set — base level should pass through. + vi.spyOn(CapLevelController.prototype, 'getMaxLevel').mockReturnValue(3); + expect(ctrl2.getMaxLevel(3)).toBe(3); + }); + }); +}); diff --git a/packages/core/src/dom/media/mux/tests/drm.test.ts b/packages/core/src/dom/media/mux/tests/drm.test.ts new file mode 100644 index 000000000..902ba160a --- /dev/null +++ b/packages/core/src/dom/media/mux/tests/drm.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getDRMConfig, toPlaybackIdFromSrc } from '../drm'; + +describe('toPlaybackIdFromSrc', () => { + it('extracts playback ID from a stream.mux.com URL', () => { + expect(toPlaybackIdFromSrc('https://stream.mux.com/abc123.m3u8')).toBe('abc123'); + }); + + it('extracts playback ID from a URL with query params', () => { + expect(toPlaybackIdFromSrc('https://stream.mux.com/abc123.m3u8?redundant_streams=true')).toBe('abc123'); + }); + + it('extracts playback ID from a subdirectory-style URL', () => { + expect(toPlaybackIdFromSrc('https://stream.mux.com/abc123/low.m3u8')).toBe('abc123'); + }); + + it('returns undefined for non-mux URLs', () => { + expect(toPlaybackIdFromSrc('https://example.com/video.m3u8')).toBeUndefined(); + }); + + it('returns undefined for non-stream subdomains', () => { + expect(toPlaybackIdFromSrc('https://image.mux.com/abc123/thumbnail.webp')).toBeUndefined(); + }); + + it('returns undefined for an empty string', () => { + expect(toPlaybackIdFromSrc('')).toBeUndefined(); + }); +}); + +describe('getDRMConfig', () => { + const playbackId = 'test-playback-id'; + const drmToken = 'test-drm-token'; + + it('sets emeEnabled to true', () => { + const config = getDRMConfig(playbackId, drmToken); + expect(config.emeEnabled).toBe(true); + }); + + it('includes FairPlay license URL', () => { + const config = getDRMConfig(playbackId, drmToken); + const fps = config.drmSystems?.['com.apple.fps']; + expect(fps?.licenseUrl).toBe(`https://license.mux.com/license/fairplay/${playbackId}?token=${drmToken}`); + }); + + it('includes FairPlay server certificate URL', () => { + const config = getDRMConfig(playbackId, drmToken); + const fps = config.drmSystems?.['com.apple.fps']; + expect(fps?.serverCertificateUrl).toBe(`https://license.mux.com/appcert/fairplay/${playbackId}?token=${drmToken}`); + }); + + it('includes Widevine license URL', () => { + const config = getDRMConfig(playbackId, drmToken); + const widevine = config.drmSystems?.['com.widevine.alpha']; + expect(widevine?.licenseUrl).toBe(`https://license.mux.com/license/widevine/${playbackId}?token=${drmToken}`); + }); + + it('includes PlayReady license URL', () => { + const config = getDRMConfig(playbackId, drmToken); + const playready = config.drmSystems?.['com.microsoft.playready']; + expect(playready?.licenseUrl).toBe(`https://license.mux.com/license/playready/${playbackId}?token=${drmToken}`); + }); + + describe('requestMediaKeySystemAccessFunc', () => { + it('passes through non-Widevine key systems unchanged', () => { + const config = getDRMConfig(playbackId, drmToken); + const mockFn = vi.fn().mockResolvedValue({}); + vi.stubGlobal('navigator', { requestMediaKeySystemAccess: mockFn }); + + const supportedConfigs = [{ videoCapabilities: [{ contentType: 'video/mp4' }] }] as any; + config.requestMediaKeySystemAccessFunc!('com.apple.fps' as any, supportedConfigs); + + expect(mockFn).toHaveBeenCalledWith('com.apple.fps', supportedConfigs); + vi.unstubAllGlobals(); + }); + + it('prepends HW_SECURE_ALL configs for Widevine', () => { + const config = getDRMConfig(playbackId, drmToken); + const mockFn = vi.fn().mockResolvedValue({}); + vi.stubGlobal('navigator', { requestMediaKeySystemAccess: mockFn }); + + const supportedConfigs = [{ videoCapabilities: [{ contentType: 'video/mp4', robustness: '' }] }] as any; + config.requestMediaKeySystemAccessFunc!('com.widevine.alpha' as any, supportedConfigs); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const calledConfigs: any[] = mockFn.mock.calls[0]![1]; + // Should have the HW_SECURE_ALL version first, then the original + expect(calledConfigs).toHaveLength(2); + expect(calledConfigs[0].videoCapabilities[0].robustness).toBe('HW_SECURE_ALL'); + expect(calledConfigs[1].videoCapabilities[0].robustness).toBe(''); + vi.unstubAllGlobals(); + }); + + it('handles Widevine configs with no videoCapabilities', () => { + const config = getDRMConfig(playbackId, drmToken); + const mockFn = vi.fn().mockResolvedValue({}); + vi.stubGlobal('navigator', { requestMediaKeySystemAccess: mockFn }); + + const supportedConfigs = [{}] as any; + config.requestMediaKeySystemAccessFunc!('com.widevine.alpha' as any, supportedConfigs); + + // Config without videoCapabilities is skipped in HW_SECURE_ALL prepend, only original remains + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const calledConfigs: any[] = mockFn.mock.calls[0]![1]; + expect(calledConfigs).toHaveLength(1); + vi.unstubAllGlobals(); + }); + }); +}); diff --git a/packages/core/src/dom/media/mux/tests/errors.test.ts b/packages/core/src/dom/media/mux/tests/errors.test.ts new file mode 100644 index 000000000..02a2618ee --- /dev/null +++ b/packages/core/src/dom/media/mux/tests/errors.test.ts @@ -0,0 +1,123 @@ +import { ErrorDetails, ErrorTypes } from 'hls.js'; +import { describe, expect, it } from 'vitest'; + +import { getErrorFromHlsErrorData, MuxErrorCategory, MuxErrorCode, MuxMediaError } from '../errors'; + +function makeErrorData(overrides: Record = {}) { + return { + type: ErrorTypes.NETWORK_ERROR, + details: ErrorDetails.MANIFEST_LOAD_ERROR, + error: new Error('test'), + fatal: true, + ...overrides, + } as any; +} + +describe('MuxMediaError', () => { + it('sets code and fatal from constructor', () => { + const err = new MuxMediaError('bad', MuxMediaError.MEDIA_ERR_NETWORK, true); + expect(err.code).toBe(MuxMediaError.MEDIA_ERR_NETWORK); + expect(err.fatal).toBe(true); + }); + + it('defaults fatal based on code range', () => { + const network = new MuxMediaError('', MuxMediaError.MEDIA_ERR_NETWORK); + expect(network.fatal).toBe(true); + const custom = new MuxMediaError('', 100); + expect(custom.fatal).toBe(false); + }); + + it('stores context', () => { + const err = new MuxMediaError('msg', MuxMediaError.MEDIA_ERR_NETWORK, true, 'ctx'); + expect(err.context).toBe('ctx'); + }); +}); + +describe('getErrorFromHlsErrorData', () => { + describe('network error with HTTP response', () => { + it('maps 412 to NETWORK_NOT_READY', () => { + const err = getErrorFromHlsErrorData( + makeErrorData({ response: { code: 412, url: 'https://example.com', text: '' } }) + ); + expect(err.muxCode).toBe(MuxErrorCode.NETWORK_NOT_READY); + expect(err.code).toBe(MuxMediaError.MEDIA_ERR_NETWORK); + expect(err.errorCategory).toBe(MuxErrorCategory.VIDEO); + }); + + it('maps 404 to NETWORK_NOT_FOUND', () => { + const err = getErrorFromHlsErrorData(makeErrorData({ response: { code: 404, url: '', text: '' } })); + expect(err.muxCode).toBe(MuxErrorCode.NETWORK_NOT_FOUND); + }); + + it('maps 403 to NETWORK_TOKEN_MISSING (no JWT inspection yet)', () => { + const err = getErrorFromHlsErrorData(makeErrorData({ response: { code: 403, url: '', text: '' } })); + expect(err.muxCode).toBe(MuxErrorCode.NETWORK_TOKEN_MISSING); + }); + + it('maps 400 to NETWORK_INVALID_URL', () => { + const err = getErrorFromHlsErrorData(makeErrorData({ response: { code: 400, url: '', text: '' } })); + expect(err.muxCode).toBe(MuxErrorCode.NETWORK_INVALID_URL); + }); + + it('maps 500 to NETWORK_GENERIC_SERVER_FAIL', () => { + const err = getErrorFromHlsErrorData(makeErrorData({ response: { code: 500, url: '', text: '' } })); + expect(err.muxCode).toBe(MuxErrorCode.NETWORK_GENERIC_SERVER_FAIL); + }); + }); + + describe('DRM / key-system errors', () => { + function makeDrmData(details: ErrorDetails, fatal = true) { + return makeErrorData({ type: ErrorTypes.KEY_SYSTEM_ERROR, details, fatal }); + } + + it('maps NO_CONFIGURED_LICENSE to ENCRYPTED_MISSING_TOKEN', () => { + const err = getErrorFromHlsErrorData(makeDrmData(ErrorDetails.KEY_SYSTEM_NO_CONFIGURED_LICENSE)); + expect(err.muxCode).toBe(MuxErrorCode.ENCRYPTED_MISSING_TOKEN); + expect(err.code).toBe(MuxMediaError.MEDIA_ERR_ENCRYPTED); + expect(err.errorCategory).toBe(MuxErrorCategory.DRM); + }); + + it('maps NO_ACCESS to ENCRYPTED_UNSUPPORTED_KEY_SYSTEM', () => { + const err = getErrorFromHlsErrorData(makeDrmData(ErrorDetails.KEY_SYSTEM_NO_ACCESS)); + expect(err.muxCode).toBe(MuxErrorCode.ENCRYPTED_UNSUPPORTED_KEY_SYSTEM); + }); + + it('maps NO_SESSION to ENCRYPTED_GENERATE_REQUEST_FAILED (always fatal)', () => { + const err = getErrorFromHlsErrorData(makeDrmData(ErrorDetails.KEY_SYSTEM_NO_SESSION, false)); + expect(err.muxCode).toBe(MuxErrorCode.ENCRYPTED_GENERATE_REQUEST_FAILED); + expect(err.fatal).toBe(true); + }); + + it('maps SESSION_UPDATE_FAILED to ENCRYPTED_UPDATE_LICENSE_FAILED', () => { + const err = getErrorFromHlsErrorData(makeDrmData(ErrorDetails.KEY_SYSTEM_SESSION_UPDATE_FAILED)); + expect(err.muxCode).toBe(MuxErrorCode.ENCRYPTED_UPDATE_LICENSE_FAILED); + }); + + it('maps STATUS_OUTPUT_RESTRICTED to ENCRYPTED_OUTPUT_RESTRICTED (non-fatal)', () => { + const err = getErrorFromHlsErrorData(makeDrmData(ErrorDetails.KEY_SYSTEM_STATUS_OUTPUT_RESTRICTED)); + expect(err.muxCode).toBe(MuxErrorCode.ENCRYPTED_OUTPUT_RESTRICTED); + expect(err.fatal).toBe(false); + }); + + it('maps unknown DRM detail to generic ENCRYPTED_ERROR', () => { + const err = getErrorFromHlsErrorData(makeDrmData(ErrorDetails.KEY_SYSTEM_STATUS_INTERNAL_ERROR)); + expect(err.errorCategory).toBe(MuxErrorCategory.DRM); + }); + }); + + describe('generic fallthrough', () => { + it('produces MEDIA_ERR_DECODE for media errors', () => { + const err = getErrorFromHlsErrorData( + makeErrorData({ type: ErrorTypes.MEDIA_ERROR, details: ErrorDetails.BUFFER_STALLED_ERROR }) + ); + expect(err.code).toBe(MuxMediaError.MEDIA_ERR_DECODE); + }); + + it('preserves fatal flag', () => { + const fatal = getErrorFromHlsErrorData(makeErrorData({ fatal: true })); + expect(fatal.fatal).toBe(true); + const nonFatal = getErrorFromHlsErrorData(makeErrorData({ fatal: false })); + expect(nonFatal.fatal).toBe(false); + }); + }); +}); diff --git a/packages/core/src/dom/media/mux/tests/stream-info.test.ts b/packages/core/src/dom/media/mux/tests/stream-info.test.ts new file mode 100644 index 000000000..e05472d95 --- /dev/null +++ b/packages/core/src/dom/media/mux/tests/stream-info.test.ts @@ -0,0 +1,99 @@ +import type { LevelDetails } from 'hls.js'; +import { describe, expect, it } from 'vitest'; + +import { getStreamInfoFromLevelDetails } from '../stream-info'; + +function makeLevelDetails(overrides: Partial): LevelDetails { + return { + live: false, + type: 'VOD', + targetduration: 6, + partTarget: 0, + partList: null, + ...overrides, + } as unknown as LevelDetails; +} + +describe('getStreamInfoFromLevelDetails', () => { + describe('on-demand (VOD)', () => { + it('returns on-demand for a VOD playlist', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: false, type: 'VOD' })); + expect(info.streamType).toBe('on-demand'); + }); + + it('returns NaN targetLiveWindow for VOD', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: false, type: 'VOD' })); + expect(info.targetLiveWindow).toBeNaN(); + }); + + it('returns NaN liveEdgeOffset for VOD', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: false, type: 'VOD' })); + expect(info.liveEdgeOffset).toBeNaN(); + }); + + it('returns on-demand when live is false regardless of type', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: false, type: null })); + expect(info.streamType).toBe('on-demand'); + }); + }); + + describe('live (sliding window)', () => { + it('returns live for a LIVE playlist', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: true, type: 'LIVE' })); + expect(info.streamType).toBe('live'); + }); + + it('returns 0 targetLiveWindow for sliding-window live', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: true, type: 'LIVE' })); + expect(info.targetLiveWindow).toBe(0); + }); + + it('returns live when type is null', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: true, type: null })); + expect(info.streamType).toBe('live'); + expect(info.targetLiveWindow).toBe(0); + }); + + it('computes liveEdgeOffset as 3× targetduration for regular live', () => { + const info = getStreamInfoFromLevelDetails( + makeLevelDetails({ live: true, type: 'LIVE', targetduration: 6, partTarget: 0 }) + ); + expect(info.liveEdgeOffset).toBe(18); + }); + }); + + describe('DVR (EVENT playlist)', () => { + it('returns live for an EVENT playlist', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: true, type: 'EVENT' })); + expect(info.streamType).toBe('live'); + }); + + it('returns Infinity targetLiveWindow for EVENT (DVR)', () => { + const info = getStreamInfoFromLevelDetails(makeLevelDetails({ live: true, type: 'EVENT' })); + expect(info.targetLiveWindow).toBe(Infinity); + }); + + it('computes liveEdgeOffset as 3× targetduration for EVENT playlist', () => { + const info = getStreamInfoFromLevelDetails( + makeLevelDetails({ live: true, type: 'EVENT', targetduration: 4, partTarget: 0 }) + ); + expect(info.liveEdgeOffset).toBe(12); + }); + }); + + describe('LL-HLS', () => { + it('uses 2× partTarget for liveEdgeOffset when partTarget is set', () => { + const info = getStreamInfoFromLevelDetails( + makeLevelDetails({ live: true, type: 'LIVE', targetduration: 6, partTarget: 0.5 }) + ); + expect(info.liveEdgeOffset).toBeCloseTo(1.0); + }); + + it('falls back to 3× targetduration when partTarget is 0', () => { + const info = getStreamInfoFromLevelDetails( + makeLevelDetails({ live: true, type: 'LIVE', targetduration: 6, partTarget: 0 }) + ); + expect(info.liveEdgeOffset).toBe(18); + }); + }); +}); diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 886703cce..26b1f55a7 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -12,6 +12,7 @@ const createConfig = (mode: BuildMode): UserConfig => ({ 'dom/media/dash/index': './src/dom/media/dash/index.ts', 'dom/media/hls/index': './src/dom/media/hls/index.ts', 'dom/media/custom-media-element/index': './src/dom/media/custom-media-element/index.ts', + 'dom/media/mux/index': './src/dom/media/mux/index.ts', 'dom/media/simple-hls/index': './src/dom/media/simple-hls/index.ts', }, platform: 'neutral', diff --git a/packages/html/package.json b/packages/html/package.json index d21da978c..f21cd483c 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -104,7 +104,8 @@ "@videojs/element": "workspace:*", "@videojs/spf": "workspace:*", "@videojs/store": "workspace:*", - "@videojs/utils": "workspace:*" + "@videojs/utils": "workspace:*", + "mux-embed": "^5.17.10" }, "devDependencies": { "@testing-library/dom": "^10.4.0", diff --git a/packages/html/src/define/media/mux-video.ts b/packages/html/src/define/media/mux-video.ts new file mode 100644 index 000000000..b8243924d --- /dev/null +++ b/packages/html/src/define/media/mux-video.ts @@ -0,0 +1,14 @@ +import { MuxVideo } from '../../media/mux-video'; +import { safeDefine } from '../safe-define'; + +export class MuxVideoElement extends MuxVideo { + static readonly tagName = 'mux-video'; +} + +safeDefine(MuxVideoElement); + +declare global { + interface HTMLElementTagNameMap { + [MuxVideoElement.tagName]: MuxVideoElement; + } +} diff --git a/packages/html/src/env.d.ts b/packages/html/src/env.d.ts new file mode 100644 index 000000000..24b39a584 --- /dev/null +++ b/packages/html/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/html/src/media/mux-video/index.ts b/packages/html/src/media/mux-video/index.ts new file mode 100644 index 000000000..edeb76f8e --- /dev/null +++ b/packages/html/src/media/mux-video/index.ts @@ -0,0 +1,90 @@ +import type { MuxMediaError } from '@videojs/core/dom/media/mux'; +import { MuxCustomMedia } from '@videojs/core/dom/media/mux'; +import { MediaAttachMixin } from '../../store/media-attach-mixin'; +import { emitMuxError, emitMuxHeartbeat, type Metadata, setupMuxData, updateMuxHlsEngine } from './mux-data'; + +export class MuxVideo extends MediaAttachMixin(MuxCustomMedia) { + static get observedAttributes() { + // biome-ignore lint/complexity/noThisInStatic: intentional use of super + return [...super.observedAttributes, 'drm-token', 'env-key']; + } + + static getTemplateHTML(attrs: Record): string { + const { src, ...rest } = attrs; + // biome-ignore lint/complexity/noThisInStatic: intentional use of super + return super.getTemplateHTML(rest); + } + + #destroyMuxData: (() => void) | null = null; + #metadata: Partial = {}; + + get metadata(): Partial { + return this.#metadata; + } + + set metadata(value: Partial) { + this.#metadata = value; + if (this.#destroyMuxData) { + emitMuxHeartbeat(this.target, value); + } + } + + constructor() { + super(); + this.attach(this.target); + } + + connectedCallback(): void { + super.connectedCallback?.(); + this.#startMuxMonitoring(); + } + + attributeChangedCallback(attrName: string, oldValue: string | null, newValue: string | null): void { + if (attrName !== 'src') { + super.attributeChangedCallback(attrName, oldValue, newValue); + } + + if (attrName === 'src' && oldValue !== newValue) { + this.src = newValue ?? ''; + // DRM may have recreated the hls.js engine — update the mux monitor if active. + if (this.#destroyMuxData && this.engine) { + updateMuxHlsEngine(this.target, this.engine); + } + } + + if (attrName === 'drm-token' && oldValue !== newValue) { + this.drmToken = newValue; + } + } + + disconnectedCallback(): void { + super.disconnectedCallback?.(); + + if (!this.hasAttribute('keep-alive')) { + this.#stopMuxMonitoring(); + this.destroy(); + } + } + + #startMuxMonitoring(): void { + const envKey = this.getAttribute('env-key'); + this.#destroyMuxData = setupMuxData(this.target, this.engine, { + envKey, + metadata: this.#metadata, + }); + this.target.addEventListener('muxerror', this.#onMuxError); + } + + #stopMuxMonitoring(): void { + this.target.removeEventListener('muxerror', this.#onMuxError); + this.#destroyMuxData?.(); + this.#destroyMuxData = null; + } + + #onMuxError = (event: Event): void => { + const error = (event as CustomEvent).detail; + if (error.fatal) { + emitMuxError(this.target, error); + } + }; +} diff --git a/packages/html/src/media/mux-video/mux-data.ts b/packages/html/src/media/mux-video/mux-data.ts new file mode 100644 index 000000000..b1a31e879 --- /dev/null +++ b/packages/html/src/media/mux-video/mux-data.ts @@ -0,0 +1,52 @@ +import type { MuxMediaError } from '@videojs/core/dom/media/mux'; +import mux, { type Metadata } from 'mux-embed'; + +export type { Metadata }; + +export function setupMuxData( + mediaEl: HTMLMediaElement, + engine: object | null, + options: { envKey: string | null; metadata: Partial } +): () => void { + const { envKey, metadata } = options; + + mux.monitor(mediaEl, { + ...(engine ? { hlsjs: engine as any } : {}), + automaticErrorTracking: false, + errorTranslator: (error) => { + // Suppress errors with no code — these are hls.js internal string-coded events + // that carry no useful context for end users or Mux Data. + if (!error.player_error_code) return false; + return error; + }, + data: { + view_session_id: crypto.randomUUID(), + player_init_time: Date.now(), + player_software_name: 'Video.js', + player_software_version: '10', + ...(envKey ? { env_key: envKey } : {}), + ...metadata, + }, + }); + + return () => { + mux.destroyMonitor(mediaEl); + }; +} + +export function updateMuxHlsEngine(mediaEl: HTMLMediaElement, engine: object): void { + mux.removeHLSJS(mediaEl); + mux.addHLSJS(mediaEl, { hlsjs: engine as any }); +} + +export function emitMuxError(mediaEl: HTMLMediaElement, error: MuxMediaError): void { + mux.emit(mediaEl, 'error', { + player_error_code: error.muxCode ?? error.code, + player_error_message: error.message, + ...(error.context !== undefined ? { player_error_context: error.context } : {}), + }); +} + +export function emitMuxHeartbeat(mediaEl: HTMLMediaElement, data: Partial): void { + mux.emit(mediaEl, 'hb', data); +} diff --git a/packages/html/src/media/mux-video/tests/mux-data.test.ts b/packages/html/src/media/mux-video/tests/mux-data.test.ts new file mode 100644 index 000000000..d4056a3f4 --- /dev/null +++ b/packages/html/src/media/mux-video/tests/mux-data.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock mux-embed before importing mux-data +const mockMonitor = vi.fn(); +const mockDestroyMonitor = vi.fn(); +const mockAddHLSJS = vi.fn(); +const mockRemoveHLSJS = vi.fn(); +const mockEmit = vi.fn(); + +vi.mock('mux-embed', () => ({ + default: { + monitor: mockMonitor, + destroyMonitor: mockDestroyMonitor, + addHLSJS: mockAddHLSJS, + removeHLSJS: mockRemoveHLSJS, + emit: mockEmit, + }, +})); + +// Import after mock is set up +const { setupMuxData, updateMuxHlsEngine, emitMuxError, emitMuxHeartbeat } = await import('../mux-data'); + +function makeMediaEl(): HTMLMediaElement { + return document.createElement('video'); +} + +function makeEngine(): any { + return { url: 'https://stream.mux.com/test.m3u8' }; +} + +describe('setupMuxData', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls mux.monitor with the media element and engine', () => { + const el = makeMediaEl(); + const engine = makeEngine(); + setupMuxData(el, engine, { envKey: null, metadata: {} }); + + expect(mockMonitor).toHaveBeenCalledOnce(); + expect(mockMonitor.mock.calls[0]![0]).toBe(el); + const opts = mockMonitor.mock.calls[0]![1]; + expect(opts.hlsjs).toBe(engine); + }); + + it('calls mux.monitor without hlsjs on the native path (engine is null)', () => { + const el = makeMediaEl(); + setupMuxData(el, null, { envKey: null, metadata: {} }); + + expect(mockMonitor).toHaveBeenCalledOnce(); + const opts = mockMonitor.mock.calls[0]![1]; + expect(opts.hlsjs).toBeUndefined(); + }); + + it('sets automaticErrorTracking to false', () => { + const el = makeMediaEl(); + setupMuxData(el, makeEngine(), { envKey: null, metadata: {} }); + const opts = mockMonitor.mock.calls[0]![1]; + expect(opts.automaticErrorTracking).toBe(false); + }); + + it('passes env_key when provided', () => { + const el = makeMediaEl(); + setupMuxData(el, makeEngine(), { envKey: 'abc123', metadata: {} }); + const opts = mockMonitor.mock.calls[0]![1]; + expect(opts.data?.env_key).toBe('abc123'); + }); + + it('omits env_key when null', () => { + const el = makeMediaEl(); + setupMuxData(el, makeEngine(), { envKey: null, metadata: {} }); + const opts = mockMonitor.mock.calls[0]![1]; + expect(opts.data?.env_key).toBeUndefined(); + }); + + it('merges metadata into data', () => { + const el = makeMediaEl(); + setupMuxData(el, makeEngine(), { + envKey: null, + metadata: { video_id: 'vid-1', video_title: 'My Video' }, + }); + const opts = mockMonitor.mock.calls[0]![1]; + expect(opts.data?.video_id).toBe('vid-1'); + expect(opts.data?.video_title).toBe('My Video'); + }); + + it('sets player_software_name to Video.js', () => { + setupMuxData(makeMediaEl(), makeEngine(), { envKey: null, metadata: {} }); + const opts = mockMonitor.mock.calls[0]![1]; + expect(opts.data?.player_software_name).toBe('Video.js'); + }); + + it('returns a cleanup function that calls destroyMonitor', () => { + const el = makeMediaEl(); + const cleanup = setupMuxData(el, makeEngine(), { envKey: null, metadata: {} }); + cleanup(); + expect(mockDestroyMonitor).toHaveBeenCalledWith(el); + }); + + describe('errorTranslator', () => { + it('suppresses errors with no player_error_code', () => { + setupMuxData(makeMediaEl(), makeEngine(), { envKey: null, metadata: {} }); + const translator = mockMonitor.mock.calls[0]![1].errorTranslator; + expect(translator({})).toBe(false); + }); + + it('passes through errors that have a player_error_code', () => { + setupMuxData(makeMediaEl(), makeEngine(), { envKey: null, metadata: {} }); + const translator = mockMonitor.mock.calls[0]![1].errorTranslator; + const error = { player_error_code: 2404000 }; + expect(translator(error)).toBe(error); + }); + }); +}); + +describe('updateMuxHlsEngine', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('removes old HLS instance then adds new one', () => { + const el = makeMediaEl(); + const engine = makeEngine(); + updateMuxHlsEngine(el, engine); + expect(mockRemoveHLSJS).toHaveBeenCalledWith(el); + expect(mockAddHLSJS).toHaveBeenCalledWith(el, { hlsjs: engine }); + }); +}); + +describe('emitMuxError', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('emits an error event with muxCode, message, and context', () => { + const el = makeMediaEl(); + const error = Object.assign(new Error('test error'), { + code: 2, + fatal: true, + muxCode: 2404000, + context: 'url: https://example.com', + }) as any; + emitMuxError(el, error); + expect(mockEmit).toHaveBeenCalledWith(el, 'error', { + player_error_code: 2404000, + player_error_message: 'test error', + player_error_context: 'url: https://example.com', + }); + }); + + it('falls back to error.code when muxCode is absent', () => { + const el = makeMediaEl(); + const error = Object.assign(new Error('decode error'), { + code: 3, + fatal: true, + }) as any; + emitMuxError(el, error); + expect(mockEmit.mock.calls[0]![2]).toMatchObject({ player_error_code: 3 }); + }); +}); + +describe('emitMuxHeartbeat', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('emits a heartbeat with the provided metadata', () => { + const el = makeMediaEl(); + const data = { view_drm_type: 'fairplay', video_id: 'v123' }; + emitMuxHeartbeat(el, data); + expect(mockEmit).toHaveBeenCalledWith(el, 'hb', data); + }); +}); diff --git a/packages/react/src/media/mux-video/index.tsx b/packages/react/src/media/mux-video/index.tsx new file mode 100644 index 000000000..051dbce12 --- /dev/null +++ b/packages/react/src/media/mux-video/index.tsx @@ -0,0 +1,22 @@ +import { MuxMedia } from '@videojs/core/dom/media/mux'; +import type { PropsWithChildren, VideoHTMLAttributes } from 'react'; +import { forwardRef } from 'react'; +import { attachMediaElement } from '../../utils/attach-media-element'; +import { mediaProps } from '../../utils/media-props'; +import { useComposedRefs } from '../../utils/use-composed-refs'; +import { useMediaInstance } from '../../utils/use-media-instance'; + +export type MuxVideoProps = PropsWithChildren>; + +export const MuxVideo = forwardRef(({ children, ...props }, ref) => { + const mediaApi = useMediaInstance(MuxMedia); + const composedRef = useComposedRefs(attachMediaElement(mediaApi), ref); + + return ( + + ); +}); + +export default MuxVideo; diff --git a/packages/sandbox/app/constants.ts b/packages/sandbox/app/constants.ts index a9bb3d915..2e1259ae2 100644 --- a/packages/sandbox/app/constants.ts +++ b/packages/sandbox/app/constants.ts @@ -1,4 +1,12 @@ export const SKINS = ['default', 'minimal'] as const; export const PLATFORMS = ['html', 'react', 'cdn'] as const; export const STYLINGS = ['css', 'tailwind'] as const; -export const PRESETS = ['video', 'hls-video', 'simple-hls-video', 'dash-video', 'audio', 'background-video'] as const; +export const PRESETS = [ + 'video', + 'hls-video', + 'simple-hls-video', + 'dash-video', + 'mux-video', + 'audio', + 'background-video', +] as const; diff --git a/packages/sandbox/app/shared/sources.ts b/packages/sandbox/app/shared/sources.ts index 8800bfe67..35c770d3c 100644 --- a/packages/sandbox/app/shared/sources.ts +++ b/packages/sandbox/app/shared/sources.ts @@ -51,6 +51,7 @@ export const SOURCES = { export type SourceId = keyof typeof SOURCES; export const SOURCE_IDS = Object.keys(SOURCES) as SourceId[]; +export const HLS_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'hls'); export const MP4_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'mp4'); export const DASH_SOURCE_IDS = SOURCE_IDS.filter((id) => SOURCES[id].type === 'dash'); export const DEFAULT_SOURCE: SourceId = 'hls-1'; diff --git a/packages/sandbox/app/shell/app.tsx b/packages/sandbox/app/shell/app.tsx index 6e7cbdecd..b27a2a33f 100644 --- a/packages/sandbox/app/shell/app.tsx +++ b/packages/sandbox/app/shell/app.tsx @@ -4,6 +4,8 @@ import { DASH_SOURCE_IDS, DEFAULT_AUDIO_SOURCE, DEFAULT_DASH_SOURCE, + DEFAULT_SOURCE, + HLS_SOURCE_IDS, MP4_SOURCE_IDS, SOURCE_IDS, SOURCES, @@ -69,6 +71,13 @@ export function App() { } }, [preset, source, setSource]); + // Constrain source to HLS when switching to mux-video + useEffect(() => { + if (preset === 'mux-video' && SOURCES[source].type !== 'hls') { + setSource(DEFAULT_SOURCE); + } + }, [preset, source, setSource]); + // CDN and background video do not have a Tailwind skin variant. useEffect(() => { if ((platform === 'cdn' || preset === 'background-video') && styling === 'tailwind') { @@ -76,7 +85,14 @@ export function App() { } }, [platform, preset, styling]); - const availableSources = preset === 'audio' ? MP4_SOURCE_IDS : preset === 'dash-video' ? DASH_SOURCE_IDS : SOURCE_IDS; + const availableSources = + preset === 'audio' + ? MP4_SOURCE_IDS + : preset === 'dash-video' + ? DASH_SOURCE_IDS + : preset === 'mux-video' + ? HLS_SOURCE_IDS + : SOURCE_IDS; const handleSourceChange = useCallback((value: string) => setSource(value as SourceId), [setSource]); diff --git a/packages/sandbox/app/shell/navbar.tsx b/packages/sandbox/app/shell/navbar.tsx index 1f6434b2a..37f887369 100644 --- a/packages/sandbox/app/shell/navbar.tsx +++ b/packages/sandbox/app/shell/navbar.tsx @@ -35,6 +35,7 @@ const PRESET_LABELS: Record = { 'hls-video': 'HLS Video', 'simple-hls-video': 'Simple HLS Video', 'dash-video': 'DASH Video', + 'mux-video': 'Mux Video', audio: 'Audio', 'background-video': 'Background Video', }; diff --git a/packages/sandbox/templates/html-mux-video/index.html b/packages/sandbox/templates/html-mux-video/index.html new file mode 100644 index 000000000..f8a7fdc8f --- /dev/null +++ b/packages/sandbox/templates/html-mux-video/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — HTML Mux Video + + + + +
+ + + diff --git a/packages/sandbox/templates/html-mux-video/main.ts b/packages/sandbox/templates/html-mux-video/main.ts new file mode 100644 index 000000000..45d92ce85 --- /dev/null +++ b/packages/sandbox/templates/html-mux-video/main.ts @@ -0,0 +1,44 @@ +import '@app/styles.css'; +import '@videojs/html/video/player'; +import '@videojs/html/media/mux-video'; +import { createHtmlSandboxState, createLatestLoader } from '@app/shared/html/sandbox-state'; +import { loadVideoSkinTag } from '@app/shared/html/skins'; +import { renderStoryboard } from '@app/shared/html/storyboard'; +import { onSkinChange, onSourceChange } from '@app/shared/sandbox-listener'; +import { getPosterSrc, getStoryboardSrc, SOURCES } from '@app/shared/sources'; + +const html = String.raw; + +const state = createHtmlSandboxState(); +const loadLatest = createLatestLoader(); + +async function render() { + const tag = await loadLatest(() => loadVideoSkinTag(state.skin, state.styling)); + if (!tag) return; + + const storyboard = getStoryboardSrc(state.source); + const poster = getPosterSrc(state.source); + + document.getElementById('root')!.innerHTML = html` + + <${tag} class="w-full aspect-video max-w-4xl mx-auto"> + + ${renderStoryboard(storyboard)} + + ${poster ? html`Video poster` : ''} + + + `; +} + +render(); + +onSkinChange((skin) => { + state.skin = skin; + render(); +}); + +onSourceChange((source) => { + state.source = source; + render(); +}); diff --git a/packages/sandbox/templates/mux-video-harness/index.html b/packages/sandbox/templates/mux-video-harness/index.html new file mode 100644 index 000000000..414d5b539 --- /dev/null +++ b/packages/sandbox/templates/mux-video-harness/index.html @@ -0,0 +1,214 @@ + + + + + + Sandbox — Mux Video Harness + + + +

Mux Video Harness

+

Test harness for <mux-video> stream type detection and delegate properties.

+ +
+ +
+ + +
+ +
+ 🔗 current page + +
+ +
+ + + + +
+ + + + +
+

Stream Info

+ + + + + + + + + + + + + + + + + +
streamType
targetLiveWindow
liveEdgeOffset
liveEdgeStart
+
+ +
+

Logs

+
+
+ +
+

State Inspector

+
Click "Inspect State" to view element properties
+
+ + + + diff --git a/packages/sandbox/templates/mux-video-harness/main.ts b/packages/sandbox/templates/mux-video-harness/main.ts new file mode 100644 index 000000000..a6932b80a --- /dev/null +++ b/packages/sandbox/templates/mux-video-harness/main.ts @@ -0,0 +1,207 @@ +// Mux Video Test Harness +// http://localhost:5173/mux-video-harness/ +// +// Supported query params: +// src= HLS stream URL to load on start + +// Side-effect import registers the custom element. +import '@videojs/html/media/mux-video'; +import type { MuxVideoElement } from '@videojs/html/media/mux-video'; + +// ── Preset sources ────────────────────────────────────────────────────────── +// Add live/DVR stream URLs here to exercise stream type detection end-to-end. +const PRESETS: { label: string; url: string }[] = [ + { label: 'VOD — Big Buck Bunny', url: 'https://stream.mux.com/VcmKA6aqzIzlg3MayLJDnbF55kX00mds028Z65QxvBYaA.m3u8' }, + { label: 'VOD — Elephants Dream', url: 'https://stream.mux.com/Sc89iWAyNkhJ3P1rQ02nrEdCFTnfT01CZ2KmaEcxXfB008.m3u8' }, + { label: 'VOD — Mad Max Trailer', url: 'https://stream.mux.com/JX01bG8eB4uaoV3OpDuK602rBfvdSgrMObjwuUOBn4JrQ.m3u8' }, + // Plug in live / DVR stream URLs to test streamType = 'live' and targetLiveWindow. + // { label: 'Live — sliding window', url: 'https://stream.mux.com/.m3u8' }, + // { label: 'DVR — EVENT playlist', url: 'https://stream.mux.com/.m3u8' }, +]; + +const DEFAULT_SRC = PRESETS[0].url; + +// ── DOM refs ──────────────────────────────────────────────────────────────── +const muxVideo = document.getElementById('mux-video') as MuxVideoElement; +const logsDiv = document.getElementById('logs') as HTMLDivElement; +const stateDiv = document.getElementById('state') as HTMLDivElement; +const srcInput = document.getElementById('src-input') as HTMLInputElement; +const presetsDiv = document.getElementById('preset-sources') as HTMLDivElement; +const shareLink = document.getElementById('share-link') as HTMLAnchorElement; + +// ── Query params ──────────────────────────────────────────────────────────── +const params = new URLSearchParams(window.location.search); +const INITIAL_SRC = params.get('src') ?? DEFAULT_SRC; + +srcInput.value = INITIAL_SRC; +updateShareUrl(); + +// ── Helpers ───────────────────────────────────────────────────────────────── +function log(msg: string, type: 'info' | 'success' | 'error' | 'warning' = 'info') { + const timestamp = new Date().toLocaleTimeString(); + console.log(`[${timestamp}] ${msg}`); + const div = document.createElement('div'); + div.className = type; + div.textContent = `[${timestamp}] ${msg}`; + logsDiv.appendChild(div); + logsDiv.scrollTop = logsDiv.scrollHeight; +} + +function formatNum(n: number): string { + if (Number.isNaN(n)) return 'NaN'; + if (!Number.isFinite(n)) return n > 0 ? 'Infinity' : '-Infinity'; + return n.toFixed(3); +} + +function updateShareUrl() { + const src = srcInput.value.trim(); + const p = new URLSearchParams(); + if (src && src !== DEFAULT_SRC) p.set('src', src); + const url = `${window.location.origin}${window.location.pathname}${p.size > 0 ? `?${p}` : ''}`; + shareLink.href = url; + shareLink.textContent = url; +} + +function updateStreamInfo() { + const streamType = (muxVideo as any).streamType ?? 'unknown'; + const targetLiveWindow = (muxVideo as any).targetLiveWindow ?? NaN; + const liveEdgeOffset = (muxVideo as any).liveEdgeOffset ?? NaN; + const liveEdgeStart = (muxVideo as any).liveEdgeStart ?? NaN; + + const stEl = document.getElementById('si-streamType')!; + stEl.textContent = streamType; + stEl.className = `value ${streamType}`; + + document.getElementById('si-targetLiveWindow')!.textContent = formatNum(targetLiveWindow); + document.getElementById('si-liveEdgeOffset')!.textContent = formatNum(liveEdgeOffset); + document.getElementById('si-liveEdgeStart')!.textContent = formatNum(liveEdgeStart); +} + +function inspectState() { + const el = muxVideo as any; + stateDiv.innerHTML = ` +

State Inspector

+

Stream Info (delegate properties)

+
${JSON.stringify(
+      {
+        streamType: el.streamType,
+        targetLiveWindow: formatNum(el.targetLiveWindow),
+        liveEdgeOffset: formatNum(el.liveEdgeOffset),
+        liveEdgeStart: formatNum(el.liveEdgeStart),
+      },
+      null,
+      2
+    )}
+

Video Element State

+
${JSON.stringify(
+      {
+        src: el.src,
+        readyState: el.readyState,
+        networkState: el.networkState,
+        currentTime: el.currentTime.toFixed(2),
+        duration: Number.isNaN(el.duration) ? 'NaN' : el.duration.toFixed(2),
+        paused: el.paused,
+        ended: el.ended,
+        seekable:
+          el.seekable.length > 0 ? `[${el.seekable.start(0).toFixed(2)}, ${el.seekable.end(0).toFixed(2)}]` : '(empty)',
+      },
+      null,
+      2
+    )}
+ `; +} + +// ── Preset buttons ─────────────────────────────────────────────────────────── +for (const preset of PRESETS) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `preset-btn${preset.url === INITIAL_SRC ? ' active' : ''}`; + btn.textContent = preset.label; + btn.addEventListener('click', () => { + srcInput.value = preset.url; + loadSrc(preset.url); + presetsDiv.querySelectorAll('.preset-btn').forEach((b) => b.classList.remove('active')); + btn.classList.add('active'); + }); + presetsDiv.appendChild(btn); +} + +// ── Load ──────────────────────────────────────────────────────────────────── +function loadSrc(url: string) { + log(`Loading: ${url}`); + muxVideo.src = url; + updateShareUrl(); + updateStreamInfo(); +} + +// ── stream info events ─────────────────────────────────────────────────────── +muxVideo.addEventListener('streamtypechange', (e) => { + const detail = (e as CustomEvent).detail; + log(`🎬 streamtypechange → ${detail}`, 'success'); + updateStreamInfo(); +}); + +muxVideo.addEventListener('targetlivewindowchange', (e) => { + const detail = (e as CustomEvent).detail; + const formatted = formatNum(detail); + log(`📡 targetlivewindowchange → ${formatted}`, 'success'); + updateStreamInfo(); +}); + +muxVideo.addEventListener('muxerror', (e) => { + const err = (e as CustomEvent).detail; + const label = err?.muxCode ? ` [${err.muxCode}]` : ''; + log(`⚠️ muxerror${label}: ${err?.message ?? ''}`, 'error'); +}); + +// ── Standard video events ──────────────────────────────────────────────────── +muxVideo.addEventListener('loadstart', () => log('📺 loadstart')); +muxVideo.addEventListener('loadedmetadata', () => { + log('📺 loadedmetadata', 'success'); + updateStreamInfo(); +}); +muxVideo.addEventListener('canplay', () => log('📺 canplay', 'success')); +muxVideo.addEventListener('playing', () => log('📺 playing', 'success')); +muxVideo.addEventListener('pause', () => log('📺 pause')); +muxVideo.addEventListener('waiting', () => log('📺 waiting', 'warning')); +muxVideo.addEventListener('ended', () => log('📺 ended', 'success')); +muxVideo.addEventListener('error', () => log(`📺 error — ${(muxVideo as any).error?.message ?? 'unknown'}`, 'error')); + +// ── Button handlers ────────────────────────────────────────────────────────── +document.getElementById('play')!.addEventListener('click', () => { + muxVideo + .play() + .then(() => log('play() resolved', 'success')) + .catch((e) => log(`play() rejected: ${e.message}`, 'error')); +}); +document.getElementById('pause')!.addEventListener('click', () => { + muxVideo.pause(); + log('paused'); +}); +document.getElementById('inspect')!.addEventListener('click', inspectState); +document.getElementById('clearLogs')!.addEventListener('click', () => { + logsDiv.innerHTML = ''; +}); +document.getElementById('open-new-tab')!.addEventListener('click', () => { + window.open(shareLink.href, '_blank', 'noopener'); +}); + +document.getElementById('set-src')!.addEventListener('click', () => { + const url = srcInput.value.trim(); + if (!url) return; + presetsDiv.querySelectorAll('.preset-btn').forEach((b) => b.classList.remove('active')); + loadSrc(url); +}); + +srcInput.addEventListener('input', updateShareUrl); +srcInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + loadSrc(srcInput.value.trim()); + } +}); + +// ── Initial load ───────────────────────────────────────────────────────────── +log('=== Mux Video Harness ==='); +(window as any).muxVideo = muxVideo; +log('Exposed as window.muxVideo'); +loadSrc(INITIAL_SRC); diff --git a/packages/sandbox/templates/react-mux-video/index.html b/packages/sandbox/templates/react-mux-video/index.html new file mode 100644 index 000000000..2596b060a --- /dev/null +++ b/packages/sandbox/templates/react-mux-video/index.html @@ -0,0 +1,14 @@ + + + + + + Sandbox — React Mux Video + + + + +
+ + + diff --git a/packages/sandbox/templates/react-mux-video/main.tsx b/packages/sandbox/templates/react-mux-video/main.tsx new file mode 100644 index 000000000..ffeed5233 --- /dev/null +++ b/packages/sandbox/templates/react-mux-video/main.tsx @@ -0,0 +1,42 @@ +import '@app/styles.css'; +import { VideoProvider } from '@app/shared/react/providers'; +import { VideoSkinComponent } from '@app/shared/react/skins'; +import { Storyboard } from '@app/shared/react/storyboard'; +import { usePoster } from '@app/shared/react/use-poster'; +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 { SOURCES } from '@app/shared/sources'; +import type { Styling } from '@app/types'; +import { MuxVideo } from '@videojs/react/media/mux-video'; +import { useMemo } from 'react'; +import { createRoot } from 'react-dom/client'; + +function readStyling(): Styling { + return new URLSearchParams(location.search).get('styling') === 'tailwind' ? 'tailwind' : 'css'; +} + +function App() { + const skin = useSkin(); + const source = useSource(); + const styling = useMemo(readStyling, []); + const poster = usePoster(); + const storyboard = useStoryboard(); + + return ( + + + + + + + + ); +} + +createRoot(document.getElementById('root')!).render(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4877bd1a..a945441ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -139,6 +139,9 @@ importers: '@videojs/utils': specifier: workspace:* version: link:../utils + mux-embed: + specifier: ^5.17.10 + version: 5.17.10 devDependencies: '@testing-library/dom': specifier: ^10.4.0 @@ -927,24 +930,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.6': resolution: {integrity: sha512-kMLaI7OF5GN1Q8Doymjro1P8rVEoy7BKQALNz6fiR8IC1WKduoNyteBtJlHT7ASIL0Cx2jR6VUOBIbcB1B8pew==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.6': resolution: {integrity: sha512-C9s98IPDu7DYarjlZNuzJKTjVHN03RUnmHV5htvqsx6vEUXCDSJ59DNwjKVD5XYoSS4N+BYhq3RTBAL8X6svEg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.6': resolution: {integrity: sha512-oHXmUFEoH8Lql1xfc3QkFLiC1hGR7qedv5eKNlC185or+o4/4HiaU7vYODAH3peRCfsuLr1g6v2fK9dFFOYdyw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.6': resolution: {integrity: sha512-xzThn87Pf3YrOGTEODFGONmqXpTwUNxovQb72iaUOdcw8sBSY3+3WD8Hm9IhMYLnPi0n32s3L3NWU6+eSjfqFg==} @@ -1715,89 +1722,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -2384,36 +2407,42 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] '@parcel/watcher-wasm@2.5.6': resolution: {integrity: sha512-byAiBZ1t3tXQvc8dMD/eoyE7lTXYorhn+6uVW5AC+JGI1KtJC/LvDche5cfUE+qiefH+Ybq0bUCJU0aB1cSHUA==} @@ -2493,36 +2522,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} @@ -2602,66 +2637,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -3067,24 +3115,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -5401,48 +5453,56 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-gnu@1.32.0: resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -5885,6 +5945,9 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mux-embed@5.17.10: + resolution: {integrity: sha512-i+eaoezVxIEliYGWPsjQztrWbA8A3Rzwqhwv1WGuRrl2npx85jFYJV5y+cjh7FASPOjT+7zJTYCJfxmcbgM7Hg==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -14081,6 +14144,8 @@ snapshots: muggle-string@0.4.1: {} + mux-embed@5.17.10: {} + nanoid@3.3.11: {} nanostores@1.1.1: {}