feat: add thumbhashes to images closes #6

This commit is contained in:
2023-08-11 16:13:20 +02:00
parent 6dd8575b15
commit 0acbbd6905
22 changed files with 489 additions and 51 deletions

8
lib/cache/cache.ts vendored
View File

@ -92,17 +92,13 @@ export function keys(prefix: string) {
return cache.keys(prefix);
}
export async function set<T extends RedisValue>(
export function set<T extends RedisValue>(
id: string,
content: T,
options?: RedisOptions,
) {
log.debug("storing ", { id });
const res = await cache.set(id, content);
if (options?.expires) {
await expire(id, options.expires);
}
return res;
return cache.set(id, content, { ex: options?.expires || undefined });
}
export const cacheFunction = async <T extends (() => Promise<unknown>)>(

72
lib/cache/image.ts vendored
View File

@ -1,29 +1,79 @@
import { hash } from "@lib/string.ts";
import { hash, isLocalImage } from "@lib/string.ts";
import * as cache from "@lib/cache/cache.ts";
import { ImageMagick } from "https://deno.land/x/imagemagick_deno@0.0.25/mod.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 ImageCacheOptions = {
url: string;
width: number;
height: number;
mediaType?: string;
suffix?: string;
};
const CACHE_KEY = "images";
const log = createLogger("cache/image");
function getCacheKey({ url: _url, width, height }: ImageCacheOptions) {
const url = new URL(_url);
function getCacheKey(
{ url: _url, width, height, suffix }: ImageCacheOptions,
) {
const isLocal = isLocalImage(_url);
const url = new URL(isLocal ? `${SILVERBULLET_SERVER}/${_url}` : _url);
const _suffix = suffix || `${width}:${height}`;
return `${CACHE_KEY}:${url.hostname}:${
url.pathname.replaceAll("/", ":")
}:${width}:${height}`
}:${_suffix}`
.replace(
"::",
":",
);
}
export function createThumbhash(
image: Uint8Array,
url: string,
): Promise<string> {
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 = generateThumbhash(bytes, _image.width, _image.height);
if (hash) {
cache.set(
getCacheKey({
url,
suffix: "thumbnail",
width: _image.width,
height: _image.height,
}),
hash,
);
res(hash);
}
});
});
} catch (err) {
rej(err);
}
});
}
function verifyImage(
imageBuffer: Uint8Array,
) {
@ -38,6 +88,18 @@ function verifyImage(
});
}
export function getThumbhash({ url }: { url: string }) {
return cache.get<Uint8Array>(
getCacheKey({
url,
suffix: "thumbnail",
width: 200,
height: 200,
}),
true,
);
}
export async function getImage({ url, width, height }: ImageCacheOptions) {
const cacheKey = getCacheKey({ url, width, height });

View File

@ -5,10 +5,40 @@ import {
transformDocument,
} from "@lib/documents.ts";
import { Root } from "https://esm.sh/remark-frontmatter@4.0.1";
import { getThumbhash } from "@lib/cache/image.ts";
type Resource = {
name: string;
id: string;
meta: {
image?: string;
author?: string;
thumbnail?: string;
};
};
export async function addThumbnailToResource<T = Resource>(
res: T,
): Promise<T> {
const imageUrl = res?.meta?.image;
if (!imageUrl) return res;
const thumbhash = await getThumbhash({ url: imageUrl });
if (!thumbhash) return res;
const base64String = btoa(String.fromCharCode(...thumbhash));
return {
...res,
meta: {
...res?.meta,
thumbnail: base64String,
},
};
}
export function createCrud<T>(
{ prefix, parse }: {
{ prefix, parse, render, hasThumbnails = false }: {
prefix: string;
hasThumbnails?: boolean;
render?: (doc: T) => string;
parse: (doc: string, id: string) => T;
},
) {
@ -19,11 +49,27 @@ export function createCrud<T>(
async function read(id: string) {
const path = pathFromId(id);
const content = await getDocument(path);
return parse(content, id);
const res = parse(content, id);
if (hasThumbnails) {
return addThumbnailToResource(res);
}
return res;
}
function create(id: string, content: string | ArrayBuffer) {
function create(id: string, content: string | ArrayBuffer | T) {
const path = pathFromId(id);
return createDocument(path, content);
if (
typeof content === "string" || content instanceof ArrayBuffer
) {
return createDocument(path, content);
}
if (render) {
return createDocument(path, render(content));
}
throw new Error("No renderer defined for " + prefix + " CRUD");
}
async function update(id: string, updater: (r: Root) => Root) {

View File

@ -5,7 +5,7 @@ enum LOG_LEVEL {
ERROR,
}
let longestScope = 0;
let logLevel = LOG_LEVEL.DEBUG;
let logLevel = LOG_LEVEL.WARN;
export function setLogLevel(level: LOG_LEVEL) {
logLevel = level;
@ -49,5 +49,3 @@ export function createLogger(scope: string, _options?: LoggerOptions) {
warn,
};
}
const log = createLogger("");

View File

@ -4,6 +4,7 @@ import { createCrud } from "@lib/crud.ts";
import { stringify } from "$std/yaml/stringify.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
import { getThumbhash } from "@lib/cache/image.ts";
export type Article = {
id: string;
@ -15,6 +16,7 @@ export type Article = {
status: "finished" | "not-finished";
date: Date;
link: string;
thumbnail?: string;
image?: string;
author?: string;
rating?: number;
@ -24,6 +26,7 @@ export type Article = {
const crud = createCrud<Article>({
prefix: "Media/articles/",
parse: parseArticle,
hasThumbnails: true,
});
function renderArticle(article: Article) {

View File

@ -1,4 +1,4 @@
import { parseDocument, renderMarkdown } from "@lib/documents.ts";
import { parseDocument } from "@lib/documents.ts";
import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
@ -13,6 +13,7 @@ export type Movie = {
meta: {
date: Date;
image: string;
thumbnail?: string;
author: string;
rating: number;
status: "not-seen" | "watch-again" | "finished";
@ -98,6 +99,7 @@ export function parseMovie(original: string, id: string): Movie {
const crud = createCrud<Movie>({
prefix: "Media/movies/",
parse: parseMovie,
hasThumbnails: true,
});
export const getMovie = crud.read;

View File

@ -37,6 +37,7 @@ export type Recipe = {
rating?: number;
portion?: number;
author?: string;
thumbnail?: string;
};
};
@ -186,6 +187,7 @@ export function parseRecipe(original: string, id: string): Recipe {
const crud = createCrud<Recipe>({
prefix: `Recipes/`,
parse: parseRecipe,
hasThumbnails: true,
});
export const getAllRecipes = crud.readAll;

View File

@ -3,6 +3,7 @@ import { parse, stringify } from "yaml";
import { createCrud } from "@lib/crud.ts";
import { extractHashTags, formatDate } from "@lib/string.ts";
import { fixRenderedMarkdown } from "@lib/helpers.ts";
import { getThumbhash } from "@lib/cache/image.ts";
export type Series = {
id: string;
@ -15,6 +16,7 @@ export type Series = {
image: string;
author: string;
rating: number;
thumbnail?: string;
status: "not-seen" | "watch-again" | "finished";
};
};
@ -98,9 +100,23 @@ export function parseSeries(original: string, id: string): Series {
const crud = createCrud<Series>({
prefix: "Media/series/",
parse: parseSeries,
hasThumbnails: true,
});
export const getSeries = crud.read;
export const getSeries = (id: string) =>
crud.read(id).then(async (serie) => {
const imageUrl = serie.meta?.image;
if (!imageUrl) return serie;
const thumbhash = await getThumbhash({ url: imageUrl });
if (!thumbhash) return serie;
return {
...serie,
meta: {
...serie.meta,
thumbnail: btoa(String.fromCharCode(...thumbhash)),
},
};
});
export const getAllSeries = crud.readAll;
export const createSeries = (series: Series) => {
const content = renderSeries(series);

17
lib/thumbhash.ts Normal file
View File

@ -0,0 +1,17 @@
import * as thumbhash from "https://esm.sh/thumbhash@0.1.1";
export function generateThumbhash(buffer: Uint8Array, w: number, h: number) {
return thumbhash.rgbaToThumbHash(w, h, buffer);
}
export function generateDataURL(hash: string) {
const decodedString = atob(hash);
// Create Uint8Array from decoded string
const uint8Array = new Uint8Array(decodedString.length);
for (let i = 0; i < decodedString.length; i++) {
uint8Array[i] = decodedString.charCodeAt(i);
}
return thumbhash.thumbHashToDataURL(uint8Array);
}