@inoo-ch/payload-image-optimizer 1.5.1 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ import type { MediaResource } from '../types.js'
2
+ import { getImageOptimizerProps, type ImageOptimizerProps } from './getImageOptimizerProps.js'
3
+ import { createVariantLoader } from './responsiveImage.js'
4
+
5
+ type ImageLoaderProps = { src: string; width: number; quality?: number | undefined }
6
+ type ImageLoader = (props: ImageLoaderProps) => string
7
+
8
+ export type OptimizedImageProps = ImageOptimizerProps & {
9
+ loader?: ImageLoader
10
+ }
11
+
12
+ /**
13
+ * Returns all optimization props for a Next.js `<Image>` component in a single
14
+ * spread-friendly object: ThumbHash blur placeholder, focal-point positioning,
15
+ * and a variant-aware responsive loader.
16
+ *
17
+ * Designed as a drop-in enhancement for the Payload website template's `ImageMedia`:
18
+ *
19
+ * ```tsx
20
+ * // In your ImageMedia component — just add the import and spread:
21
+ * import { getOptimizedImageProps } from '@inoo-ch/payload-image-optimizer/client'
22
+ *
23
+ * const optimizedProps = getOptimizedImageProps(resource)
24
+ *
25
+ * <NextImage
26
+ * {...optimizedProps}
27
+ * src={src}
28
+ * alt={alt}
29
+ * fill={fill}
30
+ * sizes={sizes}
31
+ * priority={priority}
32
+ * loading={loading}
33
+ * />
34
+ * ```
35
+ *
36
+ * What it returns:
37
+ * - `placeholder` / `blurDataURL` — per-image ThumbHash (replaces the template's hardcoded blur)
38
+ * - `style.objectPosition` — focal-point-based positioning
39
+ * - `loader` — hybrid loader that serves pre-generated Payload size variants directly,
40
+ * falling back to `/_next/image` when no close match exists (only present when
41
+ * `resource.sizes` has variants)
42
+ */
43
+ export function getOptimizedImageProps(
44
+ resource: MediaResource | null | undefined,
45
+ ): OptimizedImageProps {
46
+ const base = getImageOptimizerProps(resource)
47
+
48
+ if (!resource) return base
49
+
50
+ const loader = createVariantLoader(resource)
51
+
52
+ return loader ? { ...base, loader } : base
53
+ }
@@ -0,0 +1,91 @@
1
+ import type { MediaResource, MediaSizeVariant } from '../types.js'
2
+
3
+ type ImageLoaderProps = { src: string; width: number; quality?: number | undefined }
4
+ type ImageLoader = (props: ImageLoaderProps) => string
5
+
6
+ type ValidVariant = { url: string; width: number }
7
+
8
+ /**
9
+ * Extracts usable variants from a Payload media resource's `sizes` field.
10
+ * Filters out entries missing url or width and sorts by width ascending.
11
+ */
12
+ function getValidVariants(media: MediaResource): ValidVariant[] {
13
+ if (!media.sizes) return []
14
+
15
+ return Object.values(media.sizes)
16
+ .filter((v): v is MediaSizeVariant & { url: string; width: number } =>
17
+ v != null && typeof v.url === 'string' && typeof v.width === 'number',
18
+ )
19
+ .sort((a, b) => a.width - b.width)
20
+ }
21
+
22
+ /**
23
+ * Finds the best pre-generated variant for a requested width.
24
+ *
25
+ * Strategy:
26
+ * 1. Pick the smallest variant with width >= requested (no quality loss from upscaling)
27
+ * 2. If none is large enough, use the largest variant — but only if it covers >= 80%
28
+ * of the requested width (minor downscale is acceptable, large gap is not)
29
+ * 3. Returns null when no suitable variant exists → caller should fall back to /_next/image
30
+ */
31
+ export function findBestVariant(
32
+ variants: ValidVariant[],
33
+ requestedWidth: number,
34
+ ): ValidVariant | null {
35
+ if (variants.length === 0) return null
36
+
37
+ // Smallest variant >= requested width
38
+ const larger = variants.find((v) => v.width >= requestedWidth)
39
+ if (larger) return larger
40
+
41
+ // No variant large enough — use the largest if it's close
42
+ const largest = variants[variants.length - 1]!
43
+ if (largest.width >= requestedWidth * 0.8) return largest
44
+
45
+ return null
46
+ }
47
+
48
+ /**
49
+ * Creates a Next.js Image `loader` that maps requested widths to pre-generated
50
+ * Payload size variants when a close match exists, falling back to the default
51
+ * `/_next/image` optimization pipeline when no suitable variant is available.
52
+ *
53
+ * Returns `undefined` when the media has no usable size variants (i.e. no custom
54
+ * loader needed — let next/image use its default behavior).
55
+ *
56
+ * ```tsx
57
+ * import { createVariantLoader } from '@inoo-ch/payload-image-optimizer/client'
58
+ *
59
+ * const loader = createVariantLoader(media)
60
+ * <NextImage loader={loader} src={media.url} ... />
61
+ * ```
62
+ */
63
+ export function createVariantLoader(media: MediaResource): ImageLoader | undefined {
64
+ const variants = getValidVariants(media)
65
+ if (variants.length === 0) return undefined
66
+
67
+ const cacheBust = media.updatedAt ? `?${media.updatedAt}` : ''
68
+
69
+ return ({ src, width, quality }) => {
70
+ const match = findBestVariant(variants, width)
71
+
72
+ if (match) {
73
+ return `${match.url}${cacheBust}`
74
+ }
75
+
76
+ // Fall back to next/image optimization for unmatched widths
77
+ return `/_next/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality || 80}`
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Returns a sensible default `sizes` attribute for responsive images.
83
+ *
84
+ * For `fill` mode images without an explicit `sizes` prop, this prevents the
85
+ * browser from assuming `100vw` (which causes it to always download the
86
+ * largest srcSet variant regardless of actual display area).
87
+ */
88
+ export function getDefaultSizes(fill: boolean | undefined): string | undefined {
89
+ if (!fill) return undefined
90
+ return '(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
91
+ }