Name your image sizes once with lenses
How CalmLens lenses turn resize and format rules into short, versioned CDN URLs, and how to define them from your deploy script.
Most applications show the same image at several sizes. A grid needs a small thumbnail, a detail page needs something wider, and you probably want a modern format wherever the browser supports it. Spelling out width, fit and format in every URL works until you need to change one of those values and have to find every place that uses it.
A lens gives that set of rules a name. It is a named transform that the CalmLens CDN applies when it serves an asset. The response is a derived image; CalmLens does not create a new asset record or store a second copy you have to manage. You request it by adding its name to the asset's URL.
Request a lens by name
Asset content and previews live under https://cdn.calmlens.com/v1. To apply a lens, append /@ and the lens identifier to either path:
/assets/:projectId/:assetIdentifier/content/@thumbnail
/assets/:projectId/:assetIdentifier/preview/@thumbnailThe @ prefix tells the CDN that what follows is a lens name rather than a transform selector, so the two forms never collide. assetIdentifier can be the asset ID or your own externalId.
Request it through an SDK or over plain HTTP:
const response = await client.cdn.serve({
assetIdentifier: assetId,
lens: "thumbnail",
});
If you name a lens that doesn't exist, the CDN returns 404 with the error code lens/not-found.
Built-in lenses
Every project can use a set of built-in lenses without creating anything. They all use scale-down, so a small source image is never enlarged.
| Lens | Output |
|---|---|
thumbnail | 128 pixels wide, PNG |
sm | 640 pixels wide, WebP |
md | 768 pixels wide, WebP |
lg | 1024 pixels wide, WebP |
xl | 1280 pixels wide, WebP |
2xl | 1536 pixels wide, WebP |
modern | Original size, AVIF |
Each sized lens also has a -modern variant, such as thumbnail-modern or lg-modern, that outputs AVIF instead. The widths follow common responsive breakpoints, so a srcset can list several of them for one image.
Built-in names are resolved before your project's own lenses, so give custom lenses identifiers of their own.
Custom lenses
When the built-in sizes don't fit, define your own. A custom lens belongs to one project and has an identifier, a display name, an asset type, a version and an ordered list of transforms. Identifiers use lowercase letters, numbers and hyphens, up to 50 characters.
The transform type is imageTransform. Its options cover width and height, fit (scale-down, contain, pad, squeeze, cover or crop), format (jpg, png, webp, avif or gif), quality, rotate and flip, plus adjustments such as blur, sharpen, gravity and trim.
Define your lenses in code so they travel with your deploys. client.ensureLens() creates or updates a single lens:
import { CalmLensClient } from "@calmlens/sdk";
const client = new CalmLensClient({
apiKey: "your-api-key",
projectId: "your-project-id"
});
const response = await client.ensureLens({
"identifier": "card",
"name": "Card",
"type": "image",
"active": true,
"version": 1,
"transforms": [
{
"type": "imageTransform",
"options": {
"fit": "cover",
"width": 600,
"height": 400,
"format": "webp"
}
}
]
});
client.ensureLenses() takes a lenses array and handles several definitions in one request. Both calls are idempotent: sending the same version with the same definition succeeds without changing anything, so you can run them on every deploy. Managing lenses through the API requires a project API key.
Versions protect your cache
The CDN caches derived images. Without versions, widening the card lens could leave cached 600-pixel images in circulation next to the new size until those entries expired.
CalmLens handles this with versions. Any change to a lens must come with a higher version number, and the resolved version is part of the CDN cache key and the ETag. A request after the update can't match an entry cached under the old definition, so the new output appears without a manual purge.
The rules:
- A new lens can start at any positive version.
- Repeating the current version with an identical definition is a no-op.
- Any other change needs a greater version. Versions don't have to be consecutive.
- Reusing or lowering the version for a different definition returns
409withlens/version-conflict.
Always send the complete definition when you update, not only the fields that changed.
Direct transforms for one-off sizes
Not every size deserves a name. For a single use, put the transform in the URL without the @:
/assets/:projectId/:assetIdentifier/content/imageTransform[format=png,width=1024]Direct transforms aren't saved and have no version, so they suit experiments and rare cases. A selector that keeps reappearing across your code is a candidate for a lens. Unknown transform types and malformed selectors return asset/invalid-transform; unsupported options return asset/invalid-transform-options.
Private assets and signed URLs
A lens changes the image, not who may see it. The usual delivery rules still apply: content must be retained, rejected or removed assets are never served, and private assets need authorisation.
For a private asset shown in a browser, create a signed URL and include the lens in the signing request:
import { CalmLensClient } from "@calmlens/sdk";
const client = new CalmLensClient({
apiKey: "your-api-key",
projectId: "your-project-id"
});
const response = await client.assets.createSignedUrl({
"resource": "content",
"lens": "thumbnail",
"expiresInSeconds": 900,
"assetId": "your-asset-id"
});
The signature covers the project, asset, resource, lens and expiry. Someone holding a signed thumbnail URL can't change @thumbnail to @2xl or swap in a direct transform; the signature check fails. You can pass either lens or transform when signing, but not both.
The lenses and transforms guide covers every transform option, the bulk endpoint and the full lens API reference.