/** * 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; media?: 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 } : {}), }; // CMS-Transform wendet `ar` nur an, wenn auch `h` mitkommt — `ar` allein // wird ignoriert und das Bild behält sein intrinsisches Seitenverhältnis // (gleiches Workaround wie in RustyImage.svelte). Ohne h liefert z.B. die // 1/1-Mobile-Variante ein 1.92:1-Bild, das object-cover unscharf hochskaliert. const heightFor = (w: number): number | undefined => opts.ar && ar && fit !== 'contain' ? Math.round(w / ar) : undefined; const sources = formats.map((fmt) => { const srcset = widths .map((w) => { const h = heightFor(w); const u = getCmsImageUrl(url, { ...baseParams, width: w, ...(h ? { height: h } : {}), format: fmt, }); return `${u} ${w}w`; }) .join(', '); return { type: MIME_FOR_FORMAT[fmt], srcset }; }); const fallbackWidth = widths[widths.length - 1] ?? maxW; const fallbackHeight = heightFor(fallbackWidth); const fallbackFormat: 'jpeg' | 'webp' = formats.includes('jpeg') ? 'jpeg' : 'webp'; const fallback = getCmsImageUrl(url, { ...baseParams, width: fallbackWidth, ...(fallbackHeight ? { height: fallbackHeight } : {}), 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 } : {}), }; } /** * TopBanner-Varianten pro CSS-Breakpoint. TopBanner.svelte croppt per CSS: * <640px auf 1/1, 640–1023 auf 16/9, ab 1024 auf 24/9. Ohne eigene Quelle pro * Breakpoint lädt Mobile das breite Bild und skaliert es fürs Square-Crop * unscharf hoch. Kein AVIF: der 5-10× langsamere Encoder staut bei kaltem * Cache alle Varianten gleichzeitig — der erste Besucher hängt dann sichtbar * lange auf dem LQIP. WebP q60 holt den Großteil der Ersparnis ohne das Risiko. */ export async function buildBannerResponsive( url: string, focal?: CmsImageField['focal'], ): Promise { const formats: Array<'avif' | 'webp' | 'jpeg'> = ['webp', 'jpeg']; const quality = 60; const [mobile, tablet, desktop] = await Promise.all([ buildResponsiveImage(url, { widths: [480, 828, 1080], ar: '1/1', fit: 'cover', quality, focal, formats, lqip: false, }), buildResponsiveImage(url, { widths: [768, 1024, 1536, 2048], ar: '16/9', fit: 'cover', quality, focal, formats, lqip: false, }), buildResponsiveImage(url, { widths: [1024, 1440, 1920], ar: '24/9', fit: 'cover', quality, focal, formats, }), ]); if (!desktop) return null; const sources = [ ...(mobile?.sources.map((s) => ({ ...s, media: '(max-width: 639px)' })) ?? []), ...(tablet?.sources.map((s) => ({ ...s, media: '(min-width: 640px) and (max-width: 1023px)', })) ?? []), ...desktop.sources.map((s) => ({ ...s, media: '(min-width: 1024px)' })), ]; return { ...desktop, sources }; } /** * 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(() => {}); } } } }