diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs index 97493b1..6820a33 100644 --- a/app/components/place-photo-item.gjs +++ b/app/components/place-photo-item.gjs @@ -37,29 +37,26 @@ export default class PlacePhotoItem extends Component { uploadTask = task(async (file) => { this.error = ''; try { - // 1. Process main image + // 1. Process main image and generate blurhash in worker const mainData = await this.imageProcessor.process( file, MAX_IMAGE_DIMENSION, - IMAGE_QUALITY + IMAGE_QUALITY, + true // computeBlurhash ); - // 2. Generate blurhash from main image data - const blurhash = await this.imageProcessor.generateBlurhash( - mainData.imageData - ); - - // 3. Process thumbnail + // 2. Process thumbnail (no blurhash needed) const thumbData = await this.imageProcessor.process( file, MAX_THUMBNAIL_DIMENSION, - THUMBNAIL_QUALITY + THUMBNAIL_QUALITY, + false ); - // 4. Upload main image (to all servers concurrently) + // 3. Upload main image (to all servers concurrently) const mainUploadPromise = this.blossom.upload(mainData.blob); - // 5. Upload thumbnail (to all servers concurrently) + // 4. Upload thumbnail (to all servers concurrently) const thumbUploadPromise = this.blossom.upload(thumbData.blob); // Await both uploads @@ -76,7 +73,7 @@ export default class PlacePhotoItem extends Component { url: mainResult.url, fallbackUrls: mainResult.fallbackUrls, thumbUrl: thumbResult.url, - blurhash, + blurhash: mainData.blurhash, type: 'image/jpeg', dim: mainData.dim, hash: mainResult.hash, diff --git a/app/services/image-processor.js b/app/services/image-processor.js index 9cc4b2e..e9402d9 100644 --- a/app/services/image-processor.js +++ b/app/services/image-processor.js @@ -1,74 +1,82 @@ import Service from '@ember/service'; -import { encode } from 'blurhash'; +// We use the special Vite query parameter to load this as a web worker +import Worker from '../workers/image-processor?worker'; export default class ImageProcessorService extends Service { - async process(file, maxDimension, quality) { + _worker = null; + _callbacks = new Map(); + _msgId = 0; + + constructor() { + super(...arguments); + this._initWorker(); + } + + _initWorker() { + if (!this._worker && typeof Worker !== 'undefined') { + try { + this._worker = new Worker(); + this._worker.onmessage = this._handleMessage.bind(this); + this._worker.onerror = this._handleError.bind(this); + } catch (e) { + console.warn('Failed to initialize image-processor worker:', e); + } + } + } + + _handleMessage(e) { + const { id, success, blob, dim, blurhash, error } = e.data; + const resolver = this._callbacks.get(id); + + if (resolver) { + this._callbacks.delete(id); + if (success) { + resolver.resolve({ blob, dim, blurhash }); + } else { + resolver.reject(new Error(error)); + } + } + } + + _handleError(error) { + console.error('Image Processor Worker Error:', error); + // Reject all pending jobs + for (const [, resolver] of this._callbacks.entries()) { + resolver.reject(new Error('Worker crashed')); + } + this._callbacks.clear(); + // Restart the worker for future jobs + this._worker.terminate(); + this._worker = null; + this._initWorker(); + } + + async process(file, maxDimension, quality, computeBlurhash = false) { + if (!this._worker) { + // Fallback if worker initialization failed (e.g. incredibly old browsers) + throw new Error('Image processor worker is not available.'); + } + return new Promise((resolve, reject) => { - const url = URL.createObjectURL(file); - const img = new Image(); + const id = ++this._msgId; + this._callbacks.set(id, { resolve, reject }); - img.onload = () => { - URL.revokeObjectURL(url); - - let width = img.width; - let height = img.height; - - if (width > height) { - if (width > maxDimension) { - height = Math.round(height * (maxDimension / width)); - width = maxDimension; - } - } else { - if (height > maxDimension) { - width = Math.round(width * (maxDimension / height)); - height = maxDimension; - } - } - - const canvas = document.createElement('canvas'); - canvas.width = width; - canvas.height = height; - - const ctx = canvas.getContext('2d'); - if (!ctx) { - return reject(new Error('Failed to get canvas context')); - } - - // Draw image on canvas, this inherently strips EXIF/metadata - ctx.drawImage(img, 0, 0, width, height); - - const imageData = ctx.getImageData(0, 0, width, height); - const dim = `${width}x${height}`; - - canvas.toBlob( - (blob) => { - if (blob) { - resolve({ blob, dim, imageData }); - } else { - reject(new Error('Canvas toBlob failed')); - } - }, - 'image/jpeg', - quality - ); - }; - - img.onerror = () => { - URL.revokeObjectURL(url); - reject(new Error('Failed to load image for processing')); - }; - - img.src = url; + this._worker.postMessage({ + id, + file, + maxDimension, + quality, + computeBlurhash, + }); }); } - async generateBlurhash(imageData, componentX = 4, componentY = 3) { - return encode( - imageData.data, - imageData.width, - imageData.height, - componentX, - componentY - ); + willDestroy() { + super.willDestroy(...arguments); + if (this._worker) { + this._worker.terminate(); + this._worker = null; + } + this._callbacks.clear(); } } diff --git a/app/styles/app.css b/app/styles/app.css index 0ef1904..356f806 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -216,7 +216,7 @@ body { padding: 2rem 1.5rem; text-align: center; transition: all 0.2s ease; - margin: 1.5rem 0 1rem 0; + margin: 1.5rem 0 1rem; background-color: rgb(255 255 255 / 2%); cursor: pointer; } diff --git a/app/workers/image-processor.js b/app/workers/image-processor.js new file mode 100644 index 0000000..9b99672 --- /dev/null +++ b/app/workers/image-processor.js @@ -0,0 +1,68 @@ +import { encode } from 'blurhash'; + +self.onmessage = async (e) => { + const { id, file, maxDimension, quality, computeBlurhash } = e.data; + + try { + // 1. Decode image off main thread + const bitmap = await createImageBitmap(file); + + let width = bitmap.width; + let height = bitmap.height; + + // 2. Calculate aspect-ratio preserving dimensions + if (width > height) { + if (width > maxDimension) { + height = Math.round(height * (maxDimension / width)); + width = maxDimension; + } + } else { + if (height > maxDimension) { + width = Math.round(width * (maxDimension / height)); + height = maxDimension; + } + } + + // 3. Create OffscreenCanvas and draw + const canvas = new OffscreenCanvas(width, height); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + throw new Error('Failed to get 2d context from OffscreenCanvas'); + } + + ctx.drawImage(bitmap, 0, 0, width, height); + + // 4. Generate Blurhash (if requested) + let blurhash = null; + if (computeBlurhash) { + const imageData = ctx.getImageData(0, 0, width, height); + blurhash = encode(imageData.data, width, height, 4, 3); + } + + // 5. Compress to JPEG Blob + const blob = await canvas.convertToBlob({ + type: 'image/jpeg', + quality: quality, + }); + + const dim = `${width}x${height}`; + + // 6. Send results back to main thread + self.postMessage({ + id, + success: true, + blob, + dim, + blurhash, + }); + + bitmap.close(); + } catch (error) { + self.postMessage({ + id, + success: false, + error: error.message, + }); + } +};