@sanity/image-url
Version:
Tools to generate image urls from Sanity content
212 lines (180 loc) • 6.91 kB
text/typescript
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,
}
}