@v4/image
The pure image core (contracts, focal maths, srcSet, resolver) and its providers: @v4/image-contentful, -scoreplay and -cloudinary.
Overview
Images are served through a small pluggable system of four packages. This page covers all four, because each provider is a single adapter file over the same core:
| Package | What it is | In this repo |
|---|---|---|
@v4/image |
The PURE core: the contracts, the focal-point maths, srcSet building and the resolver |
Used everywhere |
@v4/image-contentful |
The Contentful Images API adapter — the terminal fallback | On |
@v4/image-scoreplay |
The ScorePlay dynamic-crop CDN adapter | On: the primary provider |
@v4/image-cloudinary |
The Cloudinary adapter | Present, not enabled |
The providers are described in Providers. How editors set images and focal points, and how ScorePlay was set up, is in the Images and focal points guide; the architecture is in Images.
@v4/image has no SDK, React or Node dependency — no runtime dependencies at all — so @v4/model and @v4/media can both depend on it, and each provider package depends on nothing but @v4/image, ready to be extracted on its own. It is the single home of the focal-point maths that @v4/model and @v4/media once each carried a copy of. All four packages are raw TypeScript with no build step; the app lists the three it uses in transpilePackages.
All four came from the template and are on /sync-upstream's shared paths: template changes to them arrive as sync pull requests, so Euro League-specific logic does not belong in them — it goes in club.config.ts and apps/web/src/lib/images.ts.
How an image is resolved
- The model builds an
ImageRef.imageRefFrominpackages/model/src/core/transforms/image-source.tsturns anImageentry into a ref holding the Contentful asset (native), the Focal Point app's pixel point and, when the entry has one, the ScorePlay descriptor underraw.scoreplayImage. The ref is built when EITHER source exists. - A surface asks for a crop. It passes an
ImageRequest— width, aspect,widthsandsizes— through@v4/content-app/images(slotImage,pictureSlot,resolveImageView). - The resolver tries each adapter in order. The first adapter that returns a view wins; an adapter returns
undefinedfor a ref that is "not mine". ScorePlay goes first, Contentful last.
This order is the source precedence rule: a ScorePlay picture, when set, is always the image; the Contentful asset is the fallback; with neither there is no image, never a broken one. preferredImage applies the same rule for surfaces that never ask the resolver for a crop (below).
@v4/image entry points
| Import | Exports |
|---|---|
@v4/image |
Types ImageRef, ImageRequest, ImageView, ImageAdapter, NamedRegion, ImageResolver; NAMED_REGIONS, nearestNamedRegion, objectPositionFromNormalised, objectPositionFromPixels, createResolver, srcSetFor |
@v4/image/focal |
The focal maths alone (packages/model's image-source.ts imports from here) |
@v4/image/resolve |
createResolver and ImageResolver alone |
There is no ./srcset subpath: srcSetFor is exported only from the root.
The contracts (src/types.ts)
ImageRef— what the CMS delivers, before any CDN crop:native(url,alt,width,height) — the Contentful asset, always the fallback;focalPointPx— the Focal Point app's point, in pixels of the original asset;caption,attribution;raw— provider descriptors exactly as the CMS delivers them. Each adapter reads and validates its OWN key; the core never interprets them.
ImageRequest— what a surface asks for:cropWidth(device pixels),aspectorheight,format(webp,jpg,png,avif), and eitherdprSetorwidths+sizes.widthswins overdprSet. A density pair cannot follow the layout: a phone at DPR 2 took the2xcandidate — twice the desktop width — so a 412px phone fetched the 2880 band. Withwidthsandsizesthe browser picks what the slot needs.
ImageView— the serialisable result a component renders:url,alt, optionalwidth,height,objectPosition(omitted when centred),srcSet,sizes.ImageAdapter—{ id, resolve(ref, request) → ImageView | undefined }.
Focal maths (src/focal.ts)
objectPositionFromPixels(point, width, height)— a pixel point to an"x% y%"string, rounded to two decimals and clamped to 0–100. Junk input returnsundefined, and the browser centres the image.objectPositionFromNormalised(point)— the same from a 0–1 point (ScorePlay, the media platform).nearestNamedRegion(point)— maps a 0–1 point onto a thirds grid and returns one of the nineNAMED_REGIONS(top_left…center…bottom_right); bad input returns"center". Contentful and ScorePlay can crop only to a named region, so this is their coarse CDN anchor; the fine framing is always CSSobject-positionfrom the exact point.
Resolver and srcSet
createResolver(adapters)takes an ORDERED list; the first view wins. Wiring is explicit rather than a global registry, so there is no module-singleton ordering hazard.srcSetFor(request, buildAt)— withwidths, a de-duplicated, sortedw-descriptor set plus the request'ssizes; without them,1x/2x… ofcropWidthfromdprSet; with neither,{}.
How the app wires it
| File | Role |
|---|---|
apps/web/club.config.ts |
images: { defaultProvider: "scoreplay", scoreplay: {} }. scoreplay: {} enables the provider by its presence |
apps/web/src/lib/images.ts |
The ONE configuration point: builds [scoreplay (when enabled), contentful] from club.config.images and calls configureImages(createResolver(adapters)). Importing it configures the resolver as a side effect; the [locale] layout, lib/chrome.ts and lib/error-page.ts import it |
packages/content-app/src/lib/images.ts |
@v4/content-app/images: configureImages, resolveImageView, slotImage (with a legacy fallback when no resolver is configured), pictureSlot, PICTURE_REQUESTS, the SlotImage and PictureView shapes |
packages/template-config/src/derive.ts |
deriveImageHosts adds each enabled provider's CDN host to next.config's remotePatterns |
apps/web/next.config.ts |
transpilePackages lists @v4/image, @v4/image-contentful, @v4/image-scoreplay |
The images block is typed in packages/template-config/src/schema.ts: defaultProvider (contentful | scoreplay | cloudinary, default contentful), contentful: { proxyHost? }, scoreplay: { signatureSecret? } | false, cloudinary: { cloudName } | false (both default false).
A comment in apps/web/src/lib/images.ts and in club.config.ts still says Contentful serves everything "until the scoreplayImage field + fragment land". They have landed: the field, the fragments and the transforms are live.
Other consumers
@v4/modelbuilds every ref (imageRefFrom) and the plain view (preferredImage); about a dozen core, content and basketball transforms useImageRefas a type. A surface that never asks the resolver still gets the right picture frompreferredImage: for a ScorePlay image, its CDN URL atw=1600with transform parameters, because the CDN fails on a bare URL.@v4/mediausesnearestNamedRegionandobjectPositionFromNormalisedfor platform videos. ItscropUrl(packages/media/src/views.ts) builds the ScorePlay and Contentful crop parameters itself rather than calling the adapters, so the two must be changed together.@v4/competition-engineand@v4/basketball-appuse the types only.
Scripts and tests
Each package has test / test:watch (vitest), lint and check-types. The core's suites are focal, resolve and srcset; each provider has one src/adapter.test.ts.
Providers
Each provider is one package exporting one adapter factory over @v4/image, and nothing else. The resolver tries them in the order apps/web/src/lib/images.ts lists them: ScorePlay first, Contentful last.
@v4/image-contentful — the fallback
The Contentful Images API adapter, and the terminal fallback: it resolves any ref whose native.url is not empty, so it is always placed last. It is always on.
Exports: createContentfulAdapter(options?) → an ImageAdapter with id: "contentful", and the type ContentfulAdapterOptions:
proxyHost— rewrites the asset host to a Contentful proxy (such ascontentfulproxy.stadion.io);club.config.tsdoes not set it, so this site serves assets from Contentful's own host;quality— theqparameter, 1–100, default 80.
Behaviour (packages/image-contentful/src/adapter.ts):
- The URL is normalised:
//…andhttp:becomehttps:, then the proxy host is applied if set. - An SVG is served as is, with no parameters: the Images API rasterises an SVG the moment it is given
fm/w, turning a crisp logo into a bitmap. Logos share the photographs'pictureSlotpath, so the guard lives here. - Every other request gets
fm(defaultwebp),wandq. - When the surface fixes an aspect or a height, it also gets
handfit=fill, andf=<named region>from the pixel focal point normalised against the asset size. Contentful cannot crop to an x/y, only to a named region, andfonly takes effect withfit=fill. The fine framing is CSSobject-positionfrom the exact pixel point. srcSetandsizescome fromsrcSetFor.
Configuration: images.contentful.proxyHost in club.config.ts; when set, deriveImageHosts admits the proxy host. Euro League sets no proxy host.
@v4/image-scoreplay — Euro League's primary provider
The ScorePlay dynamic-crop CDN adapter. Enabled on this site: club.config.ts sets images: { defaultProvider: "scoreplay", scoreplay: {} }.
Exports: createScoreplayAdapter(options?) (id: "scoreplay"; the one option is field, default scoreplayImage), SCOREPLAY_FIELD ("scoreplayImage"), SCOREPLAY_CDN_HOST ("dynamic-crop-cdn.scoreplay.io"), and the types ScoreplayAdapterOptions and ScoreplayDescriptor.
The descriptor is the JSON the ScorePlay picker app writes onto the Image type's scoreplayImage field. The adapter reads cdnUrl (preferred) or url, width, height, focalPoint (NORMALISED, 0–1), signature, caption and credit; other keys are ignored.
Behaviour (packages/image-scoreplay/src/adapter.ts):
- It reads
ref.raw[field]. No descriptor, or no URL on it, returnsundefined, and the Contentful adapter takes the image. - Parameters:
fmt(defaultwebp),f=<nearestNamedRegion(focalPoint)>andw; with an aspect or height,fit=fillandh; when the descriptor carries asignature,sig. altis the Contentful asset's alt, then the descriptor'scaption, then empty.objectPositioncomes from the normalised focal point.
Two CDN gotchas:
- The CDN answers 502 on a bare URL, so transform parameters are always sent — by the adapter and by
preferredImage's un-cropped fallback (w=1600) in@v4/model. - The CDN ignores anything added after an existing
?. A URL that already has a query (a signed one) is returned unchanged, with nosrcSet.
Configuration: images.scoreplay: { signatureSecret? } | false, default false. Any value other than false enables it and makes deriveImageHosts admit dynamic-crop-cdn.scoreplay.io. signatureSecret is reserved and unused: the adapter only forwards a signature that is already on the descriptor, and signing is not built.
The data side:
- The
scoreplayImagefield was added toImagebypackages/model/migrations/club/0001-add-scoreplay-image-field.cjsand moved first, onto the picker, byclub/0013-scoreplay-image-first.cjs. - The core, content, basketball and video fragments select it;
providerDescriptors,imageRefFromandpreferredImageinpackages/model/src/core/transforms/image-source.tscarry it onto the ref. - The editor-side set-up is in the ScorePlay set-up guide.
@v4/image-cloudinary — available, not enabled
Not used on this site. apps/web does not depend on it or transpile it, images.cloudinary takes its default false, and nothing in the repo imports it. It is here because it came with the template and is on /sync-upstream's shared paths: fully implemented and tested, ready to enable.
Exports: createCloudinaryAdapter(options?) (id: "cloudinary"; options cloudName, the fallback when the descriptor omits it, and field, default cloudinaryImage), CLOUDINARY_FIELD ("cloudinaryImage"), and the types CloudinaryAdapterOptions and CloudinaryDescriptor.
The descriptor is the Cloudinary Contentful app's JSON field: one asset, an array (the first is taken) or null. The adapter reads title, description, file.url, file.urlObject (baseUrl, cloudName, publicId, resourceType, type) and file.details.image (width, height).
Behaviour (packages/image-cloudinary/src/adapter.ts):
- Cloudinary is the only provider that crops to the exact focal point:
g_xy_center,x_…,y_…when a pixel focal point and the asset size are known, otherwiseg_auto. - Transforms:
c_fill,f_auto,q_auto,<gravity>,w_…, plush_…with an aspect or height, in a URL of the form<baseUrl>/<cloudName>/<resourceType>/<type>/<transforms>/<publicId>. - Without a cloud name and a
publicIdit serves the rawfile.url(as https) untransformed, with nosrcSet. altis the Contentful asset's alt, thendescription, thentitle, then empty.
To enable it:
- Set
images.cloudinary: { cloudName }inclub.config.ts— this also admitsres.cloudinary.com. - Add
@v4/image-cloudinarytoapps/web/package.jsonand totranspilePackagesinapps/web/next.config.ts. - Add
createCloudinaryAdapter(…)to the list inapps/web/src/lib/images.ts, before Contentful. - Migrate the descriptor field onto the
Imagetype, inmigrations/club/. - Carry
raw.cloudinaryImageinimageRefFrom: todayproviderDescriptorscarries onlyscoreplayImage.