Compare commits

...

5 Commits

12 changed files with 384 additions and 40 deletions

View File

@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { service } from '@ember/service';
import { on } from '@ember/modifier';
import { eq } from 'ember-truth-helpers';
import qrCode from '../modifiers/qr-code';

View File

@@ -1,13 +1,21 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
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';
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;
@service toast;
@tracked thumbnailUrl = '';
@tracked error = '';
@@ -28,27 +36,57 @@ export default class PlacePhotoItem extends Component {
}
}
@action
showErrorToast() {
if (this.error) {
this.toast.show(this.error);
}
}
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 and generate blurhash in worker
const mainData = await this.imageProcessor.process(
file,
MAX_IMAGE_DIMENSION,
IMAGE_QUALITY,
true // computeBlurhash
);
// 2. Process thumbnail (no blurhash needed)
const thumbData = await this.imageProcessor.process(
file,
MAX_THUMBNAIL_DIMENSION,
THUMBNAIL_QUALITY,
false
);
// 3. Upload main image (to all servers concurrently)
const mainUploadPromise = this.blossom.upload(mainData.blob);
// 4. 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: mainData.blurhash,
type: 'image/jpeg',
dim: mainData.dim,
hash: mainResult.hash,
thumbHash: thumbResult.hash,
});
}
} catch (e) {
@@ -76,9 +114,14 @@ export default class PlacePhotoItem extends Component {
{{/if}}
{{#if this.error}}
<div class="photo-item-overlay error-overlay" title={{this.error}}>
<button
type="button"
class="photo-item-overlay error-overlay"
title={{this.error}}
{{on "click" this.showErrorToast}}
>
<Icon @name="alert-circle" @size={{24}} @color="white" />
</div>
</button>
{{/if}}
{{#if this.isUploaded}}

View File

@@ -1,7 +1,7 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { service } from '@ember/service';
import { on } from '@ember/modifier';
import { EventFactory } from 'applesauce-core';
import { task } from 'ember-concurrency';
@@ -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);
}
@@ -202,7 +215,7 @@ export default class PlacePhotoUpload extends Component {
{{on "drop" this.handleDrop}}
>
<label for="photo-upload-input" class="dropzone-label">
<Icon @name="upload-cloud" @size={{48}} />
<Icon @name="upload-cloud" @size={{48}} @color="#ccc" />
<p>Drag and drop photos here, or click to browse</p>
</label>
<input

View File

@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';
import { service } from '@ember/service';
import Icon from '#components/icon';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';

View File

@@ -1,5 +1,6 @@
import Service, { inject as service } from '@ember/service';
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;

View File

@@ -0,0 +1,129 @@
import Service from '@ember/service';
// We use the special Vite query parameter to load this as a web worker
import Worker from '../workers/image-processor?worker';
export default class ImageProcessorService extends Service {
_worker = null;
_callbacks = new Map();
_msgId = 0;
constructor() {
super(...arguments);
this._initWorker();
}
_initWorker() {
if (!this._worker && typeof Worker !== 'undefined') {
try {
this._worker = new Worker();
this._worker.onmessage = this._handleMessage.bind(this);
this._worker.onerror = this._handleError.bind(this);
} catch (e) {
console.warn('Failed to initialize image-processor worker:', e);
}
}
}
_handleMessage(e) {
const { id, success, blob, dim, blurhash, error } = e.data;
const resolver = this._callbacks.get(id);
if (resolver) {
this._callbacks.delete(id);
if (success) {
resolver.resolve({ blob, dim, blurhash });
} else {
resolver.reject(new Error(error));
}
}
}
_handleError(error) {
console.error('Image Processor Worker Error:', error);
// Reject all pending jobs
for (const [, resolver] of this._callbacks.entries()) {
resolver.reject(new Error('Worker crashed'));
}
this._callbacks.clear();
// Restart the worker for future jobs
this._worker.terminate();
this._worker = null;
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.');
}
try {
// 1. Get dimensions safely on the main thread
const { width: origWidth, height: origHeight } =
await this._getImageDimensions(file);
// 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() {
super.willDestroy(...arguments);
if (this._worker) {
this._worker.terminate();
this._worker = null;
}
this._callbacks.clear();
}
}

View File

@@ -1,4 +1,4 @@
import Service, { inject as service } from '@ember/service';
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import {
ExtensionSigner,

View File

@@ -1,4 +1,4 @@
import Service, { inject as service } from '@ember/service';
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { EventStore } from 'applesauce-core/event-store';
import { ProfileModel } from 'applesauce-core/models/profile';

View File

@@ -211,12 +211,12 @@ body {
}
.dropzone {
border: 2px dashed #3a4b5c;
border: 2px dashed #ccc;
border-radius: 8px;
padding: 30px 20px;
padding: 2rem 1.5rem;
text-align: center;
transition: all 0.2s ease;
margin-bottom: 20px;
margin: 1.5rem 0 1rem;
background-color: rgb(255 255 255 / 2%);
cursor: pointer;
}
@@ -230,14 +230,13 @@ body {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
gap: 1rem;
cursor: pointer;
color: #a0aec0;
color: #898989;
}
.dropzone-label p {
margin: 0;
font-size: 1.1rem;
}
.file-input-hidden {
@@ -277,6 +276,11 @@ body {
.error-overlay {
background: rgb(224 108 117 / 80%);
cursor: pointer;
border: none;
padding: 0;
margin: 0;
width: 100%;
}
.success-overlay {
@@ -1563,9 +1567,9 @@ button.create-place {
cursor: pointer;
}
/* Place Photo Upload */
.place-photo-upload h2 {
margin-top: 0;
font-size: 1.2rem;
}
.alert {

View File

@@ -0,0 +1,130 @@
import { encode } from 'blurhash';
self.onmessage = async (e) => {
// 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 {
let finalCanvas;
let finalCtx;
// --- 1. Attempt Hardware Resizing (Happy Path) ---
try {
const resizedBitmap = await createImageBitmap(file, {
resizeWidth: targetWidth,
resizeHeight: targetHeight,
resizeQuality: 'high',
});
finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
finalCtx = finalCanvas.getContext('2d');
if (!finalCtx) {
throw new Error('Failed to get 2d context from OffscreenCanvas');
}
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. Generate Blurhash (if requested) ---
let blurhash = null;
if (computeBlurhash) {
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
);
}
}
// --- 4. Compress to JPEG Blob ---
const finalBlob = await finalCanvas.convertToBlob({
type: 'image/jpeg',
quality: quality,
});
const dim = `${targetWidth}x${targetHeight}`;
// --- 5. Send results back to main thread ---
self.postMessage({
id,
success: true,
blob: finalBlob,
dim,
blurhash,
});
} catch (error) {
self.postMessage({
id,
success: false,
error: error.message,
});
}
};

View File

@@ -102,11 +102,13 @@
"edition": "octane"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0",
"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",

15
pnpm-lock.yaml generated
View File

@@ -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
@@ -23,6 +26,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 +2050,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}
@@ -7537,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':
@@ -7554,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':
@@ -8053,6 +8062,8 @@ snapshots:
bluebird@3.7.2: {}
blurhash@2.0.5: {}
body-parser@1.20.4:
dependencies:
bytes: 3.1.2