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> = { '16:9': 16 / 9, '4:3': 4 / 3, '1:1': 1, } const WIDTH_PRESET_PIXELS: Record = { 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 { 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 { 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) }) }