diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs index 7581afa..97493b1 100644 --- a/app/components/place-photo-item.gjs +++ b/app/components/place-photo-item.gjs @@ -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) { diff --git a/app/components/place-photo-upload.gjs b/app/components/place-photo-upload.gjs index f85e75a..7f6df7a 100644 --- a/app/components/place-photo-upload.gjs +++ b/app/components/place-photo-upload.gjs @@ -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); } diff --git a/app/services/image-processor.js b/app/services/image-processor.js new file mode 100644 index 0000000..9cc4b2e --- /dev/null +++ b/app/services/image-processor.js @@ -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 + ); + } +} diff --git a/package.json b/package.json index 3c16d93..d0cec30 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c7b37a..4fc083f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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