/** * Server-only Bild-Helfer: Asset-Meta vom CMS, Blurhash-LQIP, Responsive-Srcset. * Nur in `+page.server.ts` / `+layout.server.ts` importieren — nutzt sharp + blurhash. */ import { env } from '$env/dynamic/public'; import { getCmsImageUrl, type RustyImageTransformParams, type CmsImageField, } from './rusty-image'; function getCmsBaseUrl(): string { return (env.PUBLIC_CMS_URL || 'http://localhost:3000').replace(/\/$/, ''); } export interface AssetMeta { width: number; height: number; hasAlpha: boolean; mimeType: string; blurhash?: string; } const metaCache = new Map>(); function toMetaEndpoint(url: string): string | null { try { const u = new URL(url); const base = getCmsBaseUrl(); const baseHost = new URL(base).host; if (u.host !== baseHost && !/^127\.0\.0\.1|localhost/.test(u.host)) { return null; } u.searchParams.set('meta', 'true'); return `${base}${u.pathname}${u.search}`; } catch { return null; } } /** * Holt AssetInfo vom CMS: `/api/assets/?meta=true`. * Cached per URL für die Prozess-Lebensdauer. */ export async function fetchAssetMeta(url: string): Promise { const endpoint = toMetaEndpoint(url); if (!endpoint) return null; const cached = metaCache.get(endpoint); if (cached) return cached; const p = (async () => { try { const res = await fetch(endpoint); if (!res.ok) return null; const json = (await res.json()) as { width?: number; height?: number; has_alpha?: boolean; mime_type?: string; blurhash?: string; }; if ( typeof json?.width !== 'number' || typeof json?.height !== 'number' || json.width <= 0 || json.height <= 0 ) { return null; } return { width: json.width, height: json.height, hasAlpha: Boolean(json.has_alpha), mimeType: json.mime_type ?? 'image/jpeg', ...(typeof json.blurhash === 'string' && json.blurhash.trim() ? { blurhash: json.blurhash } : {}), } satisfies AssetMeta; } catch { return null; } })(); metaCache.set(endpoint, p); return p; } const blurhashCache = new Map>(); /** * Dekodiert Blurhash → 32×32 RGBA → JPEG Q40 → base64 data-URL. * Liefert kompakten LQIP (~300–500 B) für CSS-`background-image`. */ export async function blurhashToDataUrl( hash: string, width = 32, height = 32, ): Promise { const key = `${hash}|${width}x${height}`; const cached = blurhashCache.get(key); if (cached) return cached; const p = (async () => { try { const [{ decode }, sharpMod] = await Promise.all([ import('blurhash'), import('sharp'), ]); const pixels = decode(hash, width, height); const sharp = sharpMod.default; const jpeg = await sharp(Buffer.from(pixels), { raw: { width, height, channels: 4 }, }) .jpeg({ quality: 40 }) .toBuffer(); return `data:image/jpeg;base64,${jpeg.toString('base64')}`; } catch { return null; } })(); blurhashCache.set(key, p); return p; } export interface ResponsiveImage { sources: { type: string; srcset: string }[]; fallback: string; width: number; height: number; widths: number[]; lqip?: string; } export interface ResponsiveImageOptions { widths?: number[]; ar?: string; fit?: RustyImageTransformParams['fit']; formats?: Array<'avif' | 'webp' | 'jpeg'>; quality?: number; focal?: CmsImageField['focal']; lqip?: boolean; } const DEFAULT_WIDTHS = [640, 960, 1280, 1600, 1920, 2400]; const DEFAULT_FORMATS: Array<'avif' | 'webp' | 'jpeg'> = ['webp', 'jpeg']; const MIME_FOR_FORMAT: Record<'avif' | 'webp' | 'jpeg', string> = { avif: 'image/avif', webp: 'image/webp', jpeg: 'image/jpeg', }; function parseAr(ar: string | undefined): number | null { if (!ar) return null; const m = ar.match(/^(\d+(?:\.\d+)?)\s*[/:x]\s*(\d+(?:\.\d+)?)$/); if (!m) return null; const w = Number(m[1]); const h = Number(m[2]); if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return null; return w / h; } /** * Baut ``-Daten (AVIF/WebP/JPEG srcset + Fallback + LQIP). * Breiten werden gegen die echte Bildbreite aus `fetchAssetMeta` geclampt (kein Upscaling). * Alle URLs zeigen auf `/cms-images?…` (erst bei Client-Request wird gerendert & gecached). */ export async function buildResponsiveImage( url: string, opts: ResponsiveImageOptions = {}, ): Promise { const meta = await fetchAssetMeta(url); const widthsRaw = (opts.widths ?? DEFAULT_WIDTHS).filter((w) => Number.isFinite(w) && w > 0); if (widthsRaw.length === 0) return null; const maxW = meta?.width ?? Math.max(...widthsRaw); const widths = [...new Set(widthsRaw.map((w) => Math.min(w, maxW)).filter((w) => w > 0))].sort( (a, b) => a - b, ); if (widths.length === 0) return null; const ar = parseAr(opts.ar) ?? (meta?.width && meta?.height ? meta.width / meta.height : null); const formats = opts.formats ?? DEFAULT_FORMATS; const fit = opts.fit ?? 'cover'; const quality = opts.quality ?? 78; const baseParams: RustyImageTransformParams = { fit, quality, ...(opts.ar ? { ar: opts.ar } : {}), ...(opts.focal ? { focalX: opts.focal.x, focalY: opts.focal.y } : {}), }; const sources = formats.map((fmt) => { const srcset = widths .map((w) => { const u = getCmsImageUrl(url, { ...baseParams, width: w, format: fmt }); return `${u} ${w}w`; }) .join(', '); return { type: MIME_FOR_FORMAT[fmt], srcset }; }); const fallbackWidth = widths[widths.length - 1] ?? maxW; const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp'; const fallback = getCmsImageUrl(url, { ...baseParams, width: fallbackWidth, format: fallbackFormat, }); const width = fallbackWidth; const height = ar ? Math.round(width / ar) : meta?.height ?? Math.round(width * 0.5625); const lqip = opts.lqip !== false && meta?.blurhash ? ((await blurhashToDataUrl(meta.blurhash)) ?? undefined) : undefined; return { sources, fallback, width, height, widths, ...(lqip ? { lqip } : {}), }; } /** * Fire-and-forget Cache-Warmup: alle srcset-Varianten + Fallback parallel anrequesten. * Nutzt SvelteKit's `event.fetch` für Self-Calls (keine echten HTTP-Roundtrips in Prod-SSR). * Blockiert den Response NICHT — läuft im Hintergrund weiter. */ export function warmResponsiveImage( responsive: ResponsiveImage | null, fetchFn: typeof fetch, ): void { if (!responsive) return; const urls = new Set(); urls.add(responsive.fallback); for (const s of responsive.sources) { for (const entry of s.srcset.split(',')) { const url = entry.trim().split(/\s+/)[0]; if (url) urls.add(url); } } for (const u of urls) { void fetchFn(u).catch(() => {}); } } /** * Fire-and-forget Warmup für PostCard-Bilder (Grid: 400px base, 2x density, webp+jpeg). * Spiegelt die URLs, die zur Laufzeit anfragt. */ export function warmPostCardImages( posts: Array<{ _rawImageUrl?: string; _imageFocal?: { x: number; y: number } | null }>, fetchFn: typeof fetch, limit = 6, ): void { const widths = [400, 800]; const formats: Array<'webp' | 'jpeg'> = ['webp', 'jpeg']; for (const post of posts.slice(0, limit)) { const raw = post._rawImageUrl; if (!raw) continue; const focal = post._imageFocal ?? undefined; for (const w of widths) { for (const format of formats) { const url = getCmsImageUrl(raw, { width: w, height: Math.round((w / 3) * 2), fit: 'cover', format, quality: 75, ar: '3/2', ...(focal ? { focalX: focal.x, focalY: focal.y } : {}), }); void fetchFn(url).catch(() => {}); } } } }