From c02c6d4ac89c31b5b83da03770ba0df5cf5dcd01 Mon Sep 17 00:00:00 2001 From: Dominik Lander Date: Tue, 2 Dec 2025 11:59:57 +0000 Subject: [PATCH 1/2] Re-enable HLS --- .../components/SelfHostedVideo.stories.tsx | 52 +++++++++---------- .../src/frontend/schemas/feArticle.json | 11 +++- .../src/frontend/schemas/feFront.json | 11 +++- dotcom-rendering/src/lib/video.ts | 7 +-- .../src/model/enhanceCards.test.ts | 7 +-- 5 files changed, 47 insertions(+), 41 deletions(-) diff --git a/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx b/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx index 1a993222bf5..51a07677a85 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.stories.tsx @@ -38,26 +38,26 @@ export const Loop5to4: Story = { 'https://media.guim.co.uk/9bdb802e6da5d3fd249b5060f367b3a817965f0c/0_0_1800_1080/master/1800.jpg', fallbackImage: '', }, -}; - -// export const WithM3U8File: Story = { -// name: 'With M3U8 file', -// args: { -// ...Default.args, -// sources: [ -// { -// src: 'https://uploads.guimcode.co.uk/2025/09/01/Loop__Japan_fireball--ace3fcf6-1378-41db-9d21-f3fc07072ab2-1.10.m3u8', -// mimeType: 'application/x-mpegURL', -// }, -// { -// src: 'https://uploads.guim.co.uk/2025%2F06%2F20%2Ftesting+only%2C+please+ignore--3cb22b60-2c3f-48d6-8bce-38c956907cce-3.mp4', -// mimeType: 'video/mp4', -// }, -// ], -// }, -// }; - -export const Loop16to9: Story = { +} satisfies Story; + +export const WithM3U8File = { + name: 'With M3U8 file', + args: { + ...Loop5to4.args, + sources: [ + { + src: 'https://uploads.guimcode.co.uk/2025/09/01/Loop__Japan_fireball--ace3fcf6-1378-41db-9d21-f3fc07072ab2-1.10.m3u8', + mimeType: 'application/x-mpegURL', + }, + { + src: 'https://uploads.guim.co.uk/2025%2F06%2F20%2Ftesting+only%2C+please+ignore--3cb22b60-2c3f-48d6-8bce-38c956907cce-3.mp4', + mimeType: 'video/mp4', + }, + ], + }, +} satisfies Story; + +export const Loop16to9 = { name: 'Looping video in 16:9 aspect ratio', args: { ...Loop5to4.args, @@ -70,14 +70,14 @@ export const Loop16to9: Story = { height: 1080, width: 1920, }, -}; +} satisfies Story; -export const WithCinemagraph: Story = { +export const WithCinemagraph = { args: { ...Loop5to4.args, videoStyle: 'Cinemagraph', }, -}; +} satisfies Story; export const PausePlay: Story = { ...Loop5to4, @@ -98,7 +98,7 @@ export const PausePlay: Story = { await userEvent.click(videoEl); await expect(canvas.queryByTestId('play-icon')).not.toBeInTheDocument(); }, -}; +} satisfies Story; export const UnmuteMute: Story = { ...Loop5to4, @@ -120,7 +120,7 @@ export const UnmuteMute: Story = { await userEvent.click(canvas.getByTestId('mute-icon')); await canvas.findByTestId('unmute-icon'); }, -}; +} satisfies Story; // Function to emulate pausing between interactions function sleep(ms: number) { @@ -150,4 +150,4 @@ export const InteractionObserver: Story = { await expect(Number(progressBar.ariaValueNow)).toEqual(progress); await expect(canvas.queryByTestId('play-icon')).not.toBeInTheDocument(); }, -}; +} satisfies Story; diff --git a/dotcom-rendering/src/frontend/schemas/feArticle.json b/dotcom-rendering/src/frontend/schemas/feArticle.json index 03ee010d1cd..d1eb900af54 100644 --- a/dotcom-rendering/src/frontend/schemas/feArticle.json +++ b/dotcom-rendering/src/frontend/schemas/feArticle.json @@ -5472,8 +5472,7 @@ "type": "string" }, "mimeType": { - "type": "string", - "const": "video/mp4" + "$ref": "#/definitions/SupportedVideoFileType" } }, "required": [ @@ -5518,6 +5517,14 @@ ], "type": "string" }, + "SupportedVideoFileType": { + "enum": [ + "application/vnd.apple.mpegurl", + "application/x-mpegURL", + "video/mp4" + ], + "type": "string" + }, "Audio": { "allOf": [ { diff --git a/dotcom-rendering/src/frontend/schemas/feFront.json b/dotcom-rendering/src/frontend/schemas/feFront.json index 8f1e4993a04..5c57e8fe986 100644 --- a/dotcom-rendering/src/frontend/schemas/feFront.json +++ b/dotcom-rendering/src/frontend/schemas/feFront.json @@ -3967,8 +3967,7 @@ "type": "string" }, "mimeType": { - "type": "string", - "const": "video/mp4" + "$ref": "#/definitions/SupportedVideoFileType" } }, "required": [ @@ -4013,6 +4012,14 @@ ], "type": "string" }, + "SupportedVideoFileType": { + "enum": [ + "application/vnd.apple.mpegurl", + "application/x-mpegURL", + "video/mp4" + ], + "type": "string" + }, "Audio": { "allOf": [ { diff --git a/dotcom-rendering/src/lib/video.ts b/dotcom-rendering/src/lib/video.ts index 757fcc778f0..7444f8f6ad3 100644 --- a/dotcom-rendering/src/lib/video.ts +++ b/dotcom-rendering/src/lib/video.ts @@ -11,13 +11,10 @@ export type Source = { /** * Order is important here - the browser will use the first type it supports. - * 'application/x-mpegURL' & 'application/vnd.apple.mpegurl' have been filtered out - * whilst a hls chrome bug is investigated - * https://issues.chromium.org/issues/454630434 */ export const supportedVideoFileTypes = [ - // 'application/x-mpegURL', // HLS format - // 'application/vnd.apple.mpegurl', // Alternative HLS format + 'application/x-mpegURL', // HLS format + 'application/vnd.apple.mpegurl', // Alternative HLS format 'video/mp4', // MP4 format ] as const; diff --git a/dotcom-rendering/src/model/enhanceCards.test.ts b/dotcom-rendering/src/model/enhanceCards.test.ts index 1a3070a1658..fa8d13dd44e 100644 --- a/dotcom-rendering/src/model/enhanceCards.test.ts +++ b/dotcom-rendering/src/model/enhanceCards.test.ts @@ -1,12 +1,7 @@ import type { FEMediaAsset, FEMediaAtom } from '../frontend/feFront'; import { getActiveMediaAtom } from './enhanceCards'; -/** - * Why has this test suite been skipped? - * - * M3U8s have been disabled whilst a chrome hls bug is investigated. - */ -describe.skip('Enhance Cards', () => { +describe('Enhance Cards', () => { it('prioritises m3u8 assets over MP4 assets', () => { const videoReplace = true; const assets: FEMediaAsset[] = [ From baf349bd421e13805cdd526466bc35ecd3c33881 Mon Sep 17 00:00:00 2001 From: Dominik Lander Date: Wed, 3 Dec 2025 13:06:40 +0000 Subject: [PATCH 2/2] Wrap logic in switch --- dotcom-rendering/src/components/Card/Card.tsx | 3 ++ .../src/components/DecideContainer.tsx | 4 +++ .../src/components/FlexibleGeneral.tsx | 14 +++++++++ .../src/components/FlexibleSpecial.tsx | 7 +++++ .../components/SelfHostedVideo.importable.tsx | 3 ++ .../src/components/SelfHostedVideoPlayer.tsx | 10 ++++-- dotcom-rendering/src/layouts/FrontLayout.tsx | 14 ++++----- dotcom-rendering/src/lib/video.test.ts | 31 +++++++++++++++++++ dotcom-rendering/src/lib/video.ts | 7 +++++ .../src/model/enhanceCards.test.ts | 7 +++-- 10 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 dotcom-rendering/src/lib/video.test.ts diff --git a/dotcom-rendering/src/components/Card/Card.tsx b/dotcom-rendering/src/components/Card/Card.tsx index bc7fd4ddb09..3b0a0610505 100644 --- a/dotcom-rendering/src/components/Card/Card.tsx +++ b/dotcom-rendering/src/components/Card/Card.tsx @@ -163,6 +163,7 @@ export type Props = { headlinePosition?: 'inner' | 'outer'; /** Feature flag for the labs redesign work */ showLabsRedesign?: boolean; + enableHls?: boolean; }; const starWrapper = (cardHasImage: boolean) => css` @@ -420,6 +421,7 @@ export const Card = ({ headlinePosition = 'inner', showLabsRedesign = false, subtitleSize = 'small', + enableHls = false, }: Props) => { const hasSublinks = supportingContent && supportingContent.length > 0; const sublinkPosition = decideSublinkPosition( @@ -978,6 +980,7 @@ export const Card = ({ media.mainMedia.subtitleSource } subtitleSize={subtitleSize} + enableHls={enableHls} /> )} diff --git a/dotcom-rendering/src/components/DecideContainer.tsx b/dotcom-rendering/src/components/DecideContainer.tsx index 0a948ef639a..ba558aa0b93 100644 --- a/dotcom-rendering/src/components/DecideContainer.tsx +++ b/dotcom-rendering/src/components/DecideContainer.tsx @@ -49,6 +49,7 @@ type Props = { containerLevel?: DCRContainerLevel; /** Feature flag for the labs redesign work */ showLabsRedesign?: boolean; + enableHls?: boolean; }; export const DecideContainer = ({ @@ -65,6 +66,7 @@ export const DecideContainer = ({ collectionId, containerLevel, showLabsRedesign = false, + enableHls = false, }: Props) => { switch (containerType) { case 'dynamic/fast': @@ -248,6 +250,7 @@ export const DecideContainer = ({ aspectRatio={aspectRatio} collectionId={collectionId} showLabsRedesign={!!showLabsRedesign} + enableHls={enableHls} /> ); case 'flexible/general': @@ -262,6 +265,7 @@ export const DecideContainer = ({ containerLevel={containerLevel} collectionId={collectionId} showLabsRedesign={!!showLabsRedesign} + enableHls={enableHls} /> ); case 'scrollable/small': diff --git a/dotcom-rendering/src/components/FlexibleGeneral.tsx b/dotcom-rendering/src/components/FlexibleGeneral.tsx index db71a598825..7223b670c3a 100644 --- a/dotcom-rendering/src/components/FlexibleGeneral.tsx +++ b/dotcom-rendering/src/components/FlexibleGeneral.tsx @@ -35,6 +35,7 @@ type Props = { collectionId: number; /** Feature flag for the labs redesign work */ showLabsRedesign?: boolean; + enableHls?: boolean; }; type RowLayout = 'oneCardHalfWidth' | 'oneCardFullWidth' | 'twoCard'; @@ -256,6 +257,7 @@ type SplashCardLayoutProps = { collectionId: number; /** Feature flag for the labs redesign work */ showLabsRedesign?: boolean; + enableHls?: boolean; }; const SplashCardLayout = ({ @@ -269,6 +271,7 @@ const SplashCardLayout = ({ containerLevel, collectionId, showLabsRedesign, + enableHls, }: SplashCardLayoutProps) => { const card = cards[0]; if (!card) return null; @@ -354,6 +357,7 @@ const SplashCardLayout = ({ subtitleSize={subtitleSize} headlinePosition={card.showLivePlayable ? 'outer' : 'inner'} showLabsRedesign={showLabsRedesign} + enableHls={enableHls} /> @@ -421,6 +425,7 @@ type FullWidthCardLayoutProps = { collectionId: number; /** Feature flag for the labs redesign work */ showLabsRedesign?: boolean; + enableHls?: boolean; }; const FullWidthCardLayout = ({ @@ -435,6 +440,7 @@ const FullWidthCardLayout = ({ containerLevel, collectionId, showLabsRedesign, + enableHls, }: FullWidthCardLayoutProps) => { const card = cards[0]; if (!card) return null; @@ -511,6 +517,7 @@ const FullWidthCardLayout = ({ showKickerImage={card.format.design === ArticleDesign.Audio} showLabsRedesign={showLabsRedesign} subtitleSize={subtitleSize} + enableHls={enableHls} /> @@ -530,6 +537,7 @@ type HalfWidthCardLayoutProps = { containerLevel: DCRContainerLevel; /** Feature flag for the labs redesign work */ showLabsRedesign?: boolean; + enableHls?: boolean; }; const HalfWidthCardLayout = ({ @@ -544,6 +552,7 @@ const HalfWidthCardLayout = ({ isLastRow, containerLevel, showLabsRedesign, + enableHls, }: HalfWidthCardLayoutProps) => { if (cards.length === 0) return null; @@ -599,6 +608,7 @@ const HalfWidthCardLayout = ({ headlineSizes={undefined} canPlayInline={false} showLabsRedesign={showLabsRedesign} + enableHls={enableHls} /> ); @@ -617,6 +627,7 @@ export const FlexibleGeneral = ({ containerLevel = 'Primary', collectionId, showLabsRedesign, + enableHls, }: Props) => { const splash = [...groupedTrails.splash].slice(0, 1).map((snap) => ({ ...snap, @@ -646,6 +657,7 @@ export const FlexibleGeneral = ({ containerLevel={containerLevel} collectionId={collectionId} showLabsRedesign={showLabsRedesign} + enableHls={enableHls} /> )} {groupedCards.map((row, i) => { @@ -665,6 +677,7 @@ export const FlexibleGeneral = ({ containerLevel={containerLevel} collectionId={collectionId} showLabsRedesign={showLabsRedesign} + enableHls={enableHls} /> ); @@ -685,6 +698,7 @@ export const FlexibleGeneral = ({ isLastRow={i === groupedCards.length - 1} containerLevel={containerLevel} showLabsRedesign={showLabsRedesign} + enableHls={enableHls} /> ); } diff --git a/dotcom-rendering/src/components/FlexibleSpecial.tsx b/dotcom-rendering/src/components/FlexibleSpecial.tsx index ef6490421a2..9f360e5be48 100644 --- a/dotcom-rendering/src/components/FlexibleSpecial.tsx +++ b/dotcom-rendering/src/components/FlexibleSpecial.tsx @@ -32,6 +32,7 @@ type Props = { containerLevel?: DCRContainerLevel; collectionId: number; showLabsRedesign?: boolean; + enableHls?: boolean; }; type BoostProperties = { @@ -134,6 +135,7 @@ type OneCardLayoutProps = { containerLevel: DCRContainerLevel; isSplashCard?: boolean; showLabsRedesign?: boolean; + enableHls?: boolean; }; export const OneCardLayout = ({ @@ -148,6 +150,7 @@ export const OneCardLayout = ({ containerLevel, isSplashCard, showLabsRedesign, + enableHls, }: OneCardLayoutProps) => { const card = cards[0]; if (!card) return null; @@ -202,6 +205,7 @@ export const OneCardLayout = ({ headlinePosition={isSplashCard ? 'outer' : 'inner'} showLabsRedesign={showLabsRedesign} subtitleSize={subtitleSize} + enableHls={enableHls} /> @@ -305,6 +309,7 @@ export const FlexibleSpecial = ({ containerLevel = 'Primary', collectionId, showLabsRedesign, + enableHls, }: Props) => { const snaps = [...groupedTrails.snap].slice(0, 1).map((snap) => ({ ...snap, @@ -334,6 +339,7 @@ export const FlexibleSpecial = ({ containerLevel={containerLevel} isSplashCard={false} showLabsRedesign={showLabsRedesign} + enableHls={enableHls} /> )} {isNonEmptyArray(splash) && ( @@ -349,6 +355,7 @@ export const FlexibleSpecial = ({ containerLevel={containerLevel} isSplashCard={true} showLabsRedesign={showLabsRedesign} + enableHls={enableHls} /> )} diff --git a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx index 5507e398d86..428b034afd2 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.importable.tsx @@ -142,6 +142,7 @@ type Props = { linkTo: string; subtitleSource?: string; subtitleSize: SubtitleSize; + enableHls: boolean; }; export const SelfHostedVideo = ({ @@ -160,6 +161,7 @@ export const SelfHostedVideo = ({ linkTo, subtitleSource, subtitleSize, + enableHls, }: Props) => { const adapted = useShouldAdapt(); const { renderingTarget } = useConfig(); @@ -709,6 +711,7 @@ export const SelfHostedVideo = ({ subtitleSource={subtitleSource} subtitleSize={subtitleSize} activeCue={activeCue} + enableHls={enableHls} /> diff --git a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx index 0ed3bea3a29..a3470fac9ce 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoPlayer.tsx @@ -14,7 +14,7 @@ import type { } from 'react'; import { forwardRef } from 'react'; import type { ActiveCue } from '../lib/useSubtitles'; -import type { Source } from '../lib/video'; +import { filterOutHlsSources, type Source } from '../lib/video'; import { palette } from '../palette'; import type { VideoPlayerFormat } from '../types/mainMedia'; import { narrowPlayIconWidth, PlayIcon } from './Card/components/PlayIcon'; @@ -126,6 +126,7 @@ type Props = { subtitleSize?: SubtitleSize; /* used in custom subtitle overlays */ activeCue?: ActiveCue | null; + enableHls: boolean; }; /** @@ -167,6 +168,7 @@ export const SelfHostedVideoPlayer = forwardRef( subtitleSource, subtitleSize, activeCue, + enableHls, }: Props, ref: React.ForwardedRef, ) => { @@ -185,6 +187,10 @@ export const SelfHostedVideoPlayer = forwardRef( showPlayIcon ? 'play' : 'pause' }-${atomId}`; + const filteredVideoSources = enableHls + ? sources + : filterOutHlsSources(sources); + return ( <> {/* eslint-disable-next-line jsx-a11y/media-has-caption -- Not all videos require captions. */} @@ -226,7 +232,7 @@ export const SelfHostedVideoPlayer = forwardRef( onKeyDown={handleKeyDown} onError={onError} > - {sources.map((source) => ( + {filteredVideoSources.map((source) => ( { export const FrontLayout = ({ front, NAV }: Props) => { const { config: { - isPaidContent, + abTests, hasPageSkin: hasPageSkinConfig, + isPaidContent, pageId, - abTests, + switches, }, editionId, } = front; @@ -145,7 +146,7 @@ export const FrontLayout = ({ front, NAV }: Props) => { * - the user is opted into the 0% server side test */ const showLabsRedesign = - !!front.config.switches.guardianLabsRedesign || + !!switches.guardianLabsRedesign || abTests.labsRedesignVariant === 'variant'; const fallbackAspectRatio = (collectionType: DCRContainerType) => { @@ -363,7 +364,7 @@ export const FrontLayout = ({ front, NAV }: Props) => { if ( collection.collectionType === 'news/most-popular' && !isPaidContent && - front.config.switches.mostViewedFronts + switches.mostViewedFronts ) { const deeplyReadData = showMostPopular ? front.deeplyRead @@ -609,6 +610,7 @@ export const FrontLayout = ({ front, NAV }: Props) => { collectionId={index + 1} containerLevel={collection.containerLevel} showLabsRedesign={showLabsRedesign} + enableHls={switches.enableHlsWeb} /> @@ -704,9 +706,7 @@ export const FrontLayout = ({ front, NAV }: Props) => { pageId={front.pressedPage.id} sectionId={front.config.section} shouldHideReaderRevenue={false} // never defined for fronts - remoteBannerSwitch={ - !!front.config.switches.remoteBanner - } + remoteBannerSwitch={!!switches.remoteBanner} tags={[]} // a front doesn't have tags /> diff --git a/dotcom-rendering/src/lib/video.test.ts b/dotcom-rendering/src/lib/video.test.ts new file mode 100644 index 00000000000..c08bbff8135 --- /dev/null +++ b/dotcom-rendering/src/lib/video.test.ts @@ -0,0 +1,31 @@ +import type { Source } from './video'; +import { filterOutHlsSources } from './video'; + +const testHlsSources: Source[] = [ + { + src: 'https://uploads.guim.co.uk/example-1.m3u8', + mimeType: 'application/x-mpegURL', + }, + { + src: 'https://uploads.guim.co.uk/example-2.m3u8', + mimeType: 'application/vnd.apple.mpegurl', + }, +]; + +const testMp4Source: Source = { + src: 'https://example.com/video.mp4', + mimeType: 'video/mp4', +}; + +describe('video', () => { + it('should filter out HLS sources', () => { + // Arrange + const sources: Source[] = [...testHlsSources, testMp4Source]; + + // Act + const filteredSources = filterOutHlsSources(sources); + + // Assert + expect(filteredSources).toEqual([testMp4Source]); + }); +}); diff --git a/dotcom-rendering/src/lib/video.ts b/dotcom-rendering/src/lib/video.ts index 7444f8f6ad3..5ae6e6395f1 100644 --- a/dotcom-rendering/src/lib/video.ts +++ b/dotcom-rendering/src/lib/video.ts @@ -18,4 +18,11 @@ export const supportedVideoFileTypes = [ 'video/mp4', // MP4 format ] as const; +export const filterOutHlsSources = (sources: Source[]): Source[] => + sources.filter( + (source) => + source.mimeType.toLowerCase() !== 'application/x-mpegurl' && + source.mimeType.toLowerCase() !== 'application/vnd.apple.mpegurl', + ); + export type SupportedVideoFileType = (typeof supportedVideoFileTypes)[number]; diff --git a/dotcom-rendering/src/model/enhanceCards.test.ts b/dotcom-rendering/src/model/enhanceCards.test.ts index fa8d13dd44e..beb4ad06763 100644 --- a/dotcom-rendering/src/model/enhanceCards.test.ts +++ b/dotcom-rendering/src/model/enhanceCards.test.ts @@ -40,7 +40,9 @@ describe('Enhance Cards', () => { duration: 15, height: 400, image: '', - type: 'LoopVideo', + type: 'SelfHostedVideo', + videoStyle: 'Loop', + subtitleSource: undefined, sources: [ { mimeType: 'application/x-mpegURL', @@ -100,8 +102,9 @@ describe('Enhance Cards', () => { duration: 15, height: 400, image: '', + type: 'SelfHostedVideo', + videoStyle: 'Loop', subtitleSource: 'https://guim-example.co.uk/atomID-1.vtt', - type: 'LoopVideo', sources: [ { mimeType: 'application/x-mpegURL',