@v4/image

The pure image core (contracts, focal maths, srcSet, resolver) and its providers: @v4/image-contentful, -scoreplay and -cloudinary.

2026-11-07

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

  1. The model builds an ImageRef. imageRefFrom in packages/model/src/core/transforms/image-source.ts turns an Image entry into a ref holding the Contentful asset (native), the Focal Point app's pixel point and, when the entry has one, the ScorePlay descriptor under raw.scoreplayImage. The ref is built when EITHER source exists.
  2. A surface asks for a crop. It passes an ImageRequest — width, aspect, widths and sizes — through @v4/content-app/images (slotImage, pictureSlot, resolveImageView).
  3. The resolver tries each adapter in order. The first adapter that returns a view wins; an adapter returns undefined for 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), aspect or height, format (webp, jpg, png, avif), and either dprSet or widths + sizes.
    • widths wins over dprSet. A density pair cannot follow the layout: a phone at DPR 2 took the 2x candidate — twice the desktop width — so a 412px phone fetched the 2880 band. With widths and sizes the browser picks what the slot needs.
  • ImageView — the serialisable result a component renders: url, alt, optional width, 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 returns undefined, 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 nine NAMED_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 CSS object-position from 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) — with widths, a de-duplicated, sorted w-descriptor set plus the request's sizes; without them, 1x/2x… of cropWidth from dprSet; 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/model builds every ref (imageRefFrom) and the plain view (preferredImage); about a dozen core, content and basketball transforms use ImageRef as a type. A surface that never asks the resolver still gets the right picture from preferredImage: for a ScorePlay image, its CDN URL at w=1600 with transform parameters, because the CDN fails on a bare URL.
  • @v4/media uses nearestNamedRegion and objectPositionFromNormalised for platform videos. Its cropUrl (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-engine and @v4/basketball-app use 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 as contentfulproxy.stadion.io); club.config.ts does not set it, so this site serves assets from Contentful's own host;
  • quality — the q parameter, 1–100, default 80.

Behaviour (packages/image-contentful/src/adapter.ts):

  • The URL is normalised: //… and http: become https:, 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' pictureSlot path, so the guard lives here.
  • Every other request gets fm (default webp), w and q.
  • When the surface fixes an aspect or a height, it also gets h and fit=fill, and f=<named region> from the pixel focal point normalised against the asset size. Contentful cannot crop to an x/y, only to a named region, and f only takes effect with fit=fill. The fine framing is CSS object-position from the exact pixel point.
  • srcSet and sizes come from srcSetFor.

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, returns undefined, and the Contentful adapter takes the image.
  • Parameters: fmt (default webp), f=<nearestNamedRegion(focalPoint)> and w; with an aspect or height, fit=fill and h; when the descriptor carries a signature, sig.
  • alt is the Contentful asset's alt, then the descriptor's caption, then empty. objectPosition comes 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 no srcSet.

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 scoreplayImage field was added to Image by packages/model/migrations/club/0001-add-scoreplay-image-field.cjs and moved first, onto the picker, by club/0013-scoreplay-image-first.cjs.
  • The core, content, basketball and video fragments select it; providerDescriptors, imageRefFrom and preferredImage in packages/model/src/core/transforms/image-source.ts carry 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, otherwise g_auto.
  • Transforms: c_fill,f_auto,q_auto,<gravity>,w_…, plus h_… with an aspect or height, in a URL of the form <baseUrl>/<cloudName>/<resourceType>/<type>/<transforms>/<publicId>.
  • Without a cloud name and a publicId it serves the raw file.url (as https) untransformed, with no srcSet.
  • alt is the Contentful asset's alt, then description, then title, then empty.

To enable it:

  1. Set images.cloudinary: { cloudName } in club.config.ts — this also admits res.cloudinary.com.
  2. Add @v4/image-cloudinary to apps/web/package.json and to transpilePackages in apps/web/next.config.ts.
  3. Add createCloudinaryAdapter(…) to the list in apps/web/src/lib/images.ts, before Contentful.
  4. Migrate the descriptor field onto the Image type, in migrations/club/.
  5. Carry raw.cloudinaryImage in imageRefFrom: today providerDescriptors carries only scoreplayImage.