Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/image-compression.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@keystatic/core': minor
---

Add optional client-side image compression to `fields.image()`

Images can now be automatically compressed before saving using the new `compression` option:

```ts
fields.image({
label: 'Cover Image',
compression: {
maxWidth: 2000, // px
maxHeight: 2000, // px
maxFileSize: 1048576, // 1MB in bytes
quality: 0.8, // 0-1 for lossy formats
format: 'preserve', // 'preserve' | 'webp' | 'jpeg'
},
})
```

When compression is enabled, the UI displays the original and compressed file sizes after upload.
1 change: 1 addition & 0 deletions docs/src/components/fields/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const ImageFieldDemo = () => {
autoFocus={false}
forceValidation={false}
transformFilename={undefined}
compression={undefined}
/>
</FieldDemoFrame>
);
Expand Down
1 change: 1 addition & 0 deletions packages/keystatic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"@urql/exchange-auth": "^2.2.0",
"@urql/exchange-graphcache": "^7.1.2",
"@urql/exchange-persisted": "^4.3.0",
"browser-image-compression": "^2.0.2",
"cookie": "^1.0.0",
"emery": "^1.4.1",
"escape-string-regexp": "^4.0.0",
Expand Down
29 changes: 29 additions & 0 deletions packages/keystatic/src/form/fields/image/compress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @jest-environment jsdom
*/
import { describe, test, expect } from '@jest/globals';
import { formatBytes } from './compress';

describe('formatBytes', () => {
test('formats 0 bytes', () => {
expect(formatBytes(0)).toBe('0 B');
});

test('formats bytes', () => {
expect(formatBytes(500)).toBe('500 B');
});

test('formats kilobytes', () => {
expect(formatBytes(1024)).toBe('1 KB');
expect(formatBytes(1536)).toBe('1.5 KB');
});

test('formats megabytes', () => {
expect(formatBytes(1048576)).toBe('1 MB');
expect(formatBytes(2621440)).toBe('2.5 MB');
});

test('formats gigabytes', () => {
expect(formatBytes(1073741824)).toBe('1 GB');
});
});
148 changes: 148 additions & 0 deletions packages/keystatic/src/form/fields/image/compress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import imageCompression from 'browser-image-compression';

export type ImageCompressionConfig = {
/** Maximum width in pixels. Image will be resized proportionally. */
maxWidth?: number;
/** Maximum height in pixels. Image will be resized proportionally. */
maxHeight?: number;
/** Maximum file size in bytes (e.g., 1048576 for 1MB). */
maxFileSize?: number;
/** Quality for lossy formats (0-1). Default: 0.8 */
quality?: number;
/** Output format. 'preserve' keeps original, 'webp' or 'jpeg' converts. Default: 'preserve' */
format?: 'preserve' | 'webp' | 'jpeg';
};

export type CompressionResult = {
data: Uint8Array;
extension: string;
originalSize: number;
compressedSize: number;
};

function getExtensionFromMimeType(mimeType: string): string {
const map: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/svg+xml': 'svg',
};
return map[mimeType] || 'jpg';
}

function getMimeTypeFromExtension(extension: string): string {
const map: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
};
return map[extension.toLowerCase()] || 'image/jpeg';
}

function getOutputMimeType(
originalExtension: string,
format: ImageCompressionConfig['format']
): string {
if (!format || format === 'preserve') {
return getMimeTypeFromExtension(originalExtension);
}
return format === 'webp' ? 'image/webp' : 'image/jpeg';
}

/**
* Compresses an image file according to the provided configuration.
* Returns the original file data if compression fails or produces a larger file.
*/
export async function compressImage(
file: File,
config: ImageCompressionConfig
): Promise<CompressionResult> {
const originalSize = file.size;
const originalExtension =
file.name.match(/\.([^.]+)$/)?.[1]?.toLowerCase() || 'jpg';

// Skip SVG (vector) and GIF (may lose animation) files
if (
originalExtension === 'svg' ||
originalExtension === 'gif' ||
file.type === 'image/svg+xml' ||
file.type === 'image/gif'
) {
const data = new Uint8Array(await file.arrayBuffer());
return {
data,
extension: originalExtension,
originalSize,
compressedSize: originalSize,
};
}

const outputMimeType = getOutputMimeType(originalExtension, config.format);
const outputExtension = getExtensionFromMimeType(outputMimeType);

try {
// Use the smaller of maxWidth/maxHeight to ensure both constraints are satisfied
// browser-image-compression only supports a single maxWidthOrHeight value
const maxDimensions = [config.maxWidth, config.maxHeight].filter(
(v): v is number => v !== undefined && v > 0
);
const maxWidthOrHeight =
maxDimensions.length > 0 ? Math.min(...maxDimensions) : undefined;

const options: Parameters<typeof imageCompression>[1] = {
maxSizeMB: config.maxFileSize
? config.maxFileSize / 1024 / 1024
: undefined,
maxWidthOrHeight,
initialQuality: config.quality ?? 0.8,
useWebWorker: true,
fileType: outputMimeType,
};

const compressedFile = await imageCompression(file, options);
const compressedSize = compressedFile.size;

// If compression made the file larger, return original
if (compressedSize >= originalSize) {
const data = new Uint8Array(await file.arrayBuffer());
return {
data,
extension: originalExtension,
originalSize,
compressedSize: originalSize,
};
}

const data = new Uint8Array(await compressedFile.arrayBuffer());
return {
data,
extension: outputExtension,
originalSize,
compressedSize,
};
} catch {
// On any error, return original file (silent fallback)
const data = new Uint8Array(await file.arrayBuffer());
return {
data,
extension: originalExtension,
originalSize,
compressedSize: originalSize,
};
}
}

/**
* Format bytes as human-readable string (e.g., "1.5 MB")
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
10 changes: 10 additions & 0 deletions packages/keystatic/src/form/fields/image/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { FieldDataError } from '../error';
import { RequiredValidation, assertRequired } from '../utils';
import { getSrcPrefix } from './getSrcPrefix';
import { ImageFieldInput } from '#field-ui/image';
import type { ImageCompressionConfig } from './compress';

export type { ImageCompressionConfig } from './compress';

export function image<IsRequired extends boolean | undefined>({
label,
Expand All @@ -12,6 +15,7 @@ export function image<IsRequired extends boolean | undefined>({
description,
publicPath,
transformFilename,
compression,
}: {
label: string;
directory?: string;
Expand All @@ -24,6 +28,11 @@ export function image<IsRequired extends boolean | undefined>({
* When used outside of editor fields, this function will **not** be used. Instead only the extension of the uploaded file is used and the start of the filename is based on the field key.
*/
transformFilename?: (originalFilename: string) => string;
/**
* Optional client-side image compression configuration.
* When provided, images will be compressed before saving.
*/
compression?: ImageCompressionConfig;
} & RequiredValidation<IsRequired>): AssetFormField<
{ data: Uint8Array; extension: string; filename: string } | null,
| { data: Uint8Array; extension: string; filename: string }
Expand All @@ -41,6 +50,7 @@ export function image<IsRequired extends boolean | undefined>({
description={description}
validation={validation}
transformFilename={transformFilename}
compression={compression}
{...props}
/>
);
Expand Down
Loading