/** * Bundle (BOM) product image — pure compositor, no Nitro / network dependencies. * * Takes the component images of a bill of materials plus a style plan and renders ONE * square product picture that shows the customer everything the bundle contains: * • every component is drawn once per configured quantity while the bundle has at most * MAX_REPEAT_TILES pieces in total (3× mug → three mugs), otherwise each component is * drawn once with a "10×" counter badge (fractional quantities always get a badge); * • tiles are laid out on a centred grid (last row centred), optionally on soft white * cards so photos with their own opaque studio background still blend into a tinted * or gradient backdrop; * • the backdrop / accent colours come from the plan (Claude derives them from the * component photos in bundleImage.ts; NEUTRAL_PLAN is the no-AI fallback). * * Text is rendered through SVG (librsvg) — font-family falls back along * DejaVu Sans → Arial → sans-serif; prod (Debian) has DejaVu + Noto installed. */ import sharp from 'sharp' export const CANVAS = 1200 export const MAX_REPEAT_TILES = 10 export interface BundleTileSource { index: number // 1-based position in the BOM (stable id for the plan) name: string value: string // product Value / article number (placeholder label) qty: number buffer: Buffer | null // original component image, null → placeholder tile opaque: boolean // false = has real transparency (cut-out PNG) } export interface BundlePlan { background: { type: 'solid' | 'gradient'; colors: string[]; angle: number } accent: string // badge / placeholder accent badgeText: string // text colour on the accent arrangement: 'grid' | 'row' tileCard: boolean // draw white rounded cards behind opaque photos components: { index: number; trim: boolean }[] // trim = cut uniform border off the photo rationale?: string } export const NEUTRAL_PLAN: BundlePlan = { background: { type: 'gradient', colors: ['#ffffff', '#eef1f5'], angle: 160 }, accent: '#1f2937', badgeText: '#ffffff', arrangement: 'grid', tileCard: true, components: [], rationale: 'neutral default' } const HEX = /^#([0-9a-f]{6})$/i export const isHex = (v: any): v is string => typeof v === 'string' && HEX.test(v.trim()) const esc = (s: string) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)) /** Relative luminance of a #rrggbb colour (0 dark … 1 light). */ export const luminance = (hex: string): number => { const m = HEX.exec(String(hex).trim()) if (!m) return 1 const [r, g, b] = [0, 2, 4].map(i => parseInt(m[1].slice(i, i + 2), 16) / 255).map(c => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)) return 0.2126 * r + 0.7152 * g + 0.0722 * b } /** Fills gaps / repairs an untrusted plan object so the compositor never has to guard. */ export const normalizePlan = (raw: any, componentCount: number): BundlePlan => { const p: any = raw && typeof raw === 'object' ? raw : {} const bg: any = p.background && typeof p.background === 'object' ? p.background : {} let colors = Array.isArray(bg.colors) ? bg.colors.filter(isHex).map((c: string) => c.trim().toLowerCase()) : [] if (!colors.length) colors = [...NEUTRAL_PLAN.background.colors] if (colors.length === 1) colors = [colors[0], colors[0]] const type: 'solid' | 'gradient' = bg.type === 'solid' ? 'solid' : 'gradient' const angle = Number.isFinite(Number(bg.angle)) ? ((Number(bg.angle) % 360) + 360) % 360 : 160 const accent = isHex(p.accent) ? p.accent.trim().toLowerCase() : NEUTRAL_PLAN.accent const badgeText = isHex(p.badgeText) ? p.badgeText.trim().toLowerCase() : (luminance(accent) > 0.45 ? '#111827' : '#ffffff') const comps: { index: number; trim: boolean }[] = [] for (let i = 1; i <= componentCount; i++) { const c = Array.isArray(p.components) ? p.components.find((x: any) => Number(x?.index) === i) : null comps.push({ index: i, trim: c ? c.trim !== false : true }) } return { background: { type, colors: colors.slice(0, 2), angle }, accent, badgeText, arrangement: p.arrangement === 'row' ? 'row' : 'grid', tileCard: typeof p.tileCard === 'boolean' ? p.tileCard : true, components: comps, rationale: typeof p.rationale === 'string' ? p.rationale.slice(0, 300) : undefined } } export const formatQty = (q: number): string => { if (Number.isInteger(q)) return String(q) return String(Math.round(q * 1000) / 1000).replace('.', ',') } export interface Tile { src: BundleTileSource; badge: string | null } /** Quantity → tiles: repeat while the bundle is small, otherwise one tile + counter badge. */ export const expandTiles = (sources: BundleTileSource[]): { tiles: Tile[]; repeated: boolean } => { const comps = sources.filter(s => s.qty > 0) const total = comps.reduce((a, c) => a + c.qty, 0) const allInteger = comps.every(c => Number.isInteger(c.qty) && c.qty >= 1) const repeated = comps.length > 0 && allInteger && total <= MAX_REPEAT_TILES const tiles: Tile[] = repeated ? comps.flatMap(c => Array.from({ length: c.qty }, () => ({ src: c, badge: null }))) : comps.map(c => ({ src: c, badge: c.qty !== 1 ? formatQty(c.qty) + '×' : null })) return { tiles, repeated } } // ---- geometry ----------------------------------------------------------------------- interface Cell { x: number; y: number; size: number } export const layoutCells = (n: number, arrangement: 'grid' | 'row', canvas = CANVAS): Cell[] => { if (n <= 0) return [] const pad = Math.round(canvas * 0.06) const gap = Math.round(canvas * 0.024) const cols = arrangement === 'row' && n <= 4 ? n : Math.ceil(Math.sqrt(n)) const rows = Math.ceil(n / cols) const cellW = (canvas - 2 * pad - gap * (cols - 1)) / cols const cellH = (canvas - 2 * pad - gap * (rows - 1)) / rows // single item: don't blow a lone photo up to the full canvas const size = Math.floor(Math.min(cellW, cellH, n === 1 ? canvas * 0.72 : canvas)) const gridH = rows * size + gap * (rows - 1) const oy = Math.round((canvas - gridH) / 2) const cells: Cell[] = [] for (let r = 0; r < rows; r++) { const count = Math.min(cols, n - r * cols) const rowW = count * size + gap * (count - 1) const ox = Math.round((canvas - rowW) / 2) for (let c = 0; c < count; c++) cells.push({ x: ox + c * (size + gap), y: oy + r * (size + gap), size }) } return cells } // ---- SVG pieces --------------------------------------------------------------------- const FONT = "DejaVu Sans, Noto Sans, Arial, Helvetica, sans-serif" const backgroundSvg = (plan: BundlePlan, canvas: number): Buffer => { const [c1, c2] = plan.background.colors const a = (plan.background.angle * Math.PI) / 180 const x1 = 50 - Math.cos(a) * 50, y1 = 50 - Math.sin(a) * 50, x2 = 50 + Math.cos(a) * 50, y2 = 50 + Math.sin(a) * 50 const fill = plan.background.type === 'solid' ? c1 : 'url(#g)' return Buffer.from(` `) } const cardSvg = (size: number): Buffer => { const r = Math.round(size * 0.07) const m = Math.round(size * 0.06) // margin for the shadow return Buffer.from(` `) } const badgeSvg = (text: string, cell: number, accent: string, textColor: string): { svg: Buffer; w: number; h: number } => { const h = Math.round(Math.max(46, cell * 0.17)) const fs = Math.round(h * 0.52) const w = Math.round(Math.max(h, fs * 0.62 * text.length + h * 0.55)) const svg = Buffer.from(` ${esc(text)} `) return { svg, w, h } } /** Placeholder for a component without a photo: box icon + article number. */ const placeholderSvg = (size: number, label: string, accent: string): Buffer => { const r = Math.round(size * 0.07) const icon = size * 0.34 const ix = (size - icon) / 2, iy = size * 0.22 const fs = Math.round(Math.max(18, size * 0.075)) const text = esc(label.length > 18 ? label.slice(0, 17) + '…' : label) return Buffer.from(` ${text} `) } // ---- image preparation -------------------------------------------------------------- /** true when the image carries an alpha channel that is actually used somewhere. */ export const hasRealTransparency = async (buffer: Buffer): Promise => { try { const meta = await sharp(buffer).metadata() if (!meta.hasAlpha) return false const stats = await sharp(buffer).stats() return stats.isOpaque === false } catch { return false } } const prepareImage = async (src: BundleTileSource, inner: number, trim: boolean): Promise<{ buf: Buffer; w: number; h: number } | null> => { if (!src.buffer) return null let img = sharp(src.buffer, { failOn: 'none' }).rotate() // honour EXIF orientation if (trim && src.opaque) { // cut a uniform studio background border so the object fills its tile try { const trimmed = await sharp(src.buffer, { failOn: 'none' }).rotate().trim({ threshold: 22 }).toBuffer() const m = await sharp(trimmed).metadata() if ((m.width || 0) >= 24 && (m.height || 0) >= 24) img = sharp(trimmed) } catch { /* entirely uniform image → keep original */ } } const out = await img.resize(inner, inner, { fit: 'inside', withoutEnlargement: false, kernel: 'lanczos3' }).png().toBuffer({ resolveWithObject: true }) return { buf: out.data, w: out.info.width, h: out.info.height } } // ---- main --------------------------------------------------------------------------- export interface ComposeResult { buffer: Buffer; tiles: number; repeated: boolean; width: number; height: number } export const composeBundleImage = async (sources: BundleTileSource[], planIn: Partial | null | undefined, canvas = CANVAS): Promise => { const plan = normalizePlan(planIn, sources.length) const { tiles, repeated } = expandTiles(sources) if (!tiles.length) throw new Error('Keine Komponenten mit Menge > 0') const cells = layoutCells(tiles.length, plan.arrangement, canvas) const layers: sharp.OverlayOptions[] = [] for (let i = 0; i < tiles.length; i++) { const t = tiles[i], cell = cells[i] const trim = plan.components.find(c => c.index === t.src.index)?.trim !== false const useCard = plan.tileCard && (t.src.opaque || !t.src.buffer) if (!t.src.buffer) { layers.push({ input: placeholderSvg(cell.size, t.src.value || t.src.name || '?', plan.accent), left: cell.x, top: cell.y }) } else { if (useCard) { const m = Math.round(cell.size * 0.06) layers.push({ input: cardSvg(cell.size), left: cell.x - m, top: cell.y - m }) } const inner = Math.round(cell.size * (useCard ? 0.82 : 0.92)) const img = await prepareImage(t.src, inner, trim) if (img) layers.push({ input: img.buf, left: Math.round(cell.x + (cell.size - img.w) / 2), top: Math.round(cell.y + (cell.size - img.h) / 2) }) } if (t.badge) { const b = badgeSvg(t.badge, cell.size, plan.accent, plan.badgeText) const left = Math.min(canvas - b.w - 8, Math.round(cell.x + cell.size - b.w + b.h * 0.25)) const top = Math.max(8, Math.round(cell.y - b.h * 0.3)) layers.push({ input: b.svg, left, top }) } } const buffer = await sharp(backgroundSvg(plan, canvas)).composite(layers).jpeg({ quality: 92, chromaSubsampling: '4:4:4', mozjpeg: true }).toBuffer() return { buffer, tiles: tiles.length, repeated, width: canvas, height: canvas } } /** Small JPEG preview of a component photo for the vision prompt (transparent → white). */ export const previewForVision = async (buffer: Buffer, max = 384): Promise => sharp(buffer, { failOn: 'none' }).rotate().resize(max, max, { fit: 'inside', withoutEnlargement: true }).flatten({ background: '#ffffff' }).jpeg({ quality: 78 }).toBuffer()