feat: compression formats

This commit is contained in:
diced
2025-08-27 16:42:36 -07:00
parent 95042e1383
commit fdb0312dbe
3 changed files with 118 additions and 27 deletions

View File

@@ -1,13 +1,62 @@
import sharp from 'sharp';
export function compressFile(filePath: string, quality: number) {
const buffer = sharp(filePath).withMetadata().jpeg({ quality: quality }).toBuffer();
export const COMPRESS_TYPES = ['jpg', 'jpeg', 'png', 'webp', 'jxl'] as const;
export type CompressType = (typeof COMPRESS_TYPES)[number];
return buffer.then((data) => {
return sharp(data).toFile(filePath);
});
export type CompressResult = {
mimetype: string;
ext: CompressType;
};
export type CompressOptions = {
quality: number;
type?: CompressType;
};
export function checkOutput(type: CompressType): boolean {
if (type === 'jpg') type = 'jpeg';
return !!(sharp.format as any)[type]?.output?.file && !!(sharp.format as any)[type]?.output?.buffer;
}
export function replaceFileNameJpg(original: string, when?: boolean) {
return when ? original.replace(/\.[a-zA-Z0-9]+$/, '.jpg') : original;
export async function compressFile(filePath: string, options: CompressOptions): Promise<CompressResult> {
const { quality, type } = options;
const image = sharp(filePath).withMetadata();
const result: CompressResult = {
mimetype: '',
ext: 'jpg',
};
let buffer: Buffer;
switch (type?.toLowerCase()) {
case 'png':
buffer = await image.png({ quality }).toBuffer();
result.mimetype = 'image/png';
result.ext = 'png';
break;
case 'webp':
buffer = await image.webp({ quality }).toBuffer();
result.mimetype = 'image/webp';
result.ext = 'webp';
break;
case 'jxl':
buffer = await image.jxl({ quality }).toBuffer();
result.mimetype = 'image/jxl';
result.ext = 'jxl';
break;
case 'jpg':
case 'jpeg':
default:
buffer = await image.jpeg({ quality }).toBuffer();
result.mimetype = 'image/jpeg';
result.ext = 'jpg';
break;
}
await sharp(buffer).toFile(filePath);
return result;
}

View File

@@ -1,5 +1,6 @@
import ms from 'ms';
import { Config } from '../config/validate';
import { checkOutput, COMPRESS_TYPES, CompressType } from '../compress';
// from ms@3.0.0-canary.1
type Unit =
@@ -42,6 +43,8 @@ type StringBoolean = 'true' | 'false';
export type UploadHeaders = {
'x-zipline-deletes-at'?: string;
'x-zipline-format'?: Config['files']['defaultFormat'];
'x-zipline-image-compression-type'?: CompressType;
'x-zipline-image-compression-percent'?: string;
'x-zipline-password'?: string;
'x-zipline-max-views'?: string;
@@ -67,11 +70,16 @@ export type UploadHeaders = {
export type UploadOptions = {
deletesAt?: Date | 'never';
format?: Config['files']['defaultFormat'];
imageCompressionPercent?: number;
password?: string;
maxViews?: number;
noJson?: boolean;
addOriginalName?: boolean;
imageCompression?: {
type?: CompressType;
percent: number;
};
overrides?: {
filename?: string;
returnDomain?: string;
@@ -128,10 +136,19 @@ export function parseExpiry(header: string): Date | null {
return human;
}
function parsePercent(header: keyof UploadHeaders, percent: string) {
const num = Number(percent);
if (isNaN(num)) return headerError(header, 'Invalid percent (NaN)');
if (num < 0 || num > 100) return headerError(header, 'Invalid percent (must be between 0 and 100)');
return num;
}
function headerError(header: keyof UploadHeaders, message: string) {
return {
header,
message,
message: `[${header}]: ${message}`
};
}
@@ -166,18 +183,41 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files']
}
const imageCompressionPercent = headers['x-zipline-image-compression-percent'];
if (imageCompressionPercent) {
const num = Number(imageCompressionPercent);
if (isNaN(num))
return headerError('x-zipline-image-compression-percent', 'Invalid compression percent (NaN)');
if (num < 0 || num > 100)
const imageCompressionType = headers['x-zipline-image-compression-type'];
if (imageCompressionType) {
if (!imageCompressionPercent)
return headerError(
'x-zipline-image-compression-percent',
'Invalid compression percent (must be between 0 and 100)',
'missing "x-zipline-image-compression-percent" when "x-zipline-image-compression-type" is provided',
);
response.imageCompressionPercent = num;
if (!COMPRESS_TYPES.includes(imageCompressionType))
return headerError(
'x-zipline-image-compression-type',
`Invalid compression type (must be one of: ${COMPRESS_TYPES.join(', ')})`,
);
if (!checkOutput(imageCompressionType))
return headerError(
'x-zipline-image-compression-type',
`Compression type "${imageCompressionType}" is not supported on the system.`,
);
const percent = parsePercent('x-zipline-image-compression-percent', imageCompressionPercent);
if (typeof percent === 'object') return percent;
response.imageCompression = {
type: imageCompressionType,
percent,
};
} else if (imageCompressionPercent) {
const percent = parsePercent('x-zipline-image-compression-percent', imageCompressionPercent);
if (typeof percent === 'object') return percent;
response.imageCompression = {
type: 'jpg',
percent,
};
}
const password = headers['x-zipline-password'];

View File

@@ -1,6 +1,6 @@
import { Prisma } from '@/prisma/client';
import { bytes } from '@/lib/bytes';
import { compressFile } from '@/lib/compress';
import { compressFile, CompressResult } from '@/lib/compress';
import { config } from '@/lib/config';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource';
@@ -45,7 +45,7 @@ export type ApiUploadResponse = {
url: string;
pending?: boolean;
removedGps?: boolean;
compressed?: boolean;
compressed?: CompressResult;
}[];
deletesAt?: string;
@@ -65,7 +65,7 @@ export default fastifyPlugin(
Headers: UploadHeaders;
}>(PATH, { preHandler: [userMiddleware, rateLimit] }, async (req, res) => {
const options = parseHeaders(req.headers, config.files);
if (options.header) return res.badRequest('bad options, receieved: ' + JSON.stringify(options));
if (options.header) return res.badRequest(`bad options: ${options.message}`);
if (options.partial) return res.badRequest('bad options, receieved: partial upload');
@@ -168,11 +168,13 @@ export default fastifyPlugin(
}
// compress the image if requested
let compressed = false;
if (mimetype.startsWith('image/') && options.imageCompressionPercent) {
await compressFile(file.filepath, options.imageCompressionPercent);
logger.c('jpg').debug(`compressed file ${file.filename}`);
compressed = true;
let compressed;
if (mimetype.startsWith('image/') && options.imageCompression) {
compressed = await compressFile(file.filepath, {
quality: options.imageCompression.percent,
type: options.imageCompression.type,
});
logger.c('compress').debug(`compressed file ${file.filename}`);
}
// remove gps metadata if requested
@@ -187,9 +189,9 @@ export default fastifyPlugin(
const tempFileStats = await stat(file.filepath);
const data: Prisma.FileCreateInput = {
name: `${fileName}${compressed ? '.jpg' : extension}`,
name: `${fileName}${compressed ? '.' + compressed.ext : extension}`,
size: tempFileStats.size,
type: compressed ? 'image/jpeg' : mimetype,
type: compressed?.mimetype ?? mimetype,
User: { connect: { id: req.user ? req.user.id : options.folder ? folder?.userId : undefined } },
};