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 { on } from '@ember/modifier';
import { fn } from '@ember/helper'; 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 { export default class PlacePhotoItem extends Component {
@service blossom; @service blossom;
@service imageProcessor;
@tracked thumbnailUrl = ''; @tracked thumbnailUrl = '';
@tracked error = ''; @tracked error = '';
@@ -31,24 +37,50 @@ export default class PlacePhotoItem extends Component {
uploadTask = task(async (file) => { uploadTask = task(async (file) => {
this.error = ''; this.error = '';
try { try {
const dim = await new Promise((resolve) => { // 1. Process main image
const img = new Image(); const mainData = await this.imageProcessor.process(
img.onload = () => resolve(`${img.width}x${img.height}`); file,
img.onerror = () => resolve(''); MAX_IMAGE_DIMENSION,
img.src = this.thumbnailUrl; 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; this.isUploaded = true;
if (this.args.onSuccess) { if (this.args.onSuccess) {
this.args.onSuccess({ this.args.onSuccess({
file, file,
url: result.url, url: mainResult.url,
fallbackUrls: result.fallbackUrls, fallbackUrls: mainResult.fallbackUrls,
type: result.type, thumbUrl: thumbResult.url,
dim, blurhash,
hash: result.hash, type: 'image/jpeg',
dim: mainData.dim,
hash: mainResult.hash,
thumbHash: thumbResult.hash,
}); });
} }
} catch (e) { } catch (e) {

View File

@@ -90,8 +90,12 @@ export default class PlacePhotoUpload extends Component {
deletePhotoTask = task(async (photoData) => { deletePhotoTask = task(async (photoData) => {
try { try {
if (!photoData.hash) return; if (photoData.hash) {
await this.blossom.delete(photoData.hash); await this.blossom.delete(photoData.hash);
}
if (photoData.thumbHash) {
await this.blossom.delete(photoData.thumbHash);
}
} catch (e) { } catch (e) {
this.toast.show(`Failed to delete photo from server: ${e.message}`, 5000); 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) { for (const photo of this.uploadedPhotos) {
const imeta = ['imeta', `url ${photo.url}`]; 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}`); imeta.push(`m ${photo.type}`);
if (photo.dim) { if (photo.dim) {
@@ -149,6 +147,21 @@ export default class PlacePhotoUpload extends Component {
} }
imeta.push('alt A photo of a place'); 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); 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-factory": "^4.0.0",
"applesauce-relay": "^5.2.0", "applesauce-relay": "^5.2.0",
"applesauce-signers": "^5.2.0", "applesauce-signers": "^5.2.0",
"blurhash": "^2.0.5",
"ember-concurrency": "^5.2.0", "ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0", "ember-lifeline": "^7.0.0",
"oauth2-pkce": "^2.1.3", "oauth2-pkce": "^2.1.3",

8
pnpm-lock.yaml generated
View File

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