Skip to content

Commit 709eccf

Browse files
authored
Merge pull request #14933 from guardian/doml/feature-card-cinemagraphs
Enable cinemagraphs on feature cards
2 parents ceeffc6 + 1dbeedd commit 709eccf

File tree

8 files changed

+295
-129
lines changed

8 files changed

+295
-129
lines changed

dotcom-rendering/src/components/FeatureCard.stories.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const cardProps: CardProps = {
3636
imageSize: 'feature',
3737
collectionId: 1,
3838
starRatingSize: 'medium',
39+
uniqueId: `collection-1-feature-0`,
3940
};
4041

4142
const aBasicLink = {
@@ -325,6 +326,10 @@ export const YoutubeVideoImmersive = {
325326
export const YoutubeVideoMainMedia = {
326327
args: {
327328
...YoutubeVideo.args,
329+
image: {
330+
src: 'https://media.guim.co.uk/4612af5f4667888fa697139cf570b6373d93a710/2446_345_3218_1931/master/3218.jpg',
331+
altText: 'alt text',
332+
},
328333
format: {
329334
...YoutubeVideo.args.format,
330335
design: ArticleDesign.Standard,
@@ -395,3 +400,69 @@ export const WithSublinksLabsImmersive = {
395400
...Immersive.args,
396401
},
397402
} satisfies Story;
403+
404+
/**
405+
* Loops look like cinemagraphs, as only cinemagraphs are currently supported in Feature Cards.
406+
*/
407+
export const WithSelfHostedLoopVideo = {
408+
args: {
409+
...cardProps,
410+
showVideo: true,
411+
mainMedia: {
412+
type: 'SelfHostedVideo',
413+
videoStyle: 'Loop',
414+
atomId: 'atom-id-123',
415+
sources: [
416+
{
417+
src: 'https://uploads.guim.co.uk/2025/11/27/4_5_Test--1d34df3e-8c92-4090-8bb6-d79fc7fb9467-1.0.mp4',
418+
mimeType: 'video/mp4',
419+
},
420+
],
421+
height: 720,
422+
width: 576,
423+
duration: 18,
424+
},
425+
},
426+
} satisfies Story;
427+
428+
export const WithSelfHostedCinemagraphVideo = {
429+
args: {
430+
...WithSelfHostedLoopVideo.args,
431+
showVideo: true,
432+
mainMedia: {
433+
...WithSelfHostedLoopVideo.args.mainMedia,
434+
videoStyle: 'Cinemagraph',
435+
},
436+
},
437+
} satisfies Story;
438+
439+
/**
440+
* Loops look like cinemagraphs, as only cinemagraphs are currently supported in Feature Cards.
441+
*/
442+
export const WithSelfHostedImmersiveLoopVideo = {
443+
args: {
444+
...WithSelfHostedLoopVideo.args,
445+
...Immersive.args,
446+
mainMedia: {
447+
...WithSelfHostedLoopVideo.args.mainMedia,
448+
sources: [
449+
{
450+
src: 'https://uploads.guim.co.uk/2025/11/27/5_3_Test--26763e61-c16b-4c10-8c16-3f11882da154-1.0.mp4',
451+
mimeType: 'video/mp4',
452+
},
453+
],
454+
height: 720,
455+
width: 1200,
456+
},
457+
},
458+
} satisfies Story;
459+
460+
export const WithSelfHostedImmersiveCinemagraphVideo = {
461+
args: {
462+
...WithSelfHostedImmersiveLoopVideo.args,
463+
mainMedia: {
464+
...WithSelfHostedImmersiveLoopVideo.args.mainMedia,
465+
videoStyle: 'Cinemagraph',
466+
},
467+
},
468+
} satisfies Story;

dotcom-rendering/src/components/FeatureCard.tsx

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import type {
2525
DCRFrontImage,
2626
DCRSupportingContent,
2727
} from '../types/front';
28-
import type { MainMedia, YoutubeVideo } from '../types/mainMedia';
28+
import type { CardMediaType } from '../types/layout';
29+
import type { MainMedia } from '../types/mainMedia';
2930
import { BrandingLabel } from './BrandingLabel';
3031
import { CardFooter } from './Card/components/CardFooter';
3132
import { CardLink } from './Card/components/CardLink';
@@ -41,6 +42,7 @@ import { FeatureCardCommentCount } from './FeatureCardCommentCount';
4142
import { FormatBoundary } from './FormatBoundary';
4243
import { Island } from './Island';
4344
import { Pill } from './Pill';
45+
import { SelfHostedVideo } from './SelfHostedVideo.importable';
4446
import { StarRating } from './StarRating/StarRating';
4547
import { StarRatingDeprecated } from './StarRating/StarRatingDeprecated';
4648
import { SupportingContent } from './SupportingContent';
@@ -49,17 +51,6 @@ import { YoutubeBlockComponent } from './YoutubeBlockComponent.importable';
4951

5052
export type Position = 'inner' | 'outer' | 'none';
5153

52-
type Media =
53-
| {
54-
type: 'picture';
55-
imageUrl: string;
56-
imageAltText?: string;
57-
}
58-
| {
59-
type: 'youtube-video';
60-
mainMedia: YoutubeVideo;
61-
};
62-
6354
const baseCardStyles = css`
6455
display: flex;
6556
flex-direction: column;
@@ -138,7 +129,7 @@ const immersiveOverlayContainerStyles = css`
138129
* 48px is to ensure the gradient does not render the content inaccessible.
139130
*/
140131
width: 268px;
141-
z-index: 1;
132+
z-index: ${getZIndex('feature-card-overlay')};
142133
}
143134
`;
144135

@@ -165,11 +156,14 @@ const overlayMaskGradientStyles = (angle: string) => css`
165156
);
166157
`;
167158
const overlayStyles = css`
159+
position: relative;
168160
display: flex;
169161
flex-direction: column;
170162
text-align: start;
171163
gap: ${space[1]}px;
172164
padding: 64px ${space[2]}px ${space[2]}px;
165+
/* Needs to be above self-hosted video */
166+
z-index: ${getZIndex('feature-card-overlay')};
173167
backdrop-filter: blur(12px) brightness(0.5);
174168
@supports not (backdrop-filter: blur(12px)) {
175169
background-color: ${transparentColour(sourcePalette.neutral[10], 0.7)};
@@ -264,7 +258,26 @@ const getMedia = ({
264258
imageAltText?: string;
265259
mainMedia?: MainMedia;
266260
showVideo?: boolean;
267-
}): Media | undefined => {
261+
}) => {
262+
if (mainMedia?.type === 'SelfHostedVideo' && showVideo) {
263+
let type: CardMediaType;
264+
switch (mainMedia.videoStyle) {
265+
case 'Loop':
266+
type = 'loop-video';
267+
break;
268+
case 'Cinemagraph':
269+
type = 'cinemagraph';
270+
break;
271+
default:
272+
type = 'default-video';
273+
}
274+
275+
return {
276+
type,
277+
mainMedia,
278+
} as const;
279+
}
280+
268281
if (mainMedia?.type === 'YoutubeVideo' && showVideo) {
269282
return {
270283
type: 'youtube-video',
@@ -328,8 +341,11 @@ export type Props = {
328341
* Note YouTube recommends a minimum width of 480px @see https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-youtube-player-size
329342
* At 300px or below, the player will begin to lose functionality e.g. volume controls being omitted.
330343
* Youtube requires a minimum width 200px.
344+
* Similarly for self-hosted videos, we shouldn't display videos in too small a container.
345+
* For example, subtitles will not be legible in too small a player.
331346
*/
332347
canPlayInline?: boolean;
348+
showVideo?: boolean;
333349
kickerText?: string;
334350
showPulsingDot?: boolean;
335351
starRating?: Rating;
@@ -354,13 +370,13 @@ export type Props = {
354370
* The highlights container above the header is 0, the first container below the header is 1, etc.
355371
*/
356372
collectionId: number;
373+
uniqueId: string;
357374
isNewsletter?: boolean;
358375
/**
359376
* An immersive feature card variant. It dictates that the card has a full width background image on
360377
* all breakpoints. It also dictates the the card change aspect ratio to 5:3 on desktop and 4:5 on mobile.
361378
*/
362379
isImmersive?: boolean;
363-
showVideo?: boolean;
364380
isStorylines?: boolean;
365381
isInStarRatingVariant?: boolean;
366382
starRatingSize: RatingSizeType;
@@ -381,6 +397,7 @@ export const FeatureCard = ({
381397
showClock,
382398
mainMedia,
383399
canPlayInline = false,
400+
showVideo = false,
384401
kickerText,
385402
showPulsingDot,
386403
dataLinkName,
@@ -396,9 +413,9 @@ export const FeatureCard = ({
396413
starRating,
397414
showQuotes,
398415
collectionId,
416+
uniqueId,
399417
isNewsletter = false,
400418
isImmersive = false,
401-
showVideo = false,
402419
isStorylines = false,
403420
isInStarRatingVariant,
404421
starRatingSize,
@@ -407,6 +424,10 @@ export const FeatureCard = ({
407424

408425
const isVideoArticle = format.design === ArticleDesign.Video;
409426

427+
/**
428+
* Determine which type of media to use for the card.
429+
* For example, a video might be available, but if we don't want to show it, use an image instead.
430+
*/
410431
const media = getMedia({
411432
imageUrl: image?.src,
412433
imageAltText: image?.altText,
@@ -419,6 +440,11 @@ export const FeatureCard = ({
419440

420441
const showCommentCount = discussionId !== undefined;
421442

443+
const isSelfHostedVideo =
444+
media?.type === 'loop-video' ||
445+
media?.type === 'default-video' ||
446+
media?.type === 'cinemagraph';
447+
422448
const labsDataAttributes = branding
423449
? getOphanComponents({
424450
branding,
@@ -507,7 +533,47 @@ export const FeatureCard = ({
507533
)};
508534
`}
509535
>
510-
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A PR to add self-hosted video is upcoming where this check will be needed. */}
536+
{isSelfHostedVideo && (
537+
<Island
538+
priority="critical"
539+
defer={{ until: 'visible' }}
540+
>
541+
<SelfHostedVideo
542+
sources={media.mainMedia.sources}
543+
atomId={media.mainMedia.atomId}
544+
uniqueId={uniqueId}
545+
height={media.mainMedia.height}
546+
width={media.mainMedia.width}
547+
containerAspectRatio={
548+
isImmersive ? 5 / 3 : 4 / 5
549+
}
550+
// Only cinemagraphs are currently supported in feature cards
551+
videoStyle="Cinemagraph"
552+
posterImage={
553+
media.mainMedia.image ?? ''
554+
}
555+
fallbackImage={
556+
media.mainMedia.image ?? ''
557+
}
558+
fallbackImageSize={imageSize}
559+
fallbackImageLoading={imageLoading}
560+
fallbackImageAlt={
561+
media.imageAltText
562+
}
563+
fallbackImageAspectRatio={
564+
isImmersive ? '5:3' : '4:5'
565+
}
566+
linkTo={linkTo}
567+
subtitleSource={
568+
media.mainMedia.subtitleSource
569+
}
570+
subtitleSize="large"
571+
enableHls={false}
572+
isFeatureCard={true}
573+
/>
574+
</Island>
575+
)}
576+
511577
{media.type === 'picture' && (
512578
<>
513579
<CardPicture
@@ -555,6 +621,11 @@ export const FeatureCard = ({
555621
overlayContainerStyles,
556622
isImmersive &&
557623
immersiveOverlayContainerStyles,
624+
// The whole card is clickable on cinemagraphs
625+
media.type === 'cinemagraph' &&
626+
css`
627+
pointer-events: none;
628+
`,
558629
]}
559630
>
560631
{mainMedia?.type === 'Audio' &&

0 commit comments

Comments
 (0)