memorium/lib/image.ts

327 lines
8.4 KiB
TypeScript
Raw Normal View History

import { rgbToHex } from "@lib/string.ts";
import { createLogger } from "@lib/log/index.ts";
import { generateThumbhash } from "@lib/thumbhash.ts";
import { parseMediaType } from "https://deno.land/std@0.224.0/media_types/parse_media_type.ts";
import path from "node:path";
import { ensureDir } from "https://deno.land/std@0.216.0/fs/mod.ts";
import { DATA_DIR } from "@lib/env.ts";
2025-01-06 16:14:29 +01:00
import { db } from "@lib/db/sqlite.ts";
import { imageTable } from "@lib/db/schema.ts";
import { eq } from "drizzle-orm";
import sharp from "npm:sharp@next";
const log = createLogger("cache/image");
const imageDir = path.join(DATA_DIR, "images");
await ensureDir(imageDir);
async function getRemoteImage(imageUrl: string) {
try {
const sourceRes = await fetch(imageUrl);
if (!sourceRes.ok) {
throw new Error(
`Failed to retrieve image from URL: ${imageUrl}. Status: ${sourceRes.status}`,
);
}
const contentType = sourceRes.headers.get("Content-Type");
if (!contentType) {
throw new Error("No Content-Type header in response");
}
const mediaType = parseMediaType(contentType)[0];
if (mediaType.split("/")[0] !== "image") {
throw new Error("URL does not return an image type");
}
2023-08-12 18:32:56 +02:00
log.debug("Fetching image", { imageUrl, mediaType });
const buffer = await sourceRes.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error("Received empty image buffer");
}
return {
buffer,
mediaType,
};
} catch (error) {
throw error;
}
2023-08-12 18:32:56 +02:00
}
/**
* Calculates dimensions maintaining aspect ratio
*/
function calculateDimensions(
current: { width: number; height: number },
target: { width?: number; height?: number },
) {
if (!target.width && !target.height) {
return current;
}
2023-08-12 18:32:56 +02:00
const ratio = current.width / current.height;
if (target.width && target.height) {
// Both dimensions specified - maintain aspect ratio using the most constraining dimension
const widthRatio = target.width / current.width;
const heightRatio = target.height / current.height;
const scale = Math.min(widthRatio, heightRatio);
return {
width: Math.round(current.width * scale),
height: Math.round(current.height * scale),
};
} else if (target.width) {
// Only width specified
return {
width: target.width,
height: Math.round(target.width / ratio),
};
} else if (target.height) {
// Only height specified
return {
width: Math.round(target.height * ratio),
height: target.height,
};
}
return current;
}
async function getLocalImagePath(
url: string,
{ width, height }: { width?: number; height?: number } = {},
) {
const { hostname, pathname } = new URL(url);
let imagePath = path.join(
imageDir,
hostname,
pathname.split("/").filter((s) => s.length).join("-"),
2023-08-12 18:32:56 +02:00
);
await ensureDir(imagePath);
if (width || height) {
imagePath = path.join(imagePath, `${width ?? "-"}x${height ?? "-"}`);
} else {
imagePath = path.join(imagePath, "original");
}
return imagePath;
}
/**
* Retrieves a cached image from local storage
*/
async function getLocalImage(
url: string,
{ width, height }: { width?: number; height?: number } = {},
) {
const imagePath = await getLocalImagePath(url, { width, height });
try {
const fileInfo = await Deno.stat(imagePath);
if (fileInfo?.isFile) {
return Deno.readFile(imagePath);
}
} catch (_) {
// File not found - normal case
}
}
/**
* Stores an image in local cache
*/
async function storeLocalImage(
url: string,
content: ArrayBuffer,
{ width, height }: { width?: number; height?: number } = {},
) {
const isValid = await verifyImage(new Uint8Array(content));
if (!isValid) {
throw new Error("Invalid image content detected during storage");
}
const imagePath = await getLocalImagePath(url, { width, height });
await Deno.writeFile(imagePath, new Uint8Array(content));
}
/**
* Resizes an image using Sharp with proper error handling
*/
async function resizeImage(
2023-08-02 02:51:20 +02:00
imageBuffer: Uint8Array,
params: {
width?: number;
height?: number;
mediaType: string;
},
2023-08-02 02:51:20 +02:00
) {
try {
log.debug("Resizing image", { params });
let sharpInstance = sharp(imageBuffer);
// Get original dimensions
const metadata = await sharpInstance.metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Could not determine image dimensions");
2023-08-02 02:51:20 +02:00
}
// Calculate new dimensions
const newDimensions = calculateDimensions(
{ width: metadata.width, height: metadata.height },
params,
);
switch (params.mediaType) {
case "image/webp":
sharpInstance = sharpInstance.webp({ quality: 85 });
break;
case "image/png":
sharpInstance = sharpInstance.png({ quality: 85 });
break;
case "image/jpeg":
default:
sharpInstance = sharpInstance.jpeg({ quality: 85 });
break;
}
// Perform resize with proper options
const resized = await sharpInstance
.resize({
width: newDimensions.width,
height: newDimensions.height,
fit: "inside",
withoutEnlargement: true,
})
.toBuffer();
return new Uint8Array(resized);
} catch (error) {
log.error("Error during image resize:", error);
throw error;
}
2023-08-02 02:51:20 +02:00
}
/**
* Creates a thumbhash for image preview
*/
async function createThumbhash(
image: Uint8Array,
): Promise<{ hash: string; average: string }> {
try {
const resizedImage = await sharp(image)
.resize(100, 100, { fit: "cover" }) // Keep aspect ratio within bounds
.toFormat("png")
.ensureAlpha()
.raw()
.toBuffer();
const [hash, average] = generateThumbhash(resizedImage, 100, 100);
return {
hash: btoa(String.fromCharCode(...hash)),
average: rgbToHex(average.r, average.g, average.b),
};
} catch (err) {
throw new Error(`Failed to create thumbhash: ${err}`);
}
}
/**
* Verifies that an image buffer contains valid image data
*/
async function verifyImage(imageBuffer: Uint8Array): Promise<boolean> {
try {
const metadata = await sharp(imageBuffer).metadata();
return !!(metadata.width && metadata.height && metadata.format);
} catch (error) {
log.error("Image verification failed:", error);
return false;
}
}
/**
* Gets image content with proper caching and resizing
*/
export async function getImageContent(
url: string,
{ width, height }: { width?: number; height?: number } = {},
): Promise<{ content: ArrayBuffer; mimeType: string }> {
log.debug("Getting image content", { url, width, height });
// Check if we have the image metadata in database
const image = await getImage(url);
// Try to get cached resized version
const cachedImage = await getLocalImage(url, { width, height });
if (cachedImage) {
return { content: cachedImage, mimeType: image.mime };
}
// Try to get cached original version
let originalImage = await getLocalImage(url);
// Fetch and cache original if needed
if (!originalImage) {
const fetchedImage = await getRemoteImage(url);
await storeLocalImage(url, fetchedImage.buffer);
originalImage = new Uint8Array(fetchedImage.buffer);
}
// Resize image
const resizedImage = await resizeImage(originalImage, {
width,
height,
mediaType: image.mime,
});
// Cache resized version
await storeLocalImage(url, resizedImage, { width, height });
return { content: resizedImage, mimeType: image.mime };
}
/**
* Gets or creates image metadata in database
*/
export async function getImage(url: string) {
log.debug("Getting image metadata", { url });
try {
// Check database first
const image = await db.select().from(imageTable)
.where(eq(imageTable.url, url))
.limit(1)
.then((images) => images[0]);
2023-08-02 02:51:20 +02:00
if (image) {
return image;
}
// Fetch and process new image
const imageContent = await getRemoteImage(url);
await storeLocalImage(url, imageContent.buffer);
// Generate thumbhash
const thumbhash = await createThumbhash(
new Uint8Array(imageContent.buffer),
);
// Store in database
const [newImage] = await db.insert(imageTable).values({
url: url,
blurhash: thumbhash.hash,
average: thumbhash.average,
mime: imageContent.mediaType,
}).returning();
return newImage;
} catch (error) {
log.error("Error in getImage:", error);
throw error;
}
}