This commit is contained in:
seb
2026-09-29 13:45:13 +02:00
parent 4cb050c285
commit 43e1ab98b7
7 changed files with 193 additions and 91 deletions

4
package-lock.json generated
View File

@@ -46,7 +46,7 @@
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"vite": "^8.3.0" "vite": "^8.3.1"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0" "react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0"
@@ -68,7 +68,7 @@
"@vitejs/plugin-react": "6.1.1", "@vitejs/plugin-react": "6.1.1",
"react": "^19.3.0", "react": "^19.3.0",
"react-dom": "^19.3.0", "react-dom": "^19.3.0",
"vite": "^8.3.0" "vite": "^8.3.1"
}, },
"peerDependencies": { "peerDependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",

View File

@@ -327,11 +327,11 @@ export class ApiClient {
delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`), delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`),
/** PUT /api/category/order */ /** PUT /api/category/order */
order: (body) => this.#json('PUT', '/api/category/order', { body }), order: (body) => this.#json('PUT', '/api/category/order', { body }),
/** POST /api/category/{id}/image — raw AVIF body */ /** POST /api/category/{id}/image — raw image body */
addImage: (id, avifBytes) => addImage: (id, imageBytes) =>
this.#json('POST', `/api/category/${enc(id)}/image`, { this.#json('POST', `/api/category/${enc(id)}/image`, {
body: avifBytes, body: imageBytes,
contentType: 'image/avif', contentType: imageBytes?.type || 'image/webp',
raw: true, raw: true,
}), }),
/** DELETE /api/category/{id}/image */ /** DELETE /api/category/{id}/image */
@@ -380,11 +380,11 @@ export class ApiClient {
delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`), delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`),
/** PUT /api/article/{sku}/prices */ /** PUT /api/article/{sku}/prices */
setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }), setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }),
/** POST /api/article/{sku}/image — raw AVIF body */ /** POST /api/article/{sku}/image — raw image body */
addImage: (sku, avifBytes) => addImage: (sku, imageBytes) =>
this.#json('POST', `/api/article/${enc(sku)}/image`, { this.#json('POST', `/api/article/${enc(sku)}/image`, {
body: avifBytes, body: imageBytes,
contentType: 'image/avif', contentType: imageBytes?.type || 'image/webp',
raw: true, raw: true,
}), }),
/** DELETE /api/article/{sku}/image/{fullHash} */ /** DELETE /api/article/{sku}/image/{fullHash} */

View File

@@ -265,13 +265,13 @@ export const article = {
/** /**
* POST /api/article/:sku/image * POST /api/article/:sku/image
* Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM) * Body: raw image bytes (Content-Type: image/webp, max 1000×1000)
* → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } } * → 201 { image: { picId, sort, full, thumb, fullHash, thumbHash } }
*/ */
uploadImage(sku, file) { uploadImage(sku, file) {
return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, {
method: 'POST', method: 'POST',
contentType: 'image/avif', contentType: file?.type || 'image/webp',
body: file, body: file,
}); });
}, },
@@ -598,13 +598,13 @@ export const category = {
/** /**
* POST /api/category/:id/image * POST /api/category/:id/image
* Body: raw AVIF bytes (Content-Type: image/avif) * Body: raw image bytes (Content-Type: image/webp)
* → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } } * → 201 { category: { id, fullHash, thumbW, thumbH, thumbHash } }
*/ */
uploadImage(categoryId, file) { uploadImage(categoryId, file) {
return httpBinary(`/api/category/${categoryId}/image`, { return httpBinary(`/api/category/${categoryId}/image`, {
method: 'POST', method: 'POST',
contentType: 'image/avif', contentType: file?.type || 'image/webp',
body: file, body: file,
}); });
}, },
@@ -659,13 +659,13 @@ export const brand = {
/** /**
* POST /api/brand/:id/image * POST /api/brand/:id/image
* Accepts full-size AVIF body (Content-Type: image/avif). * Accepts full-size image body (Content-Type: image/webp).
* → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } } * → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } }
*/ */
addImage(brandId, file) { addImage(brandId, file) {
return httpBinary(`/api/brand/${brandId}/image`, { return httpBinary(`/api/brand/${brandId}/image`, {
method: 'POST', method: 'POST',
contentType: 'image/avif', contentType: file?.type || 'image/webp',
body: file, body: file,
}); });
}, },

View File

@@ -231,9 +231,11 @@ export default class ArticleImageSearchDialog extends PureComponent {
} }
} }
// 2. Upload AVIF to article // 2. Upload photo to article
this.setState({ processingStep: 'Bild wird gespeichert...' }); this.setState({ processingStep: 'Bild wird gespeichert...' });
const file = new File([avifBlob], 'article-photo.avif', { type: 'image/avif' }); const file = avifBlob instanceof File
? avifBlob
: new File([avifBlob], 'article-photo.webp', { type: avifBlob.type || 'image/webp' });
const res = await this.context.article.addImage(articleData.sku, file); const res = await this.context.article.addImage(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/image'); requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image'); requireObject(res.image, 'image');

View File

@@ -447,10 +447,10 @@ class ArtikelKategorienPage extends PureComponent {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
try { try {
const avifFile = await prepareArticlePhoto(file); const webpFile = await prepareArticlePhoto(file);
await categoryAutomat.actions.uploadCategoryImage({ await categoryAutomat.actions.uploadCategoryImage({
categoryId: item.id, categoryId: item.id,
file: avifFile, file: webpFile,
}); });
} catch (err) { } catch (err) {
notifyError(err); notifyError(err);

View File

@@ -1,61 +1,106 @@
const MAX_EDGE = 1000; const MAX_EDGE = 1000;
const AVIF_MIME = 'image/avif'; const WEBP_MIME = 'image/webp';
const AVIF_QUALITY = 80; const WEBP_QUALITY = 0.85;
/** libaom speed 0–10; higher = faster encode, slightly larger files. Bench: s10 ~8× vs s6, +5% bytes. */
const AVIF_SPEED = 9;
let encodeAvif; let workerInstance = null;
let resizeImage; let nextRequestId = 1;
const pendingRequests = new Map();
function scaledDimensions(width, height) { function getWorker() {
const scale = Math.min(1, MAX_EDGE / width, MAX_EDGE / height); if (!workerInstance && typeof Worker !== 'undefined') {
return { workerInstance = new Worker(
width: Math.round(width * scale), new URL('../workers/imageWorker.js', import.meta.url),
height: Math.round(height * scale), { type: 'module' }
}; );
}
async function getAvifEncoder() { workerInstance.onmessage = (e) => {
if (!encodeAvif) { const { id, success, blob, error, width, height } = e.data;
const { default: encode } = await import('@jsquash/avif/encode'); const deferred = pendingRequests.get(id);
encodeAvif = encode; if (!deferred) return;
pendingRequests.delete(id);
if (success) {
deferred.resolve({ blob, width, height });
} else {
deferred.reject(new Error(error || 'Worker encoding error'));
}
};
workerInstance.onerror = (err) => {
console.error('Image Worker error:', err);
for (const [, deferred] of pendingRequests.entries()) {
deferred.reject(err);
}
pendingRequests.clear();
workerInstance.terminate();
workerInstance = null;
};
} }
return encodeAvif; return workerInstance;
} }
async function getResizer() { /**
if (!resizeImage) { * Fallback processing on main thread if Worker/OffscreenCanvas is unavailable.
const { default: resize } = await import('@jsquash/resize'); */
resizeImage = resize; async function processImageMainThread(file, maxDim = MAX_EDGE, quality = WEBP_QUALITY) {
} const tempBitmap = await createImageBitmap(file);
return resizeImage; const { width, height } = tempBitmap;
} tempBitmap.close();
async function decodeToImageData(input) { let targetWidth = width;
const bitmap = await createImageBitmap(input); let targetHeight = height;
try { if (width > maxDim || height > maxDim) {
const canvas = document.createElement('canvas'); if (width > height) {
canvas.width = bitmap.width; targetWidth = maxDim;
canvas.height = bitmap.height; targetHeight = Math.round((height * maxDim) / width);
} else {
const ctx = canvas.getContext('2d'); targetHeight = maxDim;
if (!ctx) { targetWidth = Math.round((width * maxDim) / height);
throw new Error('Canvas nicht verfügbar.');
} }
ctx.drawImage(bitmap, 0, 0);
return ctx.getImageData(0, 0, bitmap.width, bitmap.height);
} finally {
bitmap.close();
} }
const resizedBitmap = await createImageBitmap(file, {
resizeWidth: targetWidth,
resizeHeight: targetHeight,
resizeQuality: 'high',
});
const canvas = document.createElement('canvas');
canvas.width = targetWidth;
canvas.height = targetHeight;
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
resizedBitmap.close();
throw new Error('Canvas 2D context not available');
}
ctx.drawImage(resizedBitmap, 0, 0);
resizedBitmap.close();
const blob = await new Promise((resolve, reject) => {
canvas.toBlob(
(b) => {
if (b) resolve(b);
else reject(new Error('Failed to encode WebP canvas to blob'));
},
WEBP_MIME,
quality
);
});
return { blob, width: targetWidth, height: targetHeight };
} }
/** /**
* Resize to fit within 1000×1000 (aspect ratio kept, no upscale). * Resize to fit within 1000×1000 (aspect ratio kept, no upscale).
* Encodes as AVIF via @jsquash/avif (WASM). * Encodes as WebP natively via createImageBitmap + OffscreenCanvas in a Web Worker.
* @param {Blob|File} input * @param {Blob|File} input
* @param {object} [options]
* @param {number} [options.maxDim=1000]
* @param {number} [options.quality=0.85]
* @returns {Promise<File>} * @returns {Promise<File>}
*/ */
export async function prepareArticlePhoto(input) { export async function prepareArticlePhoto(input, options = {}) {
if (!(input instanceof Blob)) { if (!(input instanceof Blob)) {
throw new Error('Kein Bild.'); throw new Error('Kein Bild.');
} }
@@ -63,47 +108,45 @@ export async function prepareArticlePhoto(input) {
throw new Error('Kein Bild.'); throw new Error('Kein Bild.');
} }
const maxDim = options.maxDim ?? MAX_EDGE;
const quality = options.quality ?? WEBP_QUALITY;
const started = performance.now(); const started = performance.now();
const decodeStarted = performance.now(); let blob;
const originalImageData = await decodeToImageData(input); let width;
const decodeMs = performance.now() - decodeStarted; let height;
const { width, height } = scaledDimensions( const worker = getWorker();
originalImageData.width, if (worker && typeof OffscreenCanvas !== 'undefined') {
originalImageData.height, const id = nextRequestId++;
); const result = await new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
worker.postMessage({ id, file: input, maxDim, quality });
});
blob = result.blob;
width = result.width;
height = result.height;
} else {
const result = await processImageMainThread(input, maxDim, quality);
blob = result.blob;
width = result.width;
height = result.height;
}
const resizeStarted = performance.now(); const baseName =
const resize = await getResizer(); input instanceof File && input.name
const imageData = await resize(originalImageData, { width, height }); ? input.name.replace(/\.[^.]+$/, '')
const resizeMs = performance.now() - resizeStarted; : 'photo';
const encodeStarted = performance.now(); const file = new File([blob], `${baseName}.webp`, { type: WEBP_MIME });
const encode = await getAvifEncoder();
const avifBuffer = await encode(imageData, {
quality: AVIF_QUALITY,
speed: AVIF_SPEED,
});
const encodeMs = performance.now() - encodeStarted;
const baseName = input instanceof File && input.name
? input.name.replace(/\.[^.]+$/, '')
: 'photo';
const file = new File([avifBuffer], `${baseName}.avif`, { type: AVIF_MIME });
const totalMs = performance.now() - started; const totalMs = performance.now() - started;
console.log('[article-photo] process', { console.log('[article-photo] processed (WebP GPU pipeline)', {
totalMs: Math.round(totalMs), totalMs: Math.round(totalMs),
decodeMs: Math.round(decodeMs),
resizeMs: Math.round(resizeMs),
encodeMs: Math.round(encodeMs),
quality: AVIF_QUALITY,
speed: AVIF_SPEED,
inputBytes: input.size, inputBytes: input.size,
outputBytes: file.size, outputBytes: file.size,
size: `${width}×${height}`, size: `${width}×${height}`,
quality,
}); });
return file; return file;

View File

@@ -0,0 +1,57 @@
self.onmessage = async (e) => {
const { id, file, maxDim = 1000, quality = 0.85 } = e.data;
try {
// Step 1: Get original dimensions without full pixel context decoding
const tempBitmap = await createImageBitmap(file);
const { width, height } = tempBitmap;
tempBitmap.close(); // Immediately release memory
// Step 2: Calculate target dimensions preserving aspect ratio
let targetWidth = width;
let targetHeight = height;
if (width > maxDim || height > maxDim) {
if (width > height) {
targetWidth = maxDim;
targetHeight = Math.round((height * maxDim) / width);
} else {
targetHeight = maxDim;
targetWidth = Math.round((width * maxDim) / height);
}
}
// Step 3: Decode and scale natively via createImageBitmap on GPU
const resizedBitmap = await createImageBitmap(file, {
resizeWidth: targetWidth,
resizeHeight: targetHeight,
resizeQuality: 'high',
});
// Step 4: Draw to OffscreenCanvas
const canvas = new OffscreenCanvas(targetWidth, targetHeight);
const ctx = canvas.getContext('2d', {
alpha: true,
desynchronized: true,
});
ctx.drawImage(resizedBitmap, 0, 0);
resizedBitmap.close(); // Clean up GPU memory
// Step 5: Native C++ WebP encoding directly to Blob
const webpBlob = await canvas.convertToBlob({
type: 'image/webp',
quality,
});
self.postMessage({
id,
success: true,
blob: webpBlob,
width: targetWidth,
height: targetHeight,
});
} catch (error) {
self.postMessage({ id, success: false, error: error?.message || String(error) });
}
};