diff --git a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx index e3c4bd42029..2e33b999c3a 100644 --- a/dotcom-rendering/src/components/SelfHostedVideo.island.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideo.island.tsx @@ -19,6 +19,7 @@ import type { CustomPlayEventDetail, Source } from '../lib/video'; import { customSelfHostedVideoPlayAudioEventName, customYoutubePlayEventName, + findOptimisedSourcePerMimeType, } from '../lib/video'; import { palette } from '../palette'; import type { RoleType } from '../types/content'; @@ -312,6 +313,7 @@ export const SelfHostedVideo = ({ const [hasTrackedPlay, setHasTrackedPlay] = useState(false); const [width, setWidth] = useState(); const [height, setHeight] = useState(); + const [optimisedSources, setOptimisedSources] = useState(sources); const isWeb = renderingTarget === 'Web'; const isApps = renderingTarget === 'Apps'; @@ -428,12 +430,20 @@ export const SelfHostedVideo = ({ * Setup. * * 1. Determine whether we can autoplay video. + * 2. Use the best video size available for the user's screen size * 2. Initialise Ophan attention tracking. * 3. Creates event listeners to control playback when there are multiple videos. */ useEffect(() => { setIsAutoplayAllowed(doesUserPermitAutoplay()); + const screenWidth = window.innerWidth; + const filteredSources = findOptimisedSourcePerMimeType( + sources, + screenWidth, + ); + setOptimisedSources(filteredSources); + /** * Initialise Ophan attention tracking */ @@ -526,7 +536,7 @@ export const SelfHostedVideo = ({ handlePageBecomesVisible(); }); }; - }, [uniqueId, atomId, renderingTarget, ophanVideoStyle]); + }, [uniqueId, atomId, sources, renderingTarget, ophanVideoStyle]); /** * Track the first time the video comes into view. @@ -857,7 +867,7 @@ export const SelfHostedVideo = ({ )} > - , + ), parameters: { diff --git a/dotcom-rendering/src/components/SelfHostedVideoInArticle.tsx b/dotcom-rendering/src/components/SelfHostedVideoInArticle.tsx index 5b16f49da36..b84bd9bc30e 100644 --- a/dotcom-rendering/src/components/SelfHostedVideoInArticle.tsx +++ b/dotcom-rendering/src/components/SelfHostedVideoInArticle.tsx @@ -1,8 +1,8 @@ import type { FEAspectRatio } from '../frontend/feFront'; import type { ArticleFormat } from '../lib/articleFormat'; import { - convertAssetsToVideoSources, - DEFAULT_ASPECT_RATIO, + extractValidSourcesFromAssets, + getAspectRatioFromSources, getSubtitleAsset, } from '../lib/video'; import type { MediaAtomBlockElement, RoleType } from '../types/content'; @@ -28,7 +28,8 @@ export const SelfHostedVideoInArticle = ({ const posterImageUrl = element.posterImage?.[0]?.url; const caption = element.title; - const sources = convertAssetsToVideoSources(element.assets); + const sources = extractValidSourcesFromAssets(element.assets); + const aspectRatio = getAspectRatioFromSources(sources); const firstVideoSource = sources[0]; if (!posterImageUrl) { @@ -46,11 +47,7 @@ export const SelfHostedVideoInArticle = ({ } fallbackImageLoading="lazy" fallbackImageSize="small" - aspectRatio={ - firstVideoSource - ? firstVideoSource.width / firstVideoSource.height - : DEFAULT_ASPECT_RATIO - } + aspectRatio={aspectRatio} linkTo="Article-embed-MediaAtomBlockElement" posterImage={posterImageUrl} sources={sources} diff --git a/dotcom-rendering/src/frontend/schemas/feFootballMatchInfoPage.json b/dotcom-rendering/src/frontend/schemas/feFootballMatchInfoPage.json index 79c74eca8c3..6acf56db5e5 100644 --- a/dotcom-rendering/src/frontend/schemas/feFootballMatchInfoPage.json +++ b/dotcom-rendering/src/frontend/schemas/feFootballMatchInfoPage.json @@ -51,16 +51,16 @@ "matchInfo": { "anyOf": [ { - "$ref": "#/definitions/{type:\"LiveMatch\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"LiveMatch\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" }, { - "$ref": "#/definitions/{type:\"Fixture\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"Fixture\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}" }, { - "$ref": "#/definitions/{type:\"MatchDay\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"MatchDay\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}" }, { - "$ref": "#/definitions/{type:\"Result\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"Result\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" } ] }, @@ -975,16 +975,16 @@ "eventType" ] }, - "{type:\"LiveMatch\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { + "{id:string;type:\"LiveMatch\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "LiveMatch" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, @@ -1113,16 +1113,16 @@ "name" ] }, - "{type:\"Fixture\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}": { + "{id:string;type:\"Fixture\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "Fixture" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, @@ -1177,16 +1177,16 @@ "name" ] }, - "{type:\"MatchDay\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}": { + "{id:string;type:\"MatchDay\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "MatchDay" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, @@ -1256,16 +1256,16 @@ "type" ] }, - "{type:\"Result\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { + "{id:string;type:\"Result\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "Result" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, diff --git a/dotcom-rendering/src/frontend/schemas/feFootballMatchListPage.json b/dotcom-rendering/src/frontend/schemas/feFootballMatchListPage.json index a859e864a8c..8e19aa0b341 100644 --- a/dotcom-rendering/src/frontend/schemas/feFootballMatchListPage.json +++ b/dotcom-rendering/src/frontend/schemas/feFootballMatchListPage.json @@ -96,16 +96,16 @@ "items": { "anyOf": [ { - "$ref": "#/definitions/{type:\"LiveMatch\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"LiveMatch\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" }, { - "$ref": "#/definitions/{type:\"Fixture\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"Fixture\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}" }, { - "$ref": "#/definitions/{type:\"MatchDay\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"MatchDay\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}" }, { - "$ref": "#/definitions/{type:\"Result\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" + "$ref": "#/definitions/{id:string;type:\"Result\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}" } ] } @@ -709,16 +709,16 @@ "Record": { "type": "object" }, - "{type:\"LiveMatch\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { + "{id:string;type:\"LiveMatch\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};status:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "LiveMatch" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, @@ -847,16 +847,16 @@ "name" ] }, - "{type:\"Fixture\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}": { + "{id:string;type:\"Fixture\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};venue?:{name:string;id:string;};comments?:string;competition?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "Fixture" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, @@ -911,16 +911,16 @@ "name" ] }, - "{type:\"MatchDay\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}": { + "{id:string;type:\"MatchDay\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};liveMatch:boolean;result:boolean;previewAvailable:boolean;reportAvailable:boolean;lineupsAvailable:boolean;matchStatus:string;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};competition?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "MatchDay" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, @@ -990,16 +990,16 @@ "type" ] }, - "{type:\"Result\";id:string;stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { + "{id:string;type:\"Result\";stage:{stageNumber:string;};date:string;round:{roundNumber:string;name?:string;};leg:string;homeTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};awayTeam:{name:string;id:string;score?:number;htScore?:number;aggregateScore?:number;scorers?:string;};reportAvailable:boolean;venue?:{name:string;id:string;};comments?:string;attendance?:string;referee?:{name:string;id:string;};}": { "type": "object", "properties": { + "id": { + "type": "string" + }, "type": { "type": "string", "const": "Result" }, - "id": { - "type": "string" - }, "stage": { "$ref": "#/definitions/{stageNumber:string;}" }, diff --git a/dotcom-rendering/src/lib/video.test.ts b/dotcom-rendering/src/lib/video.test.ts index 5473ecbfd69..588f9a6ea1d 100644 --- a/dotcom-rendering/src/lib/video.test.ts +++ b/dotcom-rendering/src/lib/video.test.ts @@ -1,12 +1,18 @@ +import type { FEMediaAsset } from '../frontend/feFront'; import type { VideoAssets } from '../types/content'; +import { + convertFEMediaAssetsToVideoAssets, + extractValidSourcesFromAssets, + findOptimisedSourcePerMimeType, + getAspectRatioFromSources, +} from './video'; import type { Source } from './video'; -import { convertAssetsToVideoSources } from './video'; const mp4Asset480w: VideoAssets = { url: 'https://guim-example.co.uk/atomID-1_480w.mp4', mimeType: 'video/mp4', dimensions: { - height: 386, + height: 384, width: 480, }, aspectRatio: '5:4', @@ -17,17 +23,17 @@ const mp4Asset720h: VideoAssets = { mimeType: 'video/mp4', dimensions: { height: 720, - width: 898, + width: 900, }, aspectRatio: '5:4', }; -const m3u8Asset: VideoAssets = { +const m3u8Asset720h: VideoAssets = { url: 'https://guim-example.co.uk/atomID-1.m3u8', mimeType: 'application/x-mpegURL', dimensions: { height: 720, - width: 898, + width: 900, }, aspectRatio: '5:4', }; @@ -36,7 +42,7 @@ const unsupportedAsset: VideoAssets = { mimeType: 'video/quicktime', dimensions: { height: 720, - width: 898, + width: 900, }, aspectRatio: '5:4', }; @@ -44,43 +50,217 @@ const unsupportedAsset: VideoAssets = { const mp4Src480w: Source = { src: 'https://guim-example.co.uk/atomID-1_480w.mp4', mimeType: 'video/mp4', - height: 386, + height: 384, width: 480, aspectRatio: '5:4', }; - const mp4Src720h: Source = { src: 'https://guim-example.co.uk/atomID-1_720h.mp4', mimeType: 'video/mp4', height: 720, - width: 898, + width: 900, aspectRatio: '5:4', }; - -const m3u8Src: Source = { +const m3u8Src480w: Source = { + src: 'https://guim-example.co.uk/atomID-1.m3u8', + mimeType: 'application/x-mpegURL', + height: 384, + width: 480, + aspectRatio: '5:4', +}; +const m3u8Src720h: Source = { src: 'https://guim-example.co.uk/atomID-1.m3u8', mimeType: 'application/x-mpegURL', height: 720, - width: 898, + width: 900, aspectRatio: '5:4', }; -describe('convertAssetsToVideoSources', () => { - it('should drop unsupported assets', () => { - const assets = [mp4Asset480w, m3u8Asset, unsupportedAsset]; - const expected = [mp4Src480w, m3u8Src]; - expect(convertAssetsToVideoSources(assets)).toEqual(expected); +describe('video', () => { + describe('extractValidSourcesFromAssets', () => { + it('should drop unsupported assets', () => { + const assets = [mp4Asset480w, m3u8Asset720h, unsupportedAsset]; + const expected = [mp4Src480w, m3u8Src720h]; + expect(extractValidSourcesFromAssets(assets)).toEqual(expected); + }); + + it('should reorder sources by supportedVideoFileTypes order', () => { + const assets = [ + m3u8Asset720h, + mp4Asset480w, + m3u8Asset720h, + mp4Asset720h, + m3u8Asset720h, + ]; + const expected = [ + mp4Src480w, + mp4Src720h, + m3u8Src720h, + m3u8Src720h, + m3u8Src720h, + ]; + expect(extractValidSourcesFromAssets(assets)).toEqual(expected); + }); + }); + + describe('convertFEMediaAssetsToVideoAssets', () => { + const feMediaAsset480w: FEMediaAsset = { + id: 'https://guim-example.co.uk/atomID-1_480w.mp4', + version: 1, + platform: 'Url', + assetType: 'video', + mimeType: 'video/mp4', + dimensions: { + height: 384, + width: 480, + }, + }; + const feMediaAsset720h: FEMediaAsset = { + id: 'https://guim-example.co.uk/atomID-1_720h.mp4', + version: 1, + platform: 'Url', + assetType: 'video', + mimeType: 'video/mp4', + dimensions: { + height: 720, + width: 900, + }, + }; + + it('should convert FE media assets to video assets', () => { + expect( + convertFEMediaAssetsToVideoAssets([ + feMediaAsset480w, + feMediaAsset720h, + ]), + ).toEqual([ + { + url: 'https://guim-example.co.uk/atomID-1_480w.mp4', + mimeType: 'video/mp4', + dimensions: { + height: 384, + width: 480, + }, + }, + { + url: 'https://guim-example.co.uk/atomID-1_720h.mp4', + mimeType: 'video/mp4', + dimensions: { + height: 720, + width: 900, + }, + }, + ]); + }); + + it('should return an empty array when given an empty array', () => { + expect(convertFEMediaAssetsToVideoAssets([])).toEqual([]); + }); + }); + + describe('getAspectRatioFromSources', () => { + it('should return the aspect ratio from the first source if it is defined', () => { + const testSource: Source = { + ...mp4Src480w, + height: 720, + width: 480, + aspectRatio: '5:3', + }; + expect(getAspectRatioFromSources([testSource])).toEqual(5 / 3); + }); + + it('should calculate the aspect ratio from the width and height if aspect ratio is missing', () => { + const testSource: Source = { + ...mp4Src480w, + height: 720, + width: 480, + aspectRatio: undefined, + }; + expect(getAspectRatioFromSources([testSource])).toEqual(2 / 3); + }); + + it('should return the default aspect ratio if the aspect ratio is undefined and width is 0', () => { + const testSource: Source = { + ...mp4Src480w, + height: 720, + width: 0, + aspectRatio: undefined, + }; + expect(getAspectRatioFromSources([testSource])).toEqual(5 / 4); + }); + + it('should return the default aspect ratio if the aspect ratio is undefined and height is 0', () => { + const testSource: Source = { + ...mp4Src480w, + height: 0, + width: 480, + aspectRatio: undefined, + }; + expect(getAspectRatioFromSources([testSource])).toEqual(5 / 4); + }); }); - it('should reorder sources by supportedVideoFileTypes order and then by width', () => { - const assets = [ - m3u8Asset, - m3u8Asset, - mp4Asset480w, - m3u8Asset, - mp4Asset720h, + describe('findOptimisedSourcePerMimeType', () => { + const testSources: Source[] = [ + mp4Src480w, + mp4Src720h, + m3u8Src480w, + m3u8Src720h, ]; - const expected = [mp4Src720h, mp4Src480w, m3u8Src, m3u8Src, m3u8Src]; - expect(convertAssetsToVideoSources(assets)).toEqual(expected); + + it('selects the smaller videos when there are multiple and all are larger than the screen width.', () => { + const screenWidth = 400; + + const sources = findOptimisedSourcePerMimeType( + testSources, + screenWidth, + ); + + expect(sources).toEqual([mp4Src480w, m3u8Src480w]); + }); + + it('selects the larger videos when there are two and one is larger than the screen width and one is smaller.', () => { + const screenWidth = 600; + + const sources = findOptimisedSourcePerMimeType( + testSources, + screenWidth, + ); + + expect(sources).toEqual([mp4Src720h, m3u8Src720h]); + }); + + it('selects the larger videos when there are multiple and all are smaller than the screen width.', () => { + const screenWidth = 800; + + const sources = findOptimisedSourcePerMimeType( + testSources, + screenWidth, + ); + + expect(sources).toEqual([mp4Src720h, m3u8Src720h]); + }); + + it('selects the smaller videos when some are equal to the screen width and others are larger.', () => { + const screenWidth = 480; + + const sources = findOptimisedSourcePerMimeType( + testSources, + screenWidth, + ); + + expect(sources).toEqual([mp4Src480w, m3u8Src480w]); + }); + + it('selects the larger videos when some are equal to the screen width and others are smaller.', () => { + const screenWidth = 720; + + const sources = findOptimisedSourcePerMimeType( + testSources, + screenWidth, + ); + + expect(sources).toEqual([mp4Src720h, m3u8Src720h]); + }); }); }); diff --git a/dotcom-rendering/src/lib/video.ts b/dotcom-rendering/src/lib/video.ts index bb3c44d91b4..db0b8114d55 100644 --- a/dotcom-rendering/src/lib/video.ts +++ b/dotcom-rendering/src/lib/video.ts @@ -1,3 +1,4 @@ +import type { FEMediaAsset } from '../frontend/feFront'; import type { VideoAssets } from '../types/content'; export type CustomPlayEventDetail = { uniqueId: string }; @@ -28,37 +29,115 @@ export const supportedVideoFileTypes = [ 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'], - height: asset.dimensions?.height ?? 0, - width: asset.dimensions?.width ?? 0, - aspectRatio: asset.aspectRatio, - })) - .sort((a, b) => { - const typeOrder = - supportedVideoFileTypes.indexOf(a.mimeType) - - supportedVideoFileTypes.indexOf(b.mimeType); - /** Sort by type then by width */ - return typeOrder || Number(b.width) - Number(a.width); - }); +export const extractValidSourcesFromAssets = ( + assets: VideoAssets[], +): Source[] => + /** + * Ensure sources are ordered by the order that MIME types are specified in + * `supportedVideoFileTypes` as the browser picks the first one that it supports. + */ + supportedVideoFileTypes.reduce((acc, type) => { + const sourcesByType = assets.filter( + ({ mimeType }) => mimeType === type, + ); + if (sourcesByType.length) { + acc.push( + ...sourcesByType.map((asset) => ({ + src: asset.url, + mimeType: asset.mimeType as Source['mimeType'], + height: asset.dimensions?.height ?? 0, + width: asset.dimensions?.width ?? 0, + aspectRatio: asset.aspectRatio, + })), + ); + } + return acc; + }, []); + +export const convertFEMediaAssetsToVideoAssets = ( + assets: FEMediaAsset[], +): VideoAssets[] => + assets.map(({ id, mimeType, dimensions }) => ({ + url: id, + mimeType, + dimensions, + })); + +/** + * Aspect ratio is needed for self-hosted video so that the browser knows how much + * space the video will take up: width and height are unknown when the page first + * renders, as there can be multiple sources available with different dimensions. + * + * We use the first source to calculate aspect ratio, but we could use any of the sources. + * We make an assumption that all sources will have the same aspect ratio. + */ +export const getAspectRatioFromSources = (sources: Source[]): number => { + const firstSource = sources[0]; + + if (firstSource?.aspectRatio !== undefined) { + const [width, height] = firstSource.aspectRatio.split(':').map(Number); + if ( + width !== undefined && + height !== undefined && + width > 0 && + height > 0 + ) { + return width / height; + } + } + + if (!firstSource || firstSource.width === 0 || firstSource.height === 0) { + return DEFAULT_ASPECT_RATIO; + } + + return firstSource.width / firstSource.height; +}; export const getSubtitleAsset = (assets: VideoAssets[]): string | undefined => assets.find((asset) => asset.mimeType === 'text/vtt')?.url; + +/** + * Returns the smallest source that is larger than or equal to the screen width, unless + * all sources are smaller than the screen width, in which case it returns the largest source. + */ +const findOptimalSource = ( + sources: Source[], + screenWidth: number, +): Source | undefined => { + if (sources.length === 0) return undefined; + + return sources.reduce((a, b) => { + if (a.width < screenWidth || b.width < screenWidth) { + return a.width > b.width ? a : b; // take the larger source + } + + return a.width < b.width ? a : b; // take the smaller source + }); +}; + +export const findOptimisedSourcePerMimeType = ( + sources: Source[], + screenWidth: number, +): Source[] => { + return supportedVideoFileTypes.reduce((acc, type) => { + const sourcesForMimeType = sources.filter( + ({ mimeType }) => mimeType === type, + ); + if (sourcesForMimeType.length === 0) return acc; + + // Pick the source with the most appropriate width based on the users screen size + const optimisedSource = findOptimalSource( + sourcesForMimeType, + screenWidth, + ); + + if (optimisedSource) acc.push(optimisedSource); + return acc; + }, []); +}; diff --git a/dotcom-rendering/src/model/enhanceCards.test.ts b/dotcom-rendering/src/model/enhanceCards.test.ts index ef66d99198b..a0ecf278b89 100644 --- a/dotcom-rendering/src/model/enhanceCards.test.ts +++ b/dotcom-rendering/src/model/enhanceCards.test.ts @@ -20,15 +20,14 @@ describe('Enhance Cards', () => { width: 500, }, }; - - const testSubtitleAsset: FEMediaAsset = { - id: 'https://guim-example.co.uk/atomID-1.vtt', - version: 1, - platform: 'Url', - mimeType: 'text/vtt', - assetType: 'Subtitles', + const largeMp4Asset: FEMediaAsset = { + ...testMp4Asset, + id: 'https://guim-example.co.uk/atomID-2.mp4', + dimensions: { + height: 900, + width: 720, + }, }; - const testM3u8Asset: FEMediaAsset = { id: 'https://guim-example.co.uk/atomID-1.m3u8', version: 1, @@ -40,10 +39,31 @@ describe('Enhance Cards', () => { width: 500, }, }; + const largeM3u8Asset: FEMediaAsset = { + ...testM3u8Asset, + id: 'https://guim-example.co.uk/atomID-2.m3u8', + dimensions: { + height: 900, + width: 720, + }, + }; + const testSubtitleAsset: FEMediaAsset = { + id: 'https://guim-example.co.uk/atomID-1.vtt', + version: 1, + platform: 'Url', + mimeType: 'text/vtt', + assetType: 'Subtitles', + }; + const testYoutubeAsset: FEMediaAsset = { + id: 'test-youtube-id', + version: 1, + platform: 'Youtube', + assetType: 'Video', + }; const testMediaAtom: FEMediaAtom = { id: 'atomID', - assets: [testMp4Asset, testM3u8Asset], + assets: [testMp4Asset, largeMp4Asset, testM3u8Asset, largeM3u8Asset], title: 'Example video', duration: 15, source: '', @@ -54,9 +74,12 @@ describe('Enhance Cards', () => { }; describe('getActiveMediaAtom', () => { - it('prioritises MP4 assets over m3u8 assets', () => { + it('returns only SelfHostedVideo if the first asset is a self-hosted video', () => { const videoReplace = true; - const mediaAtom = testMediaAtom; + const mediaAtom = { + ...testMediaAtom, + assets: [testMp4Asset, testYoutubeAsset], + }; const cardTrailImage = ''; expect( @@ -76,36 +99,76 @@ describe('Enhance Cards', () => { height: 400, width: 500, }, - { - mimeType: 'application/x-mpegURL', - src: 'https://guim-example.co.uk/atomID-1.m3u8', - height: 400, - width: 500, - }, ], }); }); - it('returns the larger of two MP4 assets', () => { + it('returns only YoutubeVideo if the first asset is a YouTube video', () => { const videoReplace = true; - const mediaAtom: FEMediaAtom = { + const mediaAtom = { + ...testMediaAtom, + assets: [testYoutubeAsset, testMp4Asset], + }; + const cardTrailImage = ''; + + expect( + getActiveMediaAtom(videoReplace, mediaAtom, cardTrailImage), + ).toEqual({ + type: 'YoutubeVideo', + id: 'atomID', + videoId: 'test-youtube-id', + duration: 15, + title: 'Example video', + width: 500, + height: 300, + origin: '', + expired: false, + isLive: false, + image: '', + }); + }); + + it('returns only one YoutubeVideo if there are multiple YouTube assets', () => { + const videoReplace = true; + const mediaAtom = { ...testMediaAtom, assets: [ + testYoutubeAsset, { - ...testMp4Asset, - dimensions: { height: 400, width: 500 }, - id: 'https://guim-example.co.uk/atomID-1.mp4', - }, - { - ...testMp4Asset, - dimensions: { height: 600, width: 750 }, - id: 'https://guim-example.co.uk/atomID-2.mp4', - }, - { - ...testMp4Asset, - dimensions: { height: 500, width: 625 }, - id: 'https://guim-example.co.uk/atomID-3.mp4', + ...testYoutubeAsset, + id: 'test-youtube-id-2', }, + testMp4Asset, + ], + }; + const cardTrailImage = ''; + + expect( + getActiveMediaAtom(videoReplace, mediaAtom, cardTrailImage), + ).toEqual({ + type: 'YoutubeVideo', + id: 'atomID', + videoId: 'test-youtube-id', + duration: 15, + title: 'Example video', + width: 500, + height: 300, + origin: '', + expired: false, + isLive: false, + image: '', + }); + }); + + it('prioritises MP4 assets over m3u8 assets', () => { + const videoReplace = true; + const mediaAtom = { + ...testMediaAtom, + assets: [ + testM3u8Asset, + testMp4Asset, + largeM3u8Asset, + largeMp4Asset, ], }; const cardTrailImage = ''; @@ -121,11 +184,29 @@ describe('Enhance Cards', () => { videoStyle: 'Loop', subtitleSource: undefined, sources: [ + { + mimeType: 'video/mp4', + src: 'https://guim-example.co.uk/atomID-1.mp4', + height: 400, + width: 500, + }, { mimeType: 'video/mp4', src: 'https://guim-example.co.uk/atomID-2.mp4', - height: 600, - width: 750, + height: 900, + width: 720, + }, + { + mimeType: 'application/x-mpegURL', + src: 'https://guim-example.co.uk/atomID-1.m3u8', + height: 400, + width: 500, + }, + { + mimeType: 'application/x-mpegURL', + src: 'https://guim-example.co.uk/atomID-2.m3u8', + height: 900, + width: 720, }, ], }); @@ -240,6 +321,7 @@ describe('Enhance Cards', () => { expect(decideArticleMedia(format)).toEqual(undefined); }); + it('returns a Gallery main media object with the provided image count when the article design is Gallery', () => { const format = { display: ArticleDisplay.Standard, @@ -277,6 +359,7 @@ describe('Enhance Cards', () => { src: 'https://guim-example.co.uk/', altText: 'Podcast Image', }; + expect( decideArticleMedia( format, @@ -294,6 +377,7 @@ describe('Enhance Cards', () => { }, }); }); + it('returns an Audio main media object without the provided image when the imageHide is set to true', () => { const format = { display: ArticleDisplay.Standard, @@ -306,6 +390,7 @@ describe('Enhance Cards', () => { altText: 'Podcast Image', }; const imageHide = true; + expect( decideArticleMedia( format, @@ -324,18 +409,8 @@ describe('Enhance Cards', () => { design: ArticleDesign.Video, theme: Pillar.News, }; + const mediaAtom = { ...testMediaAtom, assets: [testMp4Asset] }; - const mediaAtom: FEMediaAtom = { - id: 'atomID', - assets: [testMp4Asset], - title: 'Example video', - duration: 15, - source: '', - posterImage: { allImages: [] }, - trailImage: { allImages: [] }, - expired: false, - activeVersion: 1, - }; expect( decideArticleMedia( format, @@ -364,22 +439,14 @@ describe('Enhance Cards', () => { }); }); }); + describe('decideReplacementMedia', () => { it('returns undefined if a mediaAtom is not provided', () => { expect(decideReplacementMedia()).toEqual(undefined); }); + it('returns undefined if a mediaAtom is provided but showMainVideo and videoReplace are both false', () => { - const mediaAtom: FEMediaAtom = { - id: 'atomID', - assets: [testMp4Asset], - title: 'Example video', - duration: 15, - source: '', - posterImage: { allImages: [] }, - trailImage: { allImages: [] }, - expired: false, - activeVersion: 1, - }; + const mediaAtom = { ...testMediaAtom, assets: [testMp4Asset] }; const showMainVideo = false; const videoReplace = false; @@ -387,18 +454,9 @@ describe('Enhance Cards', () => { decideReplacementMedia(showMainVideo, mediaAtom, videoReplace), ).toEqual(undefined); }); + it('returns a video main media if a mediaAtom is provided and showMainVideo is set to true', () => { - const mediaAtom: FEMediaAtom = { - id: 'atomID', - assets: [testMp4Asset], - title: 'Example video', - duration: 15, - source: '', - posterImage: { allImages: [] }, - trailImage: { allImages: [] }, - expired: false, - activeVersion: 1, - }; + const mediaAtom = { ...testMediaAtom, assets: [testMp4Asset] }; const showMainVideo = true; const videoReplace = false; @@ -422,17 +480,7 @@ describe('Enhance Cards', () => { }); it('returns a video main media if a mediaAtom is provided and videoReplace is set to true', () => { - const mediaAtom: FEMediaAtom = { - id: 'atomID', - assets: [testMp4Asset], - title: 'Example video', - duration: 15, - source: '', - posterImage: { allImages: [] }, - trailImage: { allImages: [] }, - expired: false, - activeVersion: 1, - }; + const mediaAtom = { ...testMediaAtom, assets: [testMp4Asset] }; const showMainVideo = false; const videoReplace = true; diff --git a/dotcom-rendering/src/model/enhanceCards.ts b/dotcom-rendering/src/model/enhanceCards.ts index 5645f3386bd..bac012cf4d5 100644 --- a/dotcom-rendering/src/model/enhanceCards.ts +++ b/dotcom-rendering/src/model/enhanceCards.ts @@ -14,8 +14,11 @@ import type { EditionId } from '../lib/edition'; import type { Group } from '../lib/getDataLinkName'; import { getDataLinkNameCard } from '../lib/getDataLinkName'; import { getLargestImageSize } from '../lib/image'; -import type { SupportedVideoFileType } from '../lib/video'; -import { DEFAULT_ASPECT_RATIO, supportedVideoFileTypes } from '../lib/video'; +import { + convertFEMediaAssetsToVideoAssets, + extractValidSourcesFromAssets, + getAspectRatioFromSources, +} from '../lib/video'; import type { Image } from '../types/content'; import type { DCRFrontCard, @@ -222,48 +225,24 @@ export const getActiveMediaAtom = ( * Therefore, we check the platform of the first asset and assume the rest are the same. */ if (firstVideoAsset.platform === 'Url') { - // Order the assets by largest width: for now, we only use the largest video, but there - // be a follow up PR to select the appropriate video source based on the users screen size. - const orderedSources = assets.sort( - (a, b) => - Number(b.dimensions?.width ?? 0) - - Number(a.dimensions?.width ?? 0), - ); - - /** - * Take one source for each supported video file type. - */ - const sources = supportedVideoFileTypes.reduce( - (acc, type) => { - const source = orderedSources.find( - ({ mimeType }) => mimeType === type, - ); - if (source) acc.push(source); - return acc; - }, - [], + const selfHostedAssets = assets.filter( + ({ platform }) => platform === 'Url', ); - if (!sources.length) return undefined; - const subtitleAsset = assets.find( ({ assetType }) => assetType === 'Subtitles', ); - const aspectRatio = firstVideoAsset.dimensions - ? firstVideoAsset.dimensions.width / - firstVideoAsset.dimensions.height - : DEFAULT_ASPECT_RATIO; + const videoAssets = + convertFEMediaAssetsToVideoAssets(selfHostedAssets); + const sources = extractValidSourcesFromAssets(videoAssets); + + const aspectRatio = getAspectRatioFromSources(sources); return { type: 'SelfHostedVideo', videoStyle: mediaAtom.videoPlayerFormat ?? 'Loop', atomId: mediaAtom.id, - sources: sources.map(({ id, mimeType, dimensions }) => ({ - src: id, - mimeType: mimeType as SupportedVideoFileType, - height: dimensions?.height ?? 0, - width: dimensions?.width ?? 0, - })), + sources, subtitleSource: subtitleAsset?.id, aspectRatio, duration: mediaAtom.duration ?? 0, @@ -288,7 +267,7 @@ export const getActiveMediaAtom = ( expired: !!mediaAtom.expired, /** * We infer that a video is a livestream if the duration is set to 0. This is - * a soft contract with Editorial who manual set the duration of videos + * a soft contract with Editorial who manually set the duration of videos. */ isLive: mediaAtom.duration === 0, image,