From 628437b9285a6122894b43d4394355f9e9e99a77 Mon Sep 17 00:00:00 2001 From: Simon Byford Date: Wed, 26 Nov 2025 10:49:00 +0000 Subject: [PATCH 01/11] Fix bug in SelfHostedVideo --- .../src/components/SelfHostedVideo.importable.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index 59bc13d3147..292d10e5493 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -72,8 +72,8 @@ const dispatchOphanAttentionEvent = ( document.dispatchEvent(event); }; -const getOptimisedPosterImage = (mainImage: string): string => { - const resolution = window.devicePixelRatio >= 2 ? 'high' : 'low'; +const getOptimisedPosterImage = (mainImage: string, dpr: number): string => { + const resolution = dpr >= 2 ? 'high' : 'low'; return generateImageURL({ mainImage, @@ -175,6 +175,8 @@ export const SelfHostedVideo = ({ const [hasBeenPlayed, setHasBeenPlayed] = useState(false); const [hasTrackedPlay, setHasTrackedPlay] = useState(false); + const [devicePixelRatio, setDevicePixelRatio] = useState(1); + const VISIBILITY_THRESHOLD = 0.5; /** @@ -350,6 +352,8 @@ export const SelfHostedVideo = ({ } }); + setDevicePixelRatio(window.devicePixelRatio); + return () => { document.removeEventListener( customSelfHostedVideoPlayAudioEventName, @@ -658,7 +662,7 @@ export const SelfHostedVideo = ({ const AudioIcon = isMuted ? SvgAudioMute : SvgAudio; const optimisedPosterImage = showPosterImage - ? getOptimisedPosterImage(posterImage) + ? getOptimisedPosterImage(posterImage, devicePixelRatio) : undefined; return ( From db38c0ddeba3f678e7d0664add9eab18a21491ac Mon Sep 17 00:00:00 2001 From: Simon Byford Date: Wed, 26 Nov 2025 10:49:37 +0000 Subject: [PATCH 02/11] Render looping video in appropriate player --- .../LoopVideoInArticle.importable.tsx | 54 +++++++++++++++++++ .../src/frontend/schemas/feArticle.json | 19 ++++--- dotcom-rendering/src/lib/renderElement.tsx | 50 +++++++++++++---- dotcom-rendering/src/lib/video.ts | 27 ++++++++++ dotcom-rendering/src/model/block-schema.json | 11 ++++ dotcom-rendering/src/types/content.ts | 6 ++- 6 files changed, 148 insertions(+), 19 deletions(-) create mode 100644 dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx diff --git a/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx b/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx new file mode 100644 index 00000000000..fdbde36d369 --- /dev/null +++ b/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx @@ -0,0 +1,54 @@ +import type { ArticleFormat } from '../lib/articleFormat'; +import { convertAssetsToVideoSources, getSubtitleAsset } from '../lib/video'; +import type { MediaAtomBlockElement } from '../types/content'; +import { Caption } from './Caption'; +import { SelfHostedVideo } from './SelfHostedVideo.importable'; + +type LoopVideoInArticleProps = { + element: MediaAtomBlockElement; + format: ArticleFormat; + isMainMedia: boolean; +}; + +export const LoopVideoInArticle = ({ + element, + format, + isMainMedia, +}: LoopVideoInArticleProps) => { + const posterImageUrl = element.posterImage?.[0]?.url; + const caption = element.title; + + if (!posterImageUrl) { + return null; + } + + return ( + <> + + {!!caption && ( + + )} + + ); +}; diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 03ee010d1cd..7b55ee3668b 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -2698,6 +2698,9 @@ }, "duration": { "type": "number" + }, + "videoPlayerFormat": { + "$ref": "#/definitions/VideoPlayerFormat" } }, "required": [ @@ -2707,6 +2710,14 @@ "id" ] }, + "VideoPlayerFormat": { + "enum": [ + "Cinemagraph", + "Default", + "Loop" + ], + "type": "string" + }, "MiniProfilesBlockElement": { "type": "object", "properties": { @@ -5510,14 +5521,6 @@ } ] }, - "VideoPlayerFormat": { - "enum": [ - "Cinemagraph", - "Default", - "Loop" - ], - "type": "string" - }, "Audio": { "allOf": [ { diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 5d28c63b295..5661327fc76 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -29,6 +29,7 @@ import { Island } from '../components/Island'; import { ItemLinkBlockElement } from '../components/ItemLinkBlockElement'; import { KeyTakeaways } from '../components/KeyTakeaways'; import { KnowledgeQuizAtom } from '../components/KnowledgeQuizAtom.importable'; +import { LoopVideoInArticle } from '../components/LoopVideoInArticle.importable'; import { MainMediaEmbedBlockComponent } from '../components/MainMediaEmbedBlockComponent'; import { MapEmbedBlockComponent } from '../components/MapEmbedBlockComponent.importable'; import { MiniProfiles } from '../components/MiniProfiles'; @@ -490,15 +491,46 @@ export const renderElement = ({ ); case 'model.dotcomrendering.pageElements.MediaAtomBlockElement': - return ( - - ); + /* + - MediaAtomBlockElement is used for self-hosted videos + - Historically, these videos have been self-hosted for legal or sensitive reasons + - These videos play in the `VideoAtom` component + - Looping videos, introduced in July 2025, are also self-hosted + - Thus they are delivered as a MediaAtomBlockElement + - However they need to display in a different video player + - We need to differentiate between the two forms of video + - We can do this by interrogating the atom's metadata, which includes the new attribute `videoPlayerFormat` + + - Note: we'll probably extend this functionality to handle new 'Cinemagraph' videos + - These may use the looping video, or yet another new, video player + - But they will still be Media Atoms + */ + if (element.videoPlayerFormat === 'Loop') { + return ( + <> + + + + + ); + } else { + return ( + + ); + } case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement': return ( { + if (!mime) return false; + + return (supportedVideoFileTypes as readonly string[]).includes(mime); +}; + +/** + * The looping video player types its `sources` attribute as `Sources`. + * However, looping videos in articles are delivered as media atoms, which type + * their `assets` as `VideoAssets`. Which means that we need to alter the shape + * of the incoming `assets` to match the requirements of the outgoing `sources`. + */ +export const convertAssetsToVideoSources = (assets: VideoAssets[]): Source[] => + assets + .filter((asset) => isSupportedMimeType(asset.mimeType)) + .map((asset) => ({ + src: asset.url, + mimeType: asset.mimeType as Source['mimeType'], + })); + +export const getSubtitleAsset = (assets: VideoAssets[]): string | undefined => + assets.find((asset) => asset.mimeType === 'text/vtt')?.url; diff --git a/dotcom-rendering/src/model/block-schema.json b/dotcom-rendering/src/model/block-schema.json index 776b641d0c7..c09fad06027 100644 --- a/dotcom-rendering/src/model/block-schema.json +++ b/dotcom-rendering/src/model/block-schema.json @@ -2186,6 +2186,9 @@ }, "duration": { "type": "number" + }, + "videoPlayerFormat": { + "$ref": "#/definitions/VideoPlayerFormat" } }, "required": [ @@ -2195,6 +2198,14 @@ "id" ] }, + "VideoPlayerFormat": { + "enum": [ + "Cinemagraph", + "Default", + "Loop" + ], + "type": "string" + }, "MiniProfilesBlockElement": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 37b0274f15c..4b4e6792935 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -1,5 +1,6 @@ import { type CrosswordProps } from '@guardian/react-crossword'; import type { ArticleFormat } from '../lib/articleFormat'; +import type { VideoPlayerFormat } from './mainMedia'; export type StarRating = 0 | 1 | 2 | 3 | 4 | 5; @@ -423,7 +424,7 @@ export interface MapBlockElement extends ThirdPartyEmbeddedContent { role?: RoleType; } -interface MediaAtomBlockElement { +export interface MediaAtomBlockElement { _type: 'model.dotcomrendering.pageElements.MediaAtomBlockElement'; elementId: string; id: string; @@ -434,6 +435,7 @@ interface MediaAtomBlockElement { }[]; title?: string; duration?: number; + videoPlayerFormat?: VideoPlayerFormat; } export interface MultiImageBlockElement { @@ -939,7 +941,7 @@ export interface Image { url: string; } -interface VideoAssets { +export interface VideoAssets { url: string; mimeType?: string; fields?: { From 452600e1fff4a3730e3eb5b4b25eb3699d13938f Mon Sep 17 00:00:00 2001 From: Rik Roots Date: Wed, 26 Nov 2025 15:25:31 +0000 Subject: [PATCH 03/11] Show video icon in looping video caption --- dotcom-rendering/src/components/Caption.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotcom-rendering/src/components/Caption.tsx b/dotcom-rendering/src/components/Caption.tsx index d13f9be7cd9..85f8aa2d735 100644 --- a/dotcom-rendering/src/components/Caption.tsx +++ b/dotcom-rendering/src/components/Caption.tsx @@ -315,7 +315,7 @@ export const Caption = ({ ]} data-spacefinder-role="inline" > - {mediaType === 'YoutubeVideo' ? ( + {mediaType === 'YoutubeVideo' || mediaType === 'SelfHostedVideo' ? ( ) : ( From 5b3c6f03e8656dd1b367dc9d3e13624ed23e7673 Mon Sep 17 00:00:00 2001 From: Simon Byford Date: Tue, 2 Dec 2025 14:09:24 +0000 Subject: [PATCH 04/11] Remove unnecessary island --- dotcom-rendering/src/lib/renderElement.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 5661327fc76..a37d325c339 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -507,18 +507,11 @@ export const renderElement = ({ */ if (element.videoPlayerFormat === 'Loop') { return ( - <> - - - - + ); } else { return ( From 08835beb32da0183fa2f2497b7ce89b005f581d5 Mon Sep 17 00:00:00 2001 From: Simon Byford Date: Tue, 2 Dec 2025 14:09:52 +0000 Subject: [PATCH 05/11] Remove comment --- dotcom-rendering/src/lib/renderElement.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index a37d325c339..5fc3e29492b 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -500,10 +500,6 @@ export const renderElement = ({ - However they need to display in a different video player - We need to differentiate between the two forms of video - We can do this by interrogating the atom's metadata, which includes the new attribute `videoPlayerFormat` - - - Note: we'll probably extend this functionality to handle new 'Cinemagraph' videos - - These may use the looping video, or yet another new, video player - - But they will still be Media Atoms */ if (element.videoPlayerFormat === 'Loop') { return ( From 34639888b190c4520e880a7a7acdd1555c724509 Mon Sep 17 00:00:00 2001 From: Simon Byford Date: Tue, 2 Dec 2025 15:18:09 +0000 Subject: [PATCH 06/11] Use dimensions from video assets --- .../LoopVideoInArticle.importable.tsx | 16 ++++++++++++---- .../src/frontend/schemas/feArticle.json | 18 ++++++++++++++++++ dotcom-rendering/src/lib/video.ts | 6 ++++++ dotcom-rendering/src/model/block-schema.json | 18 ++++++++++++++++++ dotcom-rendering/src/types/content.ts | 5 +++++ 5 files changed, 59 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx b/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx index fdbde36d369..837936c33d0 100644 --- a/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx +++ b/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx @@ -1,5 +1,10 @@ +import type { FEAspectRatio } from '../frontend/feFront'; import type { ArticleFormat } from '../lib/articleFormat'; -import { convertAssetsToVideoSources, getSubtitleAsset } from '../lib/video'; +import { + convertAssetsToVideoSources, + getFirstVideoAsset, + getSubtitleAsset, +} from '../lib/video'; import type { MediaAtomBlockElement } from '../types/content'; import { Caption } from './Caption'; import { SelfHostedVideo } from './SelfHostedVideo.importable'; @@ -17,6 +22,7 @@ export const LoopVideoInArticle = ({ }: LoopVideoInArticleProps) => { const posterImageUrl = element.posterImage?.[0]?.url; const caption = element.title; + const firstVideoAsset = getFirstVideoAsset(element.assets); if (!posterImageUrl) { return null; @@ -28,10 +34,12 @@ export const LoopVideoInArticle = ({ atomId={element.id} fallbackImage={posterImageUrl} fallbackImageAlt={caption} - fallbackImageAspectRatio="5:4" + fallbackImageAspectRatio={ + (firstVideoAsset?.aspectRatio ?? '5:4') as FEAspectRatio + } fallbackImageLoading="lazy" fallbackImageSize="small" - height={400} + height={firstVideoAsset?.dimensions?.height ?? 400} linkTo="Article-embed-MediaAtomBlockElement" posterImage={posterImageUrl} sources={convertAssetsToVideoSources(element.assets)} @@ -39,7 +47,7 @@ export const LoopVideoInArticle = ({ subtitleSource={getSubtitleAsset(element.assets)} videoStyle="Loop" uniqueId={element.id} - width={500} + width={firstVideoAsset?.dimensions?.width ?? 500} /> {!!caption && ( export const getSubtitleAsset = (assets: VideoAssets[]): string | undefined => assets.find((asset) => asset.mimeType === 'text/vtt')?.url; + +export const getFirstVideoAsset = ( + assets: VideoAssets[], +): VideoAssets | undefined => { + return assets.find((asset) => isSupportedMimeType(asset.mimeType)); +}; diff --git a/dotcom-rendering/src/model/block-schema.json b/dotcom-rendering/src/model/block-schema.json index c09fad06027..3911d9b1c47 100644 --- a/dotcom-rendering/src/model/block-schema.json +++ b/dotcom-rendering/src/model/block-schema.json @@ -1598,6 +1598,24 @@ "mimeType": { "type": "string" }, + "dimensions": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "height", + "width" + ] + }, + "aspectRatio": { + "type": "string" + }, "fields": { "type": "object", "properties": { diff --git a/dotcom-rendering/src/types/content.ts b/dotcom-rendering/src/types/content.ts index 4b4e6792935..51f2fc7cf2f 100644 --- a/dotcom-rendering/src/types/content.ts +++ b/dotcom-rendering/src/types/content.ts @@ -944,6 +944,11 @@ export interface Image { export interface VideoAssets { url: string; mimeType?: string; + dimensions?: { + width: number; + height: number; + }; + aspectRatio?: string; fields?: { source?: string; embeddable?: string; From 86ee691ed470230f23666cd9b31d27c41dcd6a4d Mon Sep 17 00:00:00 2001 From: Rik Roots Date: Fri, 5 Dec 2025 11:05:50 +0000 Subject: [PATCH 07/11] Restore Island --- ...pVideoInArticle.importable.tsx => LoopVideoInArticle.tsx} | 5 +++-- dotcom-rendering/src/lib/renderElement.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) rename dotcom-rendering/src/components/{LoopVideoInArticle.importable.tsx => LoopVideoInArticle.tsx} (93%) diff --git a/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx b/dotcom-rendering/src/components/LoopVideoInArticle.tsx similarity index 93% rename from dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx rename to dotcom-rendering/src/components/LoopVideoInArticle.tsx index 837936c33d0..57839301f9d 100644 --- a/dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx +++ b/dotcom-rendering/src/components/LoopVideoInArticle.tsx @@ -8,6 +8,7 @@ import { import type { MediaAtomBlockElement } from '../types/content'; import { Caption } from './Caption'; import { SelfHostedVideo } from './SelfHostedVideo.importable'; +import { Island } from './Island'; type LoopVideoInArticleProps = { element: MediaAtomBlockElement; @@ -29,7 +30,7 @@ export const LoopVideoInArticle = ({ } return ( - <> + )} - + ); }; diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 5fc3e29492b..560654a10f0 100644 --- a/dotcom-rendering/src/lib/renderElement.tsx +++ b/dotcom-rendering/src/lib/renderElement.tsx @@ -29,7 +29,7 @@ import { Island } from '../components/Island'; import { ItemLinkBlockElement } from '../components/ItemLinkBlockElement'; import { KeyTakeaways } from '../components/KeyTakeaways'; import { KnowledgeQuizAtom } from '../components/KnowledgeQuizAtom.importable'; -import { LoopVideoInArticle } from '../components/LoopVideoInArticle.importable'; +import { LoopVideoInArticle } from '../components/LoopVideoInArticle'; import { MainMediaEmbedBlockComponent } from '../components/MainMediaEmbedBlockComponent'; import { MapEmbedBlockComponent } from '../components/MapEmbedBlockComponent.importable'; import { MiniProfiles } from '../components/MiniProfiles'; From 97d2bba76b238601205a5c1d9478c5b26583f873 Mon Sep 17 00:00:00 2001 From: Rik Roots Date: Fri, 5 Dec 2025 11:07:22 +0000 Subject: [PATCH 08/11] Restore Island --- .../src/components/LoopVideoInArticle.tsx | 54 ++++++++++--------- 1 file changed, 28 insertions(+), 26 deletions(-) diff --git a/dotcom-rendering/src/components/LoopVideoInArticle.tsx b/dotcom-rendering/src/components/LoopVideoInArticle.tsx index 57839301f9d..cde5a2d7adc 100644 --- a/dotcom-rendering/src/components/LoopVideoInArticle.tsx +++ b/dotcom-rendering/src/components/LoopVideoInArticle.tsx @@ -31,33 +31,35 @@ export const LoopVideoInArticle = ({ return ( - - {!!caption && ( - + - )} + {!!caption && ( + + )} + ); }; From 5fc9838e9efb1346908f13eae7f5f175db2f8842 Mon Sep 17 00:00:00 2001 From: Rik Roots Date: Fri, 5 Dec 2025 12:09:06 +0000 Subject: [PATCH 09/11] Regenerate schemas --- dotcom-rendering/src/frontend/schemas/feArticle.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 0451e92c8be..4ba32f48d95 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -5538,14 +5538,6 @@ } ] }, - "VideoPlayerFormat": { - "enum": [ - "Cinemagraph", - "Default", - "Loop" - ], - "type": "string" - }, "SupportedVideoFileType": { "enum": [ "application/vnd.apple.mpegurl", From 3e8a6f3411d99991dcc95656685e3090d7575816 Mon Sep 17 00:00:00 2001 From: Rik Roots Date: Fri, 5 Dec 2025 13:49:17 +0000 Subject: [PATCH 10/11] Remove DPR fix --- .../src/components/SelfHostedVideo.importable.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index 44efce83398..428b034afd2 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -83,8 +83,8 @@ const dispatchOphanAttentionEvent = ( document.dispatchEvent(event); }; -const getOptimisedPosterImage = (mainImage: string, dpr: number): string => { - const resolution = dpr >= 2 ? 'high' : 'low'; +const getOptimisedPosterImage = (mainImage: string): string => { + const resolution = window.devicePixelRatio >= 2 ? 'high' : 'low'; return generateImageURL({ mainImage, @@ -188,8 +188,6 @@ export const SelfHostedVideo = ({ const [hasBeenPlayed, setHasBeenPlayed] = useState(false); const [hasTrackedPlay, setHasTrackedPlay] = useState(false); - const [devicePixelRatio, setDevicePixelRatio] = useState(1); - const VISIBILITY_THRESHOLD = 0.5; /** @@ -365,8 +363,6 @@ export const SelfHostedVideo = ({ } }); - setDevicePixelRatio(window.devicePixelRatio); - return () => { document.removeEventListener( customSelfHostedVideoPlayAudioEventName, @@ -675,7 +671,7 @@ export const SelfHostedVideo = ({ const AudioIcon = isMuted ? SvgAudioMute : SvgAudio; const optimisedPosterImage = showPosterImage - ? getOptimisedPosterImage(posterImage, devicePixelRatio) + ? getOptimisedPosterImage(posterImage) : undefined; return ( From 0517e37c5c05e939c55531082ae47a77c65a9a02 Mon Sep 17 00:00:00 2001 From: Simon Byford Date: Fri, 5 Dec 2025 14:43:23 +0000 Subject: [PATCH 11/11] Rearrange component --- .../src/components/LoopVideoInArticle.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/dotcom-rendering/src/components/LoopVideoInArticle.tsx b/dotcom-rendering/src/components/LoopVideoInArticle.tsx index 63b968c3587..e3719db2928 100644 --- a/dotcom-rendering/src/components/LoopVideoInArticle.tsx +++ b/dotcom-rendering/src/components/LoopVideoInArticle.tsx @@ -30,8 +30,8 @@ export const LoopVideoInArticle = ({ } return ( - -
+ <> + - {!!caption && ( - - )} -
-
+ + {!!caption && ( + + )} + ); };