/**
* 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(``)
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(``)
}
// ---- 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()