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

View File

@@ -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;