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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user