From 74e932c5cf94af2e951daa39cbdbf5e6fcc97959 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 27 Sep 2026 18:32:39 +0200 Subject: [PATCH] u --- src/api/index.js | 34 +++++++ .../article/ArticleImageSearchDialog.jsx | 92 ++++--------------- src/utils/cropLogo.js | 19 ++++ 3 files changed, 71 insertions(+), 74 deletions(-) diff --git a/src/api/index.js b/src/api/index.js index aed7755..302bb9d 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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} 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(); + }, }; /** diff --git a/src/components/article/ArticleImageSearchDialog.jsx b/src/components/article/ArticleImageSearchDialog.jsx index d041a84..98b8394 100644 --- a/src/components/article/ArticleImageSearchDialog.jsx +++ b/src/components/article/ArticleImageSearchDialog.jsx @@ -31,72 +31,6 @@ import { articleWithUploadedPhoto } from '../../utils/articleDetail'; import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto'; 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} - */ -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) { if (!article) return ''; @@ -277,19 +211,29 @@ export default class ArticleImageSearchDialog extends PureComponent { this.setState({ processingId: img.id, - processingStep: 'Herunterladen...', + processingStep: 'Lade & optimiere Originalbild...', }); try { - // 1. Download image as Blob - const blob = await downloadImageBlob(img.url, img.dataUrl); + // 1. Crawl image via backend POST /api/serper/image (bypasses CORS, crops margins, returns max 1000px AVIF) + 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) - this.setState({ processingStep: 'In AVIF konvertieren...' }); - const file = await prepareArticlePhoto(blob); - - // 3. Upload to backend + // 2. Upload AVIF to article 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); requireObject(res, 'POST /api/article/:sku/image'); requireObject(res.image, 'image'); diff --git a/src/utils/cropLogo.js b/src/utils/cropLogo.js index a4fc038..9ee2ac6 100644 --- a/src/utils/cropLogo.js +++ b/src/utils/cropLogo.js @@ -310,6 +310,25 @@ export async function encodeCanvasToAvif(canvas, filename = 'brandpic.avif') { * @returns {Promise} */ 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; try { img = await downloadLogoImage(url, fallbackDataUrl);