diff --git a/extensions/amp-video/0.1/test/test-video-cache.js b/extensions/amp-video/0.1/test/test-video-cache.js index a6dbeebc53cd..8181f5b308db 100644 --- a/extensions/amp-video/0.1/test/test-video-cache.js +++ b/extensions/amp-video/0.1/test/test-video-cache.js @@ -494,6 +494,88 @@ describes.realWin('amp-video cached-sources', {amp: true}, (env) => { }); }); + describe('web stories: inlined video', async () => { + it('should use the inlined source for the first video in the story instead of sending an XHR request', async () => { + // Set up an inlined source response for the first video in the story + const storyEl = createStoryForInlineVideoTesting(); + env.win.document.body.appendChild(storyEl); + setUpInlinedVideoResponse(); + + const xhrSpy = env.sandbox.spy(xhrService, 'fetch'); + + // Fetch the sources for the first video in the story + const videoEl = storyEl.querySelectorAll('amp-video')[0]; + await fetchCachedSources(videoEl, env.ampdoc); + + expect(xhrSpy).to.have.not.been.called; + const inlinedSources = videoEl.querySelectorAll( + 'source[src="inlined_video_response.mp4"]' + ); + expect(inlinedSources).to.have.lengthOf(1); + }); + + it('should send an XHR request for any video that is not the very first one within the story', async () => { + // Set up an inlined source response for the first video in the story + const storyEl = createStoryForInlineVideoTesting(); + env.win.document.body.appendChild(storyEl); + setUpInlinedVideoResponse(); + + const xhrSpy = env.sandbox.spy(xhrService, 'fetch'); + + // Fetch the sources for video #2: the 2nd video on the first story page + const videoEl2 = storyEl.querySelectorAll('amp-video')[1]; + await fetchCachedSources(videoEl2, env.ampdoc); + + // Fetch the sources for video #3: the 1st video on the 2nd story page + const videoEl3 = storyEl.querySelectorAll('amp-video')[2]; + await fetchCachedSources(videoEl3, env.ampdoc); + + expect(xhrSpy).to.have.been.calledWith( + 'https://example-com.cdn.ampproject.org/mbv/s/example.com/video2.mp4?amp_video_host_url=https%3A%2F%2Fcanonical.com' + ); + expect(xhrSpy).to.have.been.calledWith( + 'https://example-com.cdn.ampproject.org/mbv/s/example.com/video3.mp4?amp_video_host_url=https%3A%2F%2Fcanonical.com' + ); + }); + + it('should send XHR request if inline config not provided', async () => { + // Create story without setting an inlined source response + const storyEl = createStoryForInlineVideoTesting(); + env.win.document.body.appendChild(storyEl); + + const xhrSpy = env.sandbox.spy(xhrService, 'fetch'); + + // Fetch the sources for the first video in the story + const videoEl = storyEl.querySelectorAll('amp-video')[0]; + await fetchCachedSources(videoEl, env.ampdoc); + + expect(xhrSpy).to.have.been.calledWith( + 'https://example-com.cdn.ampproject.org/mbv/s/example.com/video1.mp4?amp_video_host_url=https%3A%2F%2Fcanonical.com' + ); + }); + + it('should send XHR request if inlined video response fails to parse', async () => { + // Set up an improperly configured response for the first video in the story + const storyEl = createStoryForInlineVideoTesting(); + env.win.document.body.appendChild(storyEl); + const scriptEl = createElementWithAttributes(env.win.document, 'script', { + 'id': 'amp-google-video-cache-response', + 'type': 'application/json', + }); + scriptEl.textContent = '{"faulty": [{}]}'; + + const xhrSpy = env.sandbox.spy(xhrService, 'fetch'); + + // Fetch the sources for the first video in the story + const videoEl = storyEl.querySelectorAll('amp-video')[0]; + await fetchCachedSources(videoEl, env.ampdoc); + + expect(xhrSpy).to.have.been.calledWith( + 'https://example-com.cdn.ampproject.org/mbv/s/example.com/video1.mp4?amp_video_host_url=https%3A%2F%2Fcanonical.com' + ); + }); + }); + function createVideo(children) { const videoEl = createElementWithAttributes(env.win.document, 'amp-video', { 'cache': 'google', @@ -510,4 +592,49 @@ describes.realWin('amp-video cached-sources', {amp: true}, (env) => { env.win.document.body.appendChild(videoEl); return videoEl; } + + function createStoryForInlineVideoTesting() { + const storyEl = env.win.document.createElement('amp-story'); + const storyPageEl1 = env.win.document.createElement('amp-story-page'); + const storyPageEl2 = env.win.document.createElement('amp-story-page'); + storyEl.appendChild(storyPageEl1); + storyEl.appendChild(storyPageEl2); + + // Place two videos on the first page. video #1 is nested more deeply than + // video #2, but it should still be considered the first video on the page. + const gridLayerEl = env.win.document.createElement('amp-story-grid-layer'); + const videoEl1 = createVideo([{src: 'video1.mp4'}]); + gridLayerEl.appendChild(videoEl1); + storyPageEl1.appendChild(gridLayerEl); + + // Place video #2 on the first page. + const videoEl2 = createVideo([{src: 'video2.mp4'}]); + storyPageEl1.appendChild(videoEl2); + + // Place video #3 on the second page. + const videoEl3 = createVideo([{src: 'video3.mp4'}]); + storyPageEl2.appendChild(videoEl3); + + return storyEl; + } + + function setUpInlinedVideoResponse() { + const scriptEl = createElementWithAttributes(env.win.document, 'script', { + 'id': 'amp-google-video-cache-response', + 'type': 'application/json', + }); + scriptEl.textContent = ` + { + "sources": [ + { + "url": "inlined_video_response.mp4", + "codec": "h264", + "type": "video/mp4", + "bitrate_kbps": 400 + } + ], + "has_audio": false + }`; + env.win.document.head.appendChild(scriptEl); + } }); diff --git a/extensions/amp-video/0.1/video-cache.js b/extensions/amp-video/0.1/video-cache.js index 6bb7908c762d..7bceefd338df 100644 --- a/extensions/amp-video/0.1/video-cache.js +++ b/extensions/amp-video/0.1/video-cache.js @@ -35,37 +35,20 @@ export function fetchCachedSources( if (Services.platformFor(win).isBot()) { return Promise.resolve(); } - if ( - !( - videoEl.getAttribute('src') || - videoEl.querySelector('source[src]')?.getAttribute('src') - ) - ) { + + const videoSrc = videoEl.getAttribute('src'); + const sourceSrc = videoEl.querySelector('source[src]')?.getAttribute('src'); + if (!videoSrc && !sourceSrc) { user().error('AMP-VIDEO', 'Video cache not properly configured'); return Promise.resolve(); } Services.performanceFor(ampdoc.win).addEnabledExperiment('video-cache'); - const {canonicalUrl, sourceUrl} = Services.documentInfoForDoc(win.document); - maybeReplaceSrcWithSourceElement(videoEl, win); - const videoUrl = resolveRelativeUrl(selectVideoSource(videoEl), sourceUrl); - return getCacheUrlService(videoEl, ampdoc) - .then((service) => service.createCacheUrl(videoUrl)) - .then((cacheUrl) => { - const requestUrl = addParamsToUrl(cacheUrl.replace(/\/[ic]\//, '/mbv/'), { - 'amp_video_host_url': - /* document url that contains the video */ canonicalUrl, - 'amp_video_require_acao_header': videoEl.hasAttribute('crossorigin') - ? 1 - : null, - }); - return Services.xhrFor(win).fetch(requestUrl, {prerenderSafe: true}); - }) - .then((response) => response.json()) - .then((jsonResponse) => { - applySourcesToVideo(videoEl, jsonResponse['sources'], maxBitrate); - applyAudioInfoToVideo(videoEl, jsonResponse['has_audio']); + return requestCachedVideoSources(videoEl, ampdoc) + .then((response) => { + applySourcesToVideo(videoEl, response['sources'], maxBitrate); + applyAudioInfoToVideo(videoEl, response['has_audio']); }) .catch(() => { // If cache fails, video should still load properly. @@ -215,3 +198,59 @@ function getCacheUrlService(videoEl, ampdoc) { .installExtensionForDoc(ampdoc, 'amp-cache-url') .then(() => Services.cacheUrlServicePromiseForDoc(videoEl)); } + +/** + * Fetch the sources for the given video element. + * @param {!Element} videoEl + * @param {!AmpDoc} ampdoc + * @return {!Promise} JSON representing AMP's cached video sources. + */ +function requestCachedVideoSources(videoEl, ampdoc) { + const {win} = ampdoc; + if (shouldUseInlineVideoResponse(videoEl, win)) { + const inlineResponseEl = win.document.getElementById( + 'amp-google-video-cache-response' + ); + try { + const inlineResponseJson = JSON.parse(inlineResponseEl.textContent); + if (inlineResponseJson['sources']) { + return Promise.resolve(inlineResponseJson); + } + } catch (err) { + // If parsing the response fails, an XHR request will be made below. + } + } + + const {canonicalUrl, sourceUrl} = Services.documentInfoForDoc(win.document); + maybeReplaceSrcWithSourceElement(videoEl, win); + const videoUrl = resolveRelativeUrl(selectVideoSource(videoEl), sourceUrl); + return getCacheUrlService(videoEl, ampdoc) + .then((service) => service.createCacheUrl(videoUrl)) + .then((cacheUrl) => { + const requestUrl = addParamsToUrl(cacheUrl.replace(/\/[ic]\//, '/mbv/'), { + 'amp_video_host_url': + /* document url that contains the video */ canonicalUrl, + 'amp_video_require_acao_header': videoEl.hasAttribute('crossorigin') + ? 1 + : null, + }); + return Services.xhrFor(win) + .fetch(requestUrl, {prerenderSafe: true}) + .then((xhrResponse) => xhrResponse.json()); + }); +} + +/** + * Returns `true` if the video's inline response should be used instead of + * issuing an XHR request. + * @param {!Element} videoEl + * @param {!Window} win + * @return {boolean} + */ +function shouldUseInlineVideoResponse(videoEl, win) { + // Google video cache inlines the first video of the first web story page. + const firstVid = win.document.querySelector( + 'amp-story-page:first-of-type amp-video' + ); + return videoEl === firstVid; +}