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' ? ( ) : ( diff --git a/dotcom-rendering/src/components/LoopVideoInArticle.tsx b/dotcom-rendering/src/components/LoopVideoInArticle.tsx new file mode 100644 index 00000000000..e3719db2928 --- /dev/null +++ b/dotcom-rendering/src/components/LoopVideoInArticle.tsx @@ -0,0 +1,66 @@ +import type { FEAspectRatio } from '../frontend/feFront'; +import type { ArticleFormat } from '../lib/articleFormat'; +import { + convertAssetsToVideoSources, + getFirstVideoAsset, + getSubtitleAsset, +} from '../lib/video'; +import type { MediaAtomBlockElement } from '../types/content'; +import { Caption } from './Caption'; +import { Island } from './Island'; +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; + const firstVideoAsset = getFirstVideoAsset(element.assets); + + 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 d1eb900af54..4ba32f48d95 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -2110,6 +2110,24 @@ "mimeType": { "type": "string" }, + "dimensions": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "height", + "width" + ] + }, + "aspectRatio": { + "type": "string" + }, "fields": { "type": "object", "properties": { @@ -2698,6 +2716,9 @@ }, "duration": { "type": "number" + }, + "videoPlayerFormat": { + "$ref": "#/definitions/VideoPlayerFormat" } }, "required": [ @@ -2707,6 +2728,14 @@ "id" ] }, + "VideoPlayerFormat": { + "enum": [ + "Cinemagraph", + "Default", + "Loop" + ], + "type": "string" + }, "MiniProfilesBlockElement": { "type": "object", "properties": { @@ -5509,14 +5538,6 @@ } ] }, - "VideoPlayerFormat": { - "enum": [ - "Cinemagraph", - "Default", - "Loop" - ], - "type": "string" - }, "SupportedVideoFileType": { "enum": [ "application/vnd.apple.mpegurl", diff --git a/dotcom-rendering/src/lib/renderElement.tsx b/dotcom-rendering/src/lib/renderElement.tsx index 5d28c63b295..560654a10f0 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'; import { MainMediaEmbedBlockComponent } from '../components/MainMediaEmbedBlockComponent'; import { MapEmbedBlockComponent } from '../components/MapEmbedBlockComponent.importable'; import { MiniProfiles } from '../components/MiniProfiles'; @@ -490,15 +491,35 @@ 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` + */ + if (element.videoPlayerFormat === 'Loop') { + return ( + + ); + } else { + return ( + + ); + } case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement': return ( ); export type SupportedVideoFileType = (typeof supportedVideoFileTypes)[number]; + +const isSupportedMimeType = ( + mime: string | undefined, +): mime is SupportedVideoFileType => { + 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; + +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 776b641d0c7..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": { @@ -2186,6 +2204,9 @@ }, "duration": { "type": "number" + }, + "videoPlayerFormat": { + "$ref": "#/definitions/VideoPlayerFormat" } }, "required": [ @@ -2195,6 +2216,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..51f2fc7cf2f 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,9 +941,14 @@ export interface Image { url: string; } -interface VideoAssets { +export interface VideoAssets { url: string; mimeType?: string; + dimensions?: { + width: number; + height: number; + }; + aspectRatio?: string; fields?: { source?: string; embeddable?: string;