/** * Bundle (BOM) product image — orchestration. * * 1. component photos: Strapi m-product of every BOM component (Main_Image or first image), * bytes via fetchStrapiFileBytes; * 2. style plan: Claude (vision) looks at the component photos + names/quantities and answers * a small JSON plan (backdrop colours in the context of the photos, accent, arrangement, * cards, per-photo trim). Without ANTHROPIC_API_KEY, on any API error or unparsable * answer the NEUTRAL_PLAN is used — image generation never fails because of the AI step; * 3. render (bundleImageCompose.ts), upload to Strapi linked to the bundle product's `image` * relation (existing pictures stay) and set it as Main_Image. * * Claude analyses pictures — it does not paint them; the picture itself is composed here. */ import Anthropic from '@anthropic-ai/sdk' import strapiHelper from '../strapiHelper' import { fetchStrapiFileBytes } from '../inbox/strapiAttach' import { cfg, anthropicClient, anthropicErrorMessage } from '../leads/leadAssessment' import { composeBundleImage, hasRealTransparency, previewForVision, normalizePlan, NEUTRAL_PLAN, MAX_REPEAT_TILES } from './bundleImageCompose' import type { BundleTileSource, BundlePlan } from './bundleImageCompose' export interface BundleComponentInput { productId: number; name: string; value: string; qty: number; strapiDocumentId: string } export interface BundleProductInput { id: number; name: string; value: string; strapiId: string | number; strapiDocumentId: string } export interface BundleImageResult { status: number message: string imageId?: number url?: string hash?: string ext?: string formats?: any plan?: BundlePlan planSource: 'claude' | 'neutral' model?: string usage?: { input: number; output: number } tiles?: number repeated?: boolean warnings: string[] } const pickMainImage = (strapiProduct: any): any | null => { const images: any[] = Array.isArray(strapiProduct?.image) ? strapiProduct.image : [] if (!images.length) return null const main = strapiProduct?.Main_Image && strapiProduct.Main_Image != 0 ? images.find(i => String(i.id) === String(strapiProduct.Main_Image)) : null return main || images[0] } /** Component photo bytes (null when the component has no Strapi record / picture). */ const loadComponentPhoto = async (event: any, strapiDocumentId: string, warnings: string[], label: string): Promise => { if (!strapiDocumentId) { warnings.push(`${label}: kein Medien-Datensatz (Strapi)`); return null } try { const res: any = await strapiHelper(event, `m-products/${strapiDocumentId}?populate=image`, 'GET', null) const img = pickMainImage(res?.data) if (!img?.url) { warnings.push(`${label}: kein Bild hinterlegt`); return null } const buf = await fetchStrapiFileBytes(event, img.url) if (!buf?.length) { warnings.push(`${label}: Bilddatei nicht erreichbar`); return null } return buf } catch (e: any) { warnings.push(`${label}: ${e?.message || 'Bild konnte nicht geladen werden'}`) return null } } // ---- Claude style plan --------------------------------------------------------------- export const PLAN_SYSTEM = `Du bist Art Director für E-Commerce-Produktbilder. Du erhältst die Komponentenfotos eines Produkt-Bundles (Stückliste) und entscheidest den Stil des zusammengesetzten Bundle-Bildes. Du malst nichts selbst – ein Renderer setzt die Fotos auf eine quadratische Fläche: pro Komponente ein Feld (bei kleinen Mengen mehrfach, sonst mit Mengen-Badge). Antworte ausschließlich mit einem JSON-Objekt, ohne Markdown und ohne Erklärtext.` export const buildPlanInstruction = (components: { index: number; name: string; qty: number; hasPhoto: boolean; transparent: boolean }[], productName: string): string => { const list = components.map(c => `${c.index}. ${c.name} – Menge ${c.qty}${c.hasPhoto ? (c.transparent ? ' (Foto mit Transparenz / Freisteller)' : ' (Foto mit eigenem Hintergrund)') : ' (kein Foto → Platzhalter)'}`).join('\n') return `Bundle: „${productName}“ Komponenten: ${list} Regeln für deine Entscheidung: - Der Hintergrund soll zum Stil der Fotos passen (Farbwelt, Helligkeit, Stimmung). Sind die Fotos neutral/weiß oder fehlen sie, bleibe neutral (Weiß bis sehr helles Grau, dezenter Verlauf). - Fotos mit eigenem opakem Hintergrund wirken nur auf hellem Grund oder auf weißen Karten gut → dann tileCard: true und einen hellen Hintergrund wählen. Freisteller können direkt auf einen Farbverlauf. - accent ist die Farbe der Mengen-Badges (z. B. „10ד); badgeText muss darauf gut lesbar sein. - arrangement "row" nur bei höchstens 4 Feldern, sonst "grid". - trim pro Komponente: true, wenn ein gleichmäßiger Rand/Hintergrund weggeschnitten werden darf, damit das Objekt das Feld füllt; false bei Fotos, die bis zum Rand Motiv zeigen. JSON-Format (exakt diese Schlüssel): {"background":{"type":"solid"|"gradient","colors":["#rrggbb","#rrggbb"],"angle":0-359},"accent":"#rrggbb","badgeText":"#rrggbb","arrangement":"grid"|"row","tileCard":true|false,"components":[{"index":1,"trim":true|false}],"rationale":"ein kurzer Satz"}` } export const parsePlanJson = (text: string): any | null => { const s = String(text || '') const start = s.indexOf('{'), end = s.lastIndexOf('}') if (start < 0 || end <= start) return null try { return JSON.parse(s.slice(start, end + 1)) } catch { return null } } const planWithClaude = async (sources: BundleTileSource[], productName: string, warnings: string[]): Promise<{ plan: BundlePlan | null; model?: string; usage?: { input: number; output: number } }> => { const { apiKey, model } = cfg() if (!apiKey) { warnings.push('ANTHROPIC_API_KEY nicht konfiguriert – neutraler Stil'); return { plan: null } } try { const client = anthropicClient(90 * 1000) const content: Anthropic.ContentBlockParam[] = [] const meta: { index: number; name: string; qty: number; hasPhoto: boolean; transparent: boolean }[] = [] for (const s of sources) { meta.push({ index: s.index, name: s.name, qty: s.qty, hasPhoto: !!s.buffer, transparent: !!s.buffer && !s.opaque }) if (s.buffer) { const prev = await previewForVision(s.buffer) content.push({ type: 'text', text: `Foto Komponente ${s.index}: ${s.name}` }) content.push({ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: prev.toString('base64') } }) } } content.push({ type: 'text', text: buildPlanInstruction(meta, productName) }) const res = await client.messages.create({ model, max_tokens: 1500, system: PLAN_SYSTEM, output_config: { effort: 'medium' }, messages: [{ role: 'user', content }] } as any) const usage = { input: Number((res as any)?.usage?.input_tokens || 0), output: Number((res as any)?.usage?.output_tokens || 0) } if ((res as any).stop_reason === 'refusal') { warnings.push('KI-Stilvorschlag abgelehnt – neutraler Stil'); return { plan: null, model, usage } } const text = (res.content || []).filter((b: any) => b.type === 'text').map((b: any) => b.text).join('\n') const raw = parsePlanJson(text) if (!raw) { warnings.push('KI-Antwort nicht lesbar – neutraler Stil'); return { plan: null, model, usage } } return { plan: normalizePlan(raw, sources.length), model, usage } } catch (e: any) { warnings.push(anthropicErrorMessage(e) || e?.message || 'KI nicht erreichbar – neutraler Stil') return { plan: null } } } // ---- Strapi upload -------------------------------------------------------------------- const uploadBundleImage = async (event: any, product: BundleProductInput, buffer: Buffer): Promise => { const config: any = useRuntimeConfig() const uploadBase = (config.api?.strapiupload || (config.public?.strapi || config.api?.strapi || '').replace(/\/media-api\/?$/, '')).replace(/\/$/, '') const stamp = new Date().toISOString().slice(0, 10) const fd: any = new FormData() fd.append('field', 'image') fd.append('ref', 'api::m-product.m-product') fd.append('refId', String(product.strapiId)) fd.append('files', new Blob([buffer], { type: 'image/jpeg' }), `bundle_${product.value || product.id}_${stamp}.jpg`) fd.append('fileInfo', JSON.stringify({ caption: String(product.id), alternativeText: `Bundle ${product.value || ''} ${product.name || ''}`.trim() })) const up: any = await $fetch(`${uploadBase}/api/upload`, { method: 'POST', headers: { Authorization: `Bearer ${config.api?.strapitoken}` }, body: fd }) return Array.isArray(up) ? up[0] : (up?.[0] ?? up) } // ---- entry point ---------------------------------------------------------------------- export const generateBundleImage = async (event: any, product: BundleProductInput, components: BundleComponentInput[]): Promise => { const warnings: string[] = [] const comps = components.filter(c => Number(c.qty) > 0) if (!comps.length) return { status: 422, message: 'Die Stückliste hat keine Komponenten mit Menge > 0', planSource: 'neutral', warnings } if (!product.strapiId || !product.strapiDocumentId) return { status: 422, message: 'Der Artikel hat keinen Medien-Datensatz (Strapi) – bitte zuerst ein Bild in der Galerie hochladen', planSource: 'neutral', warnings } const sources: BundleTileSource[] = [] for (let i = 0; i < comps.length; i++) { const c = comps[i] const label = c.value ? `${c.value} ${c.name}` : c.name const buffer = await loadComponentPhoto(event, c.strapiDocumentId, warnings, label) const opaque = buffer ? !(await hasRealTransparency(buffer)) : true sources.push({ index: i + 1, name: c.name, value: c.value, qty: Number(c.qty), buffer, opaque }) } if (!sources.some(s => s.buffer)) warnings.push('Keine Komponente hat ein Bild – es werden Platzhalter gezeigt') const { plan, model, usage } = await planWithClaude(sources, product.name, warnings) const effective = plan || NEUTRAL_PLAN const rendered = await composeBundleImage(sources, effective) let file: any try { file = await uploadBundleImage(event, product, rendered.buffer) } catch (e: any) { const detail = e?.data ?? e?.response?._data ?? e?.message console.error('[bundle-image] Strapi upload failed:', typeof detail === 'string' ? detail.slice(0, 500) : JSON.stringify(detail || {}).slice(0, 500)) return { status: 502, message: 'Upload des Bundle-Bildes nach Strapi fehlgeschlagen', planSource: plan ? 'claude' : 'neutral', warnings } } if (!file?.id) return { status: 502, message: 'Strapi hat keine Datei-ID zurückgegeben', planSource: plan ? 'claude' : 'neutral', warnings } try { await strapiHelper(event, `m-products/${product.strapiDocumentId}`, 'PUT', { data: { Main_Image: file.id } }) } catch (e: any) { warnings.push('Bild hochgeladen, aber Hauptbild konnte nicht gesetzt werden: ' + (e?.message || '')) } return { status: 200, message: '', imageId: file.id, url: file.url, hash: file.hash, ext: file.ext, formats: file.formats, plan: normalizePlan(effective, sources.length), planSource: plan ? 'claude' : 'neutral', model, usage, tiles: rendered.tiles, repeated: rendered.repeated, warnings } } export { MAX_REPEAT_TILES }