* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
665 lines
30 KiB
JavaScript
665 lines
30 KiB
JavaScript
import OpenAI from 'openai';
|
|
import { join, extname } from 'path';
|
|
import { UPLOADS_DIR } from './runtime-paths.mjs';
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
import { randomUUID } from 'crypto';
|
|
import { generateWithLocalFlux, getLocalImageConfig } from './ai-local-images.mjs';
|
|
|
|
const uploadsDir = UPLOADS_DIR;
|
|
const modelDir = join(uploadsDir, 'ai-models');
|
|
mkdirSync(modelDir, { recursive: true });
|
|
|
|
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models?output_modalities=image&sort=newest';
|
|
const OPENROUTER_IMAGES_URL = 'https://openrouter.ai/api/v1/images/generations';
|
|
|
|
let openaiClient;
|
|
function getOpenAIClient() {
|
|
if (!process.env.OPENAI_API_KEY) throw new Error('OPENAI_API_KEY fehlt.');
|
|
if (!openaiClient) openaiClient = new OpenAI();
|
|
return openaiClient;
|
|
}
|
|
|
|
const BLOCKED_KEYWORDS = [
|
|
'unterwäsche', 'lingerie', 'dessous', 'bikini', 'bademode', 'badeanzug', 'bh', 'slip', 'string', 'tanga',
|
|
'babydoll', 'korsage', 'corsage', 'fetisch', 'latex', 'erotik', 'transparent', 'see-through', 'mesh', 'sexy'
|
|
];
|
|
const KIDS_KEYWORDS = ['baby', 'kinder', 'kind', 'jungen', 'mädchen', 'maedchen', 'kleinkind', 'kids'];
|
|
const CLOTHING_KEYWORDS = ['shirt', 't-shirt', 'top', 'bluse', 'hemd', 'hoodie', 'pullover', 'sweatshirt', 'jacke', 'mantel', 'kleid', 'rock', 'hose', 'jeans', 'cardigan', 'sakko', 'blazer', 'overall', 'jumpsuit', 'schuhe', 'sneaker', 'tasche', 'bekleidung', 'fashion', 'mode'];
|
|
const PRESET_VARIANTS = {
|
|
studio: ['helles Studio mit weichem Schattenwurf', 'neutraler Editorial-Look vor hellem Hintergrund', 'cleaner Studio-Catalog-Look'],
|
|
indoor: ['natürliche Indoor-Szene in modernem Wohnraum', 'ruhige Boutique-Atmosphäre mit natürlichem Licht', 'casual Lifestyle-Interior mit sauberem Hintergrund'],
|
|
outdoor: ['ruhige urbane Außenumgebung bei natürlichem Tageslicht', 'dezente Outdoor-Szene mit unscharfem Hintergrund', 'cleaner Streetstyle-Look mit weichem Licht'],
|
|
mixed: ['helles Studio mit weichem Schattenwurf', 'natürliche Indoor-Szene in modernem Wohnraum', 'ruhige urbane Außenumgebung bei natürlichem Tageslicht'],
|
|
};
|
|
|
|
function safeJsonParse(text, fallback = null) {
|
|
try { return JSON.parse(text); } catch {}
|
|
const match = String(text || '').match(/\{[\s\S]*\}/);
|
|
if (!match) return fallback;
|
|
try { return JSON.parse(match[0]); } catch { return fallback; }
|
|
}
|
|
|
|
function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
|
|
function numeric(val) { const num = Number(val ?? 0); return Number.isFinite(num) ? num : 0; }
|
|
|
|
function normalizeListingContext(input = {}) {
|
|
return {
|
|
title: String(input.title || '').trim(),
|
|
category: String(input.category || '').trim(),
|
|
brand: String(input.brand || '').trim(),
|
|
color: String(input.color || '').trim(),
|
|
size: String(input.size || '').trim(),
|
|
condition: String(input.condition || '').trim(),
|
|
seller_notes: String(input.seller_notes || '').trim(),
|
|
platform: String(input.platform || '').trim() || 'vinted',
|
|
};
|
|
}
|
|
|
|
function keywordFlags(context) {
|
|
const hay = `${context.title} ${context.category} ${context.seller_notes}`.toLowerCase();
|
|
return {
|
|
blocked: BLOCKED_KEYWORDS.filter(word => hay.includes(word)),
|
|
kids: KIDS_KEYWORDS.filter(word => hay.includes(word)),
|
|
clothing: CLOTHING_KEYWORDS.some(word => hay.includes(word)),
|
|
};
|
|
}
|
|
|
|
function heuristicAnalysis(context) {
|
|
const flags = keywordFlags(context);
|
|
const garmentType = CLOTHING_KEYWORDS.find(word => (`${context.title} ${context.category}`).toLowerCase().includes(word)) || 'Kleidungsstück';
|
|
return {
|
|
category: context.category || 'Mode',
|
|
garmentType,
|
|
color: context.color || null,
|
|
pattern: null,
|
|
material: null,
|
|
fit: null,
|
|
style: null,
|
|
age_group: flags.kids.length ? 'kids' : 'adult',
|
|
suitable_for_model_photos: flags.blocked.length === 0,
|
|
has_person: false,
|
|
confidence: 'low',
|
|
notes: flags.clothing ? 'Heuristik erkennt Modeartikel.' : 'Heuristik ohne visuelle Analyse.',
|
|
};
|
|
}
|
|
|
|
function getUploadPath(filename) { return join(uploadsDir, String(filename || '')); }
|
|
|
|
function fileToDataUrl(filename) {
|
|
const full = getUploadPath(filename);
|
|
const ext = extname(full).toLowerCase();
|
|
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : ext === '.gif' ? 'image/gif' : 'image/jpeg';
|
|
const b64 = readFileSync(full).toString('base64');
|
|
return `data:${mime};base64,${b64}`;
|
|
}
|
|
|
|
function parseOpenRouterModel(model = {}) {
|
|
const inputModalities = model.architecture?.input_modalities || [];
|
|
const outputModalities = model.architecture?.output_modalities || [];
|
|
return {
|
|
id: model.id,
|
|
name: model.name || model.id,
|
|
description: model.description || '',
|
|
created: model.created || null,
|
|
input_modalities: inputModalities,
|
|
output_modalities: outputModalities,
|
|
pricing: model.pricing || {},
|
|
supported_parameters: model.supported_parameters || [],
|
|
is_free: isOpenRouterFreeModel(model),
|
|
supports_image_input: inputModalities.includes('image'),
|
|
supports_image_output: outputModalities.includes('image'),
|
|
};
|
|
}
|
|
|
|
function priceIsZero(value) {
|
|
if (value === undefined || value === null || value === '') return true;
|
|
const number = Number(value);
|
|
return Number.isFinite(number) && number === 0;
|
|
}
|
|
|
|
function isOpenRouterFreeModel(model = {}) {
|
|
const id = String(model.id || '').toLowerCase();
|
|
const name = String(model.name || '').toLowerCase();
|
|
if (id.endsWith(':free') || name.includes('(free)')) return true;
|
|
|
|
const pricing = model.pricing || {};
|
|
// Nur Preise bewerten, die für eine normale Bildanforderung relevant sind.
|
|
// Optionale Web-/Audio-Preise dürfen ein ansonsten kostenloses Bildmodell nicht ausschließen.
|
|
const relevantKeys = ['request', 'prompt', 'completion', 'image'];
|
|
const present = relevantKeys.filter(key => pricing[key] !== undefined && pricing[key] !== null && pricing[key] !== '');
|
|
return present.length > 0 && present.every(key => priceIsZero(pricing[key]));
|
|
}
|
|
|
|
function getOpenRouterHeaders() {
|
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
if (!apiKey) throw new Error('OPENROUTER_API_KEY fehlt.');
|
|
const headers = {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
if (process.env.OPENROUTER_SITE_URL) headers['HTTP-Referer'] = process.env.OPENROUTER_SITE_URL;
|
|
if (process.env.OPENROUTER_APP_NAME) headers['X-Title'] = process.env.OPENROUTER_APP_NAME;
|
|
return headers;
|
|
}
|
|
|
|
export async function fetchOpenRouterImageModels({ freeOnly = true } = {}) {
|
|
const response = await fetch(OPENROUTER_MODELS_URL, {
|
|
headers: { ...getOpenRouterHeaders(), 'Cache-Control': 'no-cache' },
|
|
cache: 'no-store',
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`OpenRouter Models API Fehler (${response.status})${body ? `: ${body.slice(0, 180)}` : ''}`);
|
|
}
|
|
const payload = await response.json();
|
|
const list = Array.isArray(payload?.data) ? payload.data.map(parseOpenRouterModel) : [];
|
|
const imageModels = list.filter(model => model.supports_image_output);
|
|
const filtered = freeOnly ? imageModels.filter(model => model.is_free) : imageModels;
|
|
return filtered.sort((a, b) => Number(b.created || 0) - Number(a.created || 0));
|
|
}
|
|
|
|
export async function getOpenRouterImageDiagnostics() {
|
|
const allModels = await fetchOpenRouterImageModels({ freeOnly: false });
|
|
const freeModels = allModels.filter(model => model.is_free);
|
|
return {
|
|
image_model_count: allModels.length,
|
|
free_image_model_count: freeModels.length,
|
|
free_models: freeModels,
|
|
all_models: allModels,
|
|
message: freeModels.length
|
|
? `${freeModels.length} kostenlose OpenRouter-Bildmodelle verfügbar.`
|
|
: 'Aktuell wurde kein kostenloses OpenRouter-Modell mit Bildausgabe gefunden.',
|
|
};
|
|
}
|
|
|
|
async function resolveImageProviders(settings = {}) {
|
|
const preference = String(settings.ai_model_photo_provider || 'auto').toLowerCase();
|
|
const openrouterFreeOnly = String(settings.ai_model_photo_openrouter_free_only || '1') !== '0';
|
|
const configuredOpenRouterModel = String(settings.ai_model_photo_openrouter_model || '').trim();
|
|
const configuredOpenAiModel = String(settings.ai_model_photo_openai_model || 'gpt-image-1').trim() || 'gpt-image-1';
|
|
const localConfig = getLocalImageConfig(settings);
|
|
const candidates = [];
|
|
const discoveryErrors = [];
|
|
|
|
const allowLocal = preference === 'local' || preference === 'auto';
|
|
const allowOpenRouter = preference === 'openrouter' || preference === 'auto';
|
|
const allowOpenAi = preference === 'openai' || preference === 'auto';
|
|
|
|
if (allowLocal && localConfig.enabled && localConfig.url) {
|
|
candidates.push({
|
|
provider: 'local',
|
|
model: 'flux-schnell-local',
|
|
label: 'Lokales FLUX / ComfyUI',
|
|
free: true,
|
|
local: true,
|
|
discovered_models: [],
|
|
});
|
|
} else if (preference === 'local') {
|
|
discoveryErrors.push('Lokaler FLUX/ComfyUI-Provider ist nicht aktiviert.');
|
|
}
|
|
|
|
if (allowOpenRouter && process.env.OPENROUTER_API_KEY) {
|
|
try {
|
|
const models = await fetchOpenRouterImageModels({ freeOnly: openrouterFreeOnly });
|
|
const ordered = [];
|
|
const configured = models.find(model => model.id === configuredOpenRouterModel);
|
|
if (configured) ordered.push(configured);
|
|
for (const model of models) {
|
|
if (!ordered.some(entry => entry.id === model.id)) ordered.push(model);
|
|
}
|
|
for (const model of ordered) {
|
|
candidates.push({
|
|
provider: 'openrouter',
|
|
model: model.id,
|
|
label: model.name,
|
|
free: model.is_free,
|
|
supports_image_input: model.supports_image_input,
|
|
supported_parameters: model.supported_parameters || [],
|
|
discovered_models: models,
|
|
});
|
|
}
|
|
if (!ordered.length) discoveryErrors.push(openrouterFreeOnly ? 'Aktuell sind keine kostenlosen OpenRouter-Bildmodelle mit Bildausgabe verfügbar.' : 'Keine passenden OpenRouter-Bildmodelle gefunden.');
|
|
} catch (error) {
|
|
discoveryErrors.push(error.message || 'OpenRouter-Modellabfrage fehlgeschlagen.');
|
|
}
|
|
} else if (preference === 'openrouter' && !process.env.OPENROUTER_API_KEY) {
|
|
discoveryErrors.push('OPENROUTER_API_KEY fehlt.');
|
|
}
|
|
|
|
if (allowOpenAi && process.env.OPENAI_API_KEY) {
|
|
candidates.push({
|
|
provider: 'openai',
|
|
model: configuredOpenAiModel,
|
|
label: configuredOpenAiModel,
|
|
free: false,
|
|
discovered_models: [],
|
|
});
|
|
} else if (preference === 'openai' && !process.env.OPENAI_API_KEY) {
|
|
discoveryErrors.push('OPENAI_API_KEY fehlt.');
|
|
}
|
|
|
|
if (!candidates.length) {
|
|
throw new Error(discoveryErrors.filter(Boolean).join(' | ') || 'Kein Bildprovider konfiguriert.');
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
export async function analyzeGarmentPhotos(photos = [], contextInput = {}) {
|
|
const context = normalizeListingContext(contextInput);
|
|
const base = heuristicAnalysis(context);
|
|
if (!photos.length || !process.env.OPENAI_API_KEY) return base;
|
|
|
|
const content = photos.slice(0, 4).map(photo => ({ type: 'image_url', image_url: { url: fileToDataUrl(photo), detail: 'high' } }));
|
|
content.push({
|
|
type: 'text',
|
|
text: `Analysiere diese Produktfotos für ein Second-Hand-Listing. Antworte ausschließlich als JSON mit den Feldern: category, garmentType, color, pattern, material, fit, style, age_group (adult|kids|unknown), suitable_for_model_photos (boolean), has_person (boolean), confidence, notes. Kontext: ${JSON.stringify(context)}. If a person is visible set has_person=true. If the item appears to be underwear, swimwear, lingerie, transparent erotic wear or child clothing, mark suitable_for_model_photos=false.`
|
|
});
|
|
|
|
try {
|
|
const response = await getOpenAIClient().chat.completions.create({
|
|
model: 'gpt-4o-mini',
|
|
temperature: 0.1,
|
|
response_format: { type: 'json_object' },
|
|
messages: [
|
|
{ role: 'system', content: 'Du bist eine strenge Safety- und Produktanalyse für einen Mode-Listing-Generator.' },
|
|
{ role: 'user', content },
|
|
],
|
|
max_tokens: 500,
|
|
});
|
|
const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null);
|
|
return parsed ? { ...base, ...parsed } : base;
|
|
} catch {
|
|
return base;
|
|
}
|
|
}
|
|
|
|
export async function precheckAiModelPhotos(input = {}, settings = {}) {
|
|
const context = normalizeListingContext(input);
|
|
const photos = Array.isArray(input.photos) ? input.photos.filter(Boolean) : [];
|
|
const variants = clamp(Number(input.variants || settings.ai_model_photo_default_variants || 1), 1, 3);
|
|
const preset = ['studio', 'indoor', 'outdoor', 'mixed'].includes(input.preset) ? input.preset : (settings.ai_model_photo_default_preset || 'mixed');
|
|
const mode = ['ghost_mannequin', 'flatlay'].includes(input.mode) ? input.mode : (settings.ai_model_photo_default_mode || 'flatlay');
|
|
const flags = keywordFlags(context);
|
|
if (!photos.length) return { allowed: false, reason: 'Keine Fotos vorhanden.', context, variants, preset, mode, checks: [] };
|
|
if (flags.blocked.length) return { allowed: false, reason: `Blockierter Produkttyp erkannt: ${flags.blocked[0]}.`, context, variants, preset, mode, checks: ['block_underwear'] };
|
|
if (flags.kids.length) return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['block_kids'] };
|
|
const analysis = await analyzeGarmentPhotos(photos, context);
|
|
if (String(analysis.age_group || '').toLowerCase() === 'kids') return { allowed: false, reason: 'Kinderkleidung ist für AI-Model-Fotos gesperrt.', context, variants, preset, mode, checks: ['vision_kids'], analysis };
|
|
if (analysis.suitable_for_model_photos === false) return { allowed: false, reason: 'Der Artikel ist laut Sicherheitsprüfung nicht für AI-Model-Fotos geeignet.', context, variants, preset, mode, checks: ['vision_block'], analysis };
|
|
const hay = `${context.title} ${context.category}`.toLowerCase();
|
|
const looksLikeClothing = flags.clothing || CLOTHING_KEYWORDS.some(word => hay.includes(word)) || !!analysis.garmentType;
|
|
if (!looksLikeClothing) return { allowed: false, reason: 'AI-Model-Fotos sind aktuell nur für Kleidung und Modeartikel vorgesehen.', context, variants, preset, mode, checks: ['not_clothing'], analysis };
|
|
return { allowed: true, reason: 'Freigegeben', context, variants, preset, mode, checks: ['adult_only', 'fully_clothed', 'non_sexual'], analysis };
|
|
}
|
|
|
|
|
|
function buildVariantPrompt({ analysis, context, preset, variantIndex, mode, customPrompt = '', preserveLogos = true }) {
|
|
const options = PRESET_VARIANTS[preset] || PRESET_VARIANTS.mixed;
|
|
const scene = options[variantIndex % options.length];
|
|
const productDetails = [
|
|
analysis.garmentType || context.category || 'Kleidungsstück',
|
|
analysis.color || context.color || null,
|
|
analysis.pattern ? `Muster: ${analysis.pattern}` : null,
|
|
analysis.material ? `Material: ${analysis.material}` : null,
|
|
analysis.fit ? `Schnitt/Fit: ${analysis.fit}` : null,
|
|
analysis.style ? `Stil: ${analysis.style}` : null,
|
|
context.brand ? `Marke: ${context.brand}` : null,
|
|
context.size ? `Größe: ${context.size}` : null,
|
|
context.condition ? `Zustand: ${context.condition}` : null,
|
|
].filter(Boolean).join(', ');
|
|
const logoInstruction = preserveLogos
|
|
? 'Do not alter, remove, stylize, mirror, replace, blur or invent any visible brand logo, chest print, text, embroidery, patch, label, badge or trademark on the garment. Preserve every visible logo and print exactly as shown in the source photo.'
|
|
: 'Keep visible branding and prints plausible and unchanged where possible.';
|
|
const customInstruction = String(customPrompt || '').trim() ? `Additional custom seller instruction: ${String(customPrompt || '').trim().slice(0, 500)}.` : '';
|
|
|
|
if (mode === 'ghost_mannequin') {
|
|
return [
|
|
'Generate a photorealistic apparel photo for a second-hand marketplace.',
|
|
'Treat the uploaded product photo as a mandatory visual reference. Preserve the exact garment type, hood, zipper, seams, print, color blocking and visible wear. Do not replace it with a generic garment.',
|
|
logoInstruction,
|
|
'Show the clothing item as a clean ghost mannequin presentation with no visible real person.',
|
|
'The garment must look naturally worn-in-form but with an invisible mannequin / hollow-neck apparel-photo style.',
|
|
'No visible skin, no nudity, no erotic styling, no minor-related cues.',
|
|
`The clothing item must closely match these details: ${productDetails}.`,
|
|
`Environment/look: ${scene}.`,
|
|
'Use a neutral realistic marketplace background and keep the full garment easy to inspect.',
|
|
variantIndex === 0 ? 'Use a clear front view.' : variantIndex === 1 ? 'Use a 3/4 angle or detail-friendly secondary angle.' : 'Use an alternate clean catalog angle.',
|
|
customInstruction,
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
if (mode === 'flatlay') {
|
|
return [
|
|
'Generate a photorealistic flat lay marketplace photo for a clothing item.',
|
|
'Treat the uploaded product photo as a mandatory visual reference. Preserve the exact garment type, hood, zipper, seams, print, color blocking and visible wear. Do not invent another item.',
|
|
logoInstruction,
|
|
'No person should be present. Arrange the garment neatly on a clean, realistic surface.',
|
|
'The item must be the focus, fully visible, symmetrical where appropriate, and suitable for second-hand listing use.',
|
|
'No erotic styling, no lingerie presentation, and no minor-related cues.',
|
|
`The clothing item must closely match these details: ${productDetails}.`,
|
|
`Environment/look: ${scene}.`,
|
|
variantIndex === 0 ? 'Use a clean straight-down hero shot.' : variantIndex === 1 ? 'Use a slightly styled flat lay with minimal accessories or folds only if tasteful.' : 'Use a third variation with another neat layout angle.',
|
|
customInstruction,
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
return [
|
|
'Generate a photorealistic marketplace product image without any person.',
|
|
'Use the uploaded product photo as the mandatory visual reference and preserve the item accurately.',
|
|
logoInstruction,
|
|
`The clothing item must closely match these details: ${productDetails}.`,
|
|
`Environment/look: ${scene}.`,
|
|
customInstruction,
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
async function runOutputSafetyCheck(filePath) {
|
|
if (!process.env.OPENAI_API_KEY) return { safe: true, verdict: 'unchecked', notes: 'Keine zusätzliche API-Moderation konfiguriert.' };
|
|
try {
|
|
const response = await getOpenAIClient().chat.completions.create({
|
|
model: 'gpt-4o-mini',
|
|
temperature: 0,
|
|
response_format: { type: 'json_object' },
|
|
messages: [
|
|
{ role: 'system', content: 'Prüfe Modebilder streng auf Nacktheit, Sexualisierung, Minderjährige oder unzulässige Inhalte. Antworte nur als JSON.' },
|
|
{ role: 'user', content: [
|
|
{ type: 'image_url', image_url: { url: `data:image/png;base64,${readFileSync(filePath).toString('base64')}`, detail: 'low' } },
|
|
{ type: 'text', text: 'Antworte mit JSON: {"safe": boolean, "verdict": "safe|blocked", "notes": string}. Block if nudity, lingerie pose, erotic styling or minor-looking person is visible.' },
|
|
] },
|
|
],
|
|
max_tokens: 180,
|
|
});
|
|
const parsed = safeJsonParse(response.choices?.[0]?.message?.content, null);
|
|
return parsed || { safe: true, verdict: 'safe', notes: 'Fallback safe.' };
|
|
} catch {
|
|
return { safe: true, verdict: 'unchecked', notes: 'Moderation nicht verfügbar.' };
|
|
}
|
|
}
|
|
|
|
async function generateWithOpenAI(prompt, model) {
|
|
const response = await getOpenAIClient().images.generate({ model, prompt, size: '1024x1024', quality: 'medium' });
|
|
return response.data?.[0]?.b64_json || null;
|
|
}
|
|
|
|
async function fetchWithTimeout(url, options = {}, timeoutMs = 90000) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
return await fetch(url, { ...options, signal: controller.signal });
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError') throw new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`);
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function findImageValue(value) {
|
|
if (!value) return null;
|
|
if (typeof value === 'string') {
|
|
if (value.startsWith('data:image/')) return { type: 'base64', value: value.split(',')[1] || null };
|
|
if (/^https?:\/\//i.test(value)) return { type: 'url', value };
|
|
const dataMatch = value.match(/data:image\/[a-z0-9.+-]+;base64,([a-z0-9+/=]+)/i);
|
|
if (dataMatch) return { type: 'base64', value: dataMatch[1] };
|
|
const markdownMatch = value.match(/!\[[^\]]*\]\((https?:\/\/[^)]+)\)/i);
|
|
if (markdownMatch) return { type: 'url', value: markdownMatch[1] };
|
|
return null;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (const entry of value) {
|
|
const found = findImageValue(entry);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
if (typeof value === 'object') {
|
|
if (typeof value.b64_json === 'string') return { type: 'base64', value: value.b64_json };
|
|
const candidates = [
|
|
value.url,
|
|
value.image_url?.url,
|
|
value.image_url,
|
|
value.source?.data,
|
|
value.data,
|
|
value.images,
|
|
value.content,
|
|
];
|
|
for (const candidate of candidates) {
|
|
const found = findImageValue(candidate);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function extractOpenRouterImage(payload = {}) {
|
|
return findImageValue([
|
|
payload?.data,
|
|
payload?.images,
|
|
payload?.choices?.[0]?.message?.images,
|
|
payload?.choices?.[0]?.message?.content,
|
|
payload?.output,
|
|
]);
|
|
}
|
|
|
|
async function downloadImageAsBase64(url) {
|
|
const response = await fetchWithTimeout(url, {}, 45000);
|
|
if (!response.ok) throw new Error(`Bild-Download fehlgeschlagen (${response.status})`);
|
|
return Buffer.from(await response.arrayBuffer()).toString('base64');
|
|
}
|
|
|
|
async function imageResultToBase64(result) {
|
|
if (!result?.value) return null;
|
|
return result.type === 'url' ? downloadImageAsBase64(result.value) : result.value;
|
|
}
|
|
|
|
async function generateWithOpenRouterChat(prompt, candidate, sourceDataUrl = null) {
|
|
const content = [];
|
|
if (sourceDataUrl && candidate.supports_image_input) {
|
|
content.push({ type: 'image_url', image_url: { url: sourceDataUrl } });
|
|
}
|
|
content.push({ type: 'text', text: prompt });
|
|
|
|
const baseBody = {
|
|
model: candidate.model,
|
|
modalities: ['image', 'text'],
|
|
messages: [{ role: 'user', content }],
|
|
stream: false,
|
|
};
|
|
const requestBodies = [
|
|
{ ...baseBody, image_config: { aspect_ratio: '1:1' }, provider: { allow_fallbacks: true } },
|
|
{ ...baseBody, provider: { allow_fallbacks: true } },
|
|
baseBody,
|
|
];
|
|
const errors = [];
|
|
|
|
for (const body of requestBodies) {
|
|
const response = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: getOpenRouterHeaders(),
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await response.text();
|
|
const payload = safeJsonParse(text, {});
|
|
if (response.ok) {
|
|
const result = extractOpenRouterImage(payload);
|
|
const base64 = await imageResultToBase64(result);
|
|
if (base64) return base64;
|
|
errors.push('Chat API antwortete erfolgreich, enthielt aber keine Bilddaten.');
|
|
continue;
|
|
}
|
|
|
|
const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`;
|
|
errors.push(`Chat API ${response.status}: ${message}`);
|
|
if (![400, 404, 422].includes(response.status)) break;
|
|
}
|
|
|
|
throw new Error(errors.join(' | '));
|
|
}
|
|
|
|
async function generateWithOpenRouterImagesApi(prompt, candidate) {
|
|
const response = await fetchWithTimeout(OPENROUTER_IMAGES_URL, {
|
|
method: 'POST',
|
|
headers: getOpenRouterHeaders(),
|
|
body: JSON.stringify({ model: candidate.model, prompt, size: '1024x1024', n: 1 }),
|
|
});
|
|
const text = await response.text();
|
|
const payload = safeJsonParse(text, {});
|
|
if (!response.ok) {
|
|
const message = payload?.error?.message || payload?.message || text.slice(0, 240) || `HTTP ${response.status}`;
|
|
throw new Error(`Images API ${response.status}: ${message}`);
|
|
}
|
|
const result = extractOpenRouterImage(payload);
|
|
return imageResultToBase64(result);
|
|
}
|
|
|
|
async function generateWithOpenRouter(prompt, candidate, sourceDataUrl = null) {
|
|
const errors = [];
|
|
try {
|
|
const chatImage = await generateWithOpenRouterChat(prompt, candidate, sourceDataUrl);
|
|
if (chatImage) return chatImage;
|
|
errors.push('Chat API lieferte keine Bilddaten.');
|
|
} catch (error) {
|
|
errors.push(error.message || 'Chat API fehlgeschlagen.');
|
|
}
|
|
|
|
try {
|
|
const apiImage = await generateWithOpenRouterImagesApi(prompt, candidate);
|
|
if (apiImage) return apiImage;
|
|
errors.push('Images API lieferte keine Bilddaten.');
|
|
} catch (error) {
|
|
errors.push(error.message || 'Images API fehlgeschlagen.');
|
|
}
|
|
|
|
throw new Error(errors.join(' | '));
|
|
}
|
|
|
|
async function generateWithProviderCandidate(prompt, candidate, sourceDataUrl = null, settings = {}, mode = 'model', variantIndex = 0, onProgress = null) {
|
|
let result = {};
|
|
if (candidate.provider === 'openrouter') {
|
|
result.base64 = await generateWithOpenRouter(prompt, candidate, sourceDataUrl);
|
|
result.reference_used = !!sourceDataUrl && candidate.supports_image_input !== false;
|
|
} else if (candidate.provider === 'local') {
|
|
result = await generateWithLocalFlux({ prompt, settings, mode, variantIndex, sourceDataUrl, onProgress });
|
|
} else {
|
|
result.base64 = await generateWithOpenAI(prompt, candidate.model);
|
|
result.reference_used = false;
|
|
}
|
|
if (!result.base64) throw new Error(`${candidate.label || candidate.model} lieferte keine Bilddaten zurück.`);
|
|
return result;
|
|
}
|
|
|
|
async function generateWithProviderFallback(prompt, candidates = [], sourceDataUrl = null, settings = {}, mode = 'model', variantIndex = 0, onProgress = null) {
|
|
const attempts = [];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
if (typeof onProgress === 'function') onProgress(4, `${candidate.label || candidate.model} wird vorbereitet …`);
|
|
const result = await generateWithProviderCandidate(prompt, candidate, sourceDataUrl, settings, mode, variantIndex, onProgress);
|
|
return { ...result, candidate, attempts };
|
|
} catch (error) {
|
|
attempts.push({
|
|
provider: candidate.provider,
|
|
model: candidate.model,
|
|
label: candidate.label,
|
|
error: error.message || 'Unbekannter Fehler',
|
|
});
|
|
}
|
|
}
|
|
const summary = attempts.map(item => `${item.label || item.model}: ${item.error}`).join(' | ');
|
|
const error = new Error(summary || 'Alle Bildmodelle sind fehlgeschlagen.');
|
|
error.attempts = attempts;
|
|
throw error;
|
|
}
|
|
|
|
|
|
export async function generateAiModelPhotos(input = {}, onProgress = null) {
|
|
const report = (progress, message, meta = {}) => {
|
|
if (typeof onProgress === 'function') onProgress(progress, message, meta);
|
|
};
|
|
|
|
report(5, 'Produktfotos und Sicherheitsregeln werden geprüft …');
|
|
const precheck = await precheckAiModelPhotos(input, input.settings || {});
|
|
if (!precheck.allowed) {
|
|
const error = new Error(precheck.reason || 'AI-Model-Fotos wurden blockiert.');
|
|
error.code = 'AI_MODEL_PRECHECK_FAILED';
|
|
error.precheck = precheck;
|
|
throw error;
|
|
}
|
|
|
|
|
|
report(12, 'Passender Bildprovider wird ausgewählt …');
|
|
let providerCandidates = await resolveImageProviders(input.settings || {});
|
|
const context = precheck.context;
|
|
const analysis = precheck.analysis || heuristicAnalysis(context);
|
|
const variants = precheck.variants;
|
|
const preset = precheck.preset;
|
|
const mode = precheck.mode || 'flatlay';
|
|
const created = [];
|
|
const useSourceReference = String(input.settings?.ai_model_photo_use_source_reference ?? '1') !== '0';
|
|
const preserveLogos = String(input.preserve_logos ?? input.settings?.ai_model_photo_preserve_logos ?? '1') !== '0';
|
|
const customPrompt = String(input.custom_prompt || '').trim();
|
|
const sourceDataUrl = useSourceReference && input.photos?.[0] ? fileToDataUrl(input.photos[0]) : null;
|
|
let lastProviderInfo = providerCandidates[0] || null;
|
|
const providerAttempts = [];
|
|
|
|
for (let index = 0; index < variants; index += 1) {
|
|
const variantStart = 16 + Math.round((index / variants) * 72);
|
|
const variantEnd = 16 + Math.round(((index + 1) / variants) * 72);
|
|
const prompt = buildVariantPrompt({ analysis, context, preset, variantIndex: index, mode, customPrompt, preserveLogos });
|
|
report(variantStart, `Variante ${index + 1} von ${variants} wird vorbereitet …`);
|
|
const generated = await generateWithProviderFallback(
|
|
prompt,
|
|
providerCandidates,
|
|
sourceDataUrl,
|
|
input.settings || {},
|
|
mode,
|
|
index,
|
|
(providerProgress, message) => {
|
|
const scaled = variantStart + Math.round((Math.max(0, Math.min(100, Number(providerProgress || 0))) / 100) * Math.max(1, variantEnd - variantStart - 5));
|
|
report(Math.min(91, scaled), `Variante ${index + 1}/${variants}: ${message}`);
|
|
},
|
|
);
|
|
const base64 = generated.base64;
|
|
const providerInfo = generated.candidate;
|
|
lastProviderInfo = providerInfo;
|
|
providerAttempts.push(...(generated.attempts || []));
|
|
providerCandidates = [providerInfo, ...providerCandidates.filter(candidate => !(candidate.provider === providerInfo.provider && candidate.model === providerInfo.model))];
|
|
|
|
report(Math.max(variantStart, variantEnd - 4), `Variante ${index + 1}/${variants}: Sicherheitsprüfung läuft …`);
|
|
const filename = `ai-models/${randomUUID()}.png`;
|
|
const filePath = join(uploadsDir, filename);
|
|
writeFileSync(filePath, Buffer.from(base64, 'base64'));
|
|
|
|
const moderation = await runOutputSafetyCheck(filePath);
|
|
if (!moderation.safe) continue;
|
|
created.push({
|
|
filename,
|
|
prompt,
|
|
moderation,
|
|
variant_index: index + 1,
|
|
preset,
|
|
source_photo: input.photos?.[0] || null,
|
|
public_url: `/uploads/${filename}`,
|
|
provider: providerInfo.provider,
|
|
provider_label: providerInfo.label,
|
|
provider_model: providerInfo.model,
|
|
provider_free: providerInfo.free,
|
|
mode,
|
|
reference_used: generated.reference_used ?? useSourceReference,
|
|
});
|
|
report(variantEnd, `Variante ${index + 1} von ${variants} ist fertig.`);
|
|
}
|
|
|
|
if (!created.length) throw new Error('Es konnten keine sicheren AI-Model-Fotos erzeugt werden.');
|
|
report(92, 'AI-Bilder werden für das Listing vorbereitet …');
|
|
|
|
return {
|
|
precheck,
|
|
provider: lastProviderInfo,
|
|
provider_attempts: providerAttempts,
|
|
mode,
|
|
reference_used: useSourceReference,
|
|
prompt_summary: `${analysis.garmentType || context.category || 'Kleidungsstück'} · ${mode} · ${preset} · ${created.length} Variante(n)${useSourceReference ? ' · mit Produktreferenz' : ''}${preserveLogos ? ' · Logos geschützt' : ''}${customPrompt ? ' · mit Benutzerprompt' : ''}`,
|
|
photos: created,
|
|
};
|
|
}
|