From f5c44e81edb91f065a4bec0fa914b5e713784192 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 20:13:00 +0000 Subject: [PATCH 01/11] docs(media): say the poster placeholder is a CSS image The value reaches CSS untouched, so a URL has to arrive wrapped and any blur has to be baked into the image. That follows from how the poster offers it, so it belongs with the design that does. Refs #1798 --- packages/media/src/core/state.ts | 2 +- packages/media/src/core/types.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/media/src/core/state.ts b/packages/media/src/core/state.ts index d3bf66d80..ee175dbfc 100644 --- a/packages/media/src/core/state.ts +++ b/packages/media/src/core/state.ts @@ -143,7 +143,7 @@ export interface MediaMetadataState { contentTitle: string; /** The resolved poster URL. Independent of the media element's own `poster`. */ poster: string; - /** The resolved poster placeholder: a low-resolution stand-in shown while the poster loads. */ + /** The resolved poster placeholder: a low-resolution stand-in, as a CSS image. */ posterPlaceholder: string; /** Set the user title override, or clear it with `null` or `undefined`. */ setContentTitle(value: string | null | undefined): void; diff --git a/packages/media/src/core/types.ts b/packages/media/src/core/types.ts index 3289a93c7..bb0511587 100644 --- a/packages/media/src/core/types.ts +++ b/packages/media/src/core/types.ts @@ -522,7 +522,10 @@ export interface MediaContentData { readonly title?: MediaContentValue; /** URL of a still image representing the content. */ readonly poster?: MediaContentValue; - /** A low-resolution stand-in shown while the poster loads. */ + /** + * A low-resolution stand-in shown behind the poster while it loads, as a CSS + * image — usually `url()` around a data URL with the blur already baked in. + */ readonly posterPlaceholder?: MediaContentValue; /** URL of a WebVTT storyboard describing thumbnail sprites for the content. */ readonly storyboard?: MediaContentValue; From f8bf3726398bedcff7773b73a02ef1eece02321d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 17:32:04 +0000 Subject: [PATCH 02/11] feat(packages): coordinate the poster image instead of owning one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` rendered nothing of its own, so reading the store's resolved poster left no place to put a URL. Owning an `` in a shadow root answers that, and costs three things: the image is stylable only through `::part(img)`, skins carry two styling contracts — one for the internal image, one for the replacement — and two images sit in the tree with one of them hidden. Make it a controller in the shape of `` instead. It renders no image and coordinates the one already inside it: fills in `src` when the child has none, leaves a child that brought its own `src`, `srcset`, or `` candidates alone, and reports `data-loading`, `data-loaded`, and `data-error` for whichever image is active. The packaged skins carry a plain `` as their poster slot's fallback content, so an `` displaces it and there is never a moment when a default and an override both exist. The placeholder needs no component and no second element either. The poster offers the resolved value as `--media-poster-placeholder` — the same property the skins read before this branch, now set by the component that holds the value rather than by `SkinElement` from an attribute. The skins paint it as the poster image's own `background-image`: an `` paints a background behind its content like any other element, so one image covers both jobs and the poster arrives on top. `background-size` tracks `--media-object-fit` so the stand-in occupies the poster's box. Offering the property rather than painting it keeps the component out of presentation: it never overwrites a background the image already had, it costs nothing when no skin reads it, and a skin that wants a separate layer to crossfade can still have one. Custom properties inherit down the flattened tree, so the value reaches the image through both slot hops even when that image lives in another tree. Any blur has to be baked into the image, which is what `@mux/blurup` returns and what Next.js, Angular `NgOptimizedImage`, and Nuxt Image all do. `--media-poster-placeholder-blur` is gone with the CSS filter it fed. Watching an image it does not own means the load treatment now covers one you supplied. It used to apply to the component's own image only and let a supplied one appear abruptly. It also no longer depends on load state: the skins held the image at `opacity: 0` until `data-loaded`, which would now hide the background along with it. `placeholdersrc` on `` and `placeholder` on the React skins are gone, and so is `poster` on the React skins — the URL is player configuration, which is also where the generated e2e pages set it now. `` alone renders nothing, so a hand-authored layout supplies the image and a `__DEV__` warning fires when a poster resolves without one. Refs #1798 --- apps/e2e/scripts/generate-pages.ts | 5 +- .../app/shared/react/use-placeholder.ts | 8 - .../shared/react/use-poster-placeholder.ts | 8 + apps/sandbox/app/shared/sources.ts | 13 +- apps/sandbox/templates/html-mux-video/main.ts | 9 +- .../templates/react-hlsjs-video/main.tsx | 10 +- .../templates/react-mux-video/main.tsx | 15 +- .../templates/react-native-hls-video/main.tsx | 10 +- .../templates/react-simple-hls-video/main.tsx | 10 +- apps/sandbox/templates/react-video/main.tsx | 4 +- packages/core/src/core/index.ts | 1 + .../core/src/core/ui/poster/poster-core.ts | 46 ++- .../src/core/ui/poster/poster-css-vars.ts | 13 + .../src/core/ui/poster/poster-data-attrs.ts | 3 + .../core/ui/poster/tests/poster-core.test.ts | 100 ++--- .../live-video/minimal-skin.tailwind.ts | 5 +- .../src/define/live-video/minimal-skin.ts | 5 +- .../src/define/live-video/skin.tailwind.ts | 5 +- packages/html/src/define/live-video/skin.ts | 5 +- packages/html/src/define/skin-element.ts | 16 - .../src/define/video/minimal-skin.tailwind.ts | 5 +- .../html/src/define/video/minimal-skin.ts | 5 +- .../html/src/define/video/skin.tailwind.ts | 5 +- packages/html/src/define/video/skin.ts | 5 +- packages/html/src/ui/poster/poster-element.ts | 284 +++++++++++++- .../ui/poster/tests/poster-element.test.ts | 358 ++++++++++++++++++ .../live-video/minimal-skin.tailwind.tsx | 20 +- .../src/presets/live-video/minimal-skin.tsx | 24 +- .../src/presets/live-video/skin.tailwind.tsx | 20 +- .../react/src/presets/live-video/skin.tsx | 24 +- packages/react/src/presets/types.ts | 8 - .../presets/video/minimal-skin.tailwind.tsx | 20 +- .../react/src/presets/video/minimal-skin.tsx | 24 +- .../react/src/presets/video/skin.tailwind.tsx | 20 +- packages/react/src/presets/video/skin.tsx | 24 +- packages/react/src/ui/poster/poster.tsx | 107 ++++-- .../react/src/ui/poster/tests/poster.test.tsx | 163 ++++++++ .../src/default/css/components/poster.css | 62 ++- .../src/default/tailwind/components/poster.ts | 71 +++- .../src/default/tailwind/video.tailwind.ts | 13 - .../src/minimal/css/components/poster.css | 62 ++- .../src/minimal/tailwind/components/poster.ts | 71 +++- .../src/minimal/tailwind/video.tailwind.ts | 13 - 43 files changed, 1247 insertions(+), 452 deletions(-) delete mode 100644 apps/sandbox/app/shared/react/use-placeholder.ts create mode 100644 apps/sandbox/app/shared/react/use-poster-placeholder.ts create mode 100644 packages/core/src/core/ui/poster/poster-css-vars.ts create mode 100644 packages/html/src/ui/poster/tests/poster-element.test.ts create mode 100644 packages/react/src/ui/poster/tests/poster.test.tsx diff --git a/apps/e2e/scripts/generate-pages.ts b/apps/e2e/scripts/generate-pages.ts index 14fa5d881..eb3244763 100644 --- a/apps/e2e/scripts/generate-pages.ts +++ b/apps/e2e/scripts/generate-pages.ts @@ -204,6 +204,7 @@ function reactVideoPage(media: string, resource: string, config: MediaTypeConfig ? `import { Video, VideoSkin, videoFeatures } from '@videojs/react/video';` : `import { ${reactMedia.component} } from '${reactMedia.importPath}';\nimport { VideoSkin, videoFeatures } from '@videojs/react/video';`; + // The poster URL is player configuration, not a skin prop. const posterProp = config.hasPoster ? ` poster={MEDIA.${resource}.poster}` : ''; const storyboardTrack = config.hasStoryboard ? `\n ` @@ -219,8 +220,8 @@ const Player = createPlayer({ features: videoFeatures }); function App() { return ( - - + + <${reactMedia.component} src={MEDIA.${resource}.url} playsInline crossOrigin="anonymous">${storyboardTrack} diff --git a/apps/sandbox/app/shared/react/use-placeholder.ts b/apps/sandbox/app/shared/react/use-placeholder.ts deleted file mode 100644 index cee854cb7..000000000 --- a/apps/sandbox/app/shared/react/use-placeholder.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useMemo } from 'react'; -import { getPlaceholderSrc } from '../sources'; -import { useSource } from './use-source'; - -export function usePlaceholder() { - const source = useSource(); - return useMemo(() => getPlaceholderSrc(source), [source]); -} diff --git a/apps/sandbox/app/shared/react/use-poster-placeholder.ts b/apps/sandbox/app/shared/react/use-poster-placeholder.ts new file mode 100644 index 000000000..5ab49518e --- /dev/null +++ b/apps/sandbox/app/shared/react/use-poster-placeholder.ts @@ -0,0 +1,8 @@ +import { useMemo } from 'react'; +import { getPosterPlaceholderImage } from '../sources'; +import { useSource } from './use-source'; + +export function usePosterPlaceholder() { + const source = useSource(); + return useMemo(() => getPosterPlaceholderImage(source), [source]); +} diff --git a/apps/sandbox/app/shared/sources.ts b/apps/sandbox/app/shared/sources.ts index e3338d64f..b609f2f43 100644 --- a/apps/sandbox/app/shared/sources.ts +++ b/apps/sandbox/app/shared/sources.ts @@ -270,9 +270,18 @@ export function getPosterSrc(source: SourceId): string | undefined { return id ? `https://image.mux.com/${id}/thumbnail.webp${imageQuery(source, 'poster')}` : undefined; } -export function getPlaceholderSrc(source: SourceId): string | undefined { +/** + * A CSS image, not a URL: `posterPlaceholder` reaches CSS untouched. A real + * integration would pass `@mux/blurup` output here, which arrives pre-blurred; + * this upscales a 20px thumbnail instead, and the browser's own smoothing does + * the blurring. + */ +export function getPosterPlaceholderImage(source: SourceId): string | undefined { const id = getMuxAssetId(source); - return id ? `https://image.mux.com/${id}/thumbnail.webp${imageQuery(source, 'poster', 'width=20')}` : undefined; + if (!id) return undefined; + + const url = `https://image.mux.com/${id}/thumbnail.webp${imageQuery(source, 'poster', 'width=20')}`; + return `url("${url}")`; } export function getStoryboardSrc(source: SourceId): string | undefined { diff --git a/apps/sandbox/templates/html-mux-video/main.ts b/apps/sandbox/templates/html-mux-video/main.ts index f64f741a9..429f3eb40 100644 --- a/apps/sandbox/templates/html-mux-video/main.ts +++ b/apps/sandbox/templates/html-mux-video/main.ts @@ -14,7 +14,7 @@ import { onSkinChange, onSourceChange, } from '@app/shared/sandbox-listener'; -import { getPlaceholderSrc, getPosterSrc, isLiveSource, SOURCES } from '@app/shared/sources'; +import { getPosterPlaceholderImage, getPosterSrc, isLiveSource, SOURCES } from '@app/shared/sources'; const html = String.raw; @@ -29,7 +29,7 @@ async function render() { if (!tag) return; const poster = getPosterSrc(state.source); - const placeholder = getPlaceholderSrc(state.source); + const posterPlaceholder = getPosterPlaceholderImage(state.source); const mediaAttrs = renderMediaAttrs(state); const playerTag = live ? 'live-video-player' : 'video-player'; @@ -39,14 +39,13 @@ async function render() { const srcAttr = source ? '' : ` src="${url}"`; document.getElementById('root')!.innerHTML = wrapSandboxHtmlI18n(html` - <${playerTag}> - <${tag} class="aspect-video max-w-4xl mx-auto"${placeholder ? ` placeholdersrc="${placeholder}"` : ''}> + <${playerTag}${poster ? ` poster="${poster}"` : ''}${posterPlaceholder ? ` poster-placeholder='${posterPlaceholder}'` : ''}> + <${tag} class="aspect-video max-w-4xl mx-auto"> - ${poster ? html`Video poster` : ''} `); diff --git a/apps/sandbox/templates/react-hlsjs-video/main.tsx b/apps/sandbox/templates/react-hlsjs-video/main.tsx index 458004033..53e910c6d 100644 --- a/apps/sandbox/templates/react-hlsjs-video/main.tsx +++ b/apps/sandbox/templates/react-hlsjs-video/main.tsx @@ -39,14 +39,8 @@ function App() { return ( - - + + - - + + {/* The storyboard track is derived automatically from the Mux src. */} - - + + - - + + - - + +