Process images before upload, add thumbnails, blurhash

This commit is contained in:
2026-04-20 16:24:28 +04:00
parent 1ed66ca744
commit 79777fb51a
5 changed files with 148 additions and 20 deletions

View File

@@ -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) {

View File

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

View 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
);
}
}

View File

@@ -107,6 +107,7 @@
"applesauce-factory": "^4.0.0",
"applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0",
"blurhash": "^2.0.5",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3",

8
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
applesauce-signers:
specifier: ^5.2.0
version: 5.2.0(@capacitor/core@7.6.2)(typescript@5.9.3)
blurhash:
specifier: ^2.0.5
version: 2.0.5
ember-concurrency:
specifier: ^5.2.0
version: 5.2.0(@babel/core@7.28.6)
@@ -2044,6 +2047,9 @@ packages:
bluebird@3.7.2:
resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==}
blurhash@2.0.5:
resolution: {integrity: sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==}
body-parser@1.20.4:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
@@ -8053,6 +8059,8 @@ snapshots:
bluebird@3.7.2: {}
blurhash@2.0.5: {}
body-parser@1.20.4:
dependencies:
bytes: 3.1.2