Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotcom-rendering/src/components/Caption.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ export const Caption = ({
]}
data-spacefinder-role="inline"
>
{mediaType === 'YoutubeVideo' ? (
{mediaType === 'YoutubeVideo' || mediaType === 'SelfHostedVideo' ? (
<VideoIcon format={format} />
) : (
<CameraIcon format={format} />
Expand Down
66 changes: 66 additions & 0 deletions dotcom-rendering/src/components/LoopVideoInArticle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Island priority="critical" defer={{ until: 'visible' }}>
<SelfHostedVideo
atomId={element.id}
fallbackImage={posterImageUrl}
fallbackImageAlt={caption}
fallbackImageAspectRatio={
(firstVideoAsset?.aspectRatio ?? '5:4') as FEAspectRatio
}
fallbackImageLoading="lazy"
fallbackImageSize="small"
height={firstVideoAsset?.dimensions?.height ?? 400}
linkTo="Article-embed-MediaAtomBlockElement"
posterImage={posterImageUrl}
sources={convertAssetsToVideoSources(element.assets)}
subtitleSize="medium"
subtitleSource={getSubtitleAsset(element.assets)}
videoStyle="Loop"
uniqueId={element.id}
width={firstVideoAsset?.dimensions?.width ?? 500}
enableHls={false}
/>
</Island>
{!!caption && (
<Caption
captionText={caption}
format={format}
isMainMedia={isMainMedia}
mediaType="SelfHostedVideo"
/>
)}
</>
);
};
37 changes: 29 additions & 8 deletions dotcom-rendering/src/frontend/schemas/feArticle.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -2698,6 +2716,9 @@
},
"duration": {
"type": "number"
},
"videoPlayerFormat": {
"$ref": "#/definitions/VideoPlayerFormat"
}
},
"required": [
Expand All @@ -2707,6 +2728,14 @@
"id"
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"MiniProfilesBlockElement": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -5509,14 +5538,6 @@
}
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"SupportedVideoFileType": {
"enum": [
"application/vnd.apple.mpegurl",
Expand Down
39 changes: 30 additions & 9 deletions dotcom-rendering/src/lib/renderElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -490,15 +491,35 @@ export const renderElement = ({
</Island>
);
case 'model.dotcomrendering.pageElements.MediaAtomBlockElement':
return (
<VideoAtom
format={format}
assets={element.assets}
poster={element.posterImage?.[0]?.url}
caption={element.title}
isMainMedia={isMainMedia}
/>
);
/*
- 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 (
<LoopVideoInArticle
element={element}
format={format}
isMainMedia={isMainMedia}
/>
);
} else {
return (
<VideoAtom
format={format}
assets={element.assets}
poster={element.posterImage?.[0]?.url}
caption={element.title}
isMainMedia={isMainMedia}
/>
);
}
case 'model.dotcomrendering.pageElements.MiniProfilesBlockElement':
return (
<MiniProfiles
Expand Down
33 changes: 33 additions & 0 deletions dotcom-rendering/src/lib/video.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { VideoAssets } from '../types/content';

export type CustomPlayEventDetail = { uniqueId: string };

export const customSelfHostedVideoPlayAudioEventName =
Expand Down Expand Up @@ -26,3 +28,34 @@ export const filterOutHlsSources = (sources: Source[]): Source[] =>
);

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));
};
29 changes: 29 additions & 0 deletions dotcom-rendering/src/model/block-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -2186,6 +2204,9 @@
},
"duration": {
"type": "number"
},
"videoPlayerFormat": {
"$ref": "#/definitions/VideoPlayerFormat"
}
},
"required": [
Expand All @@ -2195,6 +2216,14 @@
"id"
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"MiniProfilesBlockElement": {
"type": "object",
"properties": {
Expand Down
11 changes: 9 additions & 2 deletions dotcom-rendering/src/types/content.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -434,6 +435,7 @@ interface MediaAtomBlockElement {
}[];
title?: string;
duration?: number;
videoPlayerFormat?: VideoPlayerFormat;
}

export interface MultiImageBlockElement {
Expand Down Expand Up @@ -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;
Expand Down
Loading