246 lines
6.3 KiB
TypeScript
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)
|
|
})
|
|
}
|