feat: add thumbhashes to images closes #6
This commit is contained in:
8
lib/cache/cache.ts
vendored
8
lib/cache/cache.ts
vendored
@ -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
72
lib/cache/image.ts
vendored
@ -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 });
|
||||
|
||||
|
54
lib/crud.ts
54
lib/crud.ts
@ -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) {
|
||||
|
@ -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("");
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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
17
lib/thumbhash.ts
Normal 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);
|
||||
}
|
Reference in New Issue
Block a user