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;