/** * Hotspot and crop resolution. * * The model is Sanity's, and the reason to copy it is that it separates two decisions editors * actually make separately: *what part of this image matters* (the hotspot) and *what part of it is * worth showing at all* (the crop). Both are stored normalised, independent of pixels, so one asset * drives a 16:9 hero, a square thumbnail, and a 3:4 portrait card — each computed on demand rather * than pre-generated and stored per shape. * * The alternative — baking a crop per use — means every new template needs a person to re-crop * every image, and an image reused in a shape nobody anticipated is simply wrong. * * Nothing here touches pixels. `resolveCrop` returns a rectangle in normalised source coordinates, * which the admin preview turns into CSS and a delivery layer turns into image-CDN parameters. */ /** Focal point in normalised source coordinates. Defaults to the centre. */ export interface Hotspot { x: number; y: number; } /** Insets from each edge, normalised. `{ top: 0.1 }` discards the top tenth of the image. */ export interface Crop { top: number; right: number; bottom: number; left: number; } /** A rectangle in normalised source coordinates: `{0,0,1,1}` is the whole image. */ export interface CropRect { x: number; y: number; width: number; height: number; } /** The columns as they come off a media row, any of which may be null. */ export interface MediaCropSource { width?: number | null; height?: number | null; hotspot_x?: number | null; hotspot_y?: number | null; crop_top?: number | null; crop_right?: number | null; crop_bottom?: number | null; crop_left?: number | null; } export declare const DEFAULT_HOTSPOT: Hotspot; export declare const NO_CROP: Crop; /** * Read the hotspot off a media row, falling back to the centre. * * Centre rather than "unset" because every consumer needs *a* focal point, and making each one * decide would guarantee they disagreed. */ export declare function hotspotOf(media: MediaCropSource): Hotspot; /** * Read the crop off a media row, discarding a nonsensical one. * * Opposite insets summing to 1 or more would leave a zero-width region, and everything downstream * divides by that. A stored crop that bad can only come from a bug or a hand-written API call; * treating it as "no crop" keeps a broken value from turning into a broken page. */ export declare function cropOf(media: MediaCropSource): Crop; /** The cropped region as a rectangle, before any target shape is applied. */ export declare function cropRect(crop: Crop): CropRect; /** * The region of the source to show for a target aspect ratio. * * Two steps, in this order: * * 1. Take the crop. Everything outside it is discarded — the editor has said it is not part of the * picture, so no target shape may reach back into it. * 2. Inside that, take the largest rectangle of the requested aspect ratio and slide it so the * hotspot sits at its centre, clamped to stay within the crop. * * Clamping rather than letting the frame overhang is what makes the result always a real region of * a real image. A hotspot near an edge pulls the frame as far as it can and then stops, which is * the behaviour an editor expects from "keep this face in shot". * * `targetAspect` is width ÷ height. When the source's pixel dimensions are unknown the crop is * assumed to already be the right shape and is returned as-is — the honest answer, since without * dimensions there is no way to know how a normalised rectangle maps to proportions. */ export declare function resolveCrop(media: MediaCropSource, targetAspect: number, hotspotOverride?: Hotspot, cropOverride?: Crop): CropRect; /** * CSS that renders exactly `rect` filling its container, as a background image. * * Background rather than `object-fit: cover` with `object-position`: object-position can only slide * the *whole* image within the container's overflow, so it can express a hotspot but not a crop — * the cropped-away edges would still be on screen. Scaling the background up by the inverse of the * rectangle and then positioning it is the only pure-CSS way to show a sub-region of an image. * * The position percentages are the standard background-position identity: a background scaled * larger than its box positions by the fraction of the *overflow*, so `x / (1 - width)` is the * offset that puts the rectangle's left edge at the container's left edge. When the rectangle * spans the full axis there is no overflow to divide by, and any value is equivalent. * * This is how the admin previews four crops of one file without generating a single image. A * delivery layer would pass the same rectangle to an image CDN instead. */ export interface CropBackground { backgroundSize: string; backgroundPosition: string; } export declare function cropBackground(rect: CropRect): CropBackground; /** * The same rectangle, as an `` scaled and offset inside an `aspect-ratio` box. * * `cropBackground` is the right answer for the editor's previews, where the frames are decorative * and repeated. It is the wrong one for a page: a background image has no `alt`, is not fetched by * `srcset`, and is skipped by every image-aware crawler. Delivery needs a real ``, so the * element is scaled up by the inverse of the rectangle and offset instead. * * No distortion, given the container carries the same aspect ratio the rectangle was resolved for: * `resolveCrop` returns a rectangle whose *real-pixel* aspect is the target, so scaling width and * height by their own inverses lands back on the image's natural proportions. That precondition is * why the component owns the wrapper rather than leaving it to a caller's CSS. */ export interface CropFrame { width: string; height: string; left: string; top: string; } export declare function cropFrame(rect: CropRect): CropFrame; /** * `object-position` for an image laid over a box with `object-fit: cover`. * * Two uses, and they are the same calculation. A container whose shape is not known in advance — * a band as tall as the text over it — has no ratio to resolve a rectangle against, and the hotspot * is all that survives. And a *server-cropped* image is rendered with `cover` so that a transform * which did not happen degrades to approximately-right framing instead of the wrong picture. * * The crop is an approximation here and cannot be more: `object-position` slides the whole image * within its container's overflow, so it can express a focal point but not a sub-rectangle — the * cropped-away edges are still on screen. Centring what the editor kept is the closest it gets, and * a hotspot outranks that centre because it is the more specific statement. */ export declare function coverPosition(media: MediaCropSource): string; /** * A short, deterministic stamp of exactly the values `resolveCrop` reads. * * **This exists because `immutable` was a lie on server-cropped variants.** The media route sends * `public, max-age=31536000, immutable`, justified by the storage key containing the asset's id — * replacing an image writes a new key, so the bytes behind a key never change. That is true of the * *original* and false of a `?ar=` variant, whose rectangle is resolved from `hotspot_*` and * `crop_*`: columns an editor changes from a screen built for changing them. Measured on a live * site, an edited focal point left the page serving a year-cached crop of the old one, and there * was no remediation at all — the route emits no `cache-tag`, so no purge could target it, and no * purge reaches a browser cache anyway. * * Putting the stamp in the URL makes the header honest instead of shortening the lie: a changed * focal point is a different picture, so it gets a different address and the old entry is simply * abandoned. The consumer already holds `hotspot` and `crop` on `DeliveryMedia`, so it can compute * this without a round trip and without a new field on the wire. * * **The route never parses it.** It resolves the rectangle from the row it is already reading, so * the stamp is a cache key and nothing else — `parseMediaVariant` ignoring it is the design, not an * omission. A stamp on its own therefore still answers the untouched original, which is what an * identity variant should do. */ export declare function cropStamp(media: MediaCropSource): string; /** * The resolved rectangle in source pixels, for a resizer that crops by pixel offsets. * * `undefined` when the source's dimensions were never read, because a normalised rectangle cannot * be turned into pixels without them — and an unrecognised upload is exactly the case where * guessing would produce a visibly wrong crop rather than a missing one. */ export declare function cropRectPixels(media: MediaCropSource, targetAspect: number): { left: number; top: number; width: number; height: number; } | undefined; /** * The aspect ratios the editor previews. * * The point of showing several at once is that an editor picking a focal point is making one * decision that plays out in every shape the image will ever appear in — and they cannot judge it * from a single frame. These are the common ones; a host site with unusual shapes can pass its own. */ export declare const PREVIEW_ASPECTS: { label: string; ratio: number; hint: string; }[];