Files
windwiderstand/src/lib/rusty-image.server.ts
T
Peter Meier da294010c8
Deploy / verify (push) Successful in 50s
Deploy / deploy (push) Failing after 1m3s
feat: update site URLs and enhance image handling
- Changed site URLs in docker-compose and deployment configurations to remove "www" prefix for consistency.
- Improved image handling by introducing warmPostCardImages function for pre-fetching images in posts and tags.
- Enhanced PostCard component with responsive image sizes for better performance.
- Updated layout to include robots meta tag and JSON-LD structured data for improved SEO.
- Added loading state for images in TopBanner component to enhance user experience.
2026-04-18 12:25:45 +02:00

290 lines
8.0 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 + process.env.
*/
import { env } from '$env/dynamic/public';
import {
getCmsImageUrl,
type RustyImageTransformParams,
type CmsImageField,
} from './rusty-image';
function getCmsBaseUrl(): string {
return (env.PUBLIC_CMS_URL || process.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 }[];
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 }
: {}),
};
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<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(() => {});
}
}
}
}