diff --git a/packages/pds/src/image/index.ts b/packages/pds/src/image/index.ts index 3197b5aeb5c..ebf47088f6b 100644 --- a/packages/pds/src/image/index.ts +++ b/packages/pds/src/image/index.ts @@ -1,2 +1,80 @@ -export * from './sharp' -export type { Options, ImageInfo } from './util' +import { Readable } from 'stream' +import { pipeline } from 'stream/promises' +import sharp from 'sharp' +import { errHasMsg } from '@atproto/common' + +export async function maybeGetInfo( + stream: Readable, +): Promise { + let metadata: sharp.Metadata + try { + const processor = sharp() + const [result] = await Promise.all([ + processor.metadata(), + pipeline(stream, processor), // Handles error propagation + ]) + metadata = result + } catch (err) { + if (errHasMsg(err, 'Input buffer contains unsupported image format')) { + return null + } + throw err + } + const { size, height, width, format } = metadata + if ( + size === undefined || + height === undefined || + width === undefined || + format === undefined + ) { + return null + } + + return { + height, + width, + size, + mime: formatsToMimes[format] ?? ('unknown' as const), + } +} + +export async function getInfo(stream: Readable): Promise { + const maybeInfo = await maybeGetInfo(stream) + if (!maybeInfo) { + throw new Error('could not obtain all image metadata') + } + return maybeInfo +} + +export type Options = Dimensions & { + format: 'jpeg' | 'png' + // When 'cover' (default), scale to fill given dimensions, cropping if necessary. + // When 'inside', scale to fit within given dimensions. + fit?: 'cover' | 'inside' + // When false (default), do not scale up. + // When true, scale up to hit dimensions given in options. + // Otherwise, scale up to hit specified min dimensions. + min?: Dimensions | boolean + // A number 1-100 + quality?: number +} + +export type ImageInfo = Dimensions & { + size: number + mime: `image/${string}` | 'unknown' +} + +export type Dimensions = { height: number; width: number } + +export const formatsToMimes: { + [s in keyof sharp.FormatEnum]?: `image/${string}` +} = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + svg: 'image/svg+xml', + tif: 'image/tiff', + tiff: 'image/tiff', + webp: 'image/webp', +} diff --git a/packages/pds/src/image/sharp.ts b/packages/pds/src/image/sharp.ts deleted file mode 100644 index 1edc7a58835..00000000000 --- a/packages/pds/src/image/sharp.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Readable } from 'stream' -import { pipeline } from 'stream/promises' -import sharp from 'sharp' -import { errHasMsg, forwardStreamErrors } from '@atproto/common' -import { formatsToMimes, ImageInfo, Options } from './util' - -export type { Options } - -export async function resize( - stream: Readable, - options: Options, -): Promise { - const { height, width, min = false, fit = 'cover', format, quality } = options - - let processor = sharp() - - // Scale up to hit any specified minimum size - if (typeof min !== 'boolean') { - const upsizeProcessor = sharp().resize({ - fit: 'outside', - width: min.width, - height: min.height, - withoutReduction: true, - withoutEnlargement: false, - }) - forwardStreamErrors(stream, upsizeProcessor) - stream = stream.pipe(upsizeProcessor) - } - - // Scale down (or possibly up if min is true) to desired size - processor = processor.resize({ - fit, - width, - height, - withoutEnlargement: min !== true, - }) - - // Output to specified format - if (format === 'jpeg') { - processor = processor.jpeg({ quality: quality ?? 100 }) - } else if (format === 'png') { - processor = processor.png({ quality: quality ?? 100 }) - } else { - const exhaustiveCheck: never = format - throw new Error(`Unhandled case: ${exhaustiveCheck}`) - } - - forwardStreamErrors(stream, processor) - return stream.pipe(processor) -} - -export async function maybeGetInfo( - stream: Readable, -): Promise { - let metadata: sharp.Metadata - try { - const processor = sharp() - const [result] = await Promise.all([ - processor.metadata(), - pipeline(stream, processor), // Handles error propagation - ]) - metadata = result - } catch (err) { - if (errHasMsg(err, 'Input buffer contains unsupported image format')) { - return null - } - throw err - } - const { size, height, width, format } = metadata - if ( - size === undefined || - height === undefined || - width === undefined || - format === undefined - ) { - return null - } - - return { - height, - width, - size, - mime: formatsToMimes[format] ?? ('unknown' as const), - } -} - -export async function getInfo(stream: Readable): Promise { - const maybeInfo = await maybeGetInfo(stream) - if (!maybeInfo) { - throw new Error('could not obtain all image metadata') - } - return maybeInfo -} diff --git a/packages/pds/src/image/util.ts b/packages/pds/src/image/util.ts deleted file mode 100644 index ce18ba343d5..00000000000 --- a/packages/pds/src/image/util.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { FormatEnum } from 'sharp' - -export type Options = Dimensions & { - format: 'jpeg' | 'png' - // When 'cover' (default), scale to fill given dimensions, cropping if necessary. - // When 'inside', scale to fit within given dimensions. - fit?: 'cover' | 'inside' - // When false (default), do not scale up. - // When true, scale up to hit dimensions given in options. - // Otherwise, scale up to hit specified min dimensions. - min?: Dimensions | boolean - // A number 1-100 - quality?: number -} - -export type ImageInfo = Dimensions & { - size: number - mime: `image/${string}` | 'unknown' -} - -export type Dimensions = { height: number; width: number } - -export const formatsToMimes: { [s in keyof FormatEnum]?: `image/${string}` } = { - jpg: 'image/jpeg', - jpeg: 'image/jpeg', - png: 'image/png', - gif: 'image/gif', - svg: 'image/svg+xml', - tif: 'image/tiff', - tiff: 'image/tiff', - webp: 'image/webp', -} diff --git a/packages/pds/tests/image/sharp.test.ts b/packages/pds/tests/image/sharp.test.ts deleted file mode 100644 index d0a46b662b3..00000000000 --- a/packages/pds/tests/image/sharp.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { createReadStream } from 'fs' -import { Options, getInfo, resize } from '../../src/image/sharp' - -describe('sharp image processor', () => { - it('scales up to cover.', async () => { - const result = await processFixture('key-landscape-small.jpg', { - format: 'jpeg', - fit: 'cover', - width: 500, - height: 500, - min: true, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 500, - width: 500, - }), - ) - }) - - it('scales up to inside (landscape).', async () => { - const result = await processFixture('key-landscape-small.jpg', { - format: 'jpeg', - fit: 'inside', - width: 500, - height: 500, - min: true, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 290, - width: 500, - }), - ) - }) - - it('scales up to inside (portrait).', async () => { - const result = await processFixture('key-portrait-small.jpg', { - format: 'jpeg', - fit: 'inside', - width: 500, - height: 500, - min: true, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 500, - width: 290, - }), - ) - }) - - it('scales up to min.', async () => { - const result = await processFixture('key-landscape-small.jpg', { - format: 'jpeg', - width: 500, - height: 500, - min: { height: 200, width: 200 }, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 200, - width: 345, - }), - ) - }) - - it('does not scale image up when min is false.', async () => { - const result = await processFixture('key-landscape-small.jpg', { - format: 'jpeg', - width: 500, - height: 500, - min: false, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 87, - width: 150, - mime: 'image/jpeg', - }), - ) - }) - - it('scales down to cover.', async () => { - const result = await processFixture('key-landscape-large.jpg', { - format: 'jpeg', - fit: 'cover', - width: 500, - height: 500, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 500, - width: 500, - }), - ) - }) - - it('scales down to inside (landscape).', async () => { - const result = await processFixture('key-landscape-large.jpg', { - format: 'jpeg', - fit: 'inside', - width: 500, - height: 500, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 290, - width: 500, - }), - ) - }) - - it('scales down to inside (portrait).', async () => { - const result = await processFixture('key-portrait-large.jpg', { - format: 'jpeg', - fit: 'inside', - width: 500, - height: 500, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 500, - width: 290, - }), - ) - }) - - it('converts jpeg to png.', async () => { - const result = await processFixture('key-landscape-small.jpg', { - format: 'png', - width: 500, - height: 500, - min: false, - }) - expect(result).toEqual( - expect.objectContaining({ - height: 87, - width: 150, - size: expect.any(Number), - mime: 'image/png', - }), - ) - }) - - it('controls quality (jpeg).', async () => { - const high = await processFixture('key-portrait-small.jpg', { - format: 'jpeg', - width: 500, - height: 500, - quality: 90, - }) - const low = await processFixture('key-portrait-small.jpg', { - format: 'jpeg', - width: 500, - height: 500, - quality: 10, - }) - expect(high.size).toBeGreaterThan(1000) - expect(low.size).toBeLessThan(1000) - }) - - it('controls quality (png).', async () => { - const high = await processFixture('key-portrait-small.jpg', { - format: 'png', - width: 500, - height: 500, - quality: 80, - }) - const low = await processFixture('key-portrait-small.jpg', { - format: 'png', - width: 500, - height: 500, - quality: 10, - }) - expect(high.size).toBeGreaterThan(3000) - expect(low.size).toBeLessThan(3000) - }) - - async function processFixture(fixture: string, options: Options) { - const image = createReadStream(`${__dirname}/fixtures/${fixture}`) - const resized = await resize(image, options) - return await getInfo(resized) - } -})