u
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user