From 43e1ab98b7691e0b63e2d6f950ddaf99e0a236b2 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 29 Sep 2026 13:45:13 +0200 Subject: [PATCH] u --- package-lock.json | 4 +- src/api/ApiClient.js | 16 +- src/api/index.js | 12 +- .../article/ArticleImageSearchDialog.jsx | 6 +- src/routes/artikel/kategorien.jsx | 4 +- src/utils/prepareArticlePhoto.js | 185 +++++++++++------- src/workers/imageWorker.js | 57 ++++++ 7 files changed, 193 insertions(+), 91 deletions(-) create mode 100644 src/workers/imageWorker.js diff --git a/package-lock.json b/package-lock.json index c5b7c7c..1836879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "@vitejs/plugin-react": "^6.1.1", "react": "^19.3.0", "react-dom": "^19.3.0", - "vite": "^8.3.0" + "vite": "^8.3.1" }, "peerDependencies": { "react": ">=16.8.0 || >=17.0.0 || >=18.0.0 || >=19.0.0" @@ -68,7 +68,7 @@ "@vitejs/plugin-react": "6.1.1", "react": "^19.3.0", "react-dom": "^19.3.0", - "vite": "^8.3.0" + "vite": "^8.3.1" }, "peerDependencies": { "@emotion/react": "^11.14.0", diff --git a/src/api/ApiClient.js b/src/api/ApiClient.js index 6934640..36064ca 100644 --- a/src/api/ApiClient.js +++ b/src/api/ApiClient.js @@ -327,11 +327,11 @@ export class ApiClient { delete: (id) => this.#empty('DELETE', `/api/category/${enc(id)}`), /** PUT /api/category/order */ order: (body) => this.#json('PUT', '/api/category/order', { body }), - /** POST /api/category/{id}/image — raw AVIF body */ - addImage: (id, avifBytes) => + /** POST /api/category/{id}/image — raw image body */ + addImage: (id, imageBytes) => this.#json('POST', `/api/category/${enc(id)}/image`, { - body: avifBytes, - contentType: 'image/avif', + body: imageBytes, + contentType: imageBytes?.type || 'image/webp', raw: true, }), /** DELETE /api/category/{id}/image */ @@ -380,11 +380,11 @@ export class ApiClient { delete: (sku) => this.#empty('DELETE', `/api/article/${enc(sku)}`), /** PUT /api/article/{sku}/prices */ setPrices: (sku, body) => this.#json('PUT', `/api/article/${enc(sku)}/prices`, { body }), - /** POST /api/article/{sku}/image — raw AVIF body */ - addImage: (sku, avifBytes) => + /** POST /api/article/{sku}/image — raw image body */ + addImage: (sku, imageBytes) => this.#json('POST', `/api/article/${enc(sku)}/image`, { - body: avifBytes, - contentType: 'image/avif', + body: imageBytes, + contentType: imageBytes?.type || 'image/webp', raw: true, }), /** DELETE /api/article/{sku}/image/{fullHash} */ diff --git a/src/api/index.js b/src/api/index.js index d64ddb5..b9ef5b8 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -265,13 +265,13 @@ export const article = { /** * 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 } } */ uploadImage(sku, file) { return httpBinary(`/api/article/${encodeURIComponent(sku)}/image`, { method: 'POST', - contentType: 'image/avif', + contentType: file?.type || 'image/webp', body: file, }); }, @@ -598,13 +598,13 @@ export const category = { /** * 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 } } */ uploadImage(categoryId, file) { return httpBinary(`/api/category/${categoryId}/image`, { method: 'POST', - contentType: 'image/avif', + contentType: file?.type || 'image/webp', body: file, }); }, @@ -659,13 +659,13 @@ export const brand = { /** * 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 } } */ addImage(brandId, file) { return httpBinary(`/api/brand/${brandId}/image`, { method: 'POST', - contentType: 'image/avif', + contentType: file?.type || 'image/webp', body: file, }); }, diff --git a/src/components/article/ArticleImageSearchDialog.jsx b/src/components/article/ArticleImageSearchDialog.jsx index 98b8394..2aa2b44 100644 --- a/src/components/article/ArticleImageSearchDialog.jsx +++ b/src/components/article/ArticleImageSearchDialog.jsx @@ -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...' }); - 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); requireObject(res, 'POST /api/article/:sku/image'); requireObject(res.image, 'image'); diff --git a/src/routes/artikel/kategorien.jsx b/src/routes/artikel/kategorien.jsx index a512ebd..b1c956a 100644 --- a/src/routes/artikel/kategorien.jsx +++ b/src/routes/artikel/kategorien.jsx @@ -447,10 +447,10 @@ class ArtikelKategorienPage extends PureComponent { const file = e.target.files?.[0]; if (!file) return; try { - const avifFile = await prepareArticlePhoto(file); + const webpFile = await prepareArticlePhoto(file); await categoryAutomat.actions.uploadCategoryImage({ categoryId: item.id, - file: avifFile, + file: webpFile, }); } catch (err) { notifyError(err); diff --git a/src/utils/prepareArticlePhoto.js b/src/utils/prepareArticlePhoto.js index 122397a..dbce6cc 100644 --- a/src/utils/prepareArticlePhoto.js +++ b/src/utils/prepareArticlePhoto.js @@ -1,61 +1,106 @@ const MAX_EDGE = 1000; -const AVIF_MIME = 'image/avif'; -const AVIF_QUALITY = 80; -/** libaom speed 0–10; higher = faster encode, slightly larger files. Bench: s10 ~8× vs s6, +5% bytes. */ -const AVIF_SPEED = 9; +const WEBP_MIME = 'image/webp'; +const WEBP_QUALITY = 0.85; -let encodeAvif; -let resizeImage; +let workerInstance = null; +let nextRequestId = 1; +const pendingRequests = new Map(); -function scaledDimensions(width, height) { - const scale = Math.min(1, MAX_EDGE / width, MAX_EDGE / height); - return { - width: Math.round(width * scale), - height: Math.round(height * scale), - }; -} +function getWorker() { + if (!workerInstance && typeof Worker !== 'undefined') { + workerInstance = new Worker( + new URL('../workers/imageWorker.js', import.meta.url), + { type: 'module' } + ); -async function getAvifEncoder() { - if (!encodeAvif) { - const { default: encode } = await import('@jsquash/avif/encode'); - encodeAvif = encode; + workerInstance.onmessage = (e) => { + const { id, success, blob, error, width, height } = e.data; + const deferred = pendingRequests.get(id); + 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) { - const { default: resize } = await import('@jsquash/resize'); - resizeImage = resize; - } - return resizeImage; -} +/** + * Fallback processing on main thread if Worker/OffscreenCanvas is unavailable. + */ +async function processImageMainThread(file, maxDim = MAX_EDGE, quality = WEBP_QUALITY) { + const tempBitmap = await createImageBitmap(file); + const { width, height } = tempBitmap; + tempBitmap.close(); -async function decodeToImageData(input) { - const bitmap = await createImageBitmap(input); - try { - const canvas = document.createElement('canvas'); - canvas.width = bitmap.width; - canvas.height = bitmap.height; - - const ctx = canvas.getContext('2d'); - if (!ctx) { - throw new Error('Canvas nicht verfügbar.'); + 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); } - 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). - * Encodes as AVIF via @jsquash/avif (WASM). + * Encodes as WebP natively via createImageBitmap + OffscreenCanvas in a Web Worker. * @param {Blob|File} input + * @param {object} [options] + * @param {number} [options.maxDim=1000] + * @param {number} [options.quality=0.85] * @returns {Promise} */ -export async function prepareArticlePhoto(input) { +export async function prepareArticlePhoto(input, options = {}) { if (!(input instanceof Blob)) { throw new Error('Kein Bild.'); } @@ -63,47 +108,45 @@ export async function prepareArticlePhoto(input) { throw new Error('Kein Bild.'); } + const maxDim = options.maxDim ?? MAX_EDGE; + const quality = options.quality ?? WEBP_QUALITY; const started = performance.now(); - const decodeStarted = performance.now(); - const originalImageData = await decodeToImageData(input); - const decodeMs = performance.now() - decodeStarted; + let blob; + let width; + let height; - const { width, height } = scaledDimensions( - originalImageData.width, - originalImageData.height, - ); + const worker = getWorker(); + if (worker && typeof OffscreenCanvas !== 'undefined') { + 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 resize = await getResizer(); - const imageData = await resize(originalImageData, { width, height }); - const resizeMs = performance.now() - resizeStarted; + const baseName = + input instanceof File && input.name + ? input.name.replace(/\.[^.]+$/, '') + : 'photo'; - const encodeStarted = performance.now(); - 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 file = new File([blob], `${baseName}.webp`, { type: WEBP_MIME }); const totalMs = performance.now() - started; - console.log('[article-photo] process', { + console.log('[article-photo] processed (WebP GPU pipeline)', { 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, outputBytes: file.size, size: `${width}×${height}`, + quality, }); return file; diff --git a/src/workers/imageWorker.js b/src/workers/imageWorker.js new file mode 100644 index 0000000..b68afb7 --- /dev/null +++ b/src/workers/imageWorker.js @@ -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) }); + } +};