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 }