| 1 | import type { ImageLoader, ImageLoaderProps, ImageProps, StaticImageData } from 'next/image' |
| 2 | import Image from 'next/image' |
| 3 | import { getOssThumbnailUrl, getOssUrl } from '@/utils/oss' |
| 4 | |
| 5 | export type OssImageProps = ImageProps & { |
| 6 | thumbnailSize?: number |
| 7 | thumbnailWidth?: number |
| 8 | thumbnailHeight?: number |
| 9 | disableOssThumbnail?: boolean |
| 10 | } |
| 11 | |
| 12 | const DEVICE_PIXEL_RATIO = 2 |
| 13 | const THUMBNAIL_SHARPNESS_SCALE = 1.5 |
| 14 | const THUMBNAIL_SHARPNESS_MAX_DIMENSION = 320 |
| 15 | const DEFAULT_THUMBNAIL_MAX_DIMENSION = 2048 |
| 16 | const MIN_THUMBNAIL_DIMENSION = 16 |
| 17 | const SIZE_PIXEL_PATTERN = /(?:^|\s)([1-9]\d*)px(?:\s|$)/ |
| 18 | |
| 19 | type ResolveOssImageSrcOptions = Pick<ImageProps, 'height' | 'loader' | 'overrideSrc' | 'quality' | 'sizes' | 'width'> & Pick<OssImageProps, 'disableOssThumbnail' | 'thumbnailHeight' | 'thumbnailSize' | 'thumbnailWidth'> |
| 20 | |
| 21 | function getThumbnailMaxDimension() { |
| 22 | const rawValue = process.env.NEXT_PUBLIC_OSS_IMAGE_THUMBNAIL_MAX_WIDTH |
| 23 | const value = rawValue ? Number(rawValue) : DEFAULT_THUMBNAIL_MAX_DIMENSION |
| 24 | return Number.isFinite(value) && value > 0 ? value : DEFAULT_THUMBNAIL_MAX_DIMENSION |
| 25 | } |
| 26 | |
| 27 | function isThumbnailEnabled() { |
| 28 | return process.env.NEXT_PUBLIC_ENABLE_OSS_IMAGE_THUMBNAIL !== 'false' |
| 29 | } |
| 30 | |
| 31 | function isStaticImageImport(src: ImageProps['src']) { |
| 32 | return typeof src !== 'string' |
| 33 | } |
| 34 | |
| 35 | function isLocalOrRuntimeImageSrc(src: string) { |
| 36 | return src.startsWith('/') |
| 37 | || src.startsWith('blob:') |
| 38 | || src.startsWith('data:') |
| 39 | || src.startsWith('ossProxy') |
| 40 | || src.startsWith('/ossProxy/') |
| 41 | } |
| 42 | |
| 43 | function isSvgImageSrc(src: string) { |
| 44 | const pathname = src.split(/[?#]/)[0] |
| 45 | return pathname.toLowerCase().endsWith('.svg') |
| 46 | } |
| 47 | |
| 48 | function shouldUseOriginalSrc(src: string, options: ResolveOssImageSrcOptions) { |
| 49 | if (isLocalOrRuntimeImageSrc(src)) |
| 50 | return true |
| 51 | |
| 52 | if (isSvgImageSrc(src)) |
| 53 | return true |
| 54 | |
| 55 | if (!isThumbnailEnabled()) |
| 56 | return true |
| 57 | |
| 58 | if (options.disableOssThumbnail) |
| 59 | return true |
| 60 | |
| 61 | if (options.loader) |
| 62 | return true |
| 63 | |
| 64 | if (options.overrideSrc) |
| 65 | return true |
| 66 | |
| 67 | return false |
| 68 | } |
| 69 | |
| 70 | function getNumericDimension(value: ImageProps['width'] | ImageProps['height']) { |
| 71 | if (typeof value === 'number' && Number.isFinite(value) && value > 0) |
| 72 | return value |
| 73 | |
| 74 | if (typeof value === 'string') { |
| 75 | const numericValue = Number.parseFloat(value) |
| 76 | return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : undefined |
| 77 | } |
| 78 | |
| 79 | return undefined |
| 80 | } |
| 81 | |
| 82 | function getNumericQuality(value: ImageProps['quality']) { |
| 83 | if (typeof value === 'number' && Number.isFinite(value) && value > 0) |
| 84 | return value |
| 85 | |
| 86 | if (typeof value === 'string') { |
| 87 | const numericValue = Number.parseFloat(value) |
| 88 | return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : undefined |
| 89 | } |
| 90 | |
| 91 | return undefined |
| 92 | } |
| 93 | |
| 94 | function shouldSharpenThumbnail(width?: number, height?: number) { |
| 95 | const dimensions = [width, height].filter((dimension): dimension is number => ( |
| 96 | typeof dimension === 'number' && Number.isFinite(dimension) && dimension > 0 |
| 97 | )) |
| 98 | |
| 99 | if (dimensions.length === 0) |
| 100 | return false |
| 101 | |
| 102 | return Math.max(...dimensions) < THUMBNAIL_SHARPNESS_MAX_DIMENSION |
| 103 | } |
| 104 | |
| 105 | function normalizeThumbnailDimension(value?: number, scale = 1) { |
| 106 | if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) |
| 107 | return undefined |
| 108 | |
| 109 | const dimension = Math.ceil(value) |
| 110 | const scaledDimension = Math.ceil(dimension * scale) |
| 111 | |
| 112 | return Math.min( |
| 113 | Math.max(scaledDimension, MIN_THUMBNAIL_DIMENSION), |
| 114 | getThumbnailMaxDimension(), |
| 115 | ) |
| 116 | } |
| 117 | |
| 118 | function getLargestPixelSize(sizes?: string) { |
| 119 | if (!sizes) |
| 120 | return undefined |
| 121 | |
| 122 | const pixelSizes = sizes |
| 123 | .split(',') |
| 124 | .map((size) => { |
| 125 | const normalizedSize = size.trim() |
| 126 | const sizeValue = normalizedSize.includes(')') |
| 127 | ? normalizedSize.slice(normalizedSize.lastIndexOf(')') + 1).trim() |
| 128 | : normalizedSize |
| 129 | return SIZE_PIXEL_PATTERN.exec(sizeValue)?.[1] |
| 130 | }) |
| 131 | .filter((value): value is string => Boolean(value)) |
| 132 | .map(value => Number(value)) |
| 133 | |
| 134 | if (pixelSizes.length === 0) |
| 135 | return undefined |
| 136 | |
| 137 | return Math.max(...pixelSizes) |
| 138 | } |
| 139 | |
| 140 | function getRequestedThumbnailDimensions(options: ResolveOssImageSrcOptions) { |
| 141 | const explicitWidth = options.thumbnailWidth ?? options.thumbnailSize |
| 142 | const explicitHeight = options.thumbnailHeight ?? options.thumbnailSize |
| 143 | |
| 144 | if (explicitWidth || explicitHeight) { |
| 145 | const scale = shouldSharpenThumbnail(explicitWidth, explicitHeight) ? THUMBNAIL_SHARPNESS_SCALE : 1 |
| 146 | |
| 147 | return { |
| 148 | width: normalizeThumbnailDimension(explicitWidth, scale), |
| 149 | height: normalizeThumbnailDimension(explicitHeight, scale), |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | const width = getLargestPixelSize(options.sizes) ?? getNumericDimension(options.width) |
| 154 | const height = getNumericDimension(options.height) |
| 155 | const thumbnailWidth = width ? width * DEVICE_PIXEL_RATIO : undefined |
| 156 | const thumbnailHeight = height ? height * DEVICE_PIXEL_RATIO : undefined |
| 157 | const scale = shouldSharpenThumbnail(thumbnailWidth, thumbnailHeight) ? THUMBNAIL_SHARPNESS_SCALE : 1 |
| 158 | |
| 159 | return { |
| 160 | width: normalizeThumbnailDimension(thumbnailWidth, scale), |
| 161 | height: normalizeThumbnailDimension(thumbnailHeight, scale), |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | function resolveOssImageSrc(src: ImageProps['src'], options: ResolveOssImageSrcOptions) { |
| 166 | if (isStaticImageImport(src)) |
| 167 | return src |
| 168 | |
| 169 | if (isLocalOrRuntimeImageSrc(src)) |
| 170 | return src |
| 171 | |
| 172 | const normalizedSrc = getOssUrl(src) |
| 173 | if (shouldUseOriginalSrc(src, options)) |
| 174 | return normalizedSrc |
| 175 | |
| 176 | const { width, height } = getRequestedThumbnailDimensions(options) |
| 177 | if (!width && !height) |
| 178 | return normalizedSrc |
| 179 | |
| 180 | return getOssThumbnailUrl(normalizedSrc, { width, height, quality: getNumericQuality(options.quality) }) |
| 181 | } |
| 182 | |
| 183 | export function OssImage({ |
| 184 | src, |
| 185 | thumbnailSize, |
| 186 | thumbnailWidth, |
| 187 | thumbnailHeight, |
| 188 | disableOssThumbnail, |
| 189 | ...imageProps |
| 190 | }: OssImageProps) { |
| 191 | return ( |
| 192 | <Image |
| 193 | {...imageProps} |
| 194 | src={resolveOssImageSrc(src, { |
| 195 | ...imageProps, |
| 196 | disableOssThumbnail, |
| 197 | thumbnailHeight, |
| 198 | thumbnailSize, |
| 199 | thumbnailWidth, |
| 200 | })} |
| 201 | /> |
| 202 | ) |
| 203 | } |
| 204 | |
| 205 | export default OssImage |
| 206 | export { getImageProps } from 'next/image' |
| 207 | export type { ImageLoader, ImageLoaderProps, ImageProps, StaticImageData } |
| 208 |