Skip to content
Open
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
54 changes: 54 additions & 0 deletions dotcom-rendering/src/components/LoopVideoInArticle.importable.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<SelfHostedVideo
atomId={element.id}
fallbackImage={posterImageUrl}
fallbackImageAlt={caption}
fallbackImageAspectRatio="5:4"
fallbackImageLoading="lazy"
fallbackImageSize="small"
height={400}
linkTo="Article-embed-MediaAtomBlockElement"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this magic string @RikRootsGuardian ?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be considered. It's used for tracking events eg when video first comes into view. This was developed with Fronts in mind, so I don't know if that tracking is useful for progression through the article? I put it in as a hardcoded text, but maybe a better approach would be to use the article URL here?

posterImage={posterImageUrl}
sources={convertAssetsToVideoSources(element.assets)}
subtitleSize="medium"
subtitleSource={getSubtitleAsset(element.assets)}
videoStyle="Loop"
uniqueId={element.id}
width={500}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it definitely the case that looping videos will always be 5:4?

As it stands, videos with other aspect ratios will get cropped:

Image

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looping videos are 5:4, as mandated by Editorial and Design. All other videos currently displaying in articles (youtube + legacy self-hosted) play in 16:9. There's been no consideration of how to display 4:5 or 9:16 videos in Articles (at least we've not been told of these plans). Similarly no news on the aspect ratios of the new "Cinemascope" videos. There's also a wider issue around moving normal images to 5:4 AR in articles. This is where we stare at Calvin and ask for guidance.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looping videos are 5:4, as mandated by Editorial and Design

Cool, this gives me confidence 👍 thanks!

/>
{!!caption && (
<Caption
captionText={caption}
format={format}
isMainMedia={isMainMedia}
mediaType="SelfHostedVideo"
/>
)}
</>
);
};
10 changes: 7 additions & 3 deletions dotcom-rendering/src/components/SelfHostedVideo.importable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,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,
Expand Down Expand Up @@ -190,6 +190,8 @@ export const SelfHostedVideo = ({
const [hasBeenPlayed, setHasBeenPlayed] = useState(false);
const [hasTrackedPlay, setHasTrackedPlay] = useState(false);

const [devicePixelRatio, setDevicePixelRatio] = useState(1);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Introducing state feels a bit unnecessary here, what was your thinking?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bug in the new LoopVideo player. The bug tries to access the browser window environment for the DPR value before the element has been mounted. So I decided to move the value to state and do the window check in a useUpdate hook. Thus once we get the correct DPR the component will rectify itself.


const VISIBILITY_THRESHOLD = 0.5;

/**
Expand Down Expand Up @@ -365,6 +367,8 @@ export const SelfHostedVideo = ({
}
});

setDevicePixelRatio(window.devicePixelRatio);

return () => {
document.removeEventListener(
customSelfHostedVideoPlayAudioEventName,
Expand Down Expand Up @@ -673,7 +677,7 @@ export const SelfHostedVideo = ({
const AudioIcon = isMuted ? SvgAudioMute : SvgAudio;

const optimisedPosterImage = showPosterImage
? getOptimisedPosterImage(posterImage)
? getOptimisedPosterImage(posterImage, devicePixelRatio)
: undefined;

return (
Expand Down
19 changes: 11 additions & 8 deletions dotcom-rendering/src/frontend/schemas/feArticle.json
Original file line number Diff line number Diff line change
Expand Up @@ -2698,6 +2698,9 @@
},
"duration": {
"type": "number"
},
"videoPlayerFormat": {
"$ref": "#/definitions/VideoPlayerFormat"
}
},
"required": [
Expand All @@ -2707,6 +2710,14 @@
"id"
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"MiniProfilesBlockElement": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -5510,14 +5521,6 @@
}
]
},
"VideoPlayerFormat": {
"enum": [
"Cinemagraph",
"Default",
"Loop"
],
"type": "string"
},
"Audio": {
"allOf": [
{
Expand Down
50 changes: 41 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.importable';
import { MainMediaEmbedBlockComponent } from '../components/MainMediaEmbedBlockComponent';
import { MapEmbedBlockComponent } from '../components/MapEmbedBlockComponent.importable';
import { MiniProfiles } from '../components/MiniProfiles';
Expand Down Expand Up @@ -490,15 +491,46 @@ 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`

- 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 (
<>
<Island
priority="critical"
defer={{ until: 'visible' }}
>
<LoopVideoInArticle
element={element}
format={format}
isMainMedia={isMainMedia}
/>
</Island>
</>
);
} 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
27 changes: 27 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 All @@ -22,3 +24,28 @@ export const supportedVideoFileTypes = [
] as const;

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

interface VideoAssets {
export interface VideoAssets {
url: string;
mimeType?: string;
fields?: {
Expand Down
Loading