Process images before upload, add thumbnails, blurhash
This commit is contained in:
@@ -6,8 +6,14 @@ import Icon from '#components/icon';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
|
||||
const MAX_IMAGE_DIMENSION = 1920;
|
||||
const IMAGE_QUALITY = 0.94;
|
||||
const MAX_THUMBNAIL_DIMENSION = 350;
|
||||
const THUMBNAIL_QUALITY = 0.9;
|
||||
|
||||
export default class PlacePhotoItem extends Component {
|
||||
@service blossom;
|
||||
@service imageProcessor;
|
||||
|
||||
@tracked thumbnailUrl = '';
|
||||
@tracked error = '';
|
||||
@@ -31,24 +37,50 @@ export default class PlacePhotoItem extends Component {
|
||||
uploadTask = task(async (file) => {
|
||||
this.error = '';
|
||||
try {
|
||||
const dim = await new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(`${img.width}x${img.height}`);
|
||||
img.onerror = () => resolve('');
|
||||
img.src = this.thumbnailUrl;
|
||||
});
|
||||
// 1. Process main image
|
||||
const mainData = await this.imageProcessor.process(
|
||||
file,
|
||||
MAX_IMAGE_DIMENSION,
|
||||
IMAGE_QUALITY
|
||||
);
|
||||
|
||||
// 2. Generate blurhash from main image data
|
||||
const blurhash = await this.imageProcessor.generateBlurhash(
|
||||
mainData.imageData
|
||||
);
|
||||
|
||||
// 3. Process thumbnail
|
||||
const thumbData = await this.imageProcessor.process(
|
||||
file,
|
||||
MAX_THUMBNAIL_DIMENSION,
|
||||
THUMBNAIL_QUALITY
|
||||
);
|
||||
|
||||
// 4. Upload main image (to all servers concurrently)
|
||||
const mainUploadPromise = this.blossom.upload(mainData.blob);
|
||||
|
||||
// 5. Upload thumbnail (to all servers concurrently)
|
||||
const thumbUploadPromise = this.blossom.upload(thumbData.blob);
|
||||
|
||||
// Await both uploads
|
||||
const [mainResult, thumbResult] = await Promise.all([
|
||||
mainUploadPromise,
|
||||
thumbUploadPromise,
|
||||
]);
|
||||
|
||||
const result = await this.blossom.upload(file);
|
||||
this.isUploaded = true;
|
||||
|
||||
if (this.args.onSuccess) {
|
||||
this.args.onSuccess({
|
||||
file,
|
||||
url: result.url,
|
||||
fallbackUrls: result.fallbackUrls,
|
||||
type: result.type,
|
||||
dim,
|
||||
hash: result.hash,
|
||||
url: mainResult.url,
|
||||
fallbackUrls: mainResult.fallbackUrls,
|
||||
thumbUrl: thumbResult.url,
|
||||
blurhash,
|
||||
type: 'image/jpeg',
|
||||
dim: mainData.dim,
|
||||
hash: mainResult.hash,
|
||||
thumbHash: thumbResult.hash,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -90,8 +90,12 @@ export default class PlacePhotoUpload extends Component {
|
||||
|
||||
deletePhotoTask = task(async (photoData) => {
|
||||
try {
|
||||
if (!photoData.hash) return;
|
||||
await this.blossom.delete(photoData.hash);
|
||||
if (photoData.hash) {
|
||||
await this.blossom.delete(photoData.hash);
|
||||
}
|
||||
if (photoData.thumbHash) {
|
||||
await this.blossom.delete(photoData.thumbHash);
|
||||
}
|
||||
} catch (e) {
|
||||
this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000);
|
||||
}
|
||||
@@ -136,12 +140,6 @@ export default class PlacePhotoUpload extends Component {
|
||||
for (const photo of this.uploadedPhotos) {
|
||||
const imeta = ['imeta', `url ${photo.url}`];
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
imeta.push(`url ${fallbackUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
imeta.push(`m ${photo.type}`);
|
||||
|
||||
if (photo.dim) {
|
||||
@@ -149,6 +147,21 @@ export default class PlacePhotoUpload extends Component {
|
||||
}
|
||||
|
||||
imeta.push('alt A photo of a place');
|
||||
|
||||
if (photo.fallbackUrls && photo.fallbackUrls.length > 0) {
|
||||
for (const fallbackUrl of photo.fallbackUrls) {
|
||||
imeta.push(`fallback ${fallbackUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (photo.thumbUrl) {
|
||||
imeta.push(`thumb ${photo.thumbUrl}`);
|
||||
}
|
||||
|
||||
if (photo.blurhash) {
|
||||
imeta.push(`blurhash ${photo.blurhash}`);
|
||||
}
|
||||
|
||||
tags.push(imeta);
|
||||
}
|
||||
|
||||
|
||||
74
app/services/image-processor.js
Normal file
74
app/services/image-processor.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import Service from '@ember/service';
|
||||
import { encode } from 'blurhash';
|
||||
|
||||
export default class ImageProcessorService extends Service {
|
||||
async process(file, maxDimension, quality) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image();
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
async generateBlurhash(imageData, componentX = 4, componentY = 3) {
|
||||
return encode(
|
||||
imageData.data,
|
||||
imageData.width,
|
||||
imageData.height,
|
||||
componentX,
|
||||
componentY
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user