Add ability to resize images client-side

This commit is contained in:
Jerko Steiner 2019-06-09 09:36:23 +10:00
parent 7de6157129
commit 355d8c8e51
4 changed files with 163 additions and 74 deletions

View File

@ -0,0 +1,30 @@
function attachErrorListener(
reader: FileReader,
file: File,
reject: (err: Error) => void,
) {
reader.onerror = ev => reject(new Error('Error reading file: ' + file.name))
}
export async function readAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result as string)
}
attachErrorListener(reader, file, reject)
reader.readAsDataURL(file)
})
}
export async function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
resolve(reader.result as ArrayBuffer)
}
attachErrorListener(reader, file, reject)
reader.readAsArrayBuffer(file)
})
}

View File

@ -0,0 +1,39 @@
export async function createImageFromDataURL(dataURL: string)
: Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('Error reading image!'))
image.src = dataURL
})
}
export async function drawCanvasFromDataURL(dataURL: string)
: Promise<HTMLCanvasElement> {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const image = await createImageFromDataURL(dataURL)
canvas.width = image.width
canvas.height = image.height
ctx.drawImage(image, 0, 0)
return canvas
}
export async function getCanvasFromArrayBuffer(
arrayBuffer: ArrayBuffer,
width: number,
height: number,
) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const imageData = new ImageData(
new Uint8ClampedArray(arrayBuffer),
width,
height,
)
canvas.width = width
canvas.height = height
ctx.putImageData(imageData, 0, 0)
})
}

View File

@ -1,7 +1,10 @@
import React from 'react'
import {Resizer} from './Resizer'
import {readAsDataURL} from './Files'
import {drawCanvasFromDataURL} from './Image'
export interface IImage {
base64: string
dataURL: string
}
export interface IImageUploadProps {
@ -16,23 +19,55 @@ export class ImageUpload extends React.PureComponent<IImageUploadProps> {
this.fileInput = React.createRef()
}
handleChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const files = this.fileInput.current!.files!
Array.prototype.forEach.call(files, (file: File) => {
const reader = new FileReader()
reader.addEventListener('load', function() {
const {result} = this
console.log(file.name, result)
// TODO resize image...
})
reader.readAsDataURL(file)
})
safeHandleChange = async (event: React.SyntheticEvent<HTMLInputElement>) => {
try {
await this.handleChange(event)
} catch (err) {
console.log('Error in handleChange', err)
}
}
handleChange = async (event: React.SyntheticEvent<HTMLInputElement>) => {
const self = this
const files = Array.from(this.fileInput.current!.files!)
const resized: IImage[] = []
for (const file of files) {
const dataURL = await readAsDataURL(file)
const resizedDataURL = await this.resize(dataURL)
resized.push({
dataURL: resizedDataURL,
})
// TODO testing stuff
const img = document.createElement('img')
img.src = resizedDataURL
this.fileInput.current!.parentElement!.appendChild(img)
}
(window as any).resized = resized
this.props.onChange(resized)
}
async resize(dataURL: string): Promise<string> {
const canvas = await drawCanvasFromDataURL(dataURL)
const maxHeight = 512
// // TODO figure out what to do when the image is really wide
if (canvas.height > maxHeight) {
const height = maxHeight
const width = Math.round(canvas.width / canvas.height * maxHeight)
await new Resizer().resample(canvas, width, height)
}
return canvas.toDataURL('image/jpeg', .85)
}
render() {
return (
<div className='image-upload'>
<input
autoComplete='off'
multiple={this.props.multiple}
type='file'
accept='image/*'

View File

@ -1,35 +1,26 @@
interface IPartition {
source: ImageData
target: ImageData
startY: number
height: number
}
interface IWorkerParam {
sourceWidth: number
sourceHeight: number
width: number
height: number
core: number
source: ArrayBuffer
}
interface IWorkerParamMessage extends MessageEvent {
interface IWorkerParamMessage {
data: IWorkerParam
}
interface IWorkerResult {
core: number
target: Uint8ClampedArray
}
interface IWorkerResultMessage extends MessageEvent {
interface IWorkerResultMessage {
data: IWorkerResult
}
function ResizeWorker() {
function onmessage(event: IWorkerParamMessage) {
const core = event.data.core
function createResizeWorker(root: any = {})
: {onmessage: (event: IWorkerParamMessage) => void} {
root.onmessage = (event: IWorkerParamMessage) => {
const sourceWidth = event.data.sourceWidth
const sourceHeight = event.data.sourceHeight
const width = event.data.width
@ -102,22 +93,26 @@ function ResizeWorker() {
}
const objData: IWorkerResult = {
core,
target,
}
postMessage(objData, [target.buffer] as any)
}
return root
}
export class Resizer {
readonly cores = navigator.hardwareConcurrency || 4
readonly workerBlobURL = window.URL.createObjectURL(
new Blob(
['(', ResizeWorker.toString(), ')()'],
['(', createResizeWorker.toString(), ')(self)'],
{type: 'application/javascript'},
))
async resample(canvas: HTMLCanvasElement, width: number, height: number) {
async resample(
canvas: HTMLCanvasElement,
width: number,
height: number,
): Promise<HTMLCanvasElement> {
const {cores} = this
const sourceWidth = canvas.width
const sourceHeight = canvas.height
@ -125,84 +120,74 @@ export class Resizer {
height = Math.round(height)
const ratioH = sourceHeight / height
const workers = new Array(cores)
// TODO handle null
const ctx = canvas.getContext('2d')!
// prepare source and target data for workers
const partitions: IPartition[] = new Array(cores)
let resolve: (canvas: HTMLCanvasElement) => void
let reject: (err: Error) => void
const promise = new Promise<HTMLCanvasElement>((res, rej) => {
resolve = res
reject = rej
})
const blockHeight = Math.ceil(sourceHeight / cores / 2) * 2
let endY = -1
let activeWorkers = 0
const workers: Worker[] = []
for (let c = 0; c < cores; c++) {
// source
const offsetY = endY + 1
if (offsetY >= sourceHeight) {
// size too small, nothing left for this core
continue
}
endY = offsetY + blockHeight - 1
endY = Math.min(endY, sourceHeight - 1)
endY = Math.min(offsetY + blockHeight - 1, sourceHeight - 1)
let currentBlockHeight = blockHeight
currentBlockHeight = Math.min(blockHeight, sourceHeight - offsetY)
const currentBlockHeight = Math.min(blockHeight, sourceHeight - offsetY)
// console.log(
// 'source split: ', '#'+c, offsetY, endY, 'height: '+currentBlockHeight);
partitions[c] = {
const partition = {
source: ctx.getImageData(0, offsetY, sourceWidth, blockHeight),
target: ctx.createImageData(
width, Math.ceil(currentBlockHeight / ratioH)),
startY: Math.ceil(offsetY / ratioH),
height: currentBlockHeight,
}
}
ctx.clearRect(0, 0, sourceWidth, sourceHeight)
let resolve: () => void
const promise = new Promise(r => resolve = r)
// start
let activeWorkers = 0
for (let c = 0; c < cores; c++) {
if (partitions[c] === undefined) {
// no job for this worker
continue
}
activeWorkers++
const worker = new Worker(this.workerBlobURL)
activeWorkers += 1
workers[c] = worker
worker.onmessage = (event: IWorkerResultMessage) => {
activeWorkers--
const core = event.data.core
workers[core].terminate()
delete workers[core]
worker.terminate()
delete workers[c]
activeWorkers -= 1
partition.target.data.set(event.data.target)
ctx.putImageData(partition.target, 0, partition.startY)
// draw
// const height_part = Math.ceil(partitions[core].height / ratioH)
// partitions[core].target = ctx.createImageData(width, height_part)
partitions[core].target.data.set(event.data.target)
ctx.putImageData(partitions[core].target, 0, partitions[core].startY)
if (activeWorkers <= 0) {
resolve()
if (!activeWorkers) {
resolve(canvas)
}
}
worker.onerror = (err: ErrorEvent) => {
workers.forEach(w => w.terminate())
workers.length = 0
reject(new Error('Error resizing: ' + err.message))
}
const message: IWorkerParam = {
sourceWidth,
sourceHeight: partitions[c].height,
sourceHeight: partition.height,
width,
height: Math.ceil(partitions[c].height / ratioH),
core: c,
source: partitions[c].source.data.buffer,
height: Math.ceil(partition.height / ratioH),
source: partition.source.data.buffer,
}
worker.postMessage(message, [message.source])
}
canvas.width = width
canvas.height = height
// ctx.clearRect(0, 0, sourceWidth, sourceHeight)
return promise
}
}