Skip to content

Commit e3ef415

Browse files
committed
feat: add responsive image delivery manifests
1 parent 09a39ab commit e3ef415

3 files changed

Lines changed: 392 additions & 0 deletions

File tree

packages/ts-images/src/delivery.ts

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
import type { ImageData } from './core'
2+
import { createHash } from 'node:crypto'
3+
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
4+
import { basename, extname, join } from 'node:path'
5+
import { decode, encode } from './codecs'
6+
import { resize } from './core'
7+
import { imageToSplatHash, splatHashToBase64, splatHashToDataURL } from './splathash'
8+
9+
export type WebImageFormat = 'avif' | 'webp' | 'jpeg' | 'png'
10+
11+
export interface ImageDeliveryOptions {
12+
input: string | Uint8Array
13+
outDir: string
14+
name?: string
15+
baseUrl?: string
16+
widths?: readonly number[]
17+
formats?: readonly WebImageFormat[]
18+
fallbackFormat?: Extract<WebImageFormat, 'jpeg' | 'png'>
19+
quality?: number | Partial<Record<WebImageFormat, number>>
20+
concurrency?: number
21+
upscale?: boolean
22+
placeholder?: boolean
23+
}
24+
25+
export interface ImageVariant {
26+
path: string
27+
url: string
28+
width: number
29+
height: number
30+
bytes: number
31+
format: WebImageFormat
32+
mimeType: string
33+
cacheKey: string
34+
}
35+
36+
export interface ImageDeliveryManifest {
37+
source: { width: number, height: number, hash: string }
38+
variants: ImageVariant[]
39+
sources: Partial<Record<WebImageFormat, string>>
40+
fallback: ImageVariant
41+
placeholder?: { hash: string, dataUrl: string }
42+
}
43+
44+
export interface SelectedImageVariant {
45+
variant: ImageVariant
46+
headers: Record<string, string>
47+
}
48+
49+
interface WeightedMediaType {
50+
type: string
51+
quality: number
52+
order: number
53+
}
54+
55+
const formatMimeTypes: Record<WebImageFormat, string> = {
56+
avif: 'image/avif',
57+
webp: 'image/webp',
58+
jpeg: 'image/jpeg',
59+
png: 'image/png',
60+
}
61+
62+
const formatExtensions: Record<WebImageFormat, string> = {
63+
avif: 'avif',
64+
webp: 'webp',
65+
jpeg: 'jpg',
66+
png: 'png',
67+
}
68+
69+
const activeGenerations = new Map<string, Promise<ImageDeliveryManifest>>()
70+
71+
function clampQuality(value: number): number {
72+
if (!Number.isFinite(value)) throw new TypeError('Image quality must be a finite number')
73+
return Math.max(1, Math.min(100, Math.round(value)))
74+
}
75+
76+
function getQuality(quality: ImageDeliveryOptions['quality'], format: WebImageFormat): number {
77+
if (typeof quality === 'number') return clampQuality(quality)
78+
return clampQuality(quality?.[format] ?? (format === 'png' ? 100 : 82))
79+
}
80+
81+
function normalizeName(input: string | Uint8Array, name?: string): string {
82+
const requested = name ?? (typeof input === 'string' ? basename(input, extname(input)) : 'image')
83+
const normalized = requested.trim().replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
84+
if (!normalized) throw new TypeError('Image name must contain at least one letter or number')
85+
return normalized
86+
}
87+
88+
function normalizeBaseUrl(baseUrl = ''): string {
89+
return baseUrl ? baseUrl.replace(/\/$/, '') : ''
90+
}
91+
92+
function buildUrl(baseUrl: string, filename: string): string {
93+
return baseUrl ? `${baseUrl}/${encodeURIComponent(filename)}` : filename
94+
}
95+
96+
function canonicalFormats(formats: readonly WebImageFormat[], fallback: WebImageFormat): WebImageFormat[] {
97+
const unique = [...new Set([...formats, fallback])]
98+
if (unique.length === 0) throw new TypeError('At least one image format is required')
99+
return unique
100+
}
101+
102+
export function normalizeImageWidths(widths: readonly number[], sourceWidth: number, upscale = false): number[] {
103+
if (!Number.isInteger(sourceWidth) || sourceWidth < 1) throw new TypeError('Source width must be a positive integer')
104+
105+
const normalized = widths.map((width) => {
106+
if (!Number.isFinite(width) || width < 1) throw new TypeError('Image widths must be positive numbers')
107+
return Math.round(width)
108+
})
109+
110+
if (normalized.length === 0) normalized.push(sourceWidth)
111+
if (!upscale) normalized.push(sourceWidth)
112+
113+
const result = [...new Set(normalized)]
114+
.filter(width => upscale || width <= sourceWidth)
115+
.sort((a, b) => a - b)
116+
117+
if (result.length === 0) result.push(sourceWidth)
118+
return result
119+
}
120+
121+
function parseAccept(accept: string): WeightedMediaType[] {
122+
return accept
123+
.split(',')
124+
.map((entry, order) => {
125+
const [rawType, ...parameters] = entry.trim().toLowerCase().split(';')
126+
const q = parameters.find(parameter => parameter.trim().startsWith('q='))?.split('=')[1]
127+
const parsedQuality = q === undefined ? 1 : Number.parseFloat(q)
128+
return {
129+
type: rawType,
130+
quality: Number.isFinite(parsedQuality) ? Math.max(0, Math.min(1, parsedQuality)) : 0,
131+
order,
132+
}
133+
})
134+
.filter(entry => entry.type && entry.quality > 0)
135+
.sort((a, b) => b.quality - a.quality || a.order - b.order)
136+
}
137+
138+
export function negotiateImageFormat(
139+
accept: string | null | undefined,
140+
available: readonly WebImageFormat[],
141+
fallback: WebImageFormat = 'jpeg',
142+
): WebImageFormat {
143+
const supported = new Set(available)
144+
if (supported.size === 0) throw new TypeError('At least one available image format is required')
145+
146+
for (const entry of parseAccept(accept ?? '*/*')) {
147+
if (entry.type === '*/*' || entry.type === 'image/*') break
148+
const match = (Object.entries(formatMimeTypes) as Array<[WebImageFormat, string]>)
149+
.find(([format, mimeType]) => mimeType === entry.type && supported.has(format))
150+
if (match) return match[0]
151+
}
152+
153+
if (supported.has(fallback)) return fallback
154+
return available[0]
155+
}
156+
157+
async function writeAtomically(path: string, bytes: Uint8Array): Promise<void> {
158+
const temporaryPath = `${path}.${crypto.randomUUID()}.tmp`
159+
try {
160+
await writeFile(temporaryPath, bytes)
161+
await rename(temporaryPath, path)
162+
}
163+
catch (error) {
164+
await unlink(temporaryPath).catch(() => undefined)
165+
throw error
166+
}
167+
}
168+
169+
async function mapConcurrent<T, R>(items: T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
170+
const output = new Array<R>(items.length)
171+
let nextIndex = 0
172+
173+
async function worker(): Promise<void> {
174+
while (nextIndex < items.length) {
175+
const index = nextIndex++
176+
output[index] = await work(items[index])
177+
}
178+
}
179+
180+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()))
181+
return output
182+
}
183+
184+
function hashDelivery(bytes: Uint8Array, options: object): string {
185+
return createHash('sha256')
186+
.update(bytes)
187+
.update('\0')
188+
.update(JSON.stringify(options))
189+
.digest('hex')
190+
}
191+
192+
function createSrcset(variants: ImageVariant[]): string {
193+
return variants.map(variant => `${variant.url} ${variant.width}w`).join(', ')
194+
}
195+
196+
async function generateManifest(options: ImageDeliveryOptions): Promise<ImageDeliveryManifest> {
197+
const sourceBytes = typeof options.input === 'string'
198+
? new Uint8Array(await readFile(options.input))
199+
: options.input
200+
const source = await decode(sourceBytes)
201+
const fallbackFormat = options.fallbackFormat ?? (source.hasAlpha ? 'png' : 'jpeg')
202+
const formats = canonicalFormats(options.formats ?? ['avif', 'webp'], fallbackFormat)
203+
const widths = normalizeImageWidths(options.widths ?? [320, 640, 960, 1280, 1920], source.width, options.upscale)
204+
const concurrency = Math.max(1, Math.min(32, Math.round(options.concurrency ?? 4)))
205+
const name = normalizeName(options.input, options.name)
206+
const baseUrl = normalizeBaseUrl(options.baseUrl)
207+
const deliveryOptions = {
208+
formats,
209+
widths,
210+
quality: formats.map(format => [format, getQuality(options.quality, format)]),
211+
upscale: options.upscale ?? false,
212+
}
213+
const sourceHash = createHash('sha256').update(sourceBytes).digest('hex')
214+
const deliveryHash = hashDelivery(sourceBytes, deliveryOptions).slice(0, 16)
215+
216+
await mkdir(options.outDir, { recursive: true })
217+
218+
const tasks = formats.flatMap(format => widths.map(width => ({ format, width })))
219+
const variants = await mapConcurrent(tasks, concurrency, async ({ format, width }) => {
220+
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)
224+
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)
228+
}
229+
const file = await stat(path)
230+
return {
231+
path,
232+
url: buildUrl(baseUrl, filename),
233+
width,
234+
height: targetHeight,
235+
bytes: file.size,
236+
format,
237+
mimeType: formatMimeTypes[format],
238+
cacheKey: `${sourceHash}:${deliveryHash}:${format}:${width}`,
239+
} satisfies ImageVariant
240+
})
241+
242+
const sources: ImageDeliveryManifest['sources'] = {}
243+
for (const format of formats) {
244+
sources[format] = createSrcset(variants.filter(variant => variant.format === format))
245+
}
246+
247+
const fallbackVariants = variants.filter(variant => variant.format === fallbackFormat)
248+
const fallback = fallbackVariants.at(-1)
249+
if (!fallback) throw new Error(`No ${fallbackFormat} fallback variant was generated`)
250+
251+
const placeholder = options.placeholder === false
252+
? undefined
253+
: (() => {
254+
const hash = imageToSplatHash(source)
255+
return { hash: splatHashToBase64(hash), dataUrl: splatHashToDataURL(hash) }
256+
})()
257+
258+
return {
259+
source: { width: source.width, height: source.height, hash: sourceHash },
260+
variants,
261+
sources,
262+
fallback,
263+
placeholder,
264+
}
265+
}
266+
267+
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
271+
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,
281+
})
282+
const active = activeGenerations.get(key)
283+
if (active) return active
284+
285+
const generation = generateManifest({ ...options, input: sourceBytes })
286+
activeGenerations.set(key, generation)
287+
try {
288+
return await generation
289+
}
290+
finally {
291+
activeGenerations.delete(key)
292+
}
293+
}
294+
295+
export function selectImageVariant(
296+
manifest: ImageDeliveryManifest,
297+
options: { accept?: string | null, width?: number } = {},
298+
): SelectedImageVariant {
299+
const formats = [...new Set(manifest.variants.map(variant => variant.format))]
300+
const format = negotiateImageFormat(options.accept, formats, manifest.fallback.format)
301+
const candidates = manifest.variants.filter(variant => variant.format === format).sort((a, b) => a.width - b.width)
302+
if (candidates.length === 0) throw new Error(`Manifest has no ${format} variants`)
303+
304+
const requestedWidth = Math.max(1, Math.round(options.width ?? candidates.at(-1)!.width))
305+
const variant = candidates.find(candidate => candidate.width >= requestedWidth) ?? candidates.at(-1)!
306+
return {
307+
variant,
308+
headers: {
309+
'Accept-Ranges': 'bytes',
310+
'Cache-Control': 'public, max-age=31536000, immutable',
311+
'Content-Length': String(variant.bytes),
312+
'Content-Type': variant.mimeType,
313+
'ETag': `"${variant.cacheKey}"`,
314+
'Vary': 'Accept',
315+
},
316+
}
317+
}

packages/ts-images/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './app-icon'
88
export { decode, encode, getMetadata } from './codecs'
99
export { config } from './config'
1010
export * from './core'
11+
export * from './delivery'
1112
export * from './favicon'
1213
export * from './og'
1314
export * from './processor'

0 commit comments

Comments
 (0)