feat: store image metadata in sqlite and images on disk
This commit is contained in:
@ -1,195 +1,102 @@
|
||||
import { HandlerContext, Handlers } from "$fresh/server.ts";
|
||||
import {
|
||||
ImageMagick,
|
||||
initialize,
|
||||
MagickFormat,
|
||||
MagickGeometry,
|
||||
} from "https://deno.land/x/imagemagick_deno@0.0.31/mod.ts";
|
||||
|
||||
import { parseMediaType } from "https://deno.land/std@0.224.0/media_types/parse_media_type.ts";
|
||||
import { FreshContext, Handlers } from "$fresh/server.ts";
|
||||
import * as cache from "@lib/cache/image.ts";
|
||||
import { SILVERBULLET_SERVER } from "@lib/env.ts";
|
||||
import { ConcurrentPromiseQueue, PromiseQueue } from "@lib/promise.ts";
|
||||
import { BadRequestError } from "@lib/errors.ts";
|
||||
import { createLogger } from "@lib/log.ts";
|
||||
|
||||
await initialize();
|
||||
|
||||
type ImageParams = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
import { isLocalImage } from "@lib/string.ts";
|
||||
|
||||
const log = createLogger("api/image");
|
||||
|
||||
async function getRemoteImage(image: string) {
|
||||
log.debug("fetching", { image });
|
||||
const sourceRes = await fetch(image);
|
||||
if (!sourceRes.ok) {
|
||||
return "Error retrieving image from URL.";
|
||||
}
|
||||
const mediaType = parseMediaType(sourceRes.headers.get("Content-Type")!)[0];
|
||||
if (mediaType.split("/")[0] !== "image") {
|
||||
return "URL is not image type.";
|
||||
}
|
||||
return {
|
||||
buffer: new Uint8Array(await sourceRes.arrayBuffer()),
|
||||
mediaType,
|
||||
};
|
||||
// Constants for image processing
|
||||
const CONFIG = {
|
||||
maxDimension: 2048,
|
||||
minDimension: 1,
|
||||
acceptedMimeTypes: new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]),
|
||||
};
|
||||
|
||||
interface ImageParams {
|
||||
image: string;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
function getWidthHeight(
|
||||
current: { width: number; height: number },
|
||||
final: { width: number; height: number },
|
||||
) {
|
||||
const ratio = (current.width / final.width) > (current.height / final.height)
|
||||
? (current.height / final.height)
|
||||
: (current.width / final.width);
|
||||
|
||||
return new MagickGeometry(
|
||||
Math.floor(current.width / ratio),
|
||||
Math.floor(current.height / ratio),
|
||||
);
|
||||
}
|
||||
|
||||
function modifyImage(
|
||||
imageBuffer: Uint8Array,
|
||||
params: {
|
||||
width: number;
|
||||
mediaType: string;
|
||||
height: number;
|
||||
mode: "resize" | "crop";
|
||||
},
|
||||
) {
|
||||
return new Promise<Uint8Array>((resolve) => {
|
||||
let format = MagickFormat.Jpeg;
|
||||
|
||||
if (params.mediaType === "image/webp") {
|
||||
format = MagickFormat.Webp;
|
||||
/**
|
||||
* Validates and parses URL parameters for image processing
|
||||
*/
|
||||
function parseParams(reqUrl: URL): ImageParams | string {
|
||||
try {
|
||||
const image = reqUrl.searchParams.get("image")?.replace(/^\//, "");
|
||||
if (!image) {
|
||||
return "Missing 'image' query parameter.";
|
||||
}
|
||||
|
||||
if (params.mediaType === "image/png") {
|
||||
format = MagickFormat.Png;
|
||||
// Parse dimensions with defaults
|
||||
const height = Math.floor(Number(reqUrl.searchParams.get("height")) || 0);
|
||||
const width = Math.floor(Number(reqUrl.searchParams.get("width")) || 0);
|
||||
|
||||
// Validate dimensions
|
||||
if (height < 0 || width < 0) {
|
||||
return "Negative height or width is not supported.";
|
||||
}
|
||||
|
||||
ImageMagick.read(imageBuffer, format, (image) => {
|
||||
const sizingData = getWidthHeight(image, params);
|
||||
if (params.mode === "resize") {
|
||||
image.resize(sizingData);
|
||||
} else {
|
||||
image.crop(sizingData);
|
||||
}
|
||||
image.write((data) => resolve(data));
|
||||
});
|
||||
});
|
||||
}
|
||||
if (height > CONFIG.maxDimension || width > CONFIG.maxDimension) {
|
||||
return `Width and height cannot exceed ${CONFIG.maxDimension}.`;
|
||||
}
|
||||
|
||||
function parseParams(reqUrl: URL) {
|
||||
const image = reqUrl.searchParams.get("image")?.replace(/^\//, "");
|
||||
if (image == null) {
|
||||
return "Missing 'image' query parameter.";
|
||||
// If dimensions are provided, ensure they're not too small
|
||||
if (
|
||||
(height > 0 && height < CONFIG.minDimension) ||
|
||||
(width > 0 && width < CONFIG.minDimension)
|
||||
) {
|
||||
return `Dimensions must be at least ${CONFIG.minDimension} pixel.`;
|
||||
}
|
||||
|
||||
return { image, height, width };
|
||||
} catch (error) {
|
||||
log.error("Error parsing parameters:", error);
|
||||
return "Invalid parameters provided.";
|
||||
}
|
||||
|
||||
const height = Number(reqUrl.searchParams.get("height")) || 0;
|
||||
const width = Number(reqUrl.searchParams.get("width")) || 0;
|
||||
if (height === 0 && width === 0) {
|
||||
//return "Missing non-zero 'height' or 'width' query parameter.";
|
||||
}
|
||||
if (height < 0 || width < 0) {
|
||||
return "Negative height or width is not supported.";
|
||||
}
|
||||
const maxDimension = 2048;
|
||||
if (height > maxDimension || width > maxDimension) {
|
||||
return `Width and height cannot exceed ${maxDimension}.`;
|
||||
}
|
||||
return {
|
||||
image,
|
||||
height,
|
||||
width,
|
||||
};
|
||||
}
|
||||
|
||||
const queue = new ConcurrentPromiseQueue(2);
|
||||
|
||||
async function processImage(imageUrl: string, params: ImageParams) {
|
||||
const remoteImage = await getRemoteImage(imageUrl);
|
||||
if (typeof remoteImage === "string") {
|
||||
log.warn("error fetching ", { remoteImage });
|
||||
throw new BadRequestError();
|
||||
}
|
||||
|
||||
const modifiedImage = await modifyImage(remoteImage.buffer, {
|
||||
...params,
|
||||
mediaType: remoteImage.mediaType,
|
||||
mode: "resize",
|
||||
});
|
||||
|
||||
log.debug("resized image", {
|
||||
imageUrl,
|
||||
length: modifiedImage.length,
|
||||
});
|
||||
|
||||
return [modifiedImage, remoteImage.mediaType] as const;
|
||||
}
|
||||
|
||||
const GET = async (
|
||||
_req: Request,
|
||||
_ctx: HandlerContext,
|
||||
req: Request,
|
||||
_ctx: FreshContext,
|
||||
): Promise<Response> => {
|
||||
const url = new URL(_req.url);
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const params = parseParams(url);
|
||||
|
||||
const params = parseParams(url);
|
||||
if (typeof params === "string") {
|
||||
return new Response(params, { status: 400 });
|
||||
}
|
||||
|
||||
const imageUrl = SILVERBULLET_SERVER + "/" + params.image;
|
||||
|
||||
const cachedResponse = await cache.getImage({
|
||||
url: imageUrl,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
});
|
||||
if (cachedResponse) {
|
||||
log.debug("cached", { imageUrl });
|
||||
return new Response(cachedResponse.buffer.slice(), {
|
||||
headers: {
|
||||
"Content-Type": cachedResponse.mediaType,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
log.debug("no image in cache");
|
||||
}
|
||||
|
||||
const [resizedImage, mediaType] = await queue.enqueue(() =>
|
||||
processImage(imageUrl, params)
|
||||
);
|
||||
|
||||
const clonedImage = resizedImage.slice();
|
||||
setTimeout(() => {
|
||||
cache.setImage(clonedImage, {
|
||||
url: imageUrl,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
mediaType: mediaType,
|
||||
});
|
||||
}, 50);
|
||||
|
||||
log.debug("not-cached", { imageUrl });
|
||||
|
||||
cache.getThumbhash({ url: imageUrl }).then(([hash]) => {
|
||||
if (!hash) {
|
||||
cache.createThumbhash(clonedImage.slice(), imageUrl).catch((_err) => {
|
||||
//
|
||||
if (typeof params === "string") {
|
||||
return new Response(params, {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(resizedImage.slice(), {
|
||||
headers: {
|
||||
"Content-Type": mediaType,
|
||||
},
|
||||
});
|
||||
const imageUrl = isLocalImage(params.image)
|
||||
? `${SILVERBULLET_SERVER}/${params.image.replace(/^\//, "")}`
|
||||
: params.image;
|
||||
|
||||
log.debug("Processing image request:", { imageUrl, params });
|
||||
|
||||
const image = await cache.getImageContent(imageUrl, params);
|
||||
|
||||
return new Response(image.content, {
|
||||
headers: {
|
||||
"Content-Type": image.mimeType,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error("Error processing image:", error);
|
||||
return new Response("Internal server error", {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const handler: Handlers = {
|
||||
|
@ -24,7 +24,10 @@ export default async function Greet(
|
||||
<MainLayout url={props.url} title={`Movie > ${movie.name}`} context={movie}>
|
||||
<RedirectSearchHandler />
|
||||
<KMenu type="main" context={movie} />
|
||||
<PageHero image={movie.meta.image} thumbnail={movie.meta.thumbnail}>
|
||||
<PageHero
|
||||
image={movie.meta.image}
|
||||
thumbnail={movie.meta.thumbnail}
|
||||
>
|
||||
<PageHero.Header>
|
||||
<PageHero.BackLink href="/movies" />
|
||||
{session && (
|
||||
|
Reference in New Issue
Block a user