Move image processing to worker

This commit is contained in:
2026-04-20 16:56:51 +04:00
parent b7cce6eb7e
commit 4f55f26851
4 changed files with 150 additions and 77 deletions

View File

@@ -37,29 +37,26 @@ export default class PlacePhotoItem extends Component {
uploadTask = task(async (file) => { uploadTask = task(async (file) => {
this.error = ''; this.error = '';
try { try {
// 1. Process main image // 1. Process main image and generate blurhash in worker
const mainData = await this.imageProcessor.process( const mainData = await this.imageProcessor.process(
file, file,
MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION,
IMAGE_QUALITY IMAGE_QUALITY,
true // computeBlurhash
); );
// 2. Generate blurhash from main image data // 2. Process thumbnail (no blurhash needed)
const blurhash = await this.imageProcessor.generateBlurhash(
mainData.imageData
);
// 3. Process thumbnail
const thumbData = await this.imageProcessor.process( const thumbData = await this.imageProcessor.process(
file, file,
MAX_THUMBNAIL_DIMENSION, 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); 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); const thumbUploadPromise = this.blossom.upload(thumbData.blob);
// Await both uploads // Await both uploads
@@ -76,7 +73,7 @@ export default class PlacePhotoItem extends Component {
url: mainResult.url, url: mainResult.url,
fallbackUrls: mainResult.fallbackUrls, fallbackUrls: mainResult.fallbackUrls,
thumbUrl: thumbResult.url, thumbUrl: thumbResult.url,
blurhash, blurhash: mainData.blurhash,
type: 'image/jpeg', type: 'image/jpeg',
dim: mainData.dim, dim: mainData.dim,
hash: mainResult.hash, hash: mainResult.hash,

View File

@@ -1,74 +1,82 @@
import Service from '@ember/service'; 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 { 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) => { return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file); const id = ++this._msgId;
const img = new Image(); this._callbacks.set(id, { resolve, reject });
img.onload = () => { this._worker.postMessage({
URL.revokeObjectURL(url); id,
file,
let width = img.width; maxDimension,
let height = img.height; quality,
computeBlurhash,
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;
}); });
} }
async generateBlurhash(imageData, componentX = 4, componentY = 3) { willDestroy() {
return encode( super.willDestroy(...arguments);
imageData.data, if (this._worker) {
imageData.width, this._worker.terminate();
imageData.height, this._worker = null;
componentX, }
componentY this._callbacks.clear();
);
} }
} }

View File

@@ -216,7 +216,7 @@ body {
padding: 2rem 1.5rem; padding: 2rem 1.5rem;
text-align: center; text-align: center;
transition: all 0.2s ease; transition: all 0.2s ease;
margin: 1.5rem 0 1rem 0; margin: 1.5rem 0 1rem;
background-color: rgb(255 255 255 / 2%); background-color: rgb(255 255 255 / 2%);
cursor: pointer; cursor: pointer;
} }

View File

@@ -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,
});
}
};