This commit is contained in:
seb
2026-09-27 16:19:08 +02:00
parent 559307675c
commit 0f875f347e
16 changed files with 1768 additions and 47 deletions

339
src/utils/cropLogo.js Normal file
View File

@@ -0,0 +1,339 @@
/**
* Utility to download, auto-crop (white and transparency margins), and encode brand logos to AVIF.
*/
const MAX_EDGE = 1000;
const AVIF_QUALITY = 85;
const AVIF_SPEED = 8;
const WHITE_THRESHOLD = 240; // Pixels with R,G,B >= 240 are considered near-white
let encodeAvif;
async function getAvifEncoder() {
if (!encodeAvif) {
const { default: encode } = await import('@jsquash/avif/encode');
encodeAvif = encode;
}
return encodeAvif;
}
/**
* Downloads image from URL with fallback to streamed dataUrl if CORS fails.
* @param {string} url
* @param {string|null} fallbackDataUrl
* @returns {Promise<HTMLImageElement>}
*/
export async function downloadLogoImage(url, fallbackDataUrl) {
// Strategy 1: Attempt direct fetch with CORS mode
if (url) {
try {
const resp = await fetch(url, { mode: 'cors' });
if (resp.ok) {
const blob = await resp.blob();
return await loadImageFromBlob(blob);
}
} catch {
// CORS or network failure, proceed to fallbacks
}
}
// Strategy 2: Attempt loading with HTMLImageElement crossOrigin
if (url) {
try {
return await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
} catch {
// Canvas might still be tainted or image failed to load
}
}
// Strategy 3: Use the provided dataUrl (streamed WebP/JPEG thumbnail from Serper SSE)
if (fallbackDataUrl) {
const resp = await fetch(fallbackDataUrl);
const blob = await resp.blob();
return await loadImageFromBlob(blob);
}
throw new Error('Das Bild konnte nicht heruntergeladen werden (CORS- oder Netzwerkfehler).');
}
function loadImageFromBlob(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
resolve(img);
};
img.onerror = (e) => {
URL.revokeObjectURL(url);
reject(e);
};
img.src = url;
});
}
/**
* Checks if a pixel is background (near-white or transparent).
*/
function isBackgroundPixel(r, g, b, a) {
if (a <= 25) return true;
return r >= WHITE_THRESHOLD && g >= WHITE_THRESHOLD && b >= WHITE_THRESHOLD;
}
/**
* Flood-fills white/transparent pixels from all borders, setting outer background to alpha 0,
* and crops strictly to the remaining content bounding box (removing outer and internal margins).
*
* @param {HTMLImageElement} img
* @returns {HTMLCanvasElement} Cropped canvas with transparent background
*/
export function autoCropWhiteAndTransparent(img) {
const width = img.naturalWidth || img.width;
const height = img.naturalHeight || img.height;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) {
throw new Error('Canvas 2D-Kontext nicht verfügbar');
}
ctx.drawImage(img, 0, 0);
let imgData;
try {
imgData = ctx.getImageData(0, 0, width, height);
} catch (err) {
// Canvas tainted by CORS, throw to let caller know
throw new Error('Canvas durch CORS blockiert: ' + (err?.message || err));
}
const data = imgData.data;
const totalPixels = width * height;
const visited = new Uint8Array(totalPixels);
// Queue for BFS flood fill from edges
// Packed format: y * width + x
const queue = [];
// Seed top and bottom rows
for (let x = 0; x < width; x++) {
const topIdx = x;
const botIdx = (height - 1) * width + x;
const topOffset = topIdx * 4;
if (isBackgroundPixel(data[topOffset], data[topOffset + 1], data[topOffset + 2], data[topOffset + 3])) {
visited[topIdx] = 1;
queue.push(topIdx);
}
const botOffset = botIdx * 4;
if (isBackgroundPixel(data[botOffset], data[botOffset + 1], data[botOffset + 2], data[botOffset + 3])) {
visited[botIdx] = 1;
queue.push(botIdx);
}
}
// Seed left and right columns
for (let y = 0; y < height; y++) {
const leftIdx = y * width;
const rightIdx = y * width + (width - 1);
const leftOffset = leftIdx * 4;
if (!visited[leftIdx] && isBackgroundPixel(data[leftOffset], data[leftOffset + 1], data[leftOffset + 2], data[leftOffset + 3])) {
visited[leftIdx] = 1;
queue.push(leftIdx);
}
const rightOffset = rightIdx * 4;
if (!visited[rightIdx] && isBackgroundPixel(data[rightOffset], data[rightOffset + 1], data[rightOffset + 2], data[rightOffset + 3])) {
visited[rightIdx] = 1;
queue.push(rightIdx);
}
}
// BFS flood-fill to turn all connected outer background pixels transparent
let head = 0;
while (head < queue.length) {
const curr = queue[head++];
const cx = curr % width;
const cy = (curr / width) | 0;
// Set pixel to transparent
const offset = curr * 4;
data[offset + 3] = 0; // alpha = 0
// Check 4 neighbors
const neighbors = [
cx > 0 ? curr - 1 : -1,
cx < width - 1 ? curr + 1 : -1,
cy > 0 ? curr - width : -1,
cy < height - 1 ? curr + width : -1,
];
for (let i = 0; i < 4; i++) {
const n = neighbors[i];
if (n !== -1 && !visited[n]) {
const noffset = n * 4;
if (isBackgroundPixel(data[noffset], data[noffset + 1], data[noffset + 2], data[noffset + 3])) {
visited[n] = 1;
queue.push(n);
}
}
}
}
// Update canvas with cleared transparency
ctx.putImageData(imgData, 0, 0);
// Scan bounding box of non-transparent content
let minX = width;
let minY = height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < height; y++) {
const rowOffset = y * width * 4;
for (let x = 0; x < width; x++) {
const offset = rowOffset + x * 4;
const alpha = data[offset + 3];
const r = data[offset];
const g = data[offset + 1];
const b = data[offset + 2];
// Non-transparent and not near-white remnant
if (alpha > 20 && !(r >= 248 && g >= 248 && b >= 248)) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
// If no colored content was detected, fall back to any visible alpha
if (maxX === -1) {
for (let y = 0; y < height; y++) {
const rowOffset = y * width * 4;
for (let x = 0; x < width; x++) {
const offset = rowOffset + x * 4;
if (data[offset + 3] > 20) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
}
// If entire image is blank, return original
if (maxX === -1 || minX > maxX || minY > maxY) {
return canvas;
}
// Crop tightly to bounding box
const cropW = maxX - minX + 1;
const cropH = maxY - minY + 1;
const croppedCanvas = document.createElement('canvas');
croppedCanvas.width = cropW;
croppedCanvas.height = cropH;
const croppedCtx = croppedCanvas.getContext('2d');
if (!croppedCtx) {
return canvas;
}
croppedCtx.drawImage(canvas, minX, minY, cropW, cropH, 0, 0, cropW, cropH);
return croppedCanvas;
}
/**
* Resizes canvas if larger than MAX_EDGE (maintaining aspect ratio),
* and encodes to AVIF format.
*
* @param {HTMLCanvasElement} canvas
* @param {string} [filename='brandpic.avif']
* @returns {Promise<File>}
*/
export async function encodeCanvasToAvif(canvas, filename = 'brandpic.avif') {
let targetW = canvas.width;
let targetH = canvas.height;
if (targetW > MAX_EDGE || targetH > MAX_EDGE) {
const scale = Math.min(MAX_EDGE / targetW, MAX_EDGE / targetH);
targetW = Math.round(targetW * scale);
targetH = Math.round(targetH * scale);
}
const finalCanvas = document.createElement('canvas');
finalCanvas.width = targetW;
finalCanvas.height = targetH;
const ctx = finalCanvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas 2D-Kontext nicht verfügbar');
}
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, targetW, targetH);
const finalImageData = ctx.getImageData(0, 0, targetW, targetH);
const encode = await getAvifEncoder();
const avifBuffer = await encode(finalImageData, {
quality: AVIF_QUALITY,
speed: AVIF_SPEED,
});
return new File([avifBuffer], filename, { type: 'image/avif' });
}
/**
* Complete pipeline:
* 1. Download image from url (or fallbackDataUrl)
* 2. Auto-crop white & transparency margins
* 3. Encode to AVIF
*
* @param {string} url
* @param {string|null} fallbackDataUrl
* @returns {Promise<File>}
*/
export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) {
let img;
try {
img = await downloadLogoImage(url, fallbackDataUrl);
} catch (downloadErr) {
// If url direct download completely failed, try using fallbackDataUrl if available
if (fallbackDataUrl) {
img = await downloadLogoImage(null, fallbackDataUrl);
} else {
throw downloadErr;
}
}
let croppedCanvas;
try {
croppedCanvas = autoCropWhiteAndTransparent(img);
} catch {
// In case of any canvas security error with external url, fallback to dataUrl
if (fallbackDataUrl) {
const fallbackImg = await downloadLogoImage(null, fallbackDataUrl);
croppedCanvas = autoCropWhiteAndTransparent(fallbackImg);
} else {
throw new Error('Das Bild konnte aufgrund von CORS-Sicherheitseinstellungen nicht freigestellt werden.');
}
}
return await encodeCanvasToAvif(croppedCanvas, 'brandpic.avif');
}