feat(image): enhance image handling with focal points and responsive support
Deploy / verify (push) Failing after 14s
Deploy / deploy (push) Has been skipped

- Added support for extracting focal points and alt text from CMS image fields.
- Introduced responsive image handling in TopBanner component, allowing for AVIF/WebP sources and LQIP placeholders.
- Updated image transformation functions to accommodate focal point parameters for better cropping.
- Refactored image URL resolution logic across various components and routes to utilize new image field structure.
This commit is contained in:
Peter Meier
2026-04-18 00:42:33 +02:00
parent 4d79bf08da
commit a139372ce2
12 changed files with 734 additions and 61 deletions
+234
View File
@@ -0,0 +1,234 @@
/**
* 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'> = ['avif', '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 } : {}),
};
}