feat: image cropping / resizing T0
This commit is contained in:
1 parent
cb05b38f36
commit
e15f444603
18 files changed
+1869
-46
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user