327 lines
8.4 KiB
TypeScript
327 lines
8.4 KiB
TypeScript
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";
|
|
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");
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates dimensions maintaining aspect ratio
|
|
*/
|
|
function calculateDimensions(
|
|
current: { width: number; height: number },
|
|
target: { width?: number; height?: number },
|
|
) {
|
|
if (!target.width && !target.height) {
|
|
return current;
|
|
}
|
|
|
|
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("-"),
|
|
);
|
|
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(
|
|
imageBuffer: Uint8Array,
|
|
params: {
|
|
width?: number;
|
|
height?: number;
|
|
mediaType: string;
|
|
},
|
|
) {
|
|
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");
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
|
|
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;
|
|
}
|
|
}
|