feat: image cropping / resizing T0

This commit is contained in:
fsylvain committed 2026-09-28 07:46:12 +02:00
1 parent cb05b38f36
commit e15f444603
18 files changed
+1869 -46

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
computeOutputSize,
estimateOutputBytes,
formatEstimatedSize,
rectForRatio,
rotateRectBySteps,
rotatedDimensions,
wholeRect,
} from '../imageCrop'
describe('rectForRatio', () => {
it('centers a 16:9 crop inside a 4032x3024 image', () => {
const rect = rectForRatio('16:9', 4032, 3024)
expect(rect).toEqual({ x: 0, y: 378, width: 4032, height: 2268 })
})
it('centers a 1:1 crop inside a 4032x3024 image', () => {
const rect = rectForRatio('1:1', 4032, 3024)
expect(rect).toEqual({ x: 504, y: 0, width: 3024, height: 3024 })
})
it('returns the whole image for Original', () => {
expect(rectForRatio('Original', 4032, 3024)).toEqual(wholeRect(4032, 3024))
})
})
describe('computeOutputSize', () => {
it('never upscales past the crop width', () => {
expect(computeOutputSize(640, 640, 1600)).toEqual({ width: 640, height: 640 })
})
it('caps to the width preset when the crop is larger', () => {
expect(computeOutputSize(4032, 3024, 1600)).toEqual({ width: 1600, height: 1200 })
})
})
describe('estimateOutputBytes', () => {
it('estimates 420 KB for the default 1600x1200 output', () => {
expect(formatEstimatedSize(estimateOutputBytes(1600, 1200))).toBe('420 KB')
})
})
describe('rotateRectBySteps', () => {
it('rotates a whole-image rect 90deg clockwise into the new bounds', () => {
const rotated = rotateRectBySteps(wholeRect(4032, 3024), 4032, 3024, 1)
expect(rotated).toEqual({ x: 0, y: 0, width: 3024, height: 4032 })
})
it('round-trips back to the original after four steps', () => {
const rect = { x: 42, y: 34, width: 500, height: 300 }
const rotated = rotateRectBySteps(rect, 4032, 3024, 4)
expect(rotated).toEqual(rect)
})
})
describe('rotatedDimensions', () => {
it('swaps dimensions for a 90deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 90)).toEqual({ width: 3024, height: 4032 })
})
it('keeps dimensions for a 180deg rotation', () => {
expect(rotatedDimensions(4032, 3024, 180)).toEqual({ width: 4032, height: 3024 })
})
})
+245
View File
@@ -0,0 +1,245 @@
export interface CropRect {
x: number
y: number
width: number
height: number
}
export const ASPECT_RATIOS = ['Original', 'Free', '16:9', '4:3', '1:1'] as const
export type AspectRatioLabel = (typeof ASPECT_RATIOS)[number]
export const WIDTH_PRESETS = ['Original', '1600 px', '1200 px', '800 px'] as const
export type WidthPresetLabel = (typeof WIDTH_PRESETS)[number]
const FIXED_RATIO_VALUES: Partial<Record<AspectRatioLabel, number>> = {
'16:9': 16 / 9,
'4:3': 4 / 3,
'1:1': 1,
}
const WIDTH_PRESET_PIXELS: Record<WidthPresetLabel, number | null> = {
Original: null,
'1600 px': 1600,
'1200 px': 1200,
'800 px': 800,
}
export function widthPresetPixels(label: WidthPresetLabel): number | null {
return WIDTH_PRESET_PIXELS[label]
}
export function ratioValueFor(
ratio: AspectRatioLabel,
boundsWidth: number,
boundsHeight: number,
): number | null {
if (ratio === 'Free') {
return null
}
if (ratio === 'Original') {
return boundsWidth / boundsHeight
}
return FIXED_RATIO_VALUES[ratio] ?? null
}
export function wholeRect(width: number, height: number): CropRect {
return { x: 0, y: 0, width, height }
}
export function clampRect(rect: CropRect, boundsWidth: number, boundsHeight: number): CropRect {
const width = Math.round(Math.min(Math.max(rect.width, 1), boundsWidth))
const height = Math.round(Math.min(Math.max(rect.height, 1), boundsHeight))
const x = Math.round(Math.min(Math.max(rect.x, 0), boundsWidth - width))
const y = Math.round(Math.min(Math.max(rect.y, 0), boundsHeight - height))
return { x, y, width, height }
}
export function rectForRatio(
ratio: AspectRatioLabel,
boundsWidth: number,
boundsHeight: number,
): CropRect {
if (ratio === 'Original') {
return wholeRect(boundsWidth, boundsHeight)
}
const value = FIXED_RATIO_VALUES[ratio]
if (!value) {
return wholeRect(boundsWidth, boundsHeight)
}
const boundsRatio = boundsWidth / boundsHeight
let width: number
let height: number
if (value > boundsRatio) {
width = boundsWidth
height = Math.round(width / value)
} else {
height = boundsHeight
width = Math.round(height * value)
}
return {
x: Math.round((boundsWidth - width) / 2),
y: Math.round((boundsHeight - height) / 2),
width,
height,
}
}
export function moveRect(
rect: CropRect,
dx: number,
dy: number,
boundsWidth: number,
boundsHeight: number,
): CropRect {
return clampRect({ ...rect, x: rect.x + dx, y: rect.y + dy }, boundsWidth, boundsHeight)
}
export function resizeRect(
rect: CropRect,
dw: number,
dh: number,
boundsWidth: number,
boundsHeight: number,
lockedRatio: number | null,
): CropRect {
let width = rect.width + dw
let height = rect.height + dh
if (lockedRatio) {
if (dw !== 0) {
height = Math.round(width / lockedRatio)
} else if (dh !== 0) {
width = Math.round(height * lockedRatio)
}
}
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
}
export function resizeRectTo(
rect: CropRect,
width: number,
height: number,
boundsWidth: number,
boundsHeight: number,
): CropRect {
return clampRect({ ...rect, width, height }, boundsWidth, boundsHeight)
}
function rotateRectClockwiseOnce(rect: CropRect, width: number, height: number): CropRect {
return { x: height - rect.y - rect.height, y: rect.x, width: rect.height, height: rect.width }
}
export function rotateRectBySteps(
rect: CropRect,
width: number,
height: number,
steps: number,
): CropRect {
let r = rect
let w = width
let h = height
const n = ((steps % 4) + 4) % 4
for (let i = 0; i < n; i += 1) {
r = rotateRectClockwiseOnce(r, w, h)
;[w, h] = [h, w]
}
return r
}
export function rotatedDimensions(width: number, height: number, rotation: number) {
const turns = (((rotation / 90) % 4) + 4) % 4
return turns % 2 === 1 ? { width: height, height: width } : { width, height }
}
export function nextRotation(rotation: number): number {
return (rotation + 90) % 360
}
export function computeOutputSize(
cropWidth: number,
cropHeight: number,
targetWidth: number | null,
) {
const target = targetWidth === null ? cropWidth : targetWidth
const width = Math.min(target, cropWidth)
const height = Math.round((cropHeight * width) / cropWidth)
return { width, height }
}
const ESTIMATED_BYTES_PER_PIXEL = (420 * 1024) / (1600 * 1200)
export function estimateOutputBytes(width: number, height: number): number {
return Math.round(width * height * ESTIMATED_BYTES_PER_PIXEL)
}
export function formatEstimatedSize(bytes: number): string {
const kb = bytes / 1024
if (kb < 1000) {
return `${Math.round(kb)} KB`
}
return `${(kb / 1024).toFixed(1)} MB`
}
export interface NaturalImageSize {
width: number
height: number
}
export async function readNaturalImageSize(file: File): Promise<NaturalImageSize> {
const bitmap = await createImageBitmap(file)
const size = { width: bitmap.width, height: bitmap.height }
bitmap.close()
return size
}
export interface RenderCropOptions {
rect: CropRect
rotation: number
naturalWidth: number
naturalHeight: number
outputWidth: number
outputHeight: number
mimeType: string
}
export async function renderCroppedImage(file: File, options: RenderCropOptions): Promise<Blob> {
const bitmap = await createImageBitmap(file)
const rotated = rotatedDimensions(options.naturalWidth, options.naturalHeight, options.rotation)
const rotatedCanvas = document.createElement('canvas')
rotatedCanvas.width = rotated.width
rotatedCanvas.height = rotated.height
const rotatedCtx = rotatedCanvas.getContext('2d')
if (!rotatedCtx) {
throw new Error('canvas rendering is not supported')
}
rotatedCtx.translate(rotated.width / 2, rotated.height / 2)
rotatedCtx.rotate((options.rotation * Math.PI) / 180)
rotatedCtx.drawImage(bitmap, -options.naturalWidth / 2, -options.naturalHeight / 2)
const canvas = document.createElement('canvas')
canvas.width = options.outputWidth
canvas.height = options.outputHeight
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('canvas rendering is not supported')
}
ctx.drawImage(
rotatedCanvas,
options.rect.x,
options.rect.y,
options.rect.width,
options.rect.height,
0,
0,
options.outputWidth,
options.outputHeight,
)
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob)
return
}
reject(new Error('failed to render the cropped image'))
}, options.mimeType)
})
}
+16
View File
@@ -42,3 +42,19 @@ export function rejectionTitle(filename: string) {
export function sizeRejectionText(filename: string, bytes: number) {
return `${filename} is ${formatMegabytes(bytes)} MB. Images can be up to 10 MB.`
}
export function formatWholeKB(bytes: number): string {
const kb = bytes / 1024
const rounded = Math.round(kb * 10) / 10
if (rounded === 0) {
return '0.0'
}
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
}
export function formatUploadProgress(loadedBytes: number, totalBytes: number): string {
if (totalBytes < 1024 * 1024) {
return `Uploading ${formatWholeKB(loadedBytes)} of ${formatWholeKB(totalBytes)} KB`
}
return `Uploading ${formatMegabytes(loadedBytes)} of ${formatMegabytes(totalBytes)} MB`
}