Harden image processing, improve image quality
This commit is contained in:
@@ -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}}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
try {
|
||||||
const id = ++this._msgId;
|
// 1. Get dimensions safely on the main thread
|
||||||
this._callbacks.set(id, { resolve, reject });
|
const { width: origWidth, height: origHeight } =
|
||||||
|
await this._getImageDimensions(file);
|
||||||
|
|
||||||
this._worker.postMessage({
|
// 2. Calculate aspect-ratio preserving dimensions
|
||||||
id,
|
let targetWidth = origWidth;
|
||||||
file,
|
let targetHeight = origHeight;
|
||||||
maxDimension,
|
|
||||||
quality,
|
if (origWidth > origHeight) {
|
||||||
computeBlurhash,
|
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() {
|
willDestroy() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
throw new Error('Failed to get 2d context from OffscreenCanvas');
|
||||||
width = maxDimension;
|
|
||||||
}
|
}
|
||||||
} else {
|
finalCtx.drawImage(resizedBitmap, 0, 0, targetWidth, targetHeight);
|
||||||
if (height > maxDimension) {
|
resizedBitmap.close();
|
||||||
width = Math.round(width * (maxDimension / height));
|
} catch (hwError) {
|
||||||
height = maxDimension;
|
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
|
// --- 3. Generate Blurhash (if requested) ---
|
||||||
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)
|
|
||||||
let blurhash = null;
|
let blurhash = null;
|
||||||
if (computeBlurhash) {
|
if (computeBlurhash) {
|
||||||
const imageData = ctx.getImageData(0, 0, width, height);
|
try {
|
||||||
blurhash = encode(imageData.data, width, height, 4, 3);
|
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
|
// --- 4. Compress to JPEG Blob ---
|
||||||
const blob = await canvas.convertToBlob({
|
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,
|
||||||
|
|||||||
@@ -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
7
pnpm-lock.yaml
generated
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user