diff --git a/app/components/place-photo-item.gjs b/app/components/place-photo-item.gjs index 6820a33..ff7543e 100644 --- a/app/components/place-photo-item.gjs +++ b/app/components/place-photo-item.gjs @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { service } from '@ember/service'; +import { action } from '@ember/object'; import { task } from 'ember-concurrency'; import Icon from '#components/icon'; import { on } from '@ember/modifier'; @@ -14,6 +15,7 @@ const THUMBNAIL_QUALITY = 0.9; export default class PlacePhotoItem extends Component { @service blossom; @service imageProcessor; + @service toast; @tracked thumbnailUrl = ''; @tracked error = ''; @@ -34,6 +36,13 @@ export default class PlacePhotoItem extends Component { } } + @action + showErrorToast() { + if (this.error) { + this.toast.show(this.error); + } + } + uploadTask = task(async (file) => { this.error = ''; try { @@ -105,9 +114,14 @@ export default class PlacePhotoItem extends Component { {{/if}} {{#if this.error}} -
+ {{/if}} {{#if this.isUploaded}} diff --git a/app/services/blossom.js b/app/services/blossom.js index 05b9e38..709a4fc 100644 --- a/app/services/blossom.js +++ b/app/services/blossom.js @@ -1,5 +1,6 @@ import Service, { service } from '@ember/service'; import { EventFactory } from 'applesauce-core'; +import { sha256 } from '@noble/hashes/sha2.js'; export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build'; @@ -78,7 +79,18 @@ export default class BlossomService extends Service { if (!this.nostrAuth.isConnected) throw new Error('Not connected'); const buffer = await file.arrayBuffer(); - const hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + let hashBuffer; + + if ( + typeof crypto !== 'undefined' && + crypto.subtle && + crypto.subtle.digest + ) { + hashBuffer = await crypto.subtle.digest('SHA-256', buffer); + } else { + hashBuffer = sha256(new Uint8Array(buffer)); + } + const payloadHash = bufferToHex(hashBuffer); const servers = this.servers; diff --git a/app/services/image-processor.js b/app/services/image-processor.js index e9402d9..792ad0b 100644 --- a/app/services/image-processor.js +++ b/app/services/image-processor.js @@ -51,24 +51,71 @@ export default class ImageProcessorService extends Service { this._initWorker(); } + _getImageDimensions(file) { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + + img.onload = () => { + const dimensions = { width: img.width, height: img.height }; + URL.revokeObjectURL(url); + resolve(dimensions); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error('Could not read image dimensions')); + }; + + img.src = url; + }); + } + async process(file, maxDimension, quality, computeBlurhash = false) { if (!this._worker) { // Fallback if worker initialization failed (e.g. incredibly old browsers) throw new Error('Image processor worker is not available.'); } - return new Promise((resolve, reject) => { - const id = ++this._msgId; - this._callbacks.set(id, { resolve, reject }); + try { + // 1. Get dimensions safely on the main thread + const { width: origWidth, height: origHeight } = + await this._getImageDimensions(file); - this._worker.postMessage({ - id, - file, - maxDimension, - quality, - computeBlurhash, + // 2. Calculate aspect-ratio preserving dimensions + let targetWidth = origWidth; + let targetHeight = origHeight; + + if (origWidth > origHeight) { + if (origWidth > maxDimension) { + targetHeight = Math.round(origHeight * (maxDimension / origWidth)); + targetWidth = maxDimension; + } + } else { + if (origHeight > maxDimension) { + targetWidth = Math.round(origWidth * (maxDimension / origHeight)); + targetHeight = maxDimension; + } + } + + // 3. Send to worker for processing + return new Promise((resolve, reject) => { + const id = ++this._msgId; + this._callbacks.set(id, { resolve, reject }); + + this._worker.postMessage({ + type: 'PROCESS_IMAGE', + id, + file, + targetWidth, + targetHeight, + quality, + computeBlurhash, + }); }); - }); + } catch (e) { + throw new Error(`Failed to process image: ${e.message}`); + } } willDestroy() { diff --git a/app/styles/app.css b/app/styles/app.css index 356f806..a21c6d6 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -276,6 +276,11 @@ body { .error-overlay { background: rgb(224 108 117 / 80%); + cursor: pointer; + border: none; + padding: 0; + margin: 0; + width: 100%; } .success-overlay { diff --git a/app/workers/image-processor.js b/app/workers/image-processor.js index 9b99672..830036c 100644 --- a/app/workers/image-processor.js +++ b/app/workers/image-processor.js @@ -1,63 +1,125 @@ import { encode } from 'blurhash'; self.onmessage = async (e) => { - const { id, file, maxDimension, quality, computeBlurhash } = e.data; + // Ignore internal browser/Vite/extension pings that don't match our exact job signature + if (e.data?.type !== 'PROCESS_IMAGE') return; + + const { id, file, targetWidth, targetHeight, quality, computeBlurhash } = + e.data; try { - // 1. Decode image off main thread - const bitmap = await createImageBitmap(file); + let finalCanvas; + let finalCtx; - let width = bitmap.width; - let height = bitmap.height; + // --- 1. Attempt Hardware Resizing (Happy Path) --- + try { + const resizedBitmap = await createImageBitmap(file, { + resizeWidth: targetWidth, + resizeHeight: targetHeight, + resizeQuality: 'high', + }); - // 2. Calculate aspect-ratio preserving dimensions - if (width > height) { - if (width > maxDimension) { - height = Math.round(height * (maxDimension / width)); - width = maxDimension; + finalCanvas = new OffscreenCanvas(targetWidth, targetHeight); + finalCtx = finalCanvas.getContext('2d'); + if (!finalCtx) { + throw new Error('Failed to get 2d context from OffscreenCanvas'); } - } else { - if (height > maxDimension) { - width = Math.round(width * (maxDimension / height)); - height = maxDimension; + finalCtx.drawImage(resizedBitmap, 0, 0, targetWidth, targetHeight); + resizedBitmap.close(); + } catch (hwError) { + console.warn( + 'Hardware resize failed, falling back to stepped software scaling:', + hwError + ); + + // --- 2. Fallback to Stepped Software Scaling (Robust Path) --- + // Bypass Android File descriptor bug by reading into memory + const buffer = await file.arrayBuffer(); + const blob = new Blob([buffer], { type: file.type }); + + const source = await createImageBitmap(blob); + let srcWidth = source.width; + let srcHeight = source.height; + + let currentCanvas = new OffscreenCanvas(srcWidth, srcHeight); + let ctx = currentCanvas.getContext('2d'); + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(source, 0, 0); + + // Step down by halves until near target + while ( + currentCanvas.width * 0.5 > targetWidth && + currentCanvas.height * 0.5 > targetHeight + ) { + const nextCanvas = new OffscreenCanvas( + Math.floor(currentCanvas.width * 0.5), + Math.floor(currentCanvas.height * 0.5) + ); + const nextCtx = nextCanvas.getContext('2d'); + + nextCtx.imageSmoothingEnabled = true; + nextCtx.imageSmoothingQuality = 'high'; + + nextCtx.drawImage( + currentCanvas, + 0, + 0, + nextCanvas.width, + nextCanvas.height + ); + + currentCanvas = nextCanvas; } + + // Final resize to exact target + finalCanvas = new OffscreenCanvas(targetWidth, targetHeight); + finalCtx = finalCanvas.getContext('2d'); + + finalCtx.imageSmoothingEnabled = true; + finalCtx.imageSmoothingQuality = 'high'; + + finalCtx.drawImage(currentCanvas, 0, 0, targetWidth, targetHeight); + + source.close(); } - // 3. Create OffscreenCanvas and draw - const canvas = new OffscreenCanvas(width, height); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - throw new Error('Failed to get 2d context from OffscreenCanvas'); - } - - ctx.drawImage(bitmap, 0, 0, width, height); - - // 4. Generate Blurhash (if requested) + // --- 3. Generate Blurhash (if requested) --- let blurhash = null; if (computeBlurhash) { - const imageData = ctx.getImageData(0, 0, width, height); - blurhash = encode(imageData.data, width, height, 4, 3); + try { + const imageData = finalCtx.getImageData( + 0, + 0, + targetWidth, + targetHeight + ); + blurhash = encode(imageData.data, targetWidth, targetHeight, 4, 3); + } catch (blurhashError) { + console.warn( + 'Could not generate blurhash (possible canvas fingerprinting protection):', + blurhashError + ); + } } - // 5. Compress to JPEG Blob - const blob = await canvas.convertToBlob({ + // --- 4. Compress to JPEG Blob --- + const finalBlob = await finalCanvas.convertToBlob({ type: 'image/jpeg', quality: quality, }); - const dim = `${width}x${height}`; + const dim = `${targetWidth}x${targetHeight}`; - // 6. Send results back to main thread + // --- 5. Send results back to main thread --- self.postMessage({ id, success: true, - blob, + blob: finalBlob, dim, blurhash, }); - - bitmap.close(); } catch (error) { self.postMessage({ id, diff --git a/package.json b/package.json index d0cec30..aae5195 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "edition": "octane" }, "dependencies": { + "@noble/hashes": "^2.2.0", "@waysidemapping/pinhead": "^15.20.0", "applesauce-core": "^5.2.0", "applesauce-factory": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fc083f..ad0a0df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 '@waysidemapping/pinhead': specifier: ^15.20.0 version: 15.20.0 @@ -7543,7 +7546,7 @@ snapshots: '@scure/bip32@1.3.1': dependencies: '@noble/curves': 1.1.0 - '@noble/hashes': 1.3.1 + '@noble/hashes': 1.3.2 '@scure/base': 1.1.1 '@scure/bip32@1.7.0': @@ -7560,7 +7563,7 @@ snapshots: '@scure/bip39@1.2.1': dependencies: - '@noble/hashes': 1.3.1 + '@noble/hashes': 1.3.2 '@scure/base': 1.1.1 '@scure/bip39@2.0.1':