import { hash, isLocalImage, rgbToHex } from "@lib/string.ts"; import * as cache from "@lib/cache/cache.ts"; import { ImageMagick, MagickGeometry, } from "https://deno.land/x/imagemagick_deno@0.0.25/mod.ts"; import { createLogger } from "@lib/log.ts"; import { generateThumbhash } from "@lib/thumbhash.ts"; import { SILVERBULLET_SERVER } from "@lib/env.ts"; type ImageCacheOptionsBasic = { url: string; mediaType?: string; }; interface ImageCacheOptionsDimensions extends ImageCacheOptionsBasic { width: number; height: number; } interface ImageCacheOptionsSuffix extends ImageCacheOptionsBasic { suffix: string; } type ImageCacheOptions = ImageCacheOptionsDimensions | ImageCacheOptionsSuffix; const CACHE_KEY = "images"; const log = createLogger("cache/image"); function getCacheKey( opts: ImageCacheOptions, ) { const isLocal = isLocalImage(opts.url); const url = new URL( isLocal ? `${SILVERBULLET_SERVER}/${opts.url}` : opts.url, ); const _suffix = "suffix" in opts ? opts.suffix : `${opts.width}:${opts.height}`; const cacheId = `${CACHE_KEY}:${url.hostname}:${ url.pathname.replaceAll("/", ":") }:${_suffix}` .replace( "::", ":", ); return cacheId; } export function createThumbhash( image: Uint8Array, url: string, ): Promise { return new Promise((res, rej) => { try { ImageMagick.read(image.slice(), (_image) => { _image.resize(new MagickGeometry(100, 100)); _image.getPixels((pixels) => { const bytes = pixels.toByteArray( 0, 0, _image.width, _image.height, "RGBA", ); if (!bytes) return; const [hash, average] = generateThumbhash( bytes, _image.width, _image.height, ); if (average) { cache.set( getCacheKey({ url, suffix: "average", }), rgbToHex(average.r, average.g, average.b), ); } if (hash) { const b64 = btoa(String.fromCharCode(...hash)); cache.set( getCacheKey({ url, suffix: "thumbnail", }), b64, ); res(b64); } }); }); } catch (err) { rej(err); } }); } function verifyImage( imageBuffer: Uint8Array, ) { return new Promise((resolve) => { try { ImageMagick.read(imageBuffer, (image) => { resolve(image.height !== 0 && image.width !== 0); }); } catch (_err) { resolve(false); } }); } export function getThumbhash({ url }: { url: string }) { return Promise.all( [ cache.get( getCacheKey({ url, suffix: "thumbnail", }), ), cache.get( getCacheKey({ url, suffix: "average", }), ), ] as const, ); } export async function getImage(opts: ImageCacheOptions) { const cacheKey = getCacheKey(opts); const pointerCacheRaw = await cache.get(cacheKey); if (!pointerCacheRaw) return; const pointerCache = typeof pointerCacheRaw === "string" ? JSON.parse(pointerCacheRaw) : pointerCacheRaw; const imageContent = await cache.get(`image:${pointerCache.id}`, true); if (!imageContent) return; return { ...pointerCache, buffer: imageContent, }; } export async function setImage( buffer: Uint8Array, opts: ImageCacheOptions, ) { const clone = new Uint8Array(buffer); const imageCorrect = await verifyImage(clone); if (!imageCorrect) { log.info("failed to store image", { url: opts.url }); return; } const cacheKey = getCacheKey(opts); const pointerId = await hash(cacheKey); await cache.set(`image:${pointerId}`, clone, { expires: 60 * 60 * 24 }); await cache.set( cacheKey, JSON.stringify({ id: pointerId, ...("suffix" in opts ? { suffix: opts.suffix } : { width: opts.width, height: opts.height }), }), { expires: 60 * 60 * 24 * 7 /* 1 week */ }, ); }