u
This commit is contained in:
339
src/utils/cropLogo.js
Normal file
339
src/utils/cropLogo.js
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user