Files
windwiderstand/src/lib/rusty-image.server.ts
T
Peter Meier d9519a867e
Deploy / verify (push) Successful in 1m3s
Deploy / deploy (push) Successful in 1m47s
perf: LCP-Fix, Banner-Crops, SSR-Cache, Bulk-Windkarte
- TopBanner: JS-Fade-Gate entfernt (opacity-0 bis Hydration schob LCP
  von ~3s auf ~8.6s) — Bild malt jetzt progressiv über den LQIP
- buildBannerResponsive: Breakpoint-Varianten (1/1, 16/9, 24/9) mit
  media-Attributen, quality 60; CMS braucht explizites h neben ar,
  sonst kein Crop (Mobile bekam 1.92:1 unscharf hochskaliert)
- Layout-Preload: ein Link pro Breakpoint-Variante (WebP)
- Layout-Bootstrap-Batch + Logo-SVG-Fetch TTL-gecacht (liefen pro
  SSR-Request); invalidateCollection versteht CSV-Key-Heads
- WindkarteBlock: 40 Einzel-Fetches -> 1 List-Request mit Filter
- app.html: preconnect cms.pm86.de, api.iconify.design, analytics
- u-url Microformat: echte canonicalUrl statt href="#" (SEO-Audit)
- Icon-Subset: 'cloud' ergänzt (kein Runtime-Fetch an iconify mehr)
- StrommixBlock: nutzloses transition-colors entfernt (CLS-Audit)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 09:50:01 +02:00

356 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Promise<AssetMeta | null>>();
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/<path>?meta=true`.
* Cached per URL für die Prozess-Lebensdauer.
*/
export async function fetchAssetMeta(url: string): Promise<AssetMeta | null> {
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<string, Promise<string | null>>();
/**
* Dekodiert Blurhash → 32×32 RGBA → JPEG Q40 → base64 data-URL.
* Liefert kompakten LQIP (~300500 B) für CSS-`background-image`.
*/
export async function blurhashToDataUrl(
hash: string,
width = 32,
height = 32,
): Promise<string | null> {
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 `<picture>`-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<ResponsiveImage | null> {
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, 6401023 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<ResponsiveImage | null> {
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<string>();
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 <RustyImage width={400} aspect="3/2"/> 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(() => {});
}
}
}
}