1- import type { ImageData } from './core'
1+ import type { ImageData , ResizeFit , ResizeOptions } from './core'
22import { createHash } from 'node:crypto'
33import { mkdir , readFile , rename , stat , unlink , writeFile } from 'node:fs/promises'
44import { basename , extname , join } from 'node:path'
@@ -7,18 +7,47 @@ import { resize } from './core'
77import { imageToSplatHash , splatHashToBase64 , splatHashToDataURL } from './splathash'
88
99export 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
1131export 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
6998const 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+
71112function 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+
169228async 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
267352export 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
0 commit comments