Images and focal points

How CMS images reach the page as data-built slots, how an editor's focal point becomes a crop, and how ScorePlay images are set up.

2026-11-07

Images

CMS images travel from Contentful to the page as data, not markup. The model layer normalises an image into a plain view object; the app turns that view into the actual DOM element (the image "slot") and hands it to the @v4/ghost-content / @v4/ghost-core component. The component itself never fetches or knows about Contentful — it receives a ReactNode slot.

The view shape

A card-sized image is normalised to this shape (see packages/model/src/content/transforms/article-card.ts):

image?: {
  url: string;
  alt: string;
  width?: number | undefined;
  height?: number | undefined;
  /** CSS object-position derived from the editor's focal point. */
  objectPosition?: string | undefined;
};

url comes from the Contentful asset, alt from the asset's description (falling back to an empty string, never undefined), and width/height from the asset's intrinsic dimensions. objectPosition is the focal-point crop — covered on the next page.

Beside image the view carries imageRef — the raw, provider-agnostic ImageRef (the native asset plus the pixel focal point) that the @v4/image resolver crops from. image is the un-cropped fallback for when no provider is configured.

The article-detail image (ArticleImageView) carries the same fields plus caption and attribution.

Building the slot

The app builds the DOM element from the view in shared helpers, so listings, the home page hero and carousels all render images identically.

First the crop. slotImage(ref, request, fallback) in packages/content-app/src/lib/images.ts runs the ref through the app-configured @v4/image resolver at a surface's request and falls back to the un-cropped view when no provider is configured. pictureSlot(view, request) is the same for any view that carries its own imageRef (ContentImageView, ArticleImageView, the hero slide, the chrome's pictures). This is how every photograph reaches the CDN with a width: a picture rendered from its bare url is the full-size original, whatever its slot measures.

The requests are shared too. Cards use CARD_IMAGE_REQUEST (packages/content-app/src/lib/article-cards.tsx): a 9:5 crop, because the ELB card well is 9:5 and a square crop covering it cut the top and bottom off every picture:

export const CARD_IMAGE_REQUEST = {
  cropWidth: 640,
  aspect: 9 / 5,
  widths: [320, 480, 640, 828, 1280],
  sizes: "(min-width: 1024px) 664px, (min-width: 768px) 50vw, calc(100vw - 32px)",
} as const;

Every other surface takes one of the PICTURE_REQUESTS in images.ts — band, block, embed, strip, poster, avatar, portrait, logo, crest. A photograph asks for width candidates with the slot's rendered width in sizes, so the browser fetches what the slot needs at the device's density; only the small fixed-size slots (avatar, portrait tile, logo, crest) keep a dprSet: [1, 2] density pair. An aspect is fixed only where the component fixes one — a well that covers freely gets a width-only resize and keeps covering from the focal point. When a rail's layout changes (two-up at md, say), its sizes string changes in the same commit.

Then the element. newsCardProps builds a card's picture as cardImage(slotImage(view.imageRef, CARD_IMAGE_REQUEST, view.image), CARD_IMAGE_REQUEST) (articleCardProps spreads newsCardProps). The slot itself:

export const cardImage = (image, request?, options?: SlotLoading) =>
  image ? (
    <img
      key="card-image"
      src={image.url}
      alt={image.alt}
      {...(image.srcSet !== undefined ? { srcSet: image.srcSet } : {})}
      {...(image.srcSet !== undefined && image.sizes !== undefined ? { sizes: image.sizes } : {})}
      {...loadingProps(options)}
      {...(imageDimensions(request) ?? {})}
      style={{
        width: "100%",
        height: "100%",
        objectFit: "cover",
        ...(image.objectPosition !== undefined ? { objectPosition: image.objectPosition } : {}),
      }}
    />
  ) : null;

Loading and dimensions

  • Lazy by default. loadingProps gives every slot loading="lazy" and decoding="async". React hoists a <link rel=preload> for every picture that is not lazy, and the homepage once queued 69 image preloads ahead of its own hero. The page's main picture (a hero, a listing header) passes { priority: true }: loading="eager" and fetchPriority="high".
  • Declared dimensions. imageDimensions(request) writes the crop's own width/height (from cropWidth and aspect) on the element. CSS still sizes it — height: 100% — so the attributes only reserve the box before the bytes arrive.
  • A key. The element is made on the server and reaches the card inside an array of props, so it carries key="card-image".

Two deliberate choices:

  • A plain <img>, not next/image. CMS asset dimensions vary per entry and the slot is consumer-owned, so the component takes rendered markup rather than a framework image primitive (the @next/next/no-img-element lint is disabled at this one call site with that reason recorded).
  • objectFit: cover fills the slot at whatever aspect ratio the component's layout defines; the focal point then decides which part of the image survives the crop.

Sponsor logos are the exception. A logo must never be cropped, and its shape is the sponsor's, not the layout's. sponsorLogo(), beside cardImage(), builds the SponsorBadge logo as a bare <img src alt> with no inline style: the badge sizes it itself — its own height, the logo's proportions for the width, up to five times the height, and a wider logo scaled down whole. An inline width: 100% or object-fit: cover would beat that rule, which is how a wide wordmark came to be cropped to a square.

Why the slot is built server-side

The transforms run in server components, so the image slot is built there and the client views receive it as already-rendered ReactNode payload. Components stay presentational: they position and size the box, the app supplies the picture.

Focal points

When a slot crops an image (objectFit: cover), the naive result centres the crop and can lop the head off a subject. Editors avoid that by setting a focal point in Contentful's Focal Point app, and the model layer turns it into a CSS object-position so the important part of the image stays in frame at every aspect ratio.

The stored shape

The Focal Point app stores its data on the image's focalPoint field (JSON) as:

{ "focalPoint": { "x": 1065, "y": 442 } }

x/y are in pixels of the original asset — not percentages.

The transform

objectPositionFromFocalPoint() in packages/model/src/core/transforms/image-source.ts (re-exported by the content domain's focal-point.ts, so content imports stand) converts those pixels to object-position percentages against the asset's intrinsic dimensions:

objectPositionFromFocalPoint(image?.focalPoint, width, height);
// { focalPoint: { x: 1065, y: 442 } }, 4260, 1770  →  "25% 24.97%"

It is junk-tolerant by construction: a missing field, a wrong shape, a non-finite number, or a zero/absent width or height all return undefined, and the slot then simply omits objectPosition so the browser falls back to its default 50% 50% centre crop. The percentages are also clamped to 0–100, so an out-of-range focal point can never push the crop off the element.

This is why cardImage() spreads objectPosition conditionally — the property is only written when a valid focal point produced a value.

Where it is applied

The transform is called by every transform that builds an image view from a fragment — in packages/model/src/content/transforms/: article-card.ts (listing cards, home hero, carousels), article-page.ts (the article hero), home-page.ts, content-page.ts, content-blocks.ts and news-listing.ts; and in packages/model/src/core/transforms/site-chrome.ts for the header and footer pictures. All of them go through core/transforms/image-source.ts (imageRefFrom, preferredImage).

The fragments select focalPoint alongside their images, so the crop follows the Contentful image wherever it is shown. When a ScorePlay picture wins, its own normalised focal point frames the crop instead and this field is not read (see the ScorePlay page).

A note on the media domain

The focal-point maths now lives in ONE place: the pure @v4/image core (@v4/image/focal — objectPositionFromPixels, objectPositionFromNormalised, nearestNamedRegion). core/transforms/image-source.ts unwraps the Contentful JSON shape and delegates the pixel maths there — the same module holds imageRefFrom, the one ImageRef builder every surface uses; @v4/image is SDK/react/node-free, so the model layer depends on it without the old video-SDK layering violation that forced the duplication. @v4/media handles two encodings (the platform API can send normalised 0–1 or legacy pixels) and delegates to the same core — packages/media/src/views.ts imports objectPositionFromNormalised and nearestNamedRegion from @v4/image.

ScorePlay (Euro League's image provider)

Euro League serves images through ScorePlay's dynamic-crop CDN (dynamic-crop-cdn.scoreplay.io) rather than the Contentful Images API. This is the pluggable @v4/image provider system configured for one client — Contentful remains the terminal fallback, so nothing breaks while the provider is rolled out.

What is wired (code, live now)

  • apps/web/club.config.ts → images enables the provider: defaultProvider: "scoreplay" with scoreplay: {}. An empty object is "enabled" (as opposed to false); it is all the resolver needs.

  • apps/web/src/lib/images.ts builds the resolver and calls configureImages as a side effect (imported once from apps/web/src/app/[locale]/layout.tsx, the lib/media.ts precedent). The adapter order is ScorePlay first, Contentful last:

    const adapters = [
      ...(images.scoreplay !== false ? [createScoreplayAdapter({})] : []),
      createContentfulAdapter(/* proxyHost? */),
    ];
    configureImages(createResolver(adapters));
    
  • deriveImageHosts (template-config) adds the ScorePlay CDN host to next.config images.remotePatterns automatically because scoreplay is not false.

How an image resolves

createResolver tries each adapter in order and takes the first that returns a view. So a Contentful Image entry that carries a scoreplayImage descriptor ({ cdnUrl, focalPoint: { x, y } normalised 0–1, signature? }) is served by ScorePlay with a focal-aware crop; an Image without the descriptor falls through to the Contentful Images API; and an Image with neither never draws a broken image. The plain views the surfaces build follow the same rule (preferredImage / imageRefFrom in packages/model/src/core/transforms/image-source.ts), except the 404 page's Error Image, which still reads only the Contentful asset.

The Image entry an editor fills in

The field, the fragments and the transforms are all live; what an editor meets on an Image entry is set by three club migrations:

  • ScorePlay Image comes first (club/0001 added it, club/0013 moved it above the Contentful image and put it on the ScorePlay Picker app). When it is set, it is the picture shown on every surface except the 404 page's Error Image, which still reads only the Contentful asset.
  • Image — the Contentful asset — is the optional fallback, shown only when no ScorePlay image is set (for crests, PR announcements and the like). club/0019 made it optional: until then an entry with only a ScorePlay pick could not be published.
  • Focal Point frames the Contentful image only; a ScorePlay picture carries its own focal point in the descriptor. Its marketplace app draws "Focal point not set" in red whenever the field is empty — that is the app's own styling, and it never stops an entry being published.
  • Caption may be left empty on a ScorePlay pick. An image embed (an Embed Block, or an image in an article's Body Copy) then shows the caption ScorePlay holds for the photo, which the ScorePlay Picker stores in the descriptor's caption key. A Contentful-only image shows the asset's description instead (mediaDescription in image-source.ts).

An entry with neither picture can be published too, and no surface then draws a broken image. Most leave the picture out, but a promo block, an Article Promo or a news card keeps its empty picture frame, the navigation promo shows a grey placeholder, and the 404 page shows its default background.

signatureSecret

The images.scoreplay schema reserves a signatureSecret env marker for signed crop URLs. The adapter does not consume it yet, so it is intentionally omitted from club.config. Add it (as env.server("SCOREPLAY_SIGNATURE_SECRET")) when signed URLs are implemented — and note the template-config { … } | false union does not currently map an env marker through the union arm, which needs a small input-typing fix at that time.