Files
RenewCMS/web/src/lib/imageCrop.ts
T

246 lines
6.3 KiB
TypeScript

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)
})
}