Harden image processing, improve image quality

This commit is contained in:
2026-04-20 18:10:48 +04:00
parent 4f55f26851
commit ec31d1a59b
7 changed files with 193 additions and 49 deletions

View File

@@ -1,6 +1,7 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { action } from '@ember/object';
import { task } from 'ember-concurrency'; import { task } from 'ember-concurrency';
import Icon from '#components/icon'; import Icon from '#components/icon';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
@@ -14,6 +15,7 @@ const THUMBNAIL_QUALITY = 0.9;
export default class PlacePhotoItem extends Component { export default class PlacePhotoItem extends Component {
@service blossom; @service blossom;
@service imageProcessor; @service imageProcessor;
@service toast;
@tracked thumbnailUrl = ''; @tracked thumbnailUrl = '';
@tracked error = ''; @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) => { uploadTask = task(async (file) => {
this.error = ''; this.error = '';
try { try {
@@ -105,9 +114,14 @@ export default class PlacePhotoItem extends Component {
{{/if}} {{/if}}
{{#if this.error}} {{#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" /> <Icon @name="alert-circle" @size={{24}} @color="white" />
</div> </button>
{{/if}} {{/if}}
{{#if this.isUploaded}} {{#if this.isUploaded}}

View File

@@ -1,5 +1,6 @@
import Service, { service } from '@ember/service'; import Service, { service } from '@ember/service';
import { EventFactory } from 'applesauce-core'; import { EventFactory } from 'applesauce-core';
import { sha256 } from '@noble/hashes/sha2.js';
export const DEFAULT_BLOSSOM_SERVER = 'https://blossom.nostr.build'; 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'); if (!this.nostrAuth.isConnected) throw new Error('Not connected');
const buffer = await file.arrayBuffer(); 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 payloadHash = bufferToHex(hashBuffer);
const servers = this.servers; const servers = this.servers;

View File

@@ -51,24 +51,71 @@ export default class ImageProcessorService extends Service {
this._initWorker(); 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) { async process(file, maxDimension, quality, computeBlurhash = false) {
if (!this._worker) { if (!this._worker) {
// Fallback if worker initialization failed (e.g. incredibly old browsers) // Fallback if worker initialization failed (e.g. incredibly old browsers)
throw new Error('Image processor worker is not available.'); 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) => { return new Promise((resolve, reject) => {
const id = ++this._msgId; const id = ++this._msgId;
this._callbacks.set(id, { resolve, reject }); this._callbacks.set(id, { resolve, reject });
this._worker.postMessage({ this._worker.postMessage({
type: 'PROCESS_IMAGE',
id, id,
file, file,
maxDimension, targetWidth,
targetHeight,
quality, quality,
computeBlurhash, computeBlurhash,
}); });
}); });
} catch (e) {
throw new Error(`Failed to process image: ${e.message}`);
}
} }
willDestroy() { willDestroy() {

View File

@@ -276,6 +276,11 @@ body {
.error-overlay { .error-overlay {
background: rgb(224 108 117 / 80%); background: rgb(224 108 117 / 80%);
cursor: pointer;
border: none;
padding: 0;
margin: 0;
width: 100%;
} }
.success-overlay { .success-overlay {

View File

@@ -1,63 +1,125 @@
import { encode } from 'blurhash'; import { encode } from 'blurhash';
self.onmessage = async (e) => { 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 { try {
// 1. Decode image off main thread let finalCanvas;
const bitmap = await createImageBitmap(file); let finalCtx;
let width = bitmap.width; // --- 1. Attempt Hardware Resizing (Happy Path) ---
let height = bitmap.height; try {
const resizedBitmap = await createImageBitmap(file, {
resizeWidth: targetWidth,
resizeHeight: targetHeight,
resizeQuality: 'high',
});
// 2. Calculate aspect-ratio preserving dimensions finalCanvas = new OffscreenCanvas(targetWidth, targetHeight);
if (width > height) { finalCtx = finalCanvas.getContext('2d');
if (width > maxDimension) { if (!finalCtx) {
height = Math.round(height * (maxDimension / width));
width = maxDimension;
}
} else {
if (height > maxDimension) {
width = Math.round(width * (maxDimension / height));
height = maxDimension;
}
}
// 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'); 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
);
ctx.drawImage(bitmap, 0, 0, width, height); // --- 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 });
// 4. Generate Blurhash (if requested) const source = await createImageBitmap(blob);
let blurhash = null; let srcWidth = source.width;
if (computeBlurhash) { let srcHeight = source.height;
const imageData = ctx.getImageData(0, 0, width, height);
blurhash = encode(imageData.data, width, height, 4, 3); 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;
} }
// 5. Compress to JPEG Blob // Final resize to exact target
const blob = await canvas.convertToBlob({ 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', type: 'image/jpeg',
quality: quality, 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({ self.postMessage({
id, id,
success: true, success: true,
blob, blob: finalBlob,
dim, dim,
blurhash, blurhash,
}); });
bitmap.close();
} catch (error) { } catch (error) {
self.postMessage({ self.postMessage({
id, id,

View File

@@ -102,6 +102,7 @@
"edition": "octane" "edition": "octane"
}, },
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0",
"@waysidemapping/pinhead": "^15.20.0", "@waysidemapping/pinhead": "^15.20.0",
"applesauce-core": "^5.2.0", "applesauce-core": "^5.2.0",
"applesauce-factory": "^4.0.0", "applesauce-factory": "^4.0.0",

7
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@noble/hashes':
specifier: ^2.2.0
version: 2.2.0
'@waysidemapping/pinhead': '@waysidemapping/pinhead':
specifier: ^15.20.0 specifier: ^15.20.0
version: 15.20.0 version: 15.20.0
@@ -7543,7 +7546,7 @@ snapshots:
'@scure/bip32@1.3.1': '@scure/bip32@1.3.1':
dependencies: dependencies:
'@noble/curves': 1.1.0 '@noble/curves': 1.1.0
'@noble/hashes': 1.3.1 '@noble/hashes': 1.3.2
'@scure/base': 1.1.1 '@scure/base': 1.1.1
'@scure/bip32@1.7.0': '@scure/bip32@1.7.0':
@@ -7560,7 +7563,7 @@ snapshots:
'@scure/bip39@1.2.1': '@scure/bip39@1.2.1':
dependencies: dependencies:
'@noble/hashes': 1.3.1 '@noble/hashes': 1.3.2
'@scure/base': 1.1.1 '@scure/base': 1.1.1
'@scure/bip39@2.0.1': '@scure/bip39@2.0.1':