Skip to content

Commit 3ae5586

Browse files
committed
feat: add pluggable image delivery presets
1 parent e3ef415 commit 3ae5586

2 files changed

Lines changed: 171 additions & 34 deletions

File tree

packages/ts-images/src/delivery.ts

Lines changed: 130 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ImageData } from './core'
1+
import type { ImageData, ResizeFit, ResizeOptions } from './core'
22
import { createHash } from 'node:crypto'
33
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
44
import { basename, extname, join } from 'node:path'
@@ -7,18 +7,47 @@ import { resize } from './core'
77
import { imageToSplatHash, splatHashToBase64, splatHashToDataURL } from './splathash'
88

99
export type WebImageFormat = 'avif' | 'webp' | 'jpeg' | 'png'
10+
export type ImageDeliveryPreset = 'avatar' | 'content' | 'hero' | 'thumbnail'
11+
12+
export interface ImageStoredObject {
13+
bytes: number
14+
path?: string
15+
url?: string
16+
}
17+
18+
export interface ImageDeliveryStorage {
19+
cacheNamespace: string
20+
stat: (_key: string) => Promise<ImageStoredObject | null>
21+
write: (_key: string, _bytes: Uint8Array, _metadata: { contentType: string, cacheControl: string }) => Promise<ImageStoredObject | void>
22+
url?: (_key: string) => string | Promise<string>
23+
}
24+
25+
export interface ImageAuthorizationRequest {
26+
input: string | Uint8Array
27+
name?: string
28+
context?: unknown
29+
}
1030

1131
export interface ImageDeliveryOptions {
1232
input: string | Uint8Array
13-
outDir: string
33+
outDir?: string
34+
storage?: ImageDeliveryStorage
35+
authorize?: (_request: ImageAuthorizationRequest) => boolean | Promise<boolean>
36+
authorizationContext?: unknown
37+
preset?: ImageDeliveryPreset
1438
name?: string
1539
baseUrl?: string
1640
widths?: readonly number[]
41+
height?: number
42+
aspectRatio?: number
43+
fit?: ResizeFit
44+
position?: ResizeOptions['position']
1745
formats?: readonly WebImageFormat[]
1846
fallbackFormat?: Extract<WebImageFormat, 'jpeg' | 'png'>
1947
quality?: number | Partial<Record<WebImageFormat, number>>
2048
concurrency?: number
2149
upscale?: boolean
50+
includeOriginal?: boolean
2251
placeholder?: boolean
2352
}
2453

@@ -68,6 +97,18 @@ const formatExtensions: Record<WebImageFormat, string> = {
6897

6998
const activeGenerations = new Map<string, Promise<ImageDeliveryManifest>>()
7099

100+
const deliveryPresets: Record<ImageDeliveryPreset, Partial<ImageDeliveryOptions>> = {
101+
avatar: { widths: [64, 128, 256, 512], aspectRatio: 1, fit: 'cover', position: 'center', includeOriginal: false },
102+
content: { widths: [320, 640, 960, 1280, 1920], fit: 'inside' },
103+
hero: { widths: [640, 1280, 1920, 2560], aspectRatio: 16 / 9, fit: 'cover', position: 'center' },
104+
thumbnail: { widths: [160, 320, 640], aspectRatio: 16 / 9, fit: 'cover', position: 'center', includeOriginal: false },
105+
}
106+
107+
export function resolveImageDeliveryOptions(options: ImageDeliveryOptions): ImageDeliveryOptions {
108+
const preset = options.preset ? deliveryPresets[options.preset] : undefined
109+
return { ...preset, ...options }
110+
}
111+
71112
function clampQuality(value: number): number {
72113
if (!Number.isFinite(value)) throw new TypeError('Image quality must be a finite number')
73114
return Math.max(1, Math.min(100, Math.round(value)))
@@ -99,16 +140,16 @@ function canonicalFormats(formats: readonly WebImageFormat[], fallback: WebImage
99140
return unique
100141
}
101142

102-
export function normalizeImageWidths(widths: readonly number[], sourceWidth: number, upscale = false): number[] {
143+
export function normalizeImageWidths(widths: readonly number[], sourceWidth: number, upscale = false, includeOriginal = true): number[] {
103144
if (!Number.isInteger(sourceWidth) || sourceWidth < 1) throw new TypeError('Source width must be a positive integer')
104145

105146
const normalized = widths.map((width) => {
106-
if (!Number.isFinite(width) || width < 1) throw new TypeError('Image widths must be positive numbers')
147+
if (!Number.isFinite(width) || width < 1 || width > 16_384) throw new TypeError('Image widths must be between 1 and 16384')
107148
return Math.round(width)
108149
})
109150

110151
if (normalized.length === 0) normalized.push(sourceWidth)
111-
if (!upscale) normalized.push(sourceWidth)
152+
if (!upscale && includeOriginal) normalized.push(sourceWidth)
112153

113154
const result = [...new Set(normalized)]
114155
.filter(width => upscale || width <= sourceWidth)
@@ -166,6 +207,24 @@ async function writeAtomically(path: string, bytes: Uint8Array): Promise<void> {
166207
}
167208
}
168209

210+
function localStorage(outDir: string, baseUrl: string): ImageDeliveryStorage {
211+
return {
212+
cacheNamespace: `local:${outDir}:${baseUrl}`,
213+
async stat(key) {
214+
const path = join(outDir, key)
215+
const value = await stat(path).catch(() => null)
216+
return value ? { bytes: value.size, path, url: buildUrl(baseUrl, key) } : null
217+
},
218+
async write(key, bytes) {
219+
await mkdir(outDir, { recursive: true })
220+
const path = join(outDir, key)
221+
await writeAtomically(path, bytes)
222+
return { bytes: bytes.byteLength, path, url: buildUrl(baseUrl, key) }
223+
},
224+
url: key => buildUrl(baseUrl, key),
225+
}
226+
}
227+
169228
async function mapConcurrent<T, R>(items: T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
170229
const output = new Array<R>(items.length)
171230
let nextIndex = 0
@@ -198,41 +257,67 @@ async function generateManifest(options: ImageDeliveryOptions): Promise<ImageDel
198257
? new Uint8Array(await readFile(options.input))
199258
: options.input
200259
const source = await decode(sourceBytes)
260+
if (options.height !== undefined && (!Number.isInteger(options.height) || options.height < 1 || options.height > 16_384)) {
261+
throw new TypeError('Image height must be between 1 and 16384')
262+
}
263+
if (options.aspectRatio !== undefined && (!Number.isFinite(options.aspectRatio) || options.aspectRatio <= 0 || options.aspectRatio > 100)) {
264+
throw new TypeError('Image aspect ratio must be between 0 and 100')
265+
}
266+
if (options.height !== undefined && options.aspectRatio !== undefined) throw new TypeError('Image height and aspect ratio are mutually exclusive')
201267
const fallbackFormat = options.fallbackFormat ?? (source.hasAlpha ? 'png' : 'jpeg')
202268
const formats = canonicalFormats(options.formats ?? ['avif', 'webp'], fallbackFormat)
203-
const widths = normalizeImageWidths(options.widths ?? [320, 640, 960, 1280, 1920], source.width, options.upscale)
269+
const widths = normalizeImageWidths(options.widths ?? [320, 640, 960, 1280, 1920], source.width, options.upscale, options.includeOriginal)
204270
const concurrency = Math.max(1, Math.min(32, Math.round(options.concurrency ?? 4)))
205271
const name = normalizeName(options.input, options.name)
206272
const baseUrl = normalizeBaseUrl(options.baseUrl)
273+
if (!options.storage && !options.outDir) throw new TypeError('Image delivery requires outDir or a storage adapter')
274+
const storage = options.storage ?? localStorage(options.outDir!, baseUrl)
207275
const deliveryOptions = {
208276
formats,
209277
widths,
210278
quality: formats.map(format => [format, getQuality(options.quality, format)]),
211279
upscale: options.upscale ?? false,
280+
includeOriginal: options.includeOriginal ?? true,
281+
height: options.height,
282+
aspectRatio: options.aspectRatio,
283+
fit: options.fit ?? 'inside',
284+
position: options.position ?? 'center',
285+
storage: storage.cacheNamespace,
212286
}
213287
const sourceHash = createHash('sha256').update(sourceBytes).digest('hex')
214288
const deliveryHash = hashDelivery(sourceBytes, deliveryOptions).slice(0, 16)
215289

216-
await mkdir(options.outDir, { recursive: true })
217-
218290
const tasks = formats.flatMap(format => widths.map(width => ({ format, width })))
219291
const variants = await mapConcurrent(tasks, concurrency, async ({ format, width }) => {
220292
const filename = `${name}-${deliveryHash}-${width}.${formatExtensions[format]}`
221-
const path = join(options.outDir, filename)
222-
const targetHeight = Math.max(1, Math.round(source.height * (width / source.width)))
223-
const existing = await stat(path).catch(() => null)
293+
const requestedHeight = options.height ?? (options.aspectRatio ? Math.max(1, Math.round(width / options.aspectRatio)) : undefined)
294+
const fit = options.fit ?? 'inside'
295+
const position = options.position ?? 'center'
296+
const existing = await storage.stat(filename)
297+
let generated: ImageData | undefined
224298
if (!existing) {
225-
const image: ImageData = width === source.width ? source : resize(source, { width })
226-
const encoded = await encode(image, format, { quality: getQuality(options.quality, format) })
227-
await writeAtomically(path, encoded)
299+
const unchanged = width === source.width && requestedHeight === undefined
300+
generated = unchanged ? source : resize(source, { width, height: requestedHeight, fit, position })
301+
if (!options.upscale && (generated.width > source.width || generated.height > source.height)) {
302+
throw new TypeError(`Image variant ${generated.width}x${generated.height} would upscale the source`)
303+
}
304+
const encoded = await encode(generated, format, { quality: getQuality(options.quality, format) })
305+
await storage.write(filename, encoded, {
306+
contentType: formatMimeTypes[format],
307+
cacheControl: 'public, max-age=31536000, immutable',
308+
})
228309
}
229-
const file = await stat(path)
310+
const file = await storage.stat(filename)
311+
if (!file) throw new Error(`Image storage did not persist ${filename}`)
312+
const output = generated ?? (requestedHeight === undefined
313+
? { width, height: Math.max(1, Math.round(source.height * (width / source.width))) }
314+
: resize(source, { width, height: requestedHeight, fit, position }))
230315
return {
231-
path,
232-
url: buildUrl(baseUrl, filename),
233-
width,
234-
height: targetHeight,
235-
bytes: file.size,
316+
path: file.path ?? filename,
317+
url: file.url ?? await storage.url?.(filename) ?? buildUrl(baseUrl, filename),
318+
width: output.width,
319+
height: output.height,
320+
bytes: file.bytes,
236321
format,
237322
mimeType: formatMimeTypes[format],
238323
cacheKey: `${sourceHash}:${deliveryHash}:${format}:${width}`,
@@ -265,24 +350,36 @@ async function generateManifest(options: ImageDeliveryOptions): Promise<ImageDel
265350
}
266351

267352
export async function createImageDeliveryManifest(options: ImageDeliveryOptions): Promise<ImageDeliveryManifest> {
268-
const sourceBytes = typeof options.input === 'string'
269-
? new Uint8Array(await readFile(options.input))
270-
: options.input
353+
const resolved = resolveImageDeliveryOptions(options)
354+
if (resolved.authorize && !await resolved.authorize({
355+
input: resolved.input,
356+
name: resolved.name,
357+
context: resolved.authorizationContext,
358+
})) throw new Error('Image delivery is not authorized')
359+
const sourceBytes = typeof resolved.input === 'string'
360+
? new Uint8Array(await readFile(resolved.input))
361+
: resolved.input
271362
const key = hashDelivery(sourceBytes, {
272-
outDir: options.outDir,
273-
name: options.name,
274-
baseUrl: options.baseUrl,
275-
widths: options.widths,
276-
formats: options.formats,
277-
fallbackFormat: options.fallbackFormat,
278-
quality: options.quality,
279-
upscale: options.upscale,
280-
placeholder: options.placeholder,
363+
outDir: resolved.outDir,
364+
storage: resolved.storage?.cacheNamespace,
365+
name: resolved.name,
366+
baseUrl: resolved.baseUrl,
367+
widths: resolved.widths,
368+
height: resolved.height,
369+
aspectRatio: resolved.aspectRatio,
370+
fit: resolved.fit,
371+
position: resolved.position,
372+
formats: resolved.formats,
373+
fallbackFormat: resolved.fallbackFormat,
374+
quality: resolved.quality,
375+
upscale: resolved.upscale,
376+
includeOriginal: resolved.includeOriginal,
377+
placeholder: resolved.placeholder,
281378
})
282379
const active = activeGenerations.get(key)
283380
if (active) return active
284381

285-
const generation = generateManifest({ ...options, input: sourceBytes })
382+
const generation = generateManifest({ ...resolved, input: sourceBytes })
286383
activeGenerations.set(key, generation)
287384
try {
288385
return await generation

packages/ts-images/test/delivery.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test'
22
import { mkdtemp, readFile, rm } from 'node:fs/promises'
33
import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
5-
import { createImageDeliveryManifest, decode, negotiateImageFormat, normalizeImageWidths, selectImageVariant } from '../src'
5+
import { createImageDeliveryManifest, decode, negotiateImageFormat, normalizeImageWidths, resolveImageDeliveryOptions, selectImageVariant } from '../src'
66

77
const fixture = join(import.meta.dir, 'fixtures/app-icon.png')
88
const outputDirectories: string[] = []
@@ -71,4 +71,44 @@ describe('image delivery', () => {
7171
])
7272
expect(second.variants.map(variant => variant.cacheKey)).toEqual(first.variants.map(variant => variant.cacheKey))
7373
})
74+
75+
test('applies named crop presets before resolving defaults', () => {
76+
const options = resolveImageDeliveryOptions({ input: fixture, outDir: '/tmp/images', preset: 'avatar', widths: [32] })
77+
expect(options.widths).toEqual([32])
78+
expect(options.aspectRatio).toBe(1)
79+
expect(options.fit).toBe('cover')
80+
})
81+
82+
test('authorizes before reading source metadata', async () => {
83+
let called = false
84+
await expect(createImageDeliveryManifest({
85+
input: '/path/that/must/not/be/read.png',
86+
outDir: '/tmp/images',
87+
authorize: () => {
88+
called = true
89+
return false
90+
},
91+
})).rejects.toThrow('not authorized')
92+
expect(called).toBe(true)
93+
})
94+
95+
test('publishes variants through a storage adapter', async () => {
96+
const objects = new Map<string, Uint8Array>()
97+
const manifest = await createImageDeliveryManifest({
98+
input: fixture,
99+
preset: 'avatar',
100+
widths: [32],
101+
formats: ['webp'],
102+
fallbackFormat: 'png',
103+
placeholder: false,
104+
storage: {
105+
cacheNamespace: 'test-memory',
106+
stat: async key => objects.has(key) ? { bytes: objects.get(key)!.byteLength, url: `https://cdn.example/${key}` } : null,
107+
write: async (key, bytes) => { objects.set(key, bytes) },
108+
},
109+
})
110+
expect(manifest.variants).toHaveLength(2)
111+
expect(manifest.variants.every(variant => variant.width === 32 && variant.height === 32)).toBe(true)
112+
expect(manifest.variants.every(variant => variant.url.startsWith('https://cdn.example/'))).toBe(true)
113+
})
74114
})

0 commit comments

Comments
 (0)