Skip to content

Commit

Permalink
feat: pixel density descriptors (#624)
Browse files Browse the repository at this point in the history
  • Loading branch information
benmccann authored Oct 14, 2023
1 parent 7f32286 commit 378c863
Show file tree
Hide file tree
Showing 38 changed files with 174 additions and 149 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-frogs-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'imagetools-core': minor
---

feat: add `basePixels` directive for outputting pixel density descriptors
5 changes: 5 additions & 0 deletions .changeset/nervous-grapes-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'imagetools-core': major
---

breaking: improve types
6 changes: 6 additions & 0 deletions .changeset/tricky-readers-flash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'imagetools-core': major
'vite-imagetools': major
---

breaking: simplify picture, image, and srcset output formats and remove source output format. This is both simpler and will enable pixel density descriptors
48 changes: 8 additions & 40 deletions packages/core/src/__tests__/output-formats.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { urlFormat, metadataFormat, imgFormat, pictureFormat, sourceFormat, srcsetFormat } from '../output-formats'
import { urlFormat, metadataFormat, imgFormat, pictureFormat, srcsetFormat } from '../output-formats'
import { describe, test, expect } from 'vitest'

describe('url format', () => {
Expand Down Expand Up @@ -65,10 +65,7 @@ describe('image format', () => {
])

expect(output).toStrictEqual({
srcset: [
{ src: '/foo-100.webp', w: 100 },
{ src: '/foo-50.webp', w: 50 }
],
srcset: '/foo-100.webp 100w, /foo-50.webp 50w',
src: '/foo-100.webp',
w: 100,
h: 50
Expand All @@ -86,8 +83,8 @@ describe('picture format', () => {

expect(output).toStrictEqual({
sources: {
avif: [{ src: '/foo.avif', w: 100 }],
webp: [{ src: '/foo.webp', w: 100 }]
avif: '/foo.avif 100w',
webp: '/foo.webp 100w'
},
img: {
src: '/foo.jpg',
Expand All @@ -109,18 +106,9 @@ describe('picture format', () => {

expect(output).toStrictEqual({
sources: {
avif: [
{ src: '/foo-100.avif', w: 100 },
{ src: '/foo-50.avif', w: 50 }
],
webp: [
{ src: '/foo-100.webp', w: 100 },
{ src: '/foo-50.webp', w: 50 }
],
jpeg: [
{ src: '/foo-100.jpg', w: 100 },
{ src: '/foo-50.jpg', w: 50 }
]
avif: '/foo-100.avif 100w, /foo-50.avif 50w',
webp: '/foo-100.webp 100w, /foo-50.webp 50w',
jpeg: '/foo-100.jpg 100w, /foo-50.jpg 50w'
},
img: {
src: '/foo-100.jpg',
Expand All @@ -131,7 +119,7 @@ describe('picture format', () => {
})
})

describe('source format', () => {
describe('srcset format', () => {
test('single image', () => {
const output = srcsetFormat()([{ src: '/foo.jpg', width: 500 }])

Expand All @@ -147,23 +135,3 @@ describe('source format', () => {
expect(output).toEqual('/foo.jpg 500w, /bar.jpg 300w')
})
})

describe('srcset format', () => {
test('single image', () => {
const output = sourceFormat()([{ src: '/foo.jpg', width: 500 }])

expect(output).toEqual([{ src: '/foo.jpg', w: 500 }])
})

test('multiple images', () => {
const output = sourceFormat()([
{ src: '/foo.jpg', width: 500 },
{ src: '/bar.jpg', width: 300 }
])

expect(output).toEqual([
{ src: '/foo.jpg', w: 500 },
{ src: '/bar.jpg', w: 300 }
])
})
})
2 changes: 1 addition & 1 deletion packages/core/src/lib/apply-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export async function applyTransforms(
image: Sharp,
removeMetadata = true
): Promise<TransformResult> {
image[METADATA] = await image.metadata()
image[METADATA] = { ...(await image.metadata()) }

if (removeMetadata) {
// delete the private metadata
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/lib/metadata.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Sharp } from 'sharp'
import { ImageMetadata } from '../types'

export const METADATA = Symbol('image metadata')

declare module 'sharp' {
interface Sharp {
[METADATA]: Record<string, unknown>
[METADATA]: ImageMetadata
}
}

Expand Down
58 changes: 30 additions & 28 deletions packages/core/src/output-formats.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,35 @@
import type { ImageConfig, Img, OutputFormat, Picture, Source } from './types.js'
import type { ImageMetadata, Img, OutputFormat, Picture } from './types.js'

export const urlFormat: OutputFormat = () => (metadatas) => {
const urls: string[] = metadatas.map((metadata) => metadata.src as string)

return urls.length == 1 ? urls[0] : urls
}

export const srcsetFormat: OutputFormat = () => (metadatas) => {
const sources = metadatas.map((meta) => `${meta.src} ${meta.width}w`)

return sources.join(', ')
}
export const srcsetFormat: OutputFormat = () => metadatasToSourceset

export const metadataFormat: OutputFormat = (whitelist) => (metadatas) => {
if (whitelist) {
metadatas = metadatas.map((cfg) => Object.fromEntries(Object.entries(cfg).filter(([k]) => whitelist.includes(k))))
}
const result = whitelist
? metadatas.map((cfg) => Object.fromEntries(Object.entries(cfg).filter(([k]) => whitelist.includes(k))))
: metadatas

metadatas.forEach((m) => delete m.image)
result.forEach((m) => delete m.image)

return metadatas.length === 1 ? metadatas[0] : metadatas
return result.length === 1 ? result[0] : result
}

const metadataToSource = (m: ImageConfig) => ({ src: m.src, w: m.width }) as Source
const metadatasToSourceset = (metadatas: ImageMetadata[]) =>
metadatas
.map((meta) => {
const density = meta.pixelDensityDescriptor
return density ? `${meta.src} ${density}` : `${meta.src} ${meta.width}w`
})
.join(', ')

/** normalizes the format for use in mime-type */
const format = (m: ImageConfig) => (m.format as string).replace('jpg', 'jpeg')

export const sourceFormat: OutputFormat = () => (metadatas) => {
return metadatas.map((m) => metadataToSource(m))
const getFormat = (m: ImageMetadata) => {
if (!m.format) throw new Error(`Could not determine image format`)
return m.format.replace('jpg', 'jpeg')
}

export const imgFormat: OutputFormat = () => (metadatas) => {
Expand All @@ -49,25 +50,22 @@ export const imgFormat: OutputFormat = () => (metadatas) => {
}

if (metadatas.length >= 2) {
result.srcset = []
for (let i = 0; i < metadatas.length; i++) {
result.srcset.push(metadataToSource(metadatas[i]))
}
result.srcset = metadatasToSourceset(metadatas)
}

return result
}

/** fallback format should be specified last */
export const pictureFormat: OutputFormat = () => (metadatas) => {
const fallbackFormat = [...new Set(metadatas.map((m) => format(m)))].pop()
const fallbackFormat = [...new Set(metadatas.map((m) => getFormat(m)))].pop()

let largestFallback
let largestFallbackSize = 0
let fallbackFormatCount = 0
for (let i = 0; i < metadatas.length; i++) {
const m = metadatas[i]
if (format(m) === fallbackFormat) {
if (getFormat(m) === fallbackFormat) {
fallbackFormatCount++
if ((m.width as number) > largestFallbackSize) {
largestFallback = m
Expand All @@ -76,22 +74,27 @@ export const pictureFormat: OutputFormat = () => (metadatas) => {
}
}

const sources: Record<string, Source[]> = {}
const sourceMetadatas: Record<string, ImageMetadata[]> = {}
for (let i = 0; i < metadatas.length; i++) {
const m = metadatas[i]
const f = format(m)
const f = getFormat(m)
// we don't need to create a source tag for the fallback format if there is
// only a single image in that format
if (f === fallbackFormat && fallbackFormatCount < 2) {
continue
}
if (sources[f]) {
sources[f].push(metadataToSource(m))
if (sourceMetadatas[f]) {
sourceMetadatas[f].push(m)
} else {
sources[f] = [metadataToSource(m)]
sourceMetadatas[f] = [m]
}
}

const sources: Record<string, string> = {}
for (const [key, value] of Object.entries(sourceMetadatas)) {
sources[key] = metadatasToSourceset(value)
}

const result: Picture = {
sources,
// the fallback should be the largest image in the fallback format
Expand All @@ -108,7 +111,6 @@ export const pictureFormat: OutputFormat = () => (metadatas) => {

export const builtinOutputFormats = {
url: urlFormat,
source: sourceFormat,
srcset: srcsetFormat,
img: imgFormat,
picture: pictureFormat,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/transforms/__tests__/background.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import sharp, { Sharp } from 'sharp'
import { join } from 'path'
import { getBackground } from '../background'
import { describe, beforeEach, test, expect } from 'vitest'
import { METADATA } from '../../lib/metadata'

describe('background', () => {
let img: Sharp
beforeEach(() => {
img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
img[METADATA] = { chromaSubsampling: '' }
})

test('keyword: "background"', () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/transforms/__tests__/fit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { getFit, FitValue } from '../fit'
import { join } from 'path'
import sharp, { Sharp } from 'sharp'
import { describe, beforeEach, test, expect } from 'vitest'
import { METADATA } from '../../lib/metadata'

describe('fit', () => {
let img: Sharp
beforeEach(() => {
img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
img[METADATA] = { chromaSubsampling: '' }
})

test('keyword "fit"', () => {
Expand Down Expand Up @@ -45,7 +47,6 @@ describe('fit', () => {

describe('arguments', () => {
test('invalid', () => {
//@ts-expect-error invalid args
const res = getFit({ fit: 'invalid' }, img)

expect(res).toBeUndefined()
Expand Down
8 changes: 1 addition & 7 deletions packages/core/src/transforms/__tests__/format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('format', () => {
})

test('valid', () => {
const formats: FormatValue[] = ['avif', 'jpg', 'jpeg', 'png', 'heif', 'heic', 'webp', 'tiff']
const formats: FormatValue[] = ['avif', 'jpg', 'jpeg', 'png', 'heif', 'webp', 'tiff']

for (const f of formats) {
const res = format({ format: f }, dirCtx)
Expand Down Expand Up @@ -97,12 +97,6 @@ describe('format', () => {
expect(metadata).toHaveProperty('format', 'heif')
})

test('heic', async () => {
const { metadata } = await applyTransforms([format({ format: 'heic' }, dirCtx)!], img)

expect(metadata).toHaveProperty('format', 'heic')
})

test('tiff', async () => {
const { image } = await applyTransforms([format({ format: 'tiff' }, dirCtx)!], img)

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/transforms/__tests__/kernel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { getKernel, KernelValue } from '../kernel'
import { join } from 'path'
import sharp, { Sharp } from 'sharp'
import { describe, beforeEach, expect, test } from 'vitest'
import { METADATA } from '../../lib/metadata'

describe('kernel', () => {
let img: Sharp
beforeEach(() => {
img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
img[METADATA] = { chromaSubsampling: '' }
})

test('keyword "kernel"', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/transforms/__tests__/lossless.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import sharp, { Sharp } from 'sharp'
import { getLossless } from '../lossless'
import { join } from 'path'
import { describe, beforeEach, expect, test } from 'vitest'
import { METADATA } from '../../lib/metadata'

describe('lossless', () => {
let img: Sharp
beforeEach(() => {
img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
img[METADATA] = { chromaSubsampling: '' }
})

test('keyword "lossless"', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/transforms/__tests__/position.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { getPosition, PositionValue } from '../position'
import sharp, { Sharp } from 'sharp'
import { join } from 'path'
import { describe, beforeEach, expect, test } from 'vitest'
import { METADATA } from '../../lib/metadata'

describe('position', () => {
let img: Sharp
beforeEach(() => {
img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
img[METADATA] = { chromaSubsampling: '' }
})

test('keyword "position"', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/transforms/__tests__/progressive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import sharp, { Sharp } from 'sharp'
import { getProgressive } from '../progressive'
import { join } from 'path'
import { describe, beforeEach, expect, test } from 'vitest'
import { METADATA } from '../../lib/metadata'

describe('progressive', () => {
let img: Sharp
beforeEach(() => {
img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
img[METADATA] = { chromaSubsampling: '' }
})

test('keyword "progressive"', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/transforms/__tests__/quality.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { getQuality } from '../quality'
import sharp, { Sharp } from 'sharp'
import { join } from 'path'
import { describe, beforeEach, expect, test, it } from 'vitest'
import { METADATA } from '../../lib/metadata'

describe('quality', () => {
let img: Sharp
beforeEach(() => {
img = sharp(join(__dirname, '../../__tests__/__fixtures__/pexels-allec-gomes-5195763.png'))
img[METADATA] = { chromaSubsampling: '' }
})

test('keyword "quality"', () => {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/transforms/background.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TransformOption } from '../types.js'
import { setMetadata } from '../lib/metadata.js'
import { METADATA } from '../lib/metadata.js'

export interface BackgroundOptions {
background: string
Expand All @@ -8,7 +8,7 @@ export interface BackgroundOptions {
export const getBackground: TransformOption<BackgroundOptions, string> = ({ background }, image) => {
if (typeof background !== 'string' || !background.length) return

setMetadata(image, 'background', background)
image[METADATA].backgroundDirective = background

return background
}
Loading

0 comments on commit 378c863

Please sign in to comment.