Merged in feature/SW-3365-blurry-images (pull request #2746)

Feature/SW-3365 reduce upscaling of images (fix blurry images)

* fix: handle when images are wider than 3:2 but rendered in a 3:2 container

* use dimensions everywhere applicable

* fall back to using <img sizes='auto' /> if possible

* imageLoader: never nest

* remove empty test file


Approved-by: Anton Gunnarsson
Approved-by: Matilda Landström
This commit is contained in:
Joakim Jäderberg
2025-09-02 17:52:31 +00:00
parent f77520cd04
commit 8c3f8c74db
25 changed files with 219 additions and 59 deletions

View File

@@ -43,6 +43,7 @@ export default async function MyPages({}: PageArgs<
alt={hero_image.meta.alt || hero_image.meta.caption || ""}
src={hero_image.url}
focalPoint={hero_image.focalPoint}
dimensions={hero_image.dimensions}
sizes="100vw"
fill
priority

View File

@@ -39,6 +39,7 @@ export default function ContentCard({
fill
sizes="(min-width: 768px) 413px, 100vw"
focalPoint={image.focalPoint}
dimensions={image.dimensions}
/>
{promoText ? (
<Chip className={styles.promoTag}>{promoText}</Chip>

View File

@@ -48,6 +48,7 @@ export default async function CampaignHero({
fill
sizes="(min-width: 768px) 800px, 100vw"
focalPoint={image.focalPoint}
dimensions={image.dimensions}
/>
</div>
) : null}

View File

@@ -46,6 +46,7 @@ export default function TopImages({ images, destinationName }: TopImageProps) {
width={width}
height={height}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
className={`${styles.image} ${images.length > 1 ? styles.clickable : ""}`}
onClick={() =>
images.length

View File

@@ -25,6 +25,7 @@ export default function CardImage({
fill
sizes="(min-width: 768px) 900px, 100vw"
focalPoint={backgroundImage.focalPoint}
dimensions={backgroundImage.dimensions}
/>
</div>
)

View File

@@ -29,6 +29,7 @@ export default function InfoCard({
width={358}
height={179}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
className={styles.image}
/>
</div>

View File

@@ -46,6 +46,7 @@ export default async function StartPage({
}
src={header.hero_image.url}
focalPoint={header.hero_image.focalPoint}
dimensions={header.hero_image.dimensions}
sizes="100vw"
fill
priority

View File

@@ -69,6 +69,7 @@ export default async function StaticPage({
alt={hero_image.meta.alt || hero_image.meta.caption || ""}
src={hero_image.url}
focalPoint={hero_image.focalPoint}
dimensions={hero_image.dimensions}
/>
</div>
) : null}

View File

@@ -397,6 +397,7 @@ export const renderOptions: RenderOptions = {
src={image.url}
width={width}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
{...props}
/>
<Caption>{image.meta.caption}</Caption>

View File

@@ -1,7 +1,8 @@
import type { FocalPoint } from "@scandic-hotels/trpc/types/image"
import type { FocalPoint, Image } from "@scandic-hotels/trpc/types/image"
export interface HeroProps {
alt: string
src: string
focalPoint?: FocalPoint
dimensions?: Image["dimension"]
}

View File

@@ -4,7 +4,12 @@ import styles from "./hero.module.css"
import type { HeroProps } from "./hero"
export default async function Hero({ alt, src, focalPoint }: HeroProps) {
export default async function Hero({
alt,
src,
focalPoint,
dimensions,
}: HeroProps) {
return (
<Image
className={styles.hero}
@@ -13,6 +18,7 @@ export default async function Hero({ alt, src, focalPoint }: HeroProps) {
width={1196}
src={src}
focalPoint={focalPoint}
dimensions={dimensions}
priority
/>
)

View File

@@ -54,7 +54,6 @@ const RoomImage = memo(function RoomImage({
title={roomType}
fill
imageCountPosition="top"
sizes="(max-width: 768px) 768px, 420px"
/>
<div className={styles.toggleSidePeek}>
{roomTypeCode && room && (

View File

@@ -60,6 +60,7 @@ export default function Card({
fill
sizes="(min-width: 1367px) 700px, 900px"
focalPoint={backgroundImage.focalPoint}
dimensions={backgroundImage.dimensions}
/>
</div>
)}

View File

@@ -33,6 +33,7 @@ export default function LoyaltyCard({
className={styles.image}
alt={image.meta.alt || image.title}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
/>
) : null}
<Title as="h4" level="h3" textAlign="center">

View File

@@ -33,6 +33,7 @@ export default function TeaserCard({
alt={image.meta?.alt || ""}
className={styles.image}
focalPoint={image.focalPoint}
dimensions={image.dimensions}
fill
/>
</div>

View File

@@ -1,51 +0,0 @@
'use client'
import NextImage, {
type ImageLoaderProps,
ImageProps as NextImageProps,
} from 'next/image'
import ImageFallback from './ImageFallback'
import type { CSSProperties } from 'react'
type FocalPoint = {
x: number
y: number
}
export interface ImageProps extends NextImageProps {
focalPoint?: FocalPoint
}
function imageLoader({ quality, src, width }: ImageLoaderProps) {
const isAbsoluteUrl = src.startsWith('https://') || src.startsWith('http://')
const hasQS = src.indexOf('?') !== -1
if (width < 500) {
width += 150 // HACK! Slightly increase width for better quality
}
if (isAbsoluteUrl) {
return `https://img.scandichotels.com/.netlify/images?url=${src}&w=${width}${quality ? '&q=' + quality : ''}`
}
return `${src}${hasQS ? '&' : '?'}w=${width}${quality ? '&q=' + quality : ''}`
}
// Next/Image adds & instead of ? before the params
export default function Image({ focalPoint, style, ...props }: ImageProps) {
const styles: CSSProperties = focalPoint
? {
objectFit: 'cover',
objectPosition: `${focalPoint.x}% ${focalPoint.y}%`,
...style,
}
: { ...style }
if (!props.src) {
return <ImageFallback />
}
return <NextImage {...props} style={styles} loader={imageLoader} />
}

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { imageLoader } from './imageLoader'
describe('imageLoader', () => {
it('should generate the correct image URL for absolute URLs', () => {
const loader = imageLoader({ dimensions: { width: 800, height: 600 } })
const url = loader({
quality: 80,
src: 'https://example.com/image.jpg',
width: 800,
})
expect(url).toBe(
'https://img.scandichotels.com/.netlify/images?url=https://example.com/image.jpg&w=800&q=80'
)
})
it('should generate the correct image URL for relative URLs', () => {
const loader = imageLoader({ dimensions: { width: 800, height: 600 } })
const url = loader({
quality: 80,
src: '/image.jpg',
width: 800,
})
expect(url).toBe('/image.jpg?w=800&q=80')
})
it('should compensate for landscape 3:2 images', () => {
const loader = imageLoader({ dimensions: { width: 6000, height: 4000 } })
const url = loader({
src: '/image.jpg',
width: 400,
})
expect(url).toBe('/image.jpg?w=600')
})
it('should compensate for landscape ~3:2 images', () => {
const loader = imageLoader({ dimensions: { width: 7952, height: 5304 } })
const url = loader({
src: '/image.jpg',
width: 400,
})
expect(url).toBe('/image.jpg?w=600')
})
it('should compensate for standing 2:3 images', () => {
const loader = imageLoader({ dimensions: { width: 4000, height: 6000 } })
const url = loader({
src: '/image.jpg',
width: 800,
})
expect(url).toBe('/image.jpg?w=800')
})
it('should compensate for landscape 2:1 images', () => {
const loader = imageLoader({ dimensions: { width: 2000, height: 1000 } })
const url = loader({
src: '/image.jpg',
width: 800,
})
// used to fetch an image 800x400 image but we, probably, render it with a height of 533
expect(url).toBe('/image.jpg?w=1200')
})
})

View File

@@ -0,0 +1,57 @@
import { ImageLoaderProps } from 'next/image'
export const imageLoader =
({
dimensions,
}: {
focalPoint?: { x: number; y: number }
dimensions?: { width: number; height: number }
}) =>
({ quality, src, width }: ImageLoaderProps) => {
const isAbsoluteUrl =
src.startsWith('https://') || src.startsWith('http://')
const hasQS = src.indexOf('?') !== -1
if (
dimensions &&
isLargerThanAspectRatio(dimensions, '3:2') &&
width < dimensions.width
) {
// If image is wider than 3:2, compensate for low height when rendering in a 3:2 container
const scale = width / dimensions.width
const minWidthFor32Aspect =
dimensions.height * scale * aspectRatios['3:2'] * 2
width = Math.max(minWidthFor32Aspect, width)
}
if (width < 500) {
// Compensate for bad resizing library used by netlify
width += width / 2
}
width = roundToNearest(width, 10)
if (isAbsoluteUrl) {
return `https://img.scandichotels.com/.netlify/images?url=${src}&w=${width}${quality ? '&q=' + quality : ''}`
}
return `${src}${hasQS ? '&' : '?'}w=${width}${quality ? '&q=' + quality : ''}`
}
const aspectRatios = {
'3:2': 3 / 2,
}
function isLargerThanAspectRatio(
dimensions: {
width: number
height: number
},
aspectRatio: keyof typeof aspectRatios
) {
return dimensions.width / dimensions.height > aspectRatios[aspectRatio]
}
function roundToNearest(value: number, nearest: number) {
return Math.ceil(value / nearest) * nearest
}

View File

@@ -0,0 +1,46 @@
'use client'
import NextImage, { ImageProps as NextImageProps } from 'next/image'
import ImageFallback from '../ImageFallback'
import type { CSSProperties } from 'react'
import { imageLoader } from './imageLoader'
type FocalPoint = {
x: number
y: number
}
export type ImageProps = NextImageProps & {
focalPoint?: FocalPoint
dimensions?: { width: number; height: number }
}
// Next/Image adds & instead of ? before the params
export default function Image({
focalPoint,
dimensions,
style,
...props
}: ImageProps) {
const styles: CSSProperties = focalPoint
? {
objectFit: 'cover',
objectPosition: `${focalPoint.x}% ${focalPoint.y}%`,
...style,
}
: { ...style }
if (!props.src) {
return <ImageFallback />
}
return (
<NextImage
{...props}
style={styles}
loader={imageLoader({ dimensions, focalPoint })}
/>
)
}

View File

@@ -11,6 +11,10 @@ type Image = {
x: number
y: number
}
dimensions?: {
width: number
height: number
}
meta: {
alt?: string | null
caption?: string | null
@@ -36,6 +40,7 @@ export default function ImageContainer({
width={600}
alt={leftImage.meta.alt || leftImage.title}
focalPoint={leftImage.focalPoint}
dimensions={leftImage.dimensions}
/>
<Caption>{leftImage.meta.caption}</Caption>
</article>
@@ -47,8 +52,9 @@ export default function ImageContainer({
width={600}
alt={rightImage.meta.alt || rightImage.title}
focalPoint={rightImage.focalPoint}
dimensions={rightImage.dimensions}
/>
<Caption>{leftImage.meta.caption}</Caption>
<Caption>{rightImage.meta.caption}</Caption>
</article>
</section>
)

View File

@@ -42,7 +42,13 @@ function ImageGallery({
const intl = useIntl()
const [isOpen, setIsOpen] = useState(false)
const [imageError, setImageError] = useState(false)
const imageProps = fill ? { fill, sizes } : { height, width: height * 1.5 }
const imageProps = fill
? {
fill,
sizes:
sizes ?? 'auto, (max-width: 400px) 100vw, (min-width: 401px) 500px',
}
: { height, width: height * 1.5 }
if (!images || images.length === 0 || imageError) {
return <ImageFallback />

View File

@@ -20,6 +20,10 @@ type Image = {
x: number
y: number
}
dimensions?: {
width: number
height: number
}
meta: {
alt?: string | null
caption?: string | null

View File

@@ -495,6 +495,7 @@ export const renderOptions: RenderOptions = {
fill
sizes="(min-width: 1367px) 800px, (max-width: 1366px) and (min-width: 1200px) 1200px, 100vw"
focalPoint={image.focalPoint}
dimensions={image.dimensions}
{...props}
/>
</div>

View File

@@ -124,7 +124,7 @@
"./Icons/WardIcon": "./lib/components/Icons/Customised/Amenities_Facilities/Ward.tsx",
"./Icons/WindowNotAvailableIcon": "./lib/components/Icons/Customised/Amenities_Facilities/WindowNotAvailable.tsx",
"./Icons/WoodFloorIcon": "./lib/components/Icons/Customised/Amenities_Facilities/WoodFloor.tsx",
"./Image": "./lib/components/Image.tsx",
"./Image": "./lib/components/Image/index.tsx",
"./ImageContainer": "./lib/components/ImageContainer/index.tsx",
"./ImageFallback": "./lib/components/ImageFallback/index.tsx",
"./ImageGallery": "./lib/components/ImageGallery/index.tsx",

View File

@@ -19,9 +19,14 @@ const browserInstances = isCI
export default mergeConfig(
viteConfig,
defineConfig({
// !isCI ?
test: {
projects: [
{
test: {
name: 'unit',
environment: 'jsdom',
},
},
{
plugins: [
storybookTest({
@@ -47,6 +52,5 @@ export default mergeConfig(
},
],
},
//: {}, // Netlify CI fails to run playwright tests. Only supported locally for now
})
)