feat: trying to make image generation faster

This commit is contained in:
Max Richter
2025-10-24 19:39:31 +02:00
parent 9eadefdb51
commit 71f778671d
3 changed files with 61 additions and 115 deletions

View File

@@ -1,8 +1,7 @@
--- ---
import type { ImageMetadata } from "astro"; import type { ImageMetadata } from "astro";
import { Picture as AstroImage } from "astro:assets"; import { Picture as AstroImage } from "astro:assets";
import { inferRemoteSize } from "astro/assets/utils"; import { generateThumbHash, getImageBuffer, getExifData } from "@helpers/image";
import { generateThumbHash, getExifData } from "@helpers/image";
interface Props { interface Props {
src: ImageMetadata & { fsPath?: string; src?: string }; src: ImageMetadata & { fsPath?: string; src?: string };
alt: string; alt: string;
@@ -14,40 +13,18 @@ interface Props {
maxWidth?: number; maxWidth?: number;
} }
async function checkImage(image: ImageMetadata) {
const src = typeof image === "string" ? image : image.src;
if (!src) return false;
try {
if (src.startsWith("/@fs") || src.startsWith("/_astro")) return true;
const res = await inferRemoteSize(src);
if (res.format) {
image.format = res.format;
return true;
} else {
console.log("Failed to load: ", src);
}
return false;
} catch (err) {
console.log("\n");
console.log("Failed to fetch: ", src);
return false;
}
}
const { const {
src: image, src: image,
loader = true, loader = true,
caption,
pictureClass = "", pictureClass = "",
hash = true, hash = true,
alt, alt,
maxWidth, maxWidth,
} = Astro.props; } = Astro.props;
let thumbhash = hash && (await generateThumbHash(image)); const imageBuffer = await getImageBuffer(image);
const imageOk = await checkImage(image); let thumbhash = imageBuffer && (await generateThumbHash(imageBuffer));
let exif = imageBuffer && (await getExifData(imageBuffer));
let exif = imageOk && (await getExifData(image));
const sizes = [ const sizes = [
{ {
@@ -68,23 +45,19 @@ const sizes = [
].filter((size) => !maxWidth || size.width <= maxWidth); ].filter((size) => !maxWidth || size.width <= maxWidth);
--- ---
{ <AstroImage
imageOk ? ( src={image}
<AstroImage alt={alt}
src={image} data-thumbhash={thumbhash}
alt={alt} data-exif={JSON.stringify(exif)}
data-thumbhash={thumbhash} inferSize={true}
data-exif={JSON.stringify(exif)} pictureAttributes={{
inferSize={true} class: `${hash ? "block h-full relative" : ""} ${loader ? "thumb" : ""} ${pictureClass}`,
pictureAttributes={{ }}
class: `${hash ? "block h-full relative" : ""} ${loader ? "thumb" : ""} ${pictureClass}`, class={Astro.props.class}
}} widths={sizes.map((size) => size.width)}
class={Astro.props.class} sizes={sizes
widths={sizes.map((size) => size.width)} .map((size) => `${size.media || "100vw"} ${size.width}px`)
sizes={sizes .join(", ")}>
.map((size) => `${size.media || "100vw"} ${size.width}px`) <slot />
.join(", ")}> </AstroImage>
<slot />
</AstroImage>
) : undefined
}

View File

@@ -5,55 +5,26 @@ import { readFile } from "node:fs/promises";
import sharp from "sharp"; import sharp from "sharp";
export async function generateThumbHash( export async function generateThumbHash(
image: ImageMetadata & { fsPath?: string }, buffer: ArrayBuffer,
) { ): Promise<string | undefined> {
const scaleFactor = 100 / Math.max(image.width, image.height); if (!buffer) return;
let smallWidth = Math.floor(image.width * scaleFactor); const sp = sharp(buffer);
let smallHeight = Math.floor(image.height * scaleFactor);
try { const meta = await sp.metadata();
const imagePath = (image as ImageMetadata & { fsPath: string }).fsPath ?? const scaleFactor = 100 / Math.max(meta.width, meta.height);
image.src; const smallWidth = Math.floor(meta.width * scaleFactor);
const smallHeight = Math.floor(meta.height * scaleFactor);
if (!imagePath) return; const smallImg = await sp
.resize(smallWidth, smallHeight)
.withMetadata()
.raw()
.ensureAlpha()
.toBuffer();
if (imagePath.endsWith(".svg")) return; const hashBuffer = rgbaToThumbHash(smallWidth, smallHeight, smallImg);
return Buffer.from(hashBuffer).toString("base64");
let sp: ReturnType<typeof sharp>;
if (imagePath.startsWith("https://") || imagePath.startsWith("http://")) {
const res = await fetch(imagePath);
if (!res.ok) {
return;
}
const buffer = await res.arrayBuffer();
sp = sharp(buffer);
} else {
sp = sharp(imagePath);
}
if (!smallWidth || !smallHeight) {
const meta = await sp.metadata();
const scaleFactor = 100 / Math.max(meta.width, meta.height);
smallWidth = Math.floor(meta.width * scaleFactor);
smallHeight = Math.floor(meta.height * scaleFactor);
}
const smallImg = await sp
.resize(smallWidth, smallHeight)
.withMetadata()
.raw()
.ensureAlpha()
.toBuffer();
const buffer = rgbaToThumbHash(smallWidth, smallHeight, smallImg);
return Buffer.from(buffer).toString("base64");
} catch (_error) {
console.log(
`Could not generate thumbhash for ${image.fsPath ?? image.src}`,
);
return "";
}
} }
const allowedExif = [ const allowedExif = [
@@ -72,7 +43,9 @@ const allowedExif = [
"Model", "Model",
]; ];
export async function getExifData(image: ImageMetadata) { export async function getImageBuffer(
image: ImageMetadata,
): Promise<ArrayBuffer> {
if (image.format === "svg") return undefined; // SVGs don't have EXIF data if (image.format === "svg") return undefined; // SVGs don't have EXIF data
const imagePath = (image as ImageMetadata & { fsPath: string }).fsPath ?? const imagePath = (image as ImageMetadata & { fsPath: string }).fsPath ??
image.src; image.src;
@@ -80,31 +53,31 @@ export async function getExifData(image: ImageMetadata) {
if (!imagePath) return undefined; if (!imagePath) return undefined;
try { try {
let buffer: ArrayBufferLike;
if (imagePath.startsWith("https://") || imagePath.startsWith("http://")) { if (imagePath.startsWith("https://") || imagePath.startsWith("http://")) {
const res = await fetch(imagePath); const res = await fetch(imagePath, { signal: AbortSignal.timeout(5000) });
buffer = await res.arrayBuffer(); return await res.arrayBuffer();
} else { } else {
const b = await readFile(imagePath); const b = await readFile(imagePath);
buffer = b.buffer; return b.buffer as ArrayBuffer;
} }
} catch (err) {
const tags = await ExifReader.load(buffer, { async: true });
if (!buffer) return undefined;
const out: Record<string, any> = {};
let hasExif = false;
for (const key of allowedExif) {
if (!tags[key]) continue;
hasExif = true;
out[key] = tags[key]?.description;
}
return hasExif ? out : undefined;
} catch (error) {
console.log(`Error reading EXIF data from ${JSON.stringify(image)}`, error);
return undefined; return undefined;
} }
} }
export async function getExifData(buffer: ArrayBuffer) {
if (!buffer) return undefined;
const tags = await ExifReader.load(buffer, { async: true });
const out: Record<string, unknown> = {};
let hasExif = false;
for (const key of allowedExif) {
if (!tags[key]) continue;
hasExif = true;
out[key] = tags[key]?.description;
}
return hasExif ? out : undefined;
}

View File

@@ -28,7 +28,7 @@ export async function listResource(
): Promise<MemoriumEntry | undefined> { ): Promise<MemoriumEntry | undefined> {
const url = `${SERVER_URL}/resources/${id}`; const url = `${SERVER_URL}/resources/${id}`;
try { try {
const response = await fetch(url); const response = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (response.ok) { if (response.ok) {
const json = await response.json(); const json = await response.json();
if (json.type == "dir") { if (json.type == "dir") {