/ shared / components / src / components / Artwork / utils / srcset.ts
srcset.ts
  1  /**
  2   * COPIED FROM: https://github.pie.apple.com/amp-ui/ember-ui-media-artwork/blob/957fc3e586d4ff710b2263a45d8950d4ee65616a/addon/utils/srcset.js
  3   * and converted to TypeScript
  4   */
  5  import { replaceQualityParam } from '@amp/web-app-components/src/components/Artwork/utils/replaceQualityParam';
  6  import {
  7      DEFAULT_FILE_TYPE,
  8      DEFAULT_QUALITY,
  9      PIXEL_DENSITIES,
 10      EMBEDDED_CROP_CODE_REGEX,
 11      EFFECT_ID_REGEX,
 12      FILE_TYPE_REGEX,
 13  } from '@amp/web-app-components/src/components/Artwork/constants';
 14  import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
 15  import { memoize } from '@amp/web-app-components/src/utils/memoize';
 16  import { getDataFromProfile } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
 17  import type { MediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions';
 18  import { getMediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions';
 19  import type {
 20      FileExtension,
 21      Artwork,
 22      ArtworkMaxSizes,
 23      ImageSettings,
 24      ImageURLParams,
 25      Profile,
 26      CropCode,
 27      ChinConfig,
 28  } from '@amp/web-app-components/src/components/Artwork/types';
 29  import type { Size } from '@amp/web-app-components/src/types';
 30  
 31  type ProfileConfig = {
 32      width: number;
 33      height: number;
 34      crop: CropCode;
 35  };
 36  type SizeMap = {
 37      [key in Size]?: ProfileConfig;
 38  };
 39  
 40  const isAFillCropCode = (crop: CropCode) => crop === 'bf';
 41  
 42  const getSmallestProfileSize = (sizeMap: SizeMap) => {
 43      const { xlarge, large, medium, small, xsmall } = sizeMap;
 44      return xsmall || small || medium || large || xlarge;
 45  };
 46  
 47  const filterSizeConfig = (
 48      config: ProfileConfig,
 49      maxWidth: number | null,
 50  ): boolean => (maxWidth ? config.width <= maxWidth : true);
 51  
 52  const getSizesAndBreakpoints = (
 53      profile: Profile | string,
 54  ): [SizeMap, MediaConditions] => {
 55      const { BREAKPOINTS } = ArtworkConfig.get();
 56      const profileSize = profile ? getDataFromProfile(profile) : {};
 57  
 58      const mediaConditions = getMediaConditions(BREAKPOINTS);
 59      const SIZES = Object.keys(mediaConditions);
 60      // TODO: rdar://76402413 (Convert imperative reduce pattern
 61      // to functionalwith Object.fromEntries once on Node 12)
 62      const sizeMap: SizeMap = SIZES.reduce((accumulator, sizeName) => {
 63          // only add to size map if
 64          // profile exists for mediaCondition
 65  
 66          if (profileSize[sizeName]) {
 67              const imageWidth = profileSize[sizeName].width;
 68              const imageHeight = profileSize[sizeName].height;
 69              const imageCrop = profileSize[sizeName].crop;
 70  
 71              accumulator[sizeName] = {
 72                  width: imageWidth,
 73                  height: imageHeight,
 74                  crop: imageCrop,
 75              };
 76          }
 77  
 78          return accumulator;
 79      }, {});
 80  
 81      return [sizeMap, mediaConditions];
 82  };
 83  
 84  function deriveUrlParamsArray(
 85      urlParams: Partial<ImageURLParams>,
 86      profile: Profile | string,
 87      maxWidth: number,
 88  ): ImageURLParams[] {
 89      const [profileBySize] = getSizesAndBreakpoints(profile);
 90  
 91      let filteredSizes = Object.values(profileBySize).filter((config) =>
 92          filterSizeConfig(config, maxWidth),
 93      );
 94  
 95      // if image is smaller than all profile sizes
 96      // use the smallest profile size available
 97      if (filteredSizes.length === 0) {
 98          const smallestProfile = getSmallestProfileSize(profileBySize);
 99          filteredSizes = [smallestProfile];
100      }
101  
102      return filteredSizes.map((viewportProfile) => ({
103          crop: viewportProfile.crop,
104          width: viewportProfile.width,
105          height: viewportProfile.height,
106          quality: urlParams.quality,
107          fileType: urlParams.fileType,
108      }));
109  }
110  
111  /**
112   * Converts Artwork object to expected input for image src functions.
113   * @param artwork Artwork object
114   * @param quality image quality value
115   * @param fileType file type
116   * @param chinConfig chin configuration object
117   */
118  function deriveDataFromArtwork(
119      artwork: Artwork,
120      quality?: number,
121      fileType?: FileExtension,
122      chinConfig?: ChinConfig,
123  ): [string, Partial<ImageURLParams>, ArtworkMaxSizes] {
124      const { width, height, template } = artwork;
125      const chinHeight = chinConfig?.height ?? 0;
126  
127      const urlParams: Partial<ImageURLParams> = {
128          fileType,
129          quality,
130      };
131  
132      const ogImageSizes: ArtworkMaxSizes = {
133          maxHeight: height + chinHeight,
134          maxWidth: width,
135      };
136  
137      return [template, urlParams, ogImageSizes];
138  }
139  
140  /**
141   * Removes embedded crop codes if:
142   *  1. a `crop` is passed (i.e. if a user passed a crop code in the invocation of
143   *      the outer function)
144   *  2. the rawURL has an embedded crop code that is not an Effect ID
145   *
146   * Exception to #2 is when using an image with an Effect ID that is being used to create
147   * a chin blur (i.e. chins in Power Swoosh lockups).  This is a special case so we can
148   * have the blur effect visible in Chrome.
149   *
150   * Under these conditions the fileType is also removed, but it's not clear why.
151   *
152   * @public
153   * @param rawURL
154   * @param crop
155   * @param replaceEffectCode
156   */
157  export function fixEmbeddedCropCode(
158      rawURL: string,
159      crop: string,
160      replaceEffectCode = false,
161  ): string {
162      // Normalize URL in case crop or format are hardcoded
163      // Test against only the filename portion
164      const stringParts = rawURL.split('/');
165      const fileName = stringParts.pop();
166      let url = rawURL;
167  
168      const cropMatches = fileName.match(EMBEDDED_CROP_CODE_REGEX);
169  
170      // The last match will be the hard-coded crop code or the replacement indicator: {c}
171      const cropMatch = cropMatches ? cropMatches.pop() : null;
172  
173      // EffectIds (e.g. SH.FPESS01) are the new artwork crop codes
174      // that should not be replaced in the artwork url excpet when used
175      // for chin blurs.
176      const isEffectMatch = !replaceEffectCode && EFFECT_ID_REGEX.test(fileName);
177  
178      if (crop && cropMatch && !isEffectMatch) {
179          // Update the url to include the replacement indicator {c} instead of the hard-coded crop value
180          // Also update the URL to include the replacement indicator {f} if the file type is hard-coded
181          const updatedFilename = replaceEffectCode
182              ? // EFFECT_ID_REGEX also captures file type
183                fileName.replace(EFFECT_ID_REGEX, '$1x$2{c}.{f}')
184              : fileName
185                    .replace(EMBEDDED_CROP_CODE_REGEX, '$1x$2{c}')
186                    .replace(FILE_TYPE_REGEX, '{f}');
187  
188          url = `${stringParts.join('/')}/${updatedFilename}`;
189      }
190  
191      return url;
192  }
193  
194  /**
195   * @private
196   * Utility for build src for images
197   * @param url template url for an image
198   * @param urlParams
199   * @param options
200   * @param chinConfig optional chin configuration for style parameter
201   */
202  export function buildSrc(
203      url: string,
204      urlParams: ImageURLParams,
205      options: ImageSettings,
206      chinConfig?: ChinConfig,
207  ): string | null {
208      if (!url) return null;
209  
210      let returnedUrl = url;
211  
212      const { width, height, quality, crop, fileType } = urlParams;
213  
214      if (options?.forceCropCode !== false) {
215          returnedUrl = fixEmbeddedCropCode(returnedUrl, crop);
216      }
217      const [parsedURL, defaultQuality] = replaceQualityParam(
218          returnedUrl,
219          quality,
220      );
221      returnedUrl = parsedURL;
222  
223      const qualityValue = Number.isInteger(quality)
224          ? quality.toString()
225          : defaultQuality;
226  
227      let finalUrl = returnedUrl
228          .replace('{w}', width?.toString())
229          .replace('{h}', height?.toString())
230          .replace('{c}', crop)
231          .replace('{q}', qualityValue)
232          .replace('{f}', fileType);
233  
234      // Add style query parameter for chin effects if specified
235      if (chinConfig?.style) {
236          const separator = finalUrl.includes('?') ? '&' : '?';
237          finalUrl += `${separator}style=${chinConfig.style}`;
238      }
239  
240      return finalUrl;
241  }
242  
243  /**
244   * Wrapper for buildSrc helper
245   * - Preserves effect ids in urls used for SEO
246   * @param {string} url
247   * @param {ImageURLParams} urlParams
248   * @return string | null
249   */
250  export function buildSrcSeo(
251      url: string,
252      urlParams: ImageURLParams,
253  ): string | null {
254      const options = { ...urlParams };
255  
256      // Preserve effect ids when generating seo image urls
257      if (EFFECT_ID_REGEX.test(url)) {
258          delete options.crop;
259      }
260  
261      return buildSrc(url, options, {});
262  }
263  
264  /**
265   * This function generates a value for the `srcset` attribute
266   * based on a URL and image options.
267   *
268   * @private
269   * @param rawURL The raw URL
270   * @param urlParams custom image parameters
271   * @param pixelDensity pixel density to optimize for
272   * @param options k/v map of other constant options that don't depend on viewport size.
273   * @return The `srcset` attribute value
274   * @public
275   */
276  function buildSingleSrcset(
277      rawURL: string,
278      urlParams: ImageURLParams,
279      artworkSizes: ArtworkMaxSizes,
280      pixelDensity: number,
281      options: ImageSettings,
282      chinConfig?: ChinConfig,
283  ): string {
284      const { maxWidth } = artworkSizes;
285      const profileHeight = urlParams.height;
286      const profileWidth = urlParams.width;
287      const chinHeight = chinConfig?.height ?? 0;
288  
289      const calculatedWidth = Math.ceil(profileWidth * pixelDensity);
290      const { crop } = urlParams;
291  
292      // use profile width if maxWidth is null or 0
293      // TODO: rdar://92133085 (Add logging to shared components)
294      const artworkMaxWidth = maxWidth || calculatedWidth;
295  
296      // prevent pixel dense images from being wider
297      // than the OG size of the image
298      // unless its using a fill
299      const width = isAFillCropCode(crop)
300          ? calculatedWidth
301          : Math.min(calculatedWidth, artworkMaxWidth);
302      const height =
303          Math.round((width * profileHeight) / profileWidth) +
304          Math.round(chinHeight * pixelDensity);
305  
306      const passedOptions = options;
307  
308      const fixedUrlParams = {
309          ...urlParams,
310          crop,
311          width,
312          height,
313      };
314  
315      const url = buildSrc(rawURL, fixedUrlParams, passedOptions, chinConfig);
316  
317      return `${url} ${fixedUrlParams.width}w`;
318  }
319  
320  /**
321   * Returns a string that can be used as the value for the srcset attribute.
322   *
323   * @function buildResponsiveSrcset
324   * @param urlParams list of `urlOptions`. See `buildSrcset` for details.
325   * @param options some other options to opt into behavior. See `buildSrcset` for details.
326   * @returns srcset string
327   */
328  export function buildResponsiveSrcset(
329      url: string,
330      urlParams: Partial<ImageURLParams>,
331      profile: Profile | string,
332      artworkSizes: ArtworkMaxSizes,
333      options: ImageSettings,
334      chinConfig?: ChinConfig,
335  ): string {
336      const urlParamsArray = deriveUrlParamsArray(
337          urlParams,
338          profile,
339          artworkSizes.maxWidth,
340      );
341      const DEFAULT_OPTIONS: Partial<ImageSettings> = {
342          forceCropCode: false,
343      };
344      const {
345          pixelDensities = PIXEL_DENSITIES,
346          ...optionsWithoutPixelDensities
347      } = options;
348  
349      // merging custom options with defaults
350      const finalOptions: ImageSettings = {
351          ...DEFAULT_OPTIONS,
352          ...optionsWithoutPixelDensities,
353      };
354  
355      // using a Set to prevent multiple of the same srcs being added.
356      const srcSetStrings = new Set();
357  
358      // eslint-disable-next-line no-restricted-syntax
359      for (const pixelDensity of pixelDensities) {
360          // eslint-disable-next-line no-restricted-syntax
361          for (const singleURLParam of urlParamsArray) {
362              srcSetStrings.add(
363                  buildSingleSrcset(
364                      url,
365                      singleURLParam,
366                      artworkSizes,
367                      pixelDensity,
368                      finalOptions,
369                      chinConfig,
370                  ),
371              );
372          }
373      }
374      return [...srcSetStrings].join(',');
375  }
376  
377  /**
378   * get size attributes based on breakpoints.
379   * @param width width of image
380   * @param height height of image
381   * @param imageMultipler custom multipler to use for image sizes
382   */
383  
384  function imageSizes(
385      profile?: Profile | string,
386      maxWidth: number = null,
387  ): string {
388      const [sizeMap, mediaConditions] = getSizesAndBreakpoints(profile);
389  
390      const filteredSizes = Object.entries(sizeMap).filter(([, config]) =>
391          filterSizeConfig(config, maxWidth),
392      );
393  
394      const sizes = filteredSizes.map(([sizeName, config], index, arr) => {
395          let condition = mediaConditions[sizeName];
396          const { width } = config;
397          const widthString = `${width}px`;
398          const isFirst = index === 0;
399          const isLast = index === arr.length - 1;
400  
401          // The smallest size in the 'sizes' attribute shouldn't have a min size
402          // or it will cause anything below that size to default
403          // to the last size (aka the largest image).
404          if (isFirst) {
405              const conditions = condition.split('and');
406              if (conditions.length > 1) {
407                  const [, maxCondition] = conditions;
408                  condition = maxCondition;
409              }
410          }
411          if (isLast) {
412              // The last size in the `sizes` attr should not contain the media condition
413              // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes
414              return widthString;
415          }
416  
417          // Creates an option like this:
418          // (min-width: something) 111px;
419          return `${condition} ${widthString}`;
420      });
421      return sizes.length
422          ? sizes.join(',')
423          : `${getSmallestProfileSize(sizeMap).width}w`;
424  }
425  
426  export const getImageSizes = memoize(imageSizes);
427  
428  export function buildSourceSet(
429      artwork: Artwork,
430      options: ImageSettings,
431      profile: Profile | string,
432      chinConfig?: ChinConfig,
433  ): string | null {
434      const fileType = options.fileType || DEFAULT_FILE_TYPE;
435      let qualityValue = options.quality || DEFAULT_QUALITY;
436      let sourceSet = null;
437  
438      const isWebp = fileType === 'webp';
439      if (isWebp && qualityValue === DEFAULT_QUALITY) {
440          qualityValue = null;
441      }
442  
443      const [url, urlParams, maxSizes] = deriveDataFromArtwork(
444          artwork,
445          qualityValue,
446          fileType,
447          chinConfig,
448      );
449  
450      if (url) {
451          // If the url doesn't have a {f} (file type) placeholder, we do not want
452          // to force webp sources.
453          const isNotWebpException = !(isWebp && !url.includes('{f}'));
454          if (isNotWebpException) {
455              sourceSet = buildResponsiveSrcset(
456                  url,
457                  urlParams,
458                  profile,
459                  maxSizes,
460                  options,
461                  chinConfig,
462              );
463          }
464      }
465  
466      return sourceSet;
467  }