u
This commit is contained in:
@@ -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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user