feat(image): enhance image handling with focal points and responsive support
- 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:
@@ -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 (~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 }[];
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user