request-image.spec.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import sharp from 'sharp'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { CompressionLimiter } from '../src/compression-limiter.ts'
  8. import LocalAttachmentStore, { requestImageDimensions } from '../src/index.ts'
  9. const homes: string[] = []
  10. async function store(): Promise<LocalAttachmentStore> {
  11. const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-image-'))
  12. homes.push(dshHome)
  13. return new LocalAttachmentStore(new Context(), { dshHome })
  14. }
  15. async function image(width: number, height: number): Promise<Uint8Array> {
  16. return new Uint8Array(await sharp({
  17. create: { width, height, channels: 3, background: { r: 12, g: 34, b: 56 } },
  18. }).png().toBuffer())
  19. }
  20. async function complexOpaqueAlphaImage(width: number, height: number): Promise<Uint8Array> {
  21. const pixels = new Uint8Array(width * height * 4)
  22. let state = 0x2545f491
  23. for (let offset = 0; offset < pixels.length; offset += 4) {
  24. for (let channel = 0; channel < 3; channel += 1) {
  25. state ^= state << 13
  26. state ^= state >>> 17
  27. state ^= state << 5
  28. pixels[offset + channel] = state & 0xff
  29. }
  30. pixels[offset + 3] = 255
  31. }
  32. return new Uint8Array(await sharp(pixels, {
  33. raw: { width, height, channels: 4 },
  34. }).png().toBuffer())
  35. }
  36. afterEach(async () => {
  37. await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true })))
  38. })
  39. describe('request image dimensions', () => {
  40. it.each([
  41. [4096, 4096, 800, 800],
  42. [4096, 2048, 1130, 565],
  43. [3840, 2160, 1066, 600],
  44. [320, 240, 320, 240],
  45. ])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => {
  46. const projected = requestImageDimensions(width, height, 640_000)
  47. expect(projected).toEqual({
  48. width: expectedWidth,
  49. height: expectedHeight,
  50. })
  51. expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
  52. })
  53. it('projects a portrait within the same total-pixel budget', () => {
  54. const projected = requestImageDimensions(2160, 3840, 640_000)
  55. expect(projected).toEqual({ width: 600, height: 1066 })
  56. expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
  57. })
  58. it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => {
  59. expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
  60. })
  61. })
  62. describe('local request-image cache', () => {
  63. it('passes through an in-budget attachment and composes ordered request reads', async () => {
  64. const attachments = await store()
  65. const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
  66. const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })
  67. const firstStored = await attachments.readImage(first)
  68. const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 }
  69. const request = await attachments.readImageRequest(first, policy)
  70. const batch = await Promise.all([first, second].map(
  71. attachment => attachments.readImageRequest(attachment, policy),
  72. ))
  73. expect(request.data).toEqual(firstStored.data)
  74. expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId])
  75. })
  76. it('rejects invalid request policies', async () => {
  77. const attachments = await store()
  78. const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })
  79. await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 }))
  80. .rejects.toThrow('Image request maxPixels must be a positive integer')
  81. await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 }))
  82. .rejects.toThrow('Image request maxBytes must be a positive integer')
  83. })
  84. it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => {
  85. const attachments = await store()
  86. const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })
  87. const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 })
  88. expect(request.mediaType).toBe('image/jpeg')
  89. expect(request.bytes).toBeGreaterThan(1)
  90. expect(request).toMatchObject({ width: 1, height: 1 })
  91. })
  92. it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => {
  93. const attachments = await store()
  94. const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })
  95. const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
  96. const initial = await attachments.readImageRequest(attachment, policy)
  97. const hash = String(initial.variantId).slice('sha256:'.length)
  98. const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash)
  99. const noisyPixels = new Uint8Array(64 * 64 * 3)
  100. let state = 0x2545f491
  101. for (let index = 0; index < noisyPixels.length; index += 1) {
  102. state ^= state << 13
  103. state ^= state >>> 17
  104. state ^= state << 5
  105. noisyPixels[index] = state & 0xff
  106. }
  107. const oversized = new Uint8Array(await sharp(noisyPixels, {
  108. raw: { width: 64, height: 64, channels: 3 },
  109. }).png().toBuffer())
  110. const depth16 = new Uint8Array(await sharp({
  111. create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } },
  112. }).toColourspace('rgb16').png().toBuffer())
  113. const cmyk = new Uint8Array(await sharp({
  114. create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } },
  115. }).toColourspace('cmyk').jpeg().toBuffer())
  116. const tooWide = await image(23, 11)
  117. const unexpectedAlpha = new Uint8Array(await sharp({
  118. create: { width: 16, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 0.5 } },
  119. }).png().toBuffer())
  120. for (const invalid of [
  121. oversized,
  122. depth16,
  123. cmyk,
  124. tooWide,
  125. unexpectedAlpha,
  126. Uint8Array.of(1, 2, 3),
  127. ]) {
  128. await writeFile(path, invalid)
  129. const regenerated = await attachments.readImageRequest(attachment, policy)
  130. expect(regenerated.data).toEqual(initial.data)
  131. }
  132. })
  133. it('derives stable square and wide previews and separates route budgets in the cache key', async () => {
  134. const attachments = await store()
  135. const square = await attachments.saveImage({
  136. data: await image(2048, 2048), mediaType: 'image/png', name: 'square.png',
  137. })
  138. const wide = await attachments.saveImage({
  139. data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png',
  140. })
  141. const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
  142. const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
  143. const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
  144. const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 })
  145. expect(squareRequest).toMatchObject({ width: 800, height: 800 })
  146. expect(wideRequest).toMatchObject({ width: 1130, height: 565 })
  147. expect(repeated.variantId).toBe(wideRequest.variantId)
  148. expect(repeated.data).toEqual(wideRequest.data)
  149. expect(Buffer.from(repeated.data).toString('base64')).toBe(Buffer.from(wideRequest.data).toString('base64'))
  150. expect(low.variantId).not.toBe(wideRequest.variantId)
  151. expect(low.width * low.height).toBeLessThanOrEqual(512 * 512 + low.width)
  152. })
  153. it('routes opaque pixels to JPEG and preserves alpha on the WebP ladder', async () => {
  154. const attachments = await store()
  155. const side = 256
  156. const photoPixels = new Uint8Array(side * side * 3)
  157. const alphaPixels = new Uint8Array(side * side * 4)
  158. let state = 0x2545f491
  159. for (let pixel = 0; pixel < side * side; pixel += 1) {
  160. state ^= state << 13
  161. state ^= state >>> 17
  162. state ^= state << 5
  163. const photo = pixel * 3
  164. const alpha = pixel * 4
  165. photoPixels[photo] = state & 0xff
  166. photoPixels[photo + 1] = state >> 8 & 0xff
  167. photoPixels[photo + 2] = state >> 16 & 0xff
  168. alphaPixels[alpha] = photoPixels[photo] ?? 0
  169. alphaPixels[alpha + 1] = photoPixels[photo + 1] ?? 0
  170. alphaPixels[alpha + 2] = photoPixels[photo + 2] ?? 0
  171. alphaPixels[alpha + 3] = pixel & 0xff
  172. }
  173. const photoSource = new Uint8Array(await sharp(photoPixels, {
  174. raw: { width: side, height: side, channels: 3 },
  175. }).png().toBuffer())
  176. const alphaSource = new Uint8Array(await sharp(alphaPixels, {
  177. raw: { width: side, height: side, channels: 4 },
  178. }).png().toBuffer())
  179. const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' })
  180. const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' })
  181. const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 })
  182. const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 })
  183. expect(photoRequest.mediaType).toBe('image/jpeg')
  184. expect(alphaRequest.mediaType).toBe('image/webp')
  185. expect(alphaRequest.bytes).toBeGreaterThan(4_096)
  186. expect(alphaRequest).toMatchObject({ width: 128, height: 128 })
  187. await expect(sharp(alphaRequest.data).metadata()).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' })
  188. })
  189. it.each([3, 4] as const)('projects a 16-bit %s-channel PNG as a bounded 8-bit request image', async (channels) => {
  190. const attachments = await store()
  191. const source = new Uint8Array(await sharp({
  192. create: { width: 64, height: 32, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } },
  193. }).toColourspace('rgb16').png().toBuffer())
  194. const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
  195. const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
  196. expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
  197. expect(request.width * request.height).toBeLessThanOrEqual(16 * 16)
  198. await expect(sharp(request.data).metadata()).resolves.toMatchObject({
  199. depth: 'uchar', space: 'srgb', hasAlpha: channels === 4,
  200. })
  201. })
  202. it('accepts a resized WebP request version that omits an all-opaque alpha plane', async () => {
  203. const attachments = await store()
  204. const source = await complexOpaqueAlphaImage(64, 32)
  205. const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
  206. const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })
  207. expect(request.mediaType).toBe('image/webp')
  208. await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false })
  209. })
  210. it('keeps a complex 640,000-pixel request version below 1 MiB', async () => {
  211. const attachments = await store()
  212. const side = 1024
  213. const pixels = new Uint8Array(side * side * 3)
  214. let state = 0x6d2b79f5
  215. for (let index = 0; index < pixels.length; index += 1) {
  216. state ^= state << 13
  217. state ^= state >>> 17
  218. state ^= state << 5
  219. pixels[index] = state & 0xff
  220. }
  221. const source = new Uint8Array(await sharp(pixels, {
  222. raw: { width: side, height: side, channels: 3 },
  223. }).png().toBuffer())
  224. const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' })
  225. const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 })
  226. expect(request).toMatchObject({ width: 800, height: 800 })
  227. expect(request.bytes).toBeLessThanOrEqual(1024 * 1024)
  228. })
  229. it('shares one request transform between concurrent callers without sharing cancellation', async () => {
  230. const attachments = await store()
  231. const attachment = await attachments.saveImage({
  232. data: await image(2048, 1024), mediaType: 'image/png', name: 'shared.png',
  233. })
  234. const run = vi.spyOn(CompressionLimiter.prototype, 'run')
  235. const controller = new AbortController()
  236. const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
  237. const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
  238. const completed = attachments.readImageRequest(attachment, policy)
  239. const reason = new Error('cancel one waiter')
  240. controller.abort(reason)
  241. await expect(cancelled).rejects.toBe(reason)
  242. await expect(completed).resolves.toMatchObject({ width: 1130, height: 565 })
  243. expect(run).toHaveBeenCalledTimes(1)
  244. run.mockRestore()
  245. })
  246. it('aborts the underlying request transform after its only waiter cancels', async () => {
  247. const attachments = await store()
  248. const attachment = await attachments.saveImage({
  249. data: await image(2048, 1024), mediaType: 'image/png', name: 'cancelled.png',
  250. })
  251. let readSignal: AbortSignal | undefined
  252. const read = vi.spyOn(attachments, 'readImage').mockImplementation((_ref, signal) => {
  253. readSignal = signal
  254. return new Promise((_resolve, reject) => {
  255. signal?.addEventListener('abort', () => {
  256. reject(new Error('request transform aborted', { cause: signal.reason }))
  257. }, { once: true })
  258. })
  259. })
  260. const controller = new AbortController()
  261. const request = attachments.readImageRequest(
  262. attachment,
  263. { maxPixels: 640_000, maxBytes: 1024 * 1024 },
  264. controller.signal,
  265. )
  266. await vi.waitFor(() => {
  267. expect(read).toHaveBeenCalledTimes(1)
  268. })
  269. const reason = new Error('cancel only transform waiter')
  270. controller.abort(reason)
  271. await expect(request).rejects.toBe(reason)
  272. expect(readSignal?.reason).toBe(reason)
  273. })
  274. it('normalizes a non-Error cancellation and replaces an aborted shared transform', async () => {
  275. const attachments = await store()
  276. const attachment = await attachments.saveImage({
  277. data: await image(2048, 1024), mediaType: 'image/png', name: 'replace.png',
  278. })
  279. const actualRead = attachments.readImage.bind(attachments)
  280. let calls = 0
  281. vi.spyOn(attachments, 'readImage').mockImplementation((ref, signal) => {
  282. calls += 1
  283. if (calls === 1) {
  284. return new Promise((_resolve, reject) => {
  285. signal?.addEventListener('abort', () => {
  286. reject(new Error('request transform aborted', { cause: signal.reason }))
  287. }, { once: true })
  288. })
  289. }
  290. return actualRead(ref, signal)
  291. })
  292. const controller = new AbortController()
  293. const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
  294. const cancelled = attachments.readImageRequest(attachment, policy, controller.signal)
  295. await vi.waitFor(() => {
  296. expect(calls).toBe(1)
  297. })
  298. controller.abort('cancelled')
  299. const replacement = attachments.readImageRequest(attachment, policy)
  300. await expect(cancelled).rejects.toMatchObject({
  301. message: 'Attachment request cancelled with a non-Error reason.',
  302. cause: 'cancelled',
  303. })
  304. await expect(replacement).resolves.toMatchObject({ width: 1130, height: 565 })
  305. expect(calls).toBe(2)
  306. })
  307. })