u
This commit is contained in:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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} */
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
57
src/workers/imageWorker.js
Normal file
57
src/workers/imageWorker.js
Normal 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) });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user