Image Handling
Images are rarely shown at their original size. Instead of pre-generating every size, the library creates thumbnails on demand the first time they are requested and caches them. For that to be safe and predictable, you declare the allowed sizes once, as variants, and both server and client work from the same list. This chapter explains variants, the thumbnail URL format and the helpers that build those URLs.
Image variants
Define a variant map once — it is shared between server and client:
import { type ImageVariantMap } from '@atom-forge/attachment';
export const imageVariants = {
thumb: { w: 200, h: 200 },
avatar: { w: 400, h: 400 },
wide: { w: 800, h: 800 },
index: { w: 400 },
tall: { h: 400 },
} satisfies ImageVariantMap;
export type AppImageVariants = typeof imageVariants;
The thumbnail service derives all allowed sizes from this map automatically (1×, 2×, 3× per variant). Adding or removing a variant here updates the server allowlist immediately.
Thumbnail URL schema
/{thumbPrefix}/{groupId}-{version}/{mode-segment}/{filename}.webp
Mode segment:
| Format |
Example |
When |
{mode}.{w}x{h} |
e.400x400 |
named crop mode |
{focus12}.{w}x{h}.{hash4} |
0s0s0o0o0o7s.400x400.ab1c |
manual focus point |
Crop modes:
| Mode |
Strategy |
e |
entropy (default fallback) |
a |
attention |
c |
centre cover |
b |
box / inside (fits within, no enlarge) |
Size notation: 400x400 = fixed, 400x0 = width-only, 0x400 = height-only.
Density
The client multiplies dimensions by the density before building the URL — the server has no concept of density:
avatar @1x → e.400x400
avatar @2x → e.800x800
avatar @3x → e.1200x1200
buildVariantUrl(variants, attachment, variant, density?, mode?)
Type-safe URL builder. Accepts any AttachmentData<unknown> — no ImgStat required.
import { buildVariantUrl } from '@atom-forge/attachment/client';
buildVariantUrl(imageVariants, attachment, 'avatar')
buildVariantUrl(imageVariants, attachment, 'avatar', 2)
buildVariantUrl(imageVariants, attachment, 'thumb', 1, 'c')
Mode resolution: explicit arg → meta.img.c (if stored by imgstat) → 'e'.
When a URL is built without an explicit crop mode, the library decides which one to use in a fixed order. The flow below shows that decision.
flowchart LR
A{Explicit mode arg?} -- yes --> R[use it]
A -- no --> B{meta.img.c stored?}
B -- yes --> S[use stored mode]
B -- no --> E[fallback: e]
buildImageUrl(attachment, width, height, mode?, prefix?)
Low-level builder. Requires AttachmentData<{ img: ImgStat }> when mode is omitted.
import { buildImageUrl } from '@atom-forge/attachment/client';
buildImageUrl(attachment, 400, 400)
buildImageUrl(attachment, 400, 400, 'e')
buildImageUrl(attachment, 400, 0, 'a')
For manual focus, meta.img.ch must be present (populated by imgstat + FocusHashFn).
makeAttachmentHandler<M, V>(variants, options?)
Client-side factory. Reads the entity's attachments JSON field directly — no server round-trip.
import { makeAttachmentHandler } from '@atom-forge/attachment/client';
const h = makeAttachmentHandler(imageVariants);
const item = h.course(course).avatar.first;
const all = h.user(user).gallery.all;
item?.filename
item?.url
item?.meta
item?.img.avatar()
item?.img.avatar(2)
item?.img.thumb(1, 'c')
h.user(user).gallery.find('*.jpg')
h.user(user).gallery.last?.img.wide(2)
Animated images
Event: thumbnail:generated
The image server optionally fires thumbnail:generated after writing a cache file — useful for monitoring or pre-warming:
{ type: 'thumbnail:generated', groupId, filename, modeSeg }
Image Handling
Images are rarely shown at their original size. Instead of pre-generating every size, the library creates thumbnails on demand the first time they are requested and caches them. For that to be safe and predictable, you declare the allowed sizes once, as variants, and both server and client work from the same list. This chapter explains variants, the thumbnail URL format and the helpers that build those URLs.
Image variants
Define a variant map once — it is shared between server and client:
import { type ImageVariantMap } from '@atom-forge/attachment'; export const imageVariants = { thumb: { w: 200, h: 200 }, avatar: { w: 400, h: 400 }, wide: { w: 800, h: 800 }, index: { w: 400 }, // width-only → proportional height tall: { h: 400 }, // height-only → proportional width } satisfies ImageVariantMap; export type AppImageVariants = typeof imageVariants;The thumbnail service derives all allowed sizes from this map automatically (1×, 2×, 3× per variant). Adding or removing a variant here updates the server allowlist immediately.
Thumbnail URL schema
Mode segment:
{mode}.{w}x{h}e.400x400{focus12}.{w}x{h}.{hash4}0s0s0o0o0o7s.400x400.ab1cCrop modes:
eacbSize notation:
400x400= fixed,400x0= width-only,0x400= height-only.Density
The client multiplies dimensions by the density before building the URL — the server has no concept of density:
buildVariantUrl(variants, attachment, variant, density?, mode?)Type-safe URL builder. Accepts any
AttachmentData<unknown>— noImgStatrequired.import { buildVariantUrl } from '@atom-forge/attachment/client'; buildVariantUrl(imageVariants, attachment, 'avatar') // 1x, entropy fallback buildVariantUrl(imageVariants, attachment, 'avatar', 2) // 2x buildVariantUrl(imageVariants, attachment, 'thumb', 1, 'c') // centre cropMode resolution: explicit arg →
meta.img.c(if stored byimgstat) →'e'.When a URL is built without an explicit crop mode, the library decides which one to use in a fixed order. The flow below shows that decision.
flowchart LR A{Explicit mode arg?} -- yes --> R[use it] A -- no --> B{meta.img.c stored?} B -- yes --> S[use stored mode] B -- no --> E[fallback: e]buildImageUrl(attachment, width, height, mode?, prefix?)Low-level builder. Requires
AttachmentData<{ img: ImgStat }>whenmodeis omitted.import { buildImageUrl } from '@atom-forge/attachment/client'; buildImageUrl(attachment, 400, 400) // uses meta.img.c buildImageUrl(attachment, 400, 400, 'e') // explicit mode buildImageUrl(attachment, 400, 0, 'a') // width-onlyFor manual focus,
meta.img.chmust be present (populated byimgstat+FocusHashFn).makeAttachmentHandler<M, V>(variants, options?)Client-side factory. Reads the entity's
attachmentsJSON field directly — no server round-trip.import { makeAttachmentHandler } from '@atom-forge/attachment/client'; const h = makeAttachmentHandler(imageVariants); // options: { servePrefix?: string, thumbPrefix?: string } (defaults: '/file', '/img') const item = h.course(course).avatar.first; // ItemView | undefined const all = h.user(user).gallery.all; // ItemView[] item?.filename // 'photo.jpg' item?.url // '/file/abc123-3/photo.jpg' item?.meta // unknown (cast as needed) item?.img.avatar() // '/img/abc123-3/e.400x400/photo.jpg.webp' item?.img.avatar(2) // '/img/abc123-3/e.800x800/photo.jpg.webp' item?.img.thumb(1, 'c') // '/img/abc123-3/c.200x200/photo.jpg.webp' h.user(user).gallery.find('*.jpg') // ItemView[] — glob filter h.user(user).gallery.last?.img.wide(2)Animated images
Event:
thumbnail:generatedThe image server optionally fires
thumbnail:generatedafter writing a cache file — useful for monitoring or pre-warming:// payload { type: 'thumbnail:generated', groupId, filename, modeSeg }