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
+30 -16
View File
@@ -9,7 +9,7 @@ import {
} from "./cms";
import type { RowContentLayout } from "./block-types";
import type { CalendarItemData } from "./block-types";
import { ensureTransformedImage } from "./rusty-image";
import { ensureTransformedImage, extractCmsImageField, type CmsImageField } from "./rusty-image";
/** Aus der Tag-API: Anzeigename plus optionales Icon und Farbe (CMS-Felder icon / color). */
export type TagMeta = {
@@ -124,18 +124,28 @@ export function filterPostsByTag(
export function getPostImageUrl(
postImage: PostEntry["postImage"],
): string | null {
if (!postImage || typeof postImage === "string") return null;
return getPostImageField(postImage)?.url ?? null;
}
/**
* URL + Focal + Alt aus post.postImage. Unterstützt RustyCMS-image-Typ
* und legacy file.url / fields.file.url.
*/
export function getPostImageField(
postImage: PostEntry["postImage"],
): CmsImageField | null {
if (!postImage) return null;
if (typeof postImage === "string") return extractCmsImageField(postImage);
const field = extractCmsImageField(postImage);
if (field) return field;
const obj = postImage as {
src?: string;
file?: { url?: string };
fields?: { file?: { url?: string } };
};
const url = obj?.src ?? obj?.file?.url ?? obj?.fields?.file?.url;
return typeof url === "string"
? url.startsWith("//")
? `https:${url}`
: url
: null;
const legacy = obj?.fields?.file?.url;
if (typeof legacy === "string" && legacy.trim()) {
return { url: legacy.startsWith("//") ? `https:${legacy}` : legacy };
}
return null;
}
/** Filtert Posts: behält nur solche, die mindestens einen der Tag-Slugs haben. */
@@ -237,14 +247,16 @@ export async function loadPostsList(opts: {
const tagsMap = await getTagsMap();
for (const p of posts) resolvePostTagsInPost(p, tagsMap);
for (const post of posts) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(raw, {
const url = await ensureTransformedImage(field.url, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
focalX: field.focal?.x,
focalY: field.focal?.y,
});
(post as PostEntry & { _resolvedImageUrl?: string })._resolvedImageUrl = url;
} catch {
@@ -417,14 +429,16 @@ export async function resolvePostOverviewBlocks(
for (const post of posts) resolvePostTagsInPost(post, tagsMap);
}
for (const post of item.postsResolved as PostEntry[]) {
const raw = getPostImageUrl(post.postImage);
if (raw) {
const field = getPostImageField(post.postImage);
if (field) {
try {
const url = await ensureTransformedImage(raw, {
const url = await ensureTransformedImage(field.url, {
width: 400,
height: 267,
fit: "cover",
format: "webp",
focalX: field.focal?.x,
focalY: field.focal?.y,
});
(
post as PostEntry & { _resolvedImageUrl?: string }
+87 -14
View File
@@ -1,23 +1,67 @@
<script lang="ts">
/**
* TopBanner: Fullwidth-Banner mit Bild (über RustyCMS-Transform-API aufgelöst), optionalem Text
* und Titel/Subtitle der aktuellen Page (Markdown).
* TopBanner: Fullwidth-Banner mit Bild (über /cms-images aufgelöst), optionalem Text
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit AVIF/WebP-Sources
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
*/
import { marked } from "marked";
import type { FullwidthBannerEntry } from "$lib/cms";
marked.setOptions({ gfm: true });
interface ResponsiveBannerImage {
sources: { type: string; srcset: string }[];
fallback: string;
width: number;
height: number;
/** Optionaler LQIP (data-URL) als Unschärfe-Placeholder beim Laden. */
lqip?: string;
}
interface Props {
banner: FullwidthBannerEntry | null;
/** Einfache (legacy) einzelne URL(s) — wenn `responsive` fehlt. */
resolvedImages?: string[];
/** Bevorzugter Pfad: fertig berechneter `<picture>`-Inhalt fürs erste Banner-Bild. */
responsive?: ResponsiveBannerImage | null;
/** `sizes`-Attribut fürs srcset (Default: volle Viewport-Breite). */
sizes?: string;
/** Focal-Point als CSS-`object-position` (z. B. "69.5% 40.38%"). */
focalCss?: string | null;
/** Headline der Page (Markdown). */
headline?: string;
/** Subheadline der Page (Markdown). */
subheadline?: string;
}
let { banner = null, resolvedImages = [], headline, subheadline }: Props = $props();
let {
banner = null,
resolvedImages = [],
responsive = null,
sizes = "100vw",
focalCss = null,
headline,
subheadline,
}: Props = $props();
const imgStyle = $derived(
focalCss ? `object-position: ${focalCss};` : undefined,
);
/**
* LQIP: Blurhash-Placeholder als `background-image` auf dem Wrapper.
* Kein Blur-Filter nötig — der aus 32×32 hochskalierte JPEG ist intrinsisch weich.
* `background-position` folgt dem Focal, damit der Placeholder identisch zum Image croppt.
*/
const wrapperStyle = $derived.by(() => {
const parts: string[] = [];
if (responsive?.lqip) {
parts.push(`background-image: url("${responsive.lqip}")`);
parts.push("background-size: cover");
parts.push(`background-position: ${focalCss ?? "center"}`);
}
return parts.length > 0 ? parts.join("; ") : undefined;
});
const headlineHtml = $derived(
headline?.trim() ? (marked.parseInline(headline) as string) : "",
@@ -26,25 +70,54 @@
subheadline?.trim() ? (marked.parseInline(subheadline) as string) : "",
);
const hasImage = $derived(resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim()));
const hasResponsive = $derived(
responsive != null && responsive.fallback.trim().length > 0,
);
const hasImage = $derived(
hasResponsive || (resolvedImages.length > 0 && Boolean(resolvedImages[0]?.trim())),
);
const hasText = $derived(banner?.text != null && banner.text !== "");
const hasPageTitle = $derived((headline != null && headline !== "") || (subheadline != null && subheadline !== ""));
const showBanner = $derived(hasImage || hasText || hasPageTitle);
const altText = $derived(banner?.headline ?? headline ?? "");
</script>
{#if showBanner}
<div class="w-full">
{#if hasImage}
<div class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white">
<img
src={resolvedImages[0]}
alt={banner?.headline ?? headline ?? ""}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={1920}
height={600}
loading="eager"
fetchpriority="high"
/>
<div
class="w-full overflow-hidden relative flex justify-center items-center shadow-lg border-b border-white bg-zinc-200"
style={wrapperStyle}
>
{#if hasResponsive && responsive}
<picture>
{#each responsive.sources as s (s.type)}
<source type={s.type} srcset={s.srcset} sizes={sizes} />
{/each}
<img
src={responsive.fallback}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={responsive.width}
height={responsive.height}
style={imgStyle}
loading="eager"
fetchpriority="high"
decoding="async"
/>
</picture>
{:else}
<img
src={resolvedImages[0]}
alt={altText}
class="w-full object-cover max-h-[400px] lg:max-h-[600px] max-w-[1920px] aspect-square sm:aspect-video lg:aspect-24/9"
width={1920}
height={600}
style={imgStyle}
loading="eager"
fetchpriority="high"
/>
{/if}
{#if hasPageTitle}
<div class="absolute inset-0 flex items-center justify-start ">
<div class="container-custom mx-auto px-6 relative">
+8
View File
@@ -27,6 +27,8 @@ export interface ImageTransformParams {
format?: string;
fit?: string;
ar?: string;
fx?: number;
fy?: number;
}
const memoryCache = new Map<string, CachedImage>();
@@ -45,6 +47,10 @@ function ensureCacheDir(): string {
* Deterministischer Cache-Key aus src + allen Transform-Parametern.
* Gleiche Eingabe = gleicher Key, unabhängig von Reihenfolge.
*/
function roundFocal(v: number): number {
return Math.round(v * 10000) / 10000;
}
export function buildCacheKey(src: string, params: ImageTransformParams): string {
const parts = [src];
if (params.w != null) parts.push(`w=${params.w}`);
@@ -53,6 +59,8 @@ export function buildCacheKey(src: string, params: ImageTransformParams): string
if (params.format) parts.push(`f=${params.format}`);
if (params.fit) parts.push(`fit=${params.fit}`);
if (params.ar) parts.push(`ar=${params.ar}`);
if (params.fx != null) parts.push(`fx=${roundFocal(params.fx)}`);
if (params.fy != null) parts.push(`fy=${roundFocal(params.fy)}`);
return createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
}
+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 } : {}),
};
}
+81 -22
View File
@@ -3,30 +3,60 @@
* Der Endpoint holt bei Bedarf vom CMS /api/transform und legt Bytes unter IMAGE_CACHE_DIR ab.
*/
export interface CmsFocal {
x: number;
y: number;
}
export interface CmsImageField {
url: string;
focal?: CmsFocal;
alt?: string;
}
/**
* Normalisiert CMS-Loopback-URLs (127.0.0.1) zur konfigurierten PUBLIC_CMS_URL.
* Für Browser-seitigen Code (kein Node.js/fs nötig).
* Extrahiert URL + Focal + Alt aus einem CMS-Image-Field (string oder {src, alt?, focal?}).
* Normalisiert Protokoll-lose URLs zu https:.
*/
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
export function extractCmsImageField(field: unknown): CmsImageField | null {
if (typeof field === 'string') {
const s = field.trim();
if (!s) return null;
return s.startsWith('//') ? `https:${s}` : s;
return { url: s.startsWith('//') ? `https:${s}` : s };
}
if (field && typeof field === 'object') {
const o = field as { src?: string; file?: { url?: string } };
const u =
const o = field as {
src?: string;
file?: { url?: string };
focal?: { x?: number; y?: number };
alt?: string;
};
const rawUrl =
typeof o.src === 'string' && o.src.trim()
? o.src
: typeof o.file?.url === 'string' && o.file.url.trim()
? o.file.url
: undefined;
if (!u) return null;
return u.startsWith('//') ? `https:${u}` : u;
if (!rawUrl) return null;
const url = rawUrl.startsWith('//') ? `https:${rawUrl}` : rawUrl;
const fx = typeof o.focal?.x === 'number' ? o.focal.x : undefined;
const fy = typeof o.focal?.y === 'number' ? o.focal.y : undefined;
return {
url,
...(fx != null && fy != null ? { focal: { x: fx, y: fy } } : {}),
...(typeof o.alt === 'string' ? { alt: o.alt } : {}),
};
}
return null;
}
/**
* Legacy: nur URL. Bevorzuge extractCmsImageField für Focal/Alt.
*/
export function absoluteUrlFromCmsImageField(field: unknown): string | null {
return extractCmsImageField(field)?.url ?? null;
}
export interface RustyImageTransformParams {
width?: number;
height?: number;
@@ -34,6 +64,14 @@ export interface RustyImageTransformParams {
fit?: 'fill' | 'contain' | 'cover';
format?: 'jpeg' | 'png' | 'webp' | 'avif';
quality?: number;
/** Focal-X in [0,1] (Default 0.5) — für fit:cover-Crops. */
focalX?: number;
/** Focal-Y in [0,1] (Default 0.5). */
focalY?: number;
}
function roundFocal(v: number): number {
return Math.round(v * 10000) / 10000;
}
/**
@@ -49,6 +87,8 @@ export function getCmsImageUrl(src: string, params: RustyImageTransformParams =
if (params.fit != null) searchParams.set('fit', params.fit);
if (params.format != null) searchParams.set('format', params.format);
if (params.quality != null) searchParams.set('q', String(params.quality));
if (params.focalX != null) searchParams.set('fx', String(roundFocal(params.focalX)));
if (params.focalY != null) searchParams.set('fy', String(roundFocal(params.focalY)));
return `/cms-images?${searchParams.toString()}`;
}
@@ -64,7 +104,7 @@ export async function ensureTransformedImage(url: string, params: RustyImageTran
return getCmsImageUrl(url, params);
}
type ImageArrayItem = string | { src?: string };
type ImageArrayItem = string | { src?: string; focal?: { x?: number; y?: number } };
function toImageUrl(item: ImageArrayItem): string | null {
if (typeof item === 'string') return item.startsWith('//') ? `https:${item}` : item;
@@ -74,10 +114,20 @@ function toImageUrl(item: ImageArrayItem): string | null {
}
export async function resolveImageUrls(items: ImageArrayItem[], params: RustyImageTransformParams = {}): Promise<string[]> {
return items
.map(toImageUrl)
.filter((u): u is string => u !== null && u.startsWith('http'))
.map((u) => getCmsImageUrl(u, params));
const out: string[] = [];
for (const item of items) {
const field = extractCmsImageField(item);
if (!field || !field.url.startsWith('http')) continue;
out.push(
getCmsImageUrl(field.url, {
...params,
...(field.focal
? { focalX: params.focalX ?? field.focal.x, focalY: params.focalY ?? field.focal.y }
: {}),
}),
);
}
return out;
}
export interface RowContentLayoutLike {
@@ -169,9 +219,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
if (block._type === 'image') {
const raw = block.image ?? block.img;
const url = absoluteUrlFromCmsImageField(raw);
if (url) {
block.resolvedImageSrc = getCmsImageUrl(url, transformParams);
const field = extractCmsImageField(raw);
if (field) {
block.resolvedImageSrc = getCmsImageUrl(field.url, {
...transformParams,
...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}),
});
}
continue;
}
@@ -179,9 +232,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
if (block._type === 'image_gallery' && Array.isArray(block.images)) {
for (const img of block.images) {
if (!img || typeof img !== 'object') continue;
const url = absoluteUrlFromCmsImageField(img);
if (url) {
(img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(url, galleryParams);
const field = extractCmsImageField(img);
if (field) {
(img as { resolvedSrc?: string }).resolvedSrc = getCmsImageUrl(field.url, {
...galleryParams,
...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}),
});
}
}
continue;
@@ -191,9 +247,12 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
for (const org of block.organisations) {
if (!org || typeof org !== 'object') continue;
const o = org as { logo?: unknown; resolvedLogoSrc?: string };
const url = absoluteUrlFromCmsImageField(o.logo);
if (url) {
o.resolvedLogoSrc = getCmsImageUrl(url, orgLogoParams);
const field = extractCmsImageField(o.logo);
if (field) {
o.resolvedLogoSrc = getCmsImageUrl(field.url, {
...orgLogoParams,
...(field.focal ? { focalX: field.focal.x, focalY: field.focal.y } : {}),
});
}
}
continue;