d9519a867e
- 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>
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
/**
|
||
* 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 (~300–500 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, 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<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(() => {});
|
||
}
|
||
}
|
||
}
|
||
}
|