This commit is contained in:
seb
2026-09-27 18:32:39 +02:00
parent f9383e717f
commit 74e932c5cf
3 changed files with 71 additions and 74 deletions

View File

@@ -966,6 +966,40 @@ export const serper = {
} }
} }
}, },
/**
* POST /api/serper/image
* Crawl image from URL, crop white/transparency margins, resize to max 1000px, return AVIF Blob.
*
* @param {string} url - External image URL to crawl and process
* @param {object} [options]
* @param {AbortSignal} [options.signal] - Abort controller signal
* @returns {Promise<Blob>} AVIF image blob
*/
async fetchImage(url, { signal } = {}) {
const res = await fetch('/api/serper/image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ url }),
signal,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
message = data.error || data.message || message;
} catch {
/* ignore */
}
const err = new Error(message || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return await res.blob();
},
}; };
/** /**

View File

@@ -31,72 +31,6 @@ import { articleWithUploadedPhoto } from '../../utils/articleDetail';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto'; import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { requireObject } from '../../utils/apiShape'; import { requireObject } from '../../utils/apiShape';
/**
* Downloads image as a Blob, trying full resolution with CORS first,
* falling back to canvas crossOrigin draw, and finally the Serper SSE dataUrl.
* @param {string} url
* @param {string|null} fallbackDataUrl
* @returns {Promise<Blob>}
*/
async function downloadImageBlob(url, fallbackDataUrl) {
// Strategy 1: Direct fetch with CORS
if (url) {
try {
const resp = await fetch(url, { mode: 'cors' });
if (resp.ok) {
const blob = await resp.blob();
if (blob && blob.type.startsWith('image/')) {
return blob;
}
}
} catch {
// CORS or network failure
}
}
// Strategy 2: Attempt loading with HTMLImageElement crossOrigin
if (url) {
try {
const blob = await new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Canvas 2D-Kontext nicht verfügbar'));
return;
}
ctx.drawImage(img, 0, 0);
canvas.toBlob((b) => {
if (b) resolve(b);
else reject(new Error('Canvas toBlob fehlgeschlagen'));
}, 'image/png');
} catch (err) {
reject(err);
}
};
img.onerror = reject;
img.src = url;
});
if (blob) return blob;
} catch {
// Canvas tainted or image load failed
}
}
// Strategy 3: Streamed WebP/JPEG dataUrl from Serper SSE
if (fallbackDataUrl) {
const resp = await fetch(fallbackDataUrl);
const blob = await resp.blob();
if (blob) return blob;
}
throw new Error('Das Bild konnte nicht heruntergeladen werden (CORS- oder Netzwerkfehler).');
}
function computeDefaultQuery(article) { function computeDefaultQuery(article) {
if (!article) return ''; if (!article) return '';
@@ -277,19 +211,29 @@ export default class ArticleImageSearchDialog extends PureComponent {
this.setState({ this.setState({
processingId: img.id, processingId: img.id,
processingStep: 'Herunterladen...', processingStep: 'Lade & optimiere Originalbild...',
}); });
try { try {
// 1. Download image as Blob // 1. Crawl image via backend POST /api/serper/image (bypasses CORS, crops margins, returns max 1000px AVIF)
const blob = await downloadImageBlob(img.url, img.dataUrl); let avifBlob;
try {
avifBlob = await serper.fetchImage(img.url);
} catch (crawlErr) {
console.warn('Backend /api/serper/image crawl failed, trying thumbnail fallback:', crawlErr);
if (img.dataUrl) {
this.setState({ processingStep: 'Fallback: Konvertiere Vorschau...' });
const resp = await fetch(img.dataUrl);
const thumbBlob = await resp.blob();
avifBlob = await prepareArticlePhoto(thumbBlob);
} else {
throw crawlErr;
}
}
// 2. Prepare article photo (scale to max 1000px, encode to AVIF) // 2. Upload AVIF to article
this.setState({ processingStep: 'In AVIF konvertieren...' });
const file = await prepareArticlePhoto(blob);
// 3. Upload to backend
this.setState({ processingStep: 'Bild wird gespeichert...' }); this.setState({ processingStep: 'Bild wird gespeichert...' });
const file = new File([avifBlob], 'article-photo.avif', { type: 'image/avif' });
const res = await this.context.article.addImage(articleData.sku, file); const res = await this.context.article.addImage(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/image'); requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image'); requireObject(res.image, 'image');

View File

@@ -310,6 +310,25 @@ export async function encodeCanvasToAvif(canvas, filename = 'brandpic.avif') {
* @returns {Promise<File>} * @returns {Promise<File>}
*/ */
export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) { export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) {
if (url) {
try {
const resp = await fetch('/api/serper/image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url }),
});
if (resp.ok) {
const avifBlob = await resp.blob();
if (avifBlob && avifBlob.size > 0) {
return new File([avifBlob], 'brandpic.avif', { type: 'image/avif' });
}
}
} catch {
// Fallback to client-side processing if /api/serper/image fails
}
}
let img; let img;
try { try {
img = await downloadLogoImage(url, fallbackDataUrl); img = await downloadLogoImage(url, fallbackDataUrl);