1
0

normalization.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. import { describe, expect, it } from 'vitest'
  2. import sharp from 'sharp'
  3. import { canPassThroughNormalization, normalizeImage } from '../src/normalization.ts'
  4. import type { NormalizationPolicy } from '../src/normalization.ts'
  5. import { detectImage } from '../src/image.ts'
  6. const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 4 * 1024 * 1024 }
  7. /** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */
  8. function noisePixels(width: number, height: number): Uint8Array {
  9. const pixels = new Uint8Array(width * height * 3)
  10. let state = 0x2545f491
  11. for (let index = 0; index < pixels.length; index += 1) {
  12. state ^= state << 13
  13. state ^= state >>> 17
  14. state ^= state << 5
  15. pixels[index] = state & 0xff
  16. }
  17. return pixels
  18. }
  19. async function noiseImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise<Uint8Array> {
  20. const image = sharp(noisePixels(width, height), { raw: { width, height, channels: 3 } })
  21. return new Uint8Array(await image.toFormat(format).toBuffer())
  22. }
  23. async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif', alpha = false): Promise<Uint8Array> {
  24. const image = sharp({
  25. create: { width, height, channels: alpha ? 4 : 3, background: { r: 12, g: 200, b: 64, alpha: alpha ? 0.5 : 1 } },
  26. })
  27. return new Uint8Array(await image.toFormat(format, format === 'webp' && alpha ? { lossless: true } : {}).toBuffer())
  28. }
  29. describe('canPassThroughNormalization', () => {
  30. it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => {
  31. const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }
  32. expect(canPassThroughNormalization({ mediaType: 'image/png', width: 8192, height: 4, ...clean }, 100, POLICY)).toBe(true)
  33. expect(canPassThroughNormalization({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false)
  34. expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false)
  35. expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false)
  36. expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false)
  37. expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false)
  38. expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 2048, ...clean }, 100, POLICY)).toBe(false)
  39. expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 8193, height: 4, ...clean }, 100, POLICY)).toBe(false)
  40. expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false)
  41. })
  42. })
  43. describe('normalizeImage', () => {
  44. it('passes an already-normalized source through byte-identically', async () => {
  45. const data = await flatImage(6, 4, 'webp')
  46. const detected = await detectImage(data)
  47. const normalized = await normalizeImage(data, detected, POLICY)
  48. expect(normalized.data).toBe(data)
  49. expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 })
  50. })
  51. it.each([3, 4] as const)('converts a 16-bit %s-channel PNG to 8-bit sRGB without passthrough', async (channels) => {
  52. const data = new Uint8Array(await sharp({
  53. create: { width: 7, height: 5, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } },
  54. }).toColourspace('rgb16').png().toBuffer())
  55. const detected = await detectImage(data)
  56. expect(detected).toMatchObject({ depth: 'ushort', space: 'rgb16', hasAlpha: channels === 4 })
  57. const normalized = await normalizeImage(data, detected, POLICY)
  58. expect(normalized.data).not.toBe(data)
  59. expect(normalized.data).not.toEqual(data)
  60. await expect(detectImage(normalized.data)).resolves.toMatchObject({
  61. depth: 'uchar', space: 'srgb', hasAlpha: channels === 4, width: 7, height: 5,
  62. })
  63. })
  64. it('downscales an oversized opaque PNG to the long-edge target as JPEG', async () => {
  65. const data = await flatImage(10, 6, 'png')
  66. const detected = await detectImage(data)
  67. const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes })
  68. expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3 })
  69. await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' })
  70. const again = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes })
  71. expect(again.data).toEqual(normalized.data)
  72. })
  73. it('re-encodes the normalized output of a resize into itself (idempotence)', async () => {
  74. const data = await flatImage(10, 6, 'png')
  75. const budget = { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }
  76. const first = await normalizeImage(data, await detectImage(data), budget)
  77. const second = await normalizeImage(first.data, await detectImage(first.data), budget)
  78. expect(second.data).toBe(first.data)
  79. })
  80. it('always re-encodes GIF as a single still frame', async () => {
  81. const data = await flatImage(6, 4, 'gif')
  82. const detected = await detectImage(data)
  83. const normalized = await normalizeImage(data, detected, POLICY)
  84. // gifload always decodes to RGBA, so a GIF re-encodes on the WebP ladder.
  85. expect(detected.hasAlpha).toBe(true)
  86. expect(normalized.mediaType).toBe('image/webp')
  87. await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' })
  88. })
  89. it('keeps a transparent source on the WebP ladder', async () => {
  90. const data = await flatImage(9, 5, 'webp', true)
  91. const detected = await detectImage(data)
  92. const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 4, maxBytes: POLICY.maxBytes })
  93. expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 4, height: 2 })
  94. await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true })
  95. })
  96. it('accepts WebP output that omits an all-opaque source alpha plane', async () => {
  97. const width = 64
  98. const height = 32
  99. const rgb = noisePixels(width, height)
  100. const rgba = new Uint8Array(width * height * 4)
  101. for (let pixel = 0; pixel < width * height; pixel += 1) {
  102. rgba[pixel * 4] = rgb[pixel * 3] ?? 0
  103. rgba[pixel * 4 + 1] = rgb[pixel * 3 + 1] ?? 0
  104. rgba[pixel * 4 + 2] = rgb[pixel * 3 + 2] ?? 0
  105. rgba[pixel * 4 + 3] = 255
  106. }
  107. const data = new Uint8Array(await sharp(rgba, {
  108. raw: { width, height, channels: 4 },
  109. }).png().toBuffer())
  110. await expect(detectImage(data)).resolves.toMatchObject({ hasAlpha: true })
  111. const normalized = await normalizeImage(data, await detectImage(data), {
  112. maxPixels: POLICY.maxPixels,
  113. maxDimension: 32,
  114. maxBytes: POLICY.maxBytes,
  115. })
  116. expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 32, height: 16 })
  117. await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: false })
  118. })
  119. it('keeps the smallest transparent ladder output above an unreachable byte target without shrinking', async () => {
  120. const side = 128
  121. const pixels = new Uint8Array(side * side * 4)
  122. const noise = noisePixels(side, side)
  123. for (let pixel = 0; pixel < side * side; pixel += 1) {
  124. const target = pixel * 4
  125. const source = pixel * 3
  126. pixels[target] = noise[source] ?? 0
  127. pixels[target + 1] = noise[source + 1] ?? 0
  128. pixels[target + 2] = noise[source + 2] ?? 0
  129. pixels[target + 3] = pixel & 0xff
  130. }
  131. const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer())
  132. const normalized = await normalizeImage(data, await detectImage(data), {
  133. maxPixels: POLICY.maxPixels, maxDimension: side, maxBytes: 1_024,
  134. })
  135. expect(normalized.data.byteLength).toBeGreaterThan(1_024)
  136. expect(normalized).toMatchObject({ mediaType: 'image/webp', width: side, height: side })
  137. await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' })
  138. })
  139. it('re-encodes an oversized photographic JPEG as JPEG', async () => {
  140. const data = await noiseImage(64, 32, 'jpeg')
  141. const detected = await detectImage(data)
  142. const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 32, maxBytes: POLICY.maxBytes })
  143. expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 })
  144. })
  145. it('re-encodes an opaque gradient PNG as JPEG within the byte target', async () => {
  146. const side = 256
  147. const pixels = new Uint8Array(side * side * 3)
  148. for (let y = 0; y < side; y += 1) {
  149. for (let x = 0; x < side; x += 1) {
  150. const index = (y * side + x) * 3
  151. pixels[index] = x & 0xff
  152. pixels[index + 1] = y & 0xff
  153. pixels[index + 2] = (x + y) >> 1 & 0xff
  154. }
  155. }
  156. const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer())
  157. const detected = await detectImage(data)
  158. const budget = { maxPixels: POLICY.maxPixels, maxDimension: 128, maxBytes: POLICY.maxBytes }
  159. const normalized = await normalizeImage(data, detected, budget)
  160. expect(normalized.mediaType).toBe('image/jpeg')
  161. expect(normalized).toMatchObject({ width: 128, height: 128 })
  162. expect(normalized.data.byteLength).toBeLessThanOrEqual(budget.maxBytes)
  163. })
  164. it('keeps the smallest opaque ladder output above an unreachable byte target', async () => {
  165. const data = await noiseImage(64, 64, 'png')
  166. const normalized = await normalizeImage(data, await detectImage(data), {
  167. maxPixels: POLICY.maxPixels, maxDimension: 2048, maxBytes: 512,
  168. })
  169. expect(normalized.data.byteLength).toBeGreaterThan(512)
  170. expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 64, height: 64 })
  171. })
  172. it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => {
  173. const data = new Uint8Array(await sharp({
  174. create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } },
  175. }).jpeg().withMetadata({ orientation: 6 }).toBuffer())
  176. const detected = await detectImage(data)
  177. // Orientation 6 rotates 90°: the perceived source is 2x4.
  178. expect(detected).toMatchObject({ width: 2, height: 4, carriesMetadata: true })
  179. const normalized = await normalizeImage(data, detected, POLICY)
  180. expect(normalized.data).not.toBe(data)
  181. expect(normalized).toMatchObject({ width: 2, height: 4 })
  182. await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false })
  183. })
  184. it('re-encodes an in-budget image with an ICC profile and strips the profile', async () => {
  185. const data = new Uint8Array(await sharp({
  186. create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } },
  187. }).png().withIccProfile('p3').toBuffer())
  188. const detected = await detectImage(data)
  189. expect(detected.carriesMetadata).toBe(true)
  190. const normalized = await normalizeImage(data, detected, POLICY)
  191. expect(normalized.data).not.toBe(data)
  192. await expect(detectImage(normalized.data)).resolves.toMatchObject({ carriesMetadata: false })
  193. })
  194. it('maps an encoder fault on undecodable bytes to a storage failure', async () => {
  195. const detected = {
  196. mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false,
  197. depth: 'ushort', space: 'rgb16', hasAlpha: true,
  198. } as const
  199. await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY))
  200. .rejects.toMatchObject({
  201. code: 'ATTACHMENT_WRITE_FAILED',
  202. message: 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.',
  203. })
  204. })
  205. it.each([
  206. ['float PNG', { mediaType: 'image/png', depth: 'float' }],
  207. ['uchar JPEG', { mediaType: 'image/jpeg', depth: 'uchar' }],
  208. ] as const)('describes a failed %s conversion without exposing the encoder error', async (source, fields) => {
  209. const detected = {
  210. ...fields,
  211. width: 5000,
  212. height: 5000,
  213. animated: false,
  214. carriesMetadata: false,
  215. space: 'srgb',
  216. hasAlpha: false,
  217. } as const
  218. await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY))
  219. .rejects.toMatchObject({
  220. code: 'ATTACHMENT_WRITE_FAILED',
  221. message: `The ${source} could not be converted to the normalized 8-bit sRGB form.`,
  222. })
  223. })
  224. it('downscales by total pixels so an extreme aspect ratio keeps its short edge', async () => {
  225. const data = await flatImage(10, 40, 'png')
  226. const normalized = await normalizeImage(data, await detectImage(data), {
  227. maxPixels: 100, maxDimension: 8192, maxBytes: POLICY.maxBytes,
  228. })
  229. expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 20 })
  230. })
  231. it('caps the long edge after the total-pixel budget', async () => {
  232. const data = await flatImage(4, 64, 'png')
  233. const normalized = await normalizeImage(data, await detectImage(data), {
  234. maxPixels: 10_000, maxDimension: 16, maxBytes: POLICY.maxBytes,
  235. })
  236. expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 1, height: 16 })
  237. })
  238. it('keeps an antialiased text screenshot readable on the JPEG ladder', async () => {
  239. const source = new Uint8Array(await sharp(Buffer.from(`
  240. <svg width="1024" height="512" xmlns="http://www.w3.org/2000/svg">
  241. <rect width="1024" height="512" fill="white"/>
  242. <text x="48" y="290" font-size="170" fill="#112f4d">Readable text</text>
  243. </svg>
  244. `)).removeAlpha().png().toBuffer())
  245. const normalized = await normalizeImage(source, await detectImage(source), {
  246. maxPixels: POLICY.maxPixels,
  247. maxDimension: 512,
  248. maxBytes: POLICY.maxBytes,
  249. })
  250. const stats = await sharp(normalized.data).greyscale().stats()
  251. expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 512, height: 256 })
  252. expect(stats.channels[0]?.min).toBeLessThan(80)
  253. expect(stats.channels[0]?.max).toBeGreaterThan(240)
  254. })
  255. })