u
This commit is contained in:
@@ -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} */
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<File>}
|
||||
*/
|
||||
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;
|
||||
|
||||
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