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.
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.
loadingPropsgives every slotloading="lazy"anddecoding="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"andfetchPriority="high". - Declared dimensions.
imageDimensions(request)writes the crop's ownwidth/height(fromcropWidthandaspect) 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>, notnext/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-elementlint is disabled at this one call site with that reason recorded). objectFit: coverfills 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→imagesenables the provider:defaultProvider: "scoreplay"withscoreplay: {}. An empty object is "enabled" (as opposed tofalse); it is all the resolver needs.apps/web/src/lib/images.tsbuilds the resolver and callsconfigureImagesas a side effect (imported once fromapps/web/src/app/[locale]/layout.tsx, thelib/media.tsprecedent). 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 tonext.configimages.remotePatternsautomatically becausescoreplayis notfalse.
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/0001added it,club/0013moved 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/0019made 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
captionkey. A Contentful-only image shows the asset's description instead (mediaDescriptioninimage-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.