UNPKG

@sanity/image-url

Version:

Tools to generate image urls from Sanity content

212 lines (180 loc) 6.91 kB
import {parseAssetId} from './parseAssetId' import {parseSource, isInProgressUpload} from './parseSource' import type { CropSpec, HotspotSpec, ImageUrlBuilderOptions, ImageUrlBuilderOptionsWithAsset, SanityAsset, SanityImageFitResult, SanityImageRect, SanityReference, } from './types' export const SPEC_NAME_TO_URL_NAME_MAPPINGS = [ ['width', 'w'], ['height', 'h'], ['format', 'fm'], ['download', 'dl'], ['blur', 'blur'], ['sharpen', 'sharp'], ['invert', 'invert'], ['orientation', 'or'], ['minHeight', 'min-h'], ['maxHeight', 'max-h'], ['minWidth', 'min-w'], ['maxWidth', 'max-w'], ['quality', 'q'], ['fit', 'fit'], ['crop', 'crop'], ['saturation', 'sat'], ['auto', 'auto'], ['dpr', 'dpr'], ['pad', 'pad'], ['frame', 'frame'], ] /** * @internal */ export function urlForImage(options: ImageUrlBuilderOptions): string { let spec = {...(options || {})} const source = spec.source delete spec.source const image = parseSource(source) if (!image) { if (source && isInProgressUpload(source)) { // This is a placeholder image that will be replaced with the actual image when the upload is complete // This is a 0x0 transparent PNG image return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8HwQACfsD/QNViZkAAAAASUVORK5CYII=' } throw new Error(`Unable to resolve image URL from source (${JSON.stringify(source)})`) } const id = (image.asset as SanityReference)._ref || (image.asset as SanityAsset)._id || '' const asset = parseAssetId(id) // Compute crop rect in terms of pixel coordinates in the raw source image const cropLeft = Math.round(image.crop.left * asset.width) const cropTop = Math.round(image.crop.top * asset.height) const crop = { left: cropLeft, top: cropTop, width: Math.round(asset.width - image.crop.right * asset.width - cropLeft), height: Math.round(asset.height - image.crop.bottom * asset.height - cropTop), } // Compute hot spot rect in terms of pixel coordinates const hotSpotVerticalRadius = (image.hotspot.height * asset.height) / 2 const hotSpotHorizontalRadius = (image.hotspot.width * asset.width) / 2 const hotSpotCenterX = image.hotspot.x * asset.width const hotSpotCenterY = image.hotspot.y * asset.height const hotspot = { left: hotSpotCenterX - hotSpotHorizontalRadius, top: hotSpotCenterY - hotSpotVerticalRadius, right: hotSpotCenterX + hotSpotHorizontalRadius, bottom: hotSpotCenterY + hotSpotVerticalRadius, } // If irrelevant, or if we are requested to: don't perform crop/fit based on // the crop/hotspot. if (!(spec.rect || spec.focalPoint || spec.ignoreImageParams || spec.crop)) { spec = {...spec, ...fit({crop, hotspot}, spec)} } return specToImageUrl({...spec, asset}) } // eslint-disable-next-line complexity function specToImageUrl(spec: ImageUrlBuilderOptionsWithAsset) { const cdnUrl = (spec.baseUrl || 'https://cdn.sanity.io').replace(/\/+$/, '') const vanityStub = spec.vanityName ? `/${spec.vanityName}` : '' const filename = `${spec.asset.id}-${spec.asset.width}x${spec.asset.height}.${spec.asset.format}${vanityStub}` let baseUrl: string if (spec.mediaLibraryId) { baseUrl = `${cdnUrl}/media-libraries/${spec.mediaLibraryId}/images/${filename}` } else if (spec.canvasId) { baseUrl = `${cdnUrl}/images/canvases/${spec.canvasId}/${filename}` } else { baseUrl = `${cdnUrl}/images/${spec.projectId}/${spec.dataset}/${filename}` } const params: string[] = [] if (spec.rect) { // Only bother url with a crop if it actually crops anything const {left, top, width, height} = spec.rect const isEffectiveCrop = left !== 0 || top !== 0 || height !== spec.asset.height || width !== spec.asset.width if (isEffectiveCrop) { params.push(`rect=${left},${top},${width},${height}`) } } if (spec.bg) { params.push(`bg=${spec.bg}`) } if (spec.focalPoint) { params.push(`fp-x=${spec.focalPoint.x}`) params.push(`fp-y=${spec.focalPoint.y}`) } const flip = [spec.flipHorizontal && 'h', spec.flipVertical && 'v'].filter(Boolean).join('') if (flip) { params.push(`flip=${flip}`) } // Map from spec name to url param name, and allow using the actual param name as an alternative SPEC_NAME_TO_URL_NAME_MAPPINGS.forEach((mapping) => { const [specName, param] = mapping if (typeof spec[specName] !== 'undefined') { params.push(`${param}=${encodeURIComponent(spec[specName])}`) } else if (typeof spec[param] !== 'undefined') { params.push(`${param}=${encodeURIComponent(spec[param])}`) } }) if (params.length === 0) { return baseUrl } return `${baseUrl}?${params.join('&')}` } function fit( source: {crop: CropSpec; hotspot: HotspotSpec}, spec: ImageUrlBuilderOptions ): SanityImageFitResult { let cropRect: SanityImageRect const imgWidth = spec.width const imgHeight = spec.height // If we are not constraining the aspect ratio, we'll just use the whole crop if (!(imgWidth && imgHeight)) { return {width: imgWidth, height: imgHeight, rect: source.crop} } const crop = source.crop const hotspot = source.hotspot // If we are here, that means aspect ratio is locked and fitting will be a bit harder const desiredAspectRatio = imgWidth / imgHeight const cropAspectRatio = crop.width / crop.height if (cropAspectRatio > desiredAspectRatio) { // The crop is wider than the desired aspect ratio. That means we are cutting from the sides const height = Math.round(crop.height) const width = Math.round(height * desiredAspectRatio) const top = Math.max(0, Math.round(crop.top)) // Center output horizontally over hotspot const hotspotXCenter = Math.round((hotspot.right - hotspot.left) / 2 + hotspot.left) let left = Math.max(0, Math.round(hotspotXCenter - width / 2)) // Keep output within crop if (left < crop.left) { left = crop.left } else if (left + width > crop.left + crop.width) { left = crop.left + crop.width - width } cropRect = {left, top, width, height} } else { // The crop is taller than the desired ratio, we are cutting from top and bottom const width = crop.width const height = Math.round(width / desiredAspectRatio) const left = Math.max(0, Math.round(crop.left)) // Center output vertically over hotspot const hotspotYCenter = Math.round((hotspot.bottom - hotspot.top) / 2 + hotspot.top) let top = Math.max(0, Math.round(hotspotYCenter - height / 2)) // Keep output rect within crop if (top < crop.top) { top = crop.top } else if (top + height > crop.top + crop.height) { top = crop.top + crop.height - height } cropRect = {left, top, width, height} } return { width: imgWidth, height: imgHeight, rect: cropRect, } }