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