@ouraihub/aether-core 0.1.0 → 0.1.2

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.
@@ -1,19 +1,38 @@
1
- import type { ImagePart, KitErrorCode, Details } from '@ouraihub/aether-contracts'
1
+ import type { ImagePart, KitErrorCode, Details, TokenUsage } from '@ouraihub/aether-contracts'
2
2
  import { badRequest, KitFailure } from '../error.js'
3
3
  import type { OutboundHttp } from '../ports.js'
4
4
  import type { Connection } from '../types.js'
5
+ import { emptyUsage } from '../pricing.js'
6
+ import { validateImageBytes } from './validation.js'
7
+ export { validateImageBytes } from './validation.js'
5
8
 
6
9
  export const MAX_IMAGE_BYTES = 4 * 1024 * 1024
7
10
  export const MAX_EDIT_IMAGES = 4
8
11
  export const IMAGE_MIMES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const
9
12
  export const IMAGE_SIZES = ['1024x1024', '1024x1536', '1536x1024'] as const
10
13
  export const IMAGE_TIMEOUT_MS = 120_000
11
- const RESPONSE_BYTES = Math.ceil(MAX_IMAGE_BYTES / 3) * 4 + 64 * 1024
14
+ export const MAX_GENERATED_IMAGE_BYTES = 10 * 1024 * 1024
15
+ export const MAX_IMAGE_INPUT_BYTES = 20 * 1024 * 1024
16
+ export type ImageProfile = 'generic' | 'gpt-image' | 'siliconflow'
17
+ export type ImageQuality = 'low' | 'medium' | 'high'
18
+ export interface ImageLimits {
19
+ readonly maxImageBytes?: number
20
+ readonly timeoutMs?: number
21
+ readonly profile?: ImageProfile
22
+ }
23
+ export interface ImageGeneration {
24
+ readonly image: ImagePart
25
+ readonly revisedPrompt: string | null
26
+ readonly usage: TokenUsage
27
+ readonly totalTokens: number | null
28
+ }
12
29
  export type ImageSize = (typeof IMAGE_SIZES)[number]
13
30
  export interface ImageRequest {
14
31
  readonly prompt: string
15
32
  readonly size: ImageSize
16
33
  readonly images: readonly ImagePart[]
34
+ readonly quality?: ImageQuality
35
+ readonly mask?: ImagePart
17
36
  }
18
37
  export type ImageConnection = Pick<
19
38
  Connection,
@@ -55,7 +74,7 @@ export function checkImages(images: unknown): asserts images is ImagePart[] {
55
74
  for (const image of images) parseImage(image)
56
75
  }
57
76
 
58
- export function decodeImage(image: ImagePart): Uint8Array<ArrayBuffer> {
77
+ export function decodeImage(image: ImagePart, maxBytes = MAX_IMAGE_BYTES): Uint8Array<ArrayBuffer> {
59
78
  if (
60
79
  !record(image) ||
61
80
  Object.keys(image).some((key) => key !== 'mime' && key !== 'data') ||
@@ -65,8 +84,9 @@ export function decodeImage(image: ImagePart): Uint8Array<ArrayBuffer> {
65
84
  throw badRequest('images', 'encoding')
66
85
  if (!IMAGE_MIMES.some((mime) => mime === image.mime)) throw badRequest('images', 'mime')
67
86
  const data = image.data
68
- if (data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4) throw badRequest('images', 'size', MAX_IMAGE_BYTES)
69
- if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(data)) throw badRequest('images', 'encoding')
87
+ if (data.length > Math.ceil(maxBytes / 3) * 4) throw badRequest('images', 'size', maxBytes)
88
+ if (data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(data))
89
+ throw badRequest('images', 'encoding')
70
90
  let binary: string
71
91
  try {
72
92
  binary = atob(data)
@@ -75,7 +95,7 @@ export function decodeImage(image: ImagePart): Uint8Array<ArrayBuffer> {
75
95
  }
76
96
  // atob accepts noncanonical padding bits; standard base64 does not.
77
97
  if (btoa(binary) !== data) throw badRequest('images', 'encoding')
78
- if (binary.length > MAX_IMAGE_BYTES) throw badRequest('images', 'size', MAX_IMAGE_BYTES)
98
+ if (binary.length > maxBytes) throw badRequest('images', 'size', maxBytes)
79
99
  return Uint8Array.from(binary, (character) => character.charCodeAt(0))
80
100
  }
81
101
 
@@ -86,15 +106,6 @@ export function encodeImage(bytes: Uint8Array): string {
86
106
  return btoa(binary)
87
107
  }
88
108
 
89
- function imageMime(bytes: Uint8Array): string {
90
- if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'
91
- if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg'
92
- const signature = (start: number, end: number): string => String.fromCharCode(...bytes.subarray(start, end))
93
- if (signature(0, 4) === 'RIFF' && signature(8, 12) === 'WEBP') return 'image/webp'
94
- if (signature(0, 6) === 'GIF87a' || signature(0, 6) === 'GIF89a') return 'image/gif'
95
- throw badRequest('images', 'mime')
96
- }
97
-
98
109
  function interrupted(signal: AbortSignal): KitFailure {
99
110
  return signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError'
100
111
  ? new KitFailure('timeout', 'Image request deadline exceeded')
@@ -113,7 +124,11 @@ function abortable<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
113
124
  return promise
114
125
  }
115
126
 
116
- async function bounded(response: Response, limit: number, signal: AbortSignal): Promise<Uint8Array> {
127
+ async function bounded(
128
+ response: Response,
129
+ limit: number,
130
+ signal: AbortSignal,
131
+ ): Promise<Uint8Array> {
117
132
  const length = response.headers.get('content-length')
118
133
  if (length !== null && (!/^\d+$/u.test(length) || Number(length) > limit)) {
119
134
  void response.body?.cancel().catch(() => {})
@@ -155,7 +170,13 @@ function endpoint(base: string, edit: boolean): string {
155
170
  } catch {
156
171
  throw badRequest('url', 'invalid')
157
172
  }
158
- if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash)
173
+ if (
174
+ !['https:', 'http:'].includes(url.protocol) ||
175
+ url.username ||
176
+ url.password ||
177
+ url.search ||
178
+ url.hash
179
+ )
159
180
  throw badRequest('url', 'invalid')
160
181
  return url.href
161
182
  }
@@ -172,6 +193,28 @@ function downloadTarget(value: string): string {
172
193
  return url.href
173
194
  }
174
195
 
196
+ /** Only known profiles enable provider-specific options; callers may explicitly select one. */
197
+ export function imageCapabilities(connection: ImageConnection, limits: ImageLimits = {}) {
198
+ const host = new URL(endpoint(connection.imageBaseUrl ?? connection.baseUrl, false)).hostname
199
+ const profile =
200
+ limits.profile ??
201
+ (['api.siliconflow.cn', 'api.siliconflow.com'].includes(host)
202
+ ? 'siliconflow'
203
+ : ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5', 'gpt-image-2'].includes(
204
+ connection.modelId,
205
+ )
206
+ ? 'gpt-image'
207
+ : 'generic')
208
+ if (!['generic', 'gpt-image', 'siliconflow'].includes(profile))
209
+ throw badRequest('profile', 'invalid')
210
+ return {
211
+ profile,
212
+ edit: connection.canVision && profile !== 'siliconflow',
213
+ quality: profile === 'gpt-image',
214
+ mask: profile === 'gpt-image',
215
+ }
216
+ }
217
+
175
218
  /**
176
219
  * One independent generation/edit. OutboundHttp MUST enforce host address policy
177
220
  * for BOTH endpoint and returned download URLs, including DNS, and reject redirects.
@@ -182,26 +225,73 @@ export async function generateImage(
182
225
  connection: ImageConnection,
183
226
  input: ImageRequest,
184
227
  signal: AbortSignal,
185
- ): Promise<ImagePart> {
228
+ limits: ImageLimits = {},
229
+ ): Promise<ImageGeneration> {
186
230
  let dispatch: ImageDispatch = 'not_dispatched'
187
231
  const controller = new AbortController()
188
232
  const cancel = (): void => controller.abort(signal.reason)
189
233
  signal.addEventListener('abort', cancel, { once: true })
190
234
  if (signal.aborted) cancel()
191
- const timer = setTimeout(
192
- () => controller.abort(new DOMException('Image request deadline exceeded', 'TimeoutError')),
193
- IMAGE_TIMEOUT_MS,
194
- )
235
+ const timeoutMs = limits.timeoutMs ?? IMAGE_TIMEOUT_MS
236
+ const maxImageBytes = limits.maxImageBytes ?? MAX_IMAGE_BYTES
237
+ const validLimits =
238
+ Number.isSafeInteger(timeoutMs) &&
239
+ timeoutMs > 0 &&
240
+ timeoutMs <= 180_000 &&
241
+ Number.isSafeInteger(maxImageBytes) &&
242
+ maxImageBytes > 0 &&
243
+ maxImageBytes <= MAX_GENERATED_IMAGE_BYTES
244
+ const timer = validLimits
245
+ ? setTimeout(
246
+ () => controller.abort(new DOMException('Image request deadline exceeded', 'TimeoutError')),
247
+ timeoutMs,
248
+ )
249
+ : undefined
195
250
  const active = controller.signal
196
251
  try {
197
252
  if (active.aborted) throw interrupted(active)
253
+ if (!validLimits) throw badRequest('limits', 'invalid')
198
254
  const prompt = input.prompt.trim()
199
255
  if (!prompt || [...prompt].length > 4000) throw badRequest('prompt', 'invalid')
200
256
  if (!IMAGE_SIZES.some((size) => size === input.size)) throw badRequest('size', 'invalid')
201
257
  if (input.images.length > MAX_EDIT_IMAGES) throw badRequest('images', 'count', MAX_EDIT_IMAGES)
202
258
  const edit = input.images.length > 0
203
- if (edit && !connection.canVision) throw new KitFailure('unsupported', 'Image editing unsupported')
204
- const target = endpoint((edit ? connection.imageEditBaseUrl : connection.imageBaseUrl) ?? connection.baseUrl, edit)
259
+ const capabilities = imageCapabilities(connection, limits)
260
+ if (edit && !capabilities.edit) throw new KitFailure('unsupported', 'Image editing unsupported')
261
+ if (input.quality !== undefined) {
262
+ if (!['low', 'medium', 'high'].includes(input.quality)) throw badRequest('quality', 'invalid')
263
+ if (!capabilities.quality) throw new KitFailure('unsupported', 'Image quality unsupported')
264
+ }
265
+ if (input.mask !== undefined && (!edit || !capabilities.mask))
266
+ throw new KitFailure('unsupported', 'Image mask unsupported')
267
+ let inputBytes = 0
268
+ const decoded = input.images.map((image) => {
269
+ const bytes = decodeImage(image, maxImageBytes)
270
+ validateImageBytes(bytes, image.mime, maxImageBytes)
271
+ inputBytes += bytes.length
272
+ return bytes
273
+ })
274
+ const maskBytes = input.mask === undefined ? undefined : decodeImage(input.mask, maxImageBytes)
275
+ if (maskBytes && input.mask) {
276
+ const mask = validateImageBytes(maskBytes, 'image/png', maxImageBytes)
277
+ const firstBytes = decoded[0]
278
+ if (firstBytes === undefined) throw badRequest('mask', 'requires_image')
279
+ const first = validateImageBytes(firstBytes, undefined, maxImageBytes)
280
+ if (
281
+ input.mask.mime !== 'image/png' ||
282
+ !mask.alpha ||
283
+ mask.width !== first.width ||
284
+ mask.height !== first.height
285
+ )
286
+ throw badRequest('mask', 'invalid')
287
+ inputBytes += maskBytes.length
288
+ }
289
+ if (inputBytes > MAX_IMAGE_INPUT_BYTES)
290
+ throw badRequest('images', 'size', MAX_IMAGE_INPUT_BYTES)
291
+ const target = endpoint(
292
+ (edit ? connection.imageEditBaseUrl : connection.imageBaseUrl) ?? connection.baseUrl,
293
+ edit,
294
+ )
205
295
  let payload: BodyInit
206
296
  if (edit) {
207
297
  const form = new FormData()
@@ -209,9 +299,16 @@ export async function generateImage(
209
299
  form.set('prompt', prompt)
210
300
  form.set('size', input.size)
211
301
  form.set('n', '1')
302
+ if (capabilities.profile === 'gpt-image') {
303
+ form.set('output_format', 'png')
304
+ form.set('background', 'opaque')
305
+ }
306
+ if (input.quality !== undefined) form.set('quality', input.quality)
307
+ if (maskBytes) form.set('mask', new Blob([maskBytes], { type: 'image/png' }), 'mask.png')
212
308
  for (const [index, raw] of input.images.entries()) {
213
309
  const image = raw
214
- const bytes = decodeImage(image)
310
+ const bytes = decoded[index]
311
+ if (bytes === undefined) throw badRequest('images', 'encoding')
215
312
  const extension = image.mime === 'image/jpeg' ? 'jpg' : image.mime.slice(6)
216
313
  form.append(
217
314
  input.images.length === 1 ? 'image' : 'image[]',
@@ -221,7 +318,20 @@ export async function generateImage(
221
318
  }
222
319
  payload = form
223
320
  } else {
224
- payload = JSON.stringify({ model: connection.modelId, prompt, size: input.size, n: 1 })
321
+ payload = JSON.stringify(
322
+ capabilities.profile === 'siliconflow'
323
+ ? { model: connection.modelId, prompt, image_size: input.size }
324
+ : {
325
+ model: connection.modelId,
326
+ prompt,
327
+ size: input.size,
328
+ n: 1,
329
+ ...(capabilities.profile === 'gpt-image'
330
+ ? { output_format: 'png', background: 'opaque' }
331
+ : {}),
332
+ ...(input.quality === undefined ? {} : { quality: input.quality }),
333
+ },
334
+ )
225
335
  }
226
336
  const baseHeaders = new Headers(connection.headers)
227
337
  // Never allow a custom JSON header to break the platform multipart boundary.
@@ -235,19 +345,35 @@ export async function generateImage(
235
345
  if (key !== undefined) headers.set('authorization', `Bearer ${key}`)
236
346
  dispatch = 'unknown'
237
347
  const response = await abortable(
238
- http.fetch(target, { method: 'POST', headers, body: payload, redirect: 'manual', signal: active }),
348
+ http.fetch(target, {
349
+ method: 'POST',
350
+ headers,
351
+ body: payload,
352
+ redirect: 'manual',
353
+ signal: active,
354
+ }),
239
355
  active,
240
356
  )
241
357
  if (response.redirected || (response.status >= 300 && response.status < 400)) {
242
358
  void response.body?.cancel().catch(() => {})
243
359
  throw new KitFailure('upstream', 'Image redirect refused', { status: response.status })
244
360
  }
245
- if (!response.ok) dispatch = 'rejected'
246
- const bytes = await bounded(response, response.ok ? RESPONSE_BYTES : 8192, active)
361
+ if (response.status >= 400 && response.status < 500) dispatch = 'rejected'
362
+ const bytes = await bounded(
363
+ response,
364
+ response.ok ? Math.ceil(maxImageBytes / 3) * 4 + 64 * 1024 : 8192,
365
+ active,
366
+ )
247
367
  if (!response.ok) {
248
368
  if ([401, 403, 429].includes(response.status) && attempt + 1 < attempts) continue
249
369
  // Deliberately never surface provider bodies: they can contain credentials.
250
- throw new KitFailure('upstream', 'Image provider rejected request', { status: response.status })
370
+ const code =
371
+ response.status === 401 || response.status === 403
372
+ ? 'unauthorized'
373
+ : response.status === 429
374
+ ? 'rate_limited'
375
+ : 'upstream'
376
+ throw new KitFailure(code, 'Image provider rejected request', { status: response.status })
251
377
  }
252
378
  let body: unknown
253
379
  try {
@@ -255,14 +381,30 @@ export async function generateImage(
255
381
  } catch {
256
382
  throw new KitFailure('upstream', 'Invalid image response')
257
383
  }
258
- const first: unknown = record(body) && Array.isArray(body.data) ? body.data[0] : undefined
259
- if (!record(first)) throw new KitFailure('upstream', 'No image in response')
384
+ const images: unknown = record(body)
385
+ ? capabilities.profile === 'siliconflow'
386
+ ? body.images
387
+ : body.data
388
+ : undefined
389
+ if (!Array.isArray(images) || images.length !== 1)
390
+ throw new KitFailure('upstream', 'Expected exactly one image')
391
+ const first: unknown = images[0]
392
+ if (
393
+ !record(first) ||
394
+ (typeof first.b64_json === 'string') === (typeof first.url === 'string')
395
+ )
396
+ throw new KitFailure('upstream', 'Invalid image response')
260
397
  let imageBytes: Uint8Array
398
+ let expectedMime: string | undefined
261
399
  if (typeof first.b64_json === 'string') {
262
- imageBytes = decodeImage({ mime: 'image/png', data: first.b64_json })
400
+ imageBytes = decodeImage({ mime: 'image/png', data: first.b64_json }, maxImageBytes)
263
401
  } else if (typeof first.url === 'string') {
264
402
  const image = await abortable(
265
- http.fetch(downloadTarget(first.url), { method: 'GET', redirect: 'manual', signal: active }),
403
+ http.fetch(downloadTarget(first.url), {
404
+ method: 'GET',
405
+ redirect: 'manual',
406
+ signal: active,
407
+ }),
266
408
  active,
267
409
  )
268
410
  if (image.redirected || !image.ok) {
@@ -270,14 +412,45 @@ export async function generateImage(
270
412
  throw new KitFailure('upstream', 'Image download failed', { status: image.status })
271
413
  }
272
414
  const mime = image.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
273
- if (mime !== undefined && !IMAGE_MIMES.some((allowed) => allowed === mime)) {
415
+ if (
416
+ mime &&
417
+ mime !== 'application/octet-stream' &&
418
+ !IMAGE_MIMES.some((allowed) => allowed === mime)
419
+ ) {
274
420
  void image.body?.cancel().catch(() => {})
275
421
  throw badRequest('images', 'mime')
276
422
  }
277
- imageBytes = await bounded(image, MAX_IMAGE_BYTES, active)
278
- if (mime !== undefined && imageMime(imageBytes) !== mime) throw badRequest('images', 'mime')
423
+ imageBytes = await bounded(image, maxImageBytes, active)
424
+ expectedMime = mime && mime !== 'application/octet-stream' ? mime : undefined
279
425
  } else throw new KitFailure('upstream', 'No image in response')
280
- return { mime: imageMime(imageBytes), data: encodeImage(imageBytes) }
426
+ const checked = validateImageBytes(imageBytes, expectedMime, maxImageBytes)
427
+ const usage = emptyUsage()
428
+ let totalTokens: number | null = null
429
+ if (record(body) && record(body.usage)) {
430
+ const count = (value: unknown): number | null =>
431
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null
432
+ totalTokens = count(body.usage.total_tokens)
433
+ usage.inputTokens = count(body.usage.input_tokens ?? body.usage.prompt_tokens)
434
+ usage.outputTokens = count(body.usage.output_tokens ?? body.usage.completion_tokens)
435
+ const inputDetails = body.usage.input_tokens_details ?? body.usage.prompt_tokens_details
436
+ const outputDetails =
437
+ body.usage.output_tokens_details ?? body.usage.completion_tokens_details
438
+ if (record(inputDetails)) {
439
+ usage.cacheReadTokens = count(inputDetails.cached_tokens)
440
+ usage.cacheWriteTokens = count(
441
+ inputDetails.cache_creation_tokens ?? body.usage.cache_creation_input_tokens,
442
+ )
443
+ }
444
+ if (!record(inputDetails))
445
+ usage.cacheWriteTokens = count(body.usage.cache_creation_input_tokens)
446
+ if (record(outputDetails)) usage.reasoningTokens = count(outputDetails.reasoning_tokens)
447
+ }
448
+ return {
449
+ image: { mime: checked.mime, data: encodeImage(imageBytes) },
450
+ revisedPrompt: typeof first.revised_prompt === 'string' ? first.revised_prompt : null,
451
+ usage,
452
+ totalTokens,
453
+ }
281
454
  }
282
455
  throw new KitFailure('internal', 'Image request has no attempts')
283
456
  } catch (error: unknown) {
@@ -288,7 +461,7 @@ export async function generateImage(
288
461
  : new KitFailure('network', 'Image provider unreachable')
289
462
  throw new ImageFailure(failure.code, failure.message, dispatch, failure.details)
290
463
  } finally {
291
- clearTimeout(timer)
464
+ clearTimeout(timer ?? 0)
292
465
  signal.removeEventListener('abort', cancel)
293
466
  }
294
467
  }
@@ -0,0 +1,276 @@
1
+ import { badRequest } from '../error.js'
2
+
3
+ const invalid = (): never => {
4
+ throw badRequest('images', 'encoding')
5
+ }
6
+
7
+ /** Structural image validation shared by independent generation and host storage adapters. */
8
+ export function validateImageBytes(
9
+ bytes: Uint8Array,
10
+ expectedMime?: string,
11
+ maxBytes = 4 * 1024 * 1024,
12
+ ): { mime: string; width: number; height: number; alpha: boolean } {
13
+ if (!bytes.length) return invalid()
14
+ if (bytes.length > maxBytes) throw badRequest('images', 'size', maxBytes)
15
+ const signature = (start: number, end: number): string =>
16
+ String.fromCharCode(...bytes.subarray(start, end))
17
+ const mime =
18
+ bytes[0] === 137 && signature(1, 4) === 'PNG'
19
+ ? 'image/png'
20
+ : bytes[0] === 255 && bytes[1] === 216
21
+ ? 'image/jpeg'
22
+ : signature(0, 4) === 'RIFF' && signature(8, 12) === 'WEBP'
23
+ ? 'image/webp'
24
+ : ['GIF87a', 'GIF89a'].includes(signature(0, 6))
25
+ ? 'image/gif'
26
+ : undefined
27
+ if (!mime || (expectedMime !== undefined && expectedMime !== mime))
28
+ throw badRequest('images', 'mime')
29
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
30
+ const tag = (offset: number, text: string): boolean => {
31
+ for (let index = 0; index < text.length; index++)
32
+ if (bytes[offset + index] !== text.charCodeAt(index)) return false
33
+ return true
34
+ }
35
+ let width = 0,
36
+ height = 0,
37
+ alpha = false
38
+ if (mime === 'image/png') {
39
+ if (bytes.length < 57 || ![137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => bytes[i] === v))
40
+ return invalid()
41
+ let offset = 8,
42
+ first = true,
43
+ data = false,
44
+ end = false
45
+ while (offset + 12 <= bytes.length) {
46
+ const size = view.getUint32(offset)
47
+ if (size > bytes.length - offset - 12) return invalid()
48
+ const kind = offset + 4
49
+ if (first) {
50
+ if (!tag(kind, 'IHDR') || size !== 13) return invalid()
51
+ width = view.getUint32(offset + 8)
52
+ height = view.getUint32(offset + 12)
53
+ const depth = bytes[offset + 16],
54
+ color = bytes[offset + 17]
55
+ if (
56
+ depth === undefined ||
57
+ color === undefined ||
58
+ ![0, 2, 3, 4, 6].includes(color) ||
59
+ !(color === 0 ? [1, 2, 4, 8, 16] : color === 3 ? [1, 2, 4, 8] : [8, 16]).includes(
60
+ depth,
61
+ ) ||
62
+ bytes[offset + 18] !== 0 ||
63
+ bytes[offset + 19] !== 0 ||
64
+ ![0, 1].includes(bytes[offset + 20] ?? -1)
65
+ )
66
+ return invalid()
67
+ alpha = color === 4 || color === 6
68
+ first = false
69
+ } else if (tag(kind, 'IHDR')) return invalid()
70
+ if (tag(kind, 'tRNS')) alpha = true
71
+ if (tag(kind, 'IDAT') && size > 0) data = true
72
+ offset += size + 12
73
+ if (tag(kind, 'IEND')) {
74
+ if (size !== 0 || offset !== bytes.length) return invalid()
75
+ end = true
76
+ break
77
+ }
78
+ }
79
+ if (!data || !end) return invalid()
80
+ } else if (mime === 'image/jpeg') {
81
+ if (bytes.length < 12 || bytes[0] !== 255 || bytes[1] !== 216) return invalid()
82
+ let offset = 2,
83
+ scan = false,
84
+ end = false
85
+ while (offset < bytes.length) {
86
+ if (bytes[offset++] !== 255) return invalid()
87
+ while (bytes[offset] === 255) offset++
88
+ const marker = bytes[offset++]
89
+ if (marker === 217) {
90
+ end = offset === bytes.length
91
+ break
92
+ }
93
+ if (marker === undefined || marker === 0 || marker === 216 || offset + 2 > bytes.length)
94
+ return invalid()
95
+ if (marker >= 208 && marker <= 215) continue
96
+ const size = view.getUint16(offset)
97
+ if (size < 2 || offset + size > bytes.length) return invalid()
98
+ if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
99
+ if (size < 8 || width !== 0) return invalid()
100
+ height = view.getUint16(offset + 3)
101
+ width = view.getUint16(offset + 5)
102
+ }
103
+ offset += size
104
+ if (marker === 218) {
105
+ scan = true
106
+ while (offset < bytes.length) {
107
+ if (bytes[offset] !== 255) {
108
+ offset++
109
+ continue
110
+ }
111
+ if (
112
+ bytes[offset + 1] === 0 ||
113
+ ((bytes[offset + 1] ?? 0) >= 208 && (bytes[offset + 1] ?? 0) <= 215)
114
+ ) {
115
+ offset += 2
116
+ continue
117
+ }
118
+ break
119
+ }
120
+ }
121
+ }
122
+ if (!scan || !end) return invalid()
123
+ } else if (mime === 'image/webp') {
124
+ if (
125
+ bytes.length < 26 ||
126
+ !tag(0, 'RIFF') ||
127
+ !tag(8, 'WEBP') ||
128
+ view.getUint32(4, true) + 8 !== bytes.length
129
+ )
130
+ return invalid()
131
+ let offset = 12,
132
+ image = false,
133
+ extended = false,
134
+ animated = false,
135
+ animationHeader = false
136
+ while (offset + 8 <= bytes.length) {
137
+ const size = view.getUint32(offset + 4, true),
138
+ body = offset + 8
139
+ if (size > bytes.length - body) return invalid()
140
+ if (tag(offset, 'VP8X')) {
141
+ if (offset !== 12 || size !== 10) return invalid()
142
+ extended = true
143
+ alpha = ((bytes[body] ?? 0) & 16) !== 0
144
+ animated = ((bytes[body] ?? 0) & 2) !== 0
145
+ width =
146
+ 1 +
147
+ (bytes[body + 4] ?? 0) +
148
+ ((bytes[body + 5] ?? 0) << 8) +
149
+ ((bytes[body + 6] ?? 0) << 16)
150
+ height =
151
+ 1 +
152
+ (bytes[body + 7] ?? 0) +
153
+ ((bytes[body + 8] ?? 0) << 8) +
154
+ ((bytes[body + 9] ?? 0) << 16)
155
+ } else if (tag(offset, 'ANIM')) {
156
+ if (!animated || size !== 6 || animationHeader) return invalid()
157
+ animationHeader = true
158
+ } else if (tag(offset, 'ANMF')) {
159
+ if (!animated || !animationHeader || size < 24) return invalid()
160
+ const u24 = (at: number): number =>
161
+ (bytes[at] ?? 0) + ((bytes[at + 1] ?? 0) << 8) + ((bytes[at + 2] ?? 0) << 16)
162
+ const x = u24(body) * 2,
163
+ y = u24(body + 3) * 2,
164
+ w = u24(body + 6) + 1,
165
+ h = u24(body + 9) + 1
166
+ if (x + w > width || y + h > height || ((bytes[body + 15] ?? 0) & 252) !== 0)
167
+ return invalid()
168
+ let frame = body + 16,
169
+ pixels = false
170
+ while (frame + 8 <= body + size) {
171
+ const length = view.getUint32(frame + 4, true),
172
+ data = frame + 8
173
+ if (data + length > body + size) return invalid()
174
+ if (tag(frame, 'VP8 ')) {
175
+ if (
176
+ pixels ||
177
+ length < 10 ||
178
+ ((bytes[data] ?? 1) & 1) !== 0 ||
179
+ !tag(data + 3, '\u009d\u0001\u002a') ||
180
+ (view.getUint16(data + 6, true) & 16383) !== w ||
181
+ (view.getUint16(data + 8, true) & 16383) !== h
182
+ )
183
+ return invalid()
184
+ pixels = true
185
+ } else if (tag(frame, 'VP8L')) {
186
+ if (pixels || length < 5 || bytes[data] !== 47) return invalid()
187
+ const bits = view.getUint32(data + 1, true)
188
+ if (bits >>> 29 !== 0 || (bits & 16383) + 1 !== w || ((bits >>> 14) & 16383) + 1 !== h)
189
+ return invalid()
190
+ alpha ||= ((bits >>> 28) & 1) !== 0
191
+ pixels = true
192
+ } else if (!tag(frame, 'ALPH')) return invalid()
193
+ frame = data + length + (length & 1)
194
+ }
195
+ if (!pixels || frame !== body + size) return invalid()
196
+ image = true
197
+ } else if (tag(offset, 'VP8 ') || tag(offset, 'VP8L')) {
198
+ if (animated) return invalid()
199
+ if (image) return invalid()
200
+ image = true
201
+ let w: number, h: number
202
+ if (tag(offset, 'VP8 ')) {
203
+ if (size < 10 || ((bytes[body] ?? 1) & 1) !== 0 || !tag(body + 3, '\u009d\u0001\u002a'))
204
+ return invalid()
205
+ w = view.getUint16(body + 6, true) & 16383
206
+ h = view.getUint16(body + 8, true) & 16383
207
+ } else {
208
+ if (size < 5 || bytes[body] !== 47) return invalid()
209
+ const bits = view.getUint32(body + 1, true)
210
+ if (bits >>> 29 !== 0) return invalid()
211
+ w = (bits & 16383) + 1
212
+ h = ((bits >>> 14) & 16383) + 1
213
+ alpha ||= ((bits >>> 28) & 1) !== 0
214
+ }
215
+ if (extended && (w !== width || h !== height)) return invalid()
216
+ width = w
217
+ height = h
218
+ }
219
+ offset = body + size + (size & 1)
220
+ }
221
+ if (!image || offset !== bytes.length) return invalid()
222
+ } else if (mime === 'image/gif') {
223
+ if (bytes.length < 14 || (!tag(0, 'GIF87a') && !tag(0, 'GIF89a'))) return invalid()
224
+ width = view.getUint16(6, true)
225
+ height = view.getUint16(8, true)
226
+ let offset = 13
227
+ const packed = bytes[10] ?? 0
228
+ if (packed & 128) offset += 3 * (1 << ((packed & 7) + 1))
229
+ const blocks = (): void => {
230
+ while (true) {
231
+ if (offset >= bytes.length) return invalid()
232
+ const size = bytes[offset++] ?? 0
233
+ if (!size) return
234
+ if (size > bytes.length - offset) return invalid()
235
+ offset += size
236
+ }
237
+ }
238
+ let frames = 0,
239
+ end = false
240
+ while (offset < bytes.length) {
241
+ const marker = bytes[offset++]
242
+ if (marker === 59) {
243
+ end = offset === bytes.length
244
+ break
245
+ }
246
+ if (marker === 33) {
247
+ if (offset >= bytes.length) return invalid()
248
+ const label = bytes[offset++]
249
+ if (label === 249) {
250
+ if (bytes[offset] !== 4 || offset + 6 > bytes.length) return invalid()
251
+ alpha ||= ((bytes[offset + 1] ?? 0) & 1) !== 0
252
+ }
253
+ blocks()
254
+ } else if (marker === 44) {
255
+ if (offset + 9 > bytes.length) return invalid()
256
+ const x = view.getUint16(offset, true),
257
+ y = view.getUint16(offset + 2, true)
258
+ const w = view.getUint16(offset + 4, true),
259
+ h = view.getUint16(offset + 6, true)
260
+ if (!w || !h || x + w > width || y + h > height) return invalid()
261
+ const flags = bytes[offset + 8] ?? 0
262
+ offset += 9
263
+ if (flags & 128) offset += 3 * (1 << ((flags & 7) + 1))
264
+ const codeSize = bytes[offset++]
265
+ if (codeSize === undefined || codeSize < 2 || codeSize > 8 || !bytes[offset])
266
+ return invalid()
267
+ blocks()
268
+ frames++
269
+ } else return invalid()
270
+ }
271
+ if (!frames || !end) return invalid()
272
+ } else return invalid()
273
+ if (width < 1 || height < 1 || width * height > 40_000_000)
274
+ throw badRequest('images', 'dimensions')
275
+ return { mime, width, height, alpha }
276
+ }