feat: add thumbhashes to images closes #6
This commit is contained in:
parent
6dd8575b15
commit
0acbbd6905
@ -2,15 +2,15 @@ import { isYoutubeLink } from "@lib/string.ts";
|
|||||||
import { IconBrandYoutube } from "@components/icons.tsx";
|
import { IconBrandYoutube } from "@components/icons.tsx";
|
||||||
|
|
||||||
export function Card(
|
export function Card(
|
||||||
{ link, title, image, backgroundSize = 100 }: {
|
{ link, title, image, thumbnail, backgroundSize = 100 }: {
|
||||||
backgroundSize?: number;
|
backgroundSize?: number;
|
||||||
|
thumbnail?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const backgroundStyle = {
|
const backgroundStyle = {
|
||||||
backgroundImage: `url(${image})`,
|
|
||||||
backgroundSize: "cover",
|
backgroundSize: "cover",
|
||||||
boxShadow: "0px -60px 90px black inset, 0px 10px 20px #fff1 inset",
|
boxShadow: "0px -60px 90px black inset, 0px 10px 20px #fff1 inset",
|
||||||
};
|
};
|
||||||
@ -25,20 +25,32 @@ export function Card(
|
|||||||
<a
|
<a
|
||||||
href={link}
|
href={link}
|
||||||
style={backgroundStyle}
|
style={backgroundStyle}
|
||||||
class="text-white rounded-3xl shadow-md p-4 relative
|
data-thumb={thumbnail}
|
||||||
|
class="text-white rounded-3xl shadow-md relative
|
||||||
lg:w-56 lg:h-56
|
lg:w-56 lg:h-56
|
||||||
sm:w-48 sm:h-48
|
sm:w-48 sm:h-48
|
||||||
w-[37vw] h-[37vw]"
|
w-[37vw] h-[37vw]"
|
||||||
>
|
>
|
||||||
{!image?.includes("placeholder.svg") && false &&
|
{true && (
|
||||||
(
|
<img
|
||||||
<img
|
class="absolute rounded-3xl top-0 left-0 object-cover w-full h-full"
|
||||||
class="absolute opacity-30 top-0 left-0 object-cover w-full h-full -z-10"
|
data-thumb-img
|
||||||
src={image}
|
loading="lazy"
|
||||||
style={{ filter: "blur(30px)" }}
|
src={image || "/placeholder.svg"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div class="h-full flex flex-col justify-between relative z-10">
|
<div
|
||||||
|
class="p-4 flex flex-col justify-between relative z-10"
|
||||||
|
style={{
|
||||||
|
outline: "solid 2px #141218",
|
||||||
|
borderRadius: "1.4rem",
|
||||||
|
height: "calc(100% + 0.5px)",
|
||||||
|
width: "calc(100% + 0.5px)",
|
||||||
|
marginTop: "-0.5px",
|
||||||
|
marginLeft: "-0.5px",
|
||||||
|
boxShadow: "0px -60px 90px black inset, 0px 10px 20px #fff1 inset",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
{/* Recipe Card content */}
|
{/* Recipe Card content */}
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,20 +6,34 @@ const Image = (
|
|||||||
class: string;
|
class: string;
|
||||||
src: string;
|
src: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
thumbnail?: string;
|
||||||
width?: number | string;
|
width?: number | string;
|
||||||
height?: number | string;
|
height?: number | string;
|
||||||
style?: CSS.HtmlAttributes;
|
style?: CSS.HtmlAttributes;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
return (
|
return (
|
||||||
<img
|
<span
|
||||||
alt={props.alt}
|
style={{
|
||||||
style={props.style}
|
position: "absolute",
|
||||||
src={asset(props.src)}
|
width: "100%",
|
||||||
width={props.width}
|
height: "100%",
|
||||||
height={props.height}
|
zIndex: -1,
|
||||||
class={props.class}
|
}}
|
||||||
/>
|
data-thumb={props.thumbnail}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
data-thumb={props.thumbnail}
|
||||||
|
data-thumb-img
|
||||||
|
loading="lazy"
|
||||||
|
alt={props.alt}
|
||||||
|
style={props.style}
|
||||||
|
src={asset(props.src)}
|
||||||
|
width={props.width}
|
||||||
|
height={props.height}
|
||||||
|
class={props.class}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -15,6 +15,7 @@ export function MovieCard(
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
title={movie.name}
|
title={movie.name}
|
||||||
|
thumbnail={movie.meta.thumbnail}
|
||||||
image={imageUrl}
|
image={imageUrl}
|
||||||
link={`/${sublink}/${movie.id}`}
|
link={`/${sublink}/${movie.id}`}
|
||||||
/>
|
/>
|
||||||
|
@ -13,6 +13,7 @@ export function RecipeCard({ recipe }: { recipe: Recipe }) {
|
|||||||
<Card
|
<Card
|
||||||
title={recipe.name}
|
title={recipe.name}
|
||||||
image={imageUrl}
|
image={imageUrl}
|
||||||
|
thumbnail={recipe?.meta?.thumbnail}
|
||||||
link={`/recipes/${recipe.id}`}
|
link={`/recipes/${recipe.id}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -12,7 +12,10 @@ export function RecipeHero(
|
|||||||
backlink: string;
|
backlink: string;
|
||||||
subline?: string[];
|
subline?: string[];
|
||||||
editLink?: string;
|
editLink?: string;
|
||||||
data: { meta?: { image?: string; link?: string }; name: string };
|
data: {
|
||||||
|
meta?: { thumbnail?: string; image?: string; link?: string };
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const { meta: { image } = {} } = data;
|
const { meta: { image } = {} } = data;
|
||||||
@ -31,6 +34,7 @@ export function RecipeHero(
|
|||||||
(
|
(
|
||||||
<Image
|
<Image
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
|
thumbnail={data.meta?.thumbnail}
|
||||||
alt="Recipe Banner"
|
alt="Recipe Banner"
|
||||||
style={{ objectPosition: "0% 25%" }}
|
style={{ objectPosition: "0% 25%" }}
|
||||||
class="absolute object-cover w-full h-full -z-10"
|
class="absolute object-cover w-full h-full -z-10"
|
||||||
|
@ -103,7 +103,6 @@ export const KMenu = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEventListener("keydown", (ev: KeyboardEvent) => {
|
useEventListener("keydown", (ev: KeyboardEvent) => {
|
||||||
console.log("kMenu", { key: ev.key });
|
|
||||||
if (ev.key === "k") {
|
if (ev.key === "k") {
|
||||||
if (ev?.target?.nodeName == "INPUT") {
|
if (ev?.target?.nodeName == "INPUT") {
|
||||||
return;
|
return;
|
||||||
|
@ -8,7 +8,6 @@ export const addSeriesInfo: MenuEntry = {
|
|||||||
meta: "",
|
meta: "",
|
||||||
icon: "IconReportSearch",
|
icon: "IconReportSearch",
|
||||||
cb: async (state, context) => {
|
cb: async (state, context) => {
|
||||||
console.log({ state, context });
|
|
||||||
state.activeState.value = "loading";
|
state.activeState.value = "loading";
|
||||||
const series = context as Series;
|
const series = context as Series;
|
||||||
|
|
||||||
@ -28,7 +27,6 @@ export const addSeriesInfo: MenuEntry = {
|
|||||||
title: `${m.name || m.original_name} released ${m.first_air_date}`,
|
title: `${m.name || m.original_name} released ${m.first_air_date}`,
|
||||||
cb: async () => {
|
cb: async () => {
|
||||||
state.activeState.value = "loading";
|
state.activeState.value = "loading";
|
||||||
console.log({ m });
|
|
||||||
await fetch(`/api/series/enhance/${series.name}/`, {
|
await fetch(`/api/series/enhance/${series.name}/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ tmdbId: m.id }),
|
body: JSON.stringify({ tmdbId: m.id }),
|
||||||
|
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);
|
return cache.keys(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function set<T extends RedisValue>(
|
export function set<T extends RedisValue>(
|
||||||
id: string,
|
id: string,
|
||||||
content: T,
|
content: T,
|
||||||
options?: RedisOptions,
|
options?: RedisOptions,
|
||||||
) {
|
) {
|
||||||
log.debug("storing ", { id });
|
log.debug("storing ", { id });
|
||||||
const res = await cache.set(id, content);
|
return cache.set(id, content, { ex: options?.expires || undefined });
|
||||||
if (options?.expires) {
|
|
||||||
await expire(id, options.expires);
|
|
||||||
}
|
|
||||||
return res;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const cacheFunction = async <T extends (() => Promise<unknown>)>(
|
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 * 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 { createLogger } from "@lib/log.ts";
|
||||||
|
import { generateThumbhash } from "@lib/thumbhash.ts";
|
||||||
|
import { SILVERBULLET_SERVER } from "@lib/env.ts";
|
||||||
|
|
||||||
type ImageCacheOptions = {
|
type ImageCacheOptions = {
|
||||||
url: string;
|
url: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
|
suffix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CACHE_KEY = "images";
|
const CACHE_KEY = "images";
|
||||||
const log = createLogger("cache/image");
|
const log = createLogger("cache/image");
|
||||||
|
|
||||||
function getCacheKey({ url: _url, width, height }: ImageCacheOptions) {
|
function getCacheKey(
|
||||||
const url = new URL(_url);
|
{ 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}:${
|
return `${CACHE_KEY}:${url.hostname}:${
|
||||||
url.pathname.replaceAll("/", ":")
|
url.pathname.replaceAll("/", ":")
|
||||||
}:${width}:${height}`
|
}:${_suffix}`
|
||||||
.replace(
|
.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(
|
function verifyImage(
|
||||||
imageBuffer: Uint8Array,
|
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) {
|
export async function getImage({ url, width, height }: ImageCacheOptions) {
|
||||||
const cacheKey = getCacheKey({ url, width, height });
|
const cacheKey = getCacheKey({ url, width, height });
|
||||||
|
|
||||||
|
54
lib/crud.ts
54
lib/crud.ts
@ -5,10 +5,40 @@ import {
|
|||||||
transformDocument,
|
transformDocument,
|
||||||
} from "@lib/documents.ts";
|
} from "@lib/documents.ts";
|
||||||
import { Root } from "https://esm.sh/remark-frontmatter@4.0.1";
|
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>(
|
export function createCrud<T>(
|
||||||
{ prefix, parse }: {
|
{ prefix, parse, render, hasThumbnails = false }: {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
|
hasThumbnails?: boolean;
|
||||||
|
render?: (doc: T) => string;
|
||||||
parse: (doc: string, id: string) => T;
|
parse: (doc: string, id: string) => T;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@ -19,11 +49,27 @@ export function createCrud<T>(
|
|||||||
async function read(id: string) {
|
async function read(id: string) {
|
||||||
const path = pathFromId(id);
|
const path = pathFromId(id);
|
||||||
const content = await getDocument(path);
|
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);
|
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) {
|
async function update(id: string, updater: (r: Root) => Root) {
|
||||||
|
@ -5,7 +5,7 @@ enum LOG_LEVEL {
|
|||||||
ERROR,
|
ERROR,
|
||||||
}
|
}
|
||||||
let longestScope = 0;
|
let longestScope = 0;
|
||||||
let logLevel = LOG_LEVEL.DEBUG;
|
let logLevel = LOG_LEVEL.WARN;
|
||||||
|
|
||||||
export function setLogLevel(level: LOG_LEVEL) {
|
export function setLogLevel(level: LOG_LEVEL) {
|
||||||
logLevel = level;
|
logLevel = level;
|
||||||
@ -49,5 +49,3 @@ export function createLogger(scope: string, _options?: LoggerOptions) {
|
|||||||
warn,
|
warn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = createLogger("");
|
|
||||||
|
@ -4,6 +4,7 @@ import { createCrud } from "@lib/crud.ts";
|
|||||||
import { stringify } from "$std/yaml/stringify.ts";
|
import { stringify } from "$std/yaml/stringify.ts";
|
||||||
import { extractHashTags, formatDate } from "@lib/string.ts";
|
import { extractHashTags, formatDate } from "@lib/string.ts";
|
||||||
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
||||||
|
import { getThumbhash } from "@lib/cache/image.ts";
|
||||||
|
|
||||||
export type Article = {
|
export type Article = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -15,6 +16,7 @@ export type Article = {
|
|||||||
status: "finished" | "not-finished";
|
status: "finished" | "not-finished";
|
||||||
date: Date;
|
date: Date;
|
||||||
link: string;
|
link: string;
|
||||||
|
thumbnail?: string;
|
||||||
image?: string;
|
image?: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
rating?: number;
|
rating?: number;
|
||||||
@ -24,6 +26,7 @@ export type Article = {
|
|||||||
const crud = createCrud<Article>({
|
const crud = createCrud<Article>({
|
||||||
prefix: "Media/articles/",
|
prefix: "Media/articles/",
|
||||||
parse: parseArticle,
|
parse: parseArticle,
|
||||||
|
hasThumbnails: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderArticle(article: Article) {
|
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 { parse, stringify } from "yaml";
|
||||||
import { createCrud } from "@lib/crud.ts";
|
import { createCrud } from "@lib/crud.ts";
|
||||||
import { extractHashTags, formatDate } from "@lib/string.ts";
|
import { extractHashTags, formatDate } from "@lib/string.ts";
|
||||||
@ -13,6 +13,7 @@ export type Movie = {
|
|||||||
meta: {
|
meta: {
|
||||||
date: Date;
|
date: Date;
|
||||||
image: string;
|
image: string;
|
||||||
|
thumbnail?: string;
|
||||||
author: string;
|
author: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
status: "not-seen" | "watch-again" | "finished";
|
status: "not-seen" | "watch-again" | "finished";
|
||||||
@ -98,6 +99,7 @@ export function parseMovie(original: string, id: string): Movie {
|
|||||||
const crud = createCrud<Movie>({
|
const crud = createCrud<Movie>({
|
||||||
prefix: "Media/movies/",
|
prefix: "Media/movies/",
|
||||||
parse: parseMovie,
|
parse: parseMovie,
|
||||||
|
hasThumbnails: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getMovie = crud.read;
|
export const getMovie = crud.read;
|
||||||
|
@ -37,6 +37,7 @@ export type Recipe = {
|
|||||||
rating?: number;
|
rating?: number;
|
||||||
portion?: number;
|
portion?: number;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
thumbnail?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -186,6 +187,7 @@ export function parseRecipe(original: string, id: string): Recipe {
|
|||||||
const crud = createCrud<Recipe>({
|
const crud = createCrud<Recipe>({
|
||||||
prefix: `Recipes/`,
|
prefix: `Recipes/`,
|
||||||
parse: parseRecipe,
|
parse: parseRecipe,
|
||||||
|
hasThumbnails: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAllRecipes = crud.readAll;
|
export const getAllRecipes = crud.readAll;
|
||||||
|
@ -3,6 +3,7 @@ import { parse, stringify } from "yaml";
|
|||||||
import { createCrud } from "@lib/crud.ts";
|
import { createCrud } from "@lib/crud.ts";
|
||||||
import { extractHashTags, formatDate } from "@lib/string.ts";
|
import { extractHashTags, formatDate } from "@lib/string.ts";
|
||||||
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
import { fixRenderedMarkdown } from "@lib/helpers.ts";
|
||||||
|
import { getThumbhash } from "@lib/cache/image.ts";
|
||||||
|
|
||||||
export type Series = {
|
export type Series = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -15,6 +16,7 @@ export type Series = {
|
|||||||
image: string;
|
image: string;
|
||||||
author: string;
|
author: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
|
thumbnail?: string;
|
||||||
status: "not-seen" | "watch-again" | "finished";
|
status: "not-seen" | "watch-again" | "finished";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -98,9 +100,23 @@ export function parseSeries(original: string, id: string): Series {
|
|||||||
const crud = createCrud<Series>({
|
const crud = createCrud<Series>({
|
||||||
prefix: "Media/series/",
|
prefix: "Media/series/",
|
||||||
parse: parseSeries,
|
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 getAllSeries = crud.readAll;
|
||||||
export const createSeries = (series: Series) => {
|
export const createSeries = (series: Series) => {
|
||||||
const content = renderSeries(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);
|
||||||
|
}
|
@ -29,6 +29,7 @@ export default function App({ Component }: AppProps) {
|
|||||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
||||||
<meta name="msapplication-TileColor" content="#da532c" />
|
<meta name="msapplication-TileColor" content="#da532c" />
|
||||||
<meta name="theme-color" content="#141218" />
|
<meta name="theme-color" content="#141218" />
|
||||||
|
<script src="/thumbnails.js" type="module"></script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
|
@ -55,8 +55,6 @@ export const handler: Handlers = {
|
|||||||
exp: getNumericDate(SESSION_DURATION),
|
exp: getNumericDate(SESSION_DURATION),
|
||||||
}, JWT_SECRET);
|
}, JWT_SECRET);
|
||||||
|
|
||||||
console.log({ redirect });
|
|
||||||
|
|
||||||
const headers = new Headers({
|
const headers = new Headers({
|
||||||
location: redirect || "/",
|
location: redirect || "/",
|
||||||
});
|
});
|
||||||
|
@ -152,7 +152,7 @@ const GET = async (
|
|||||||
});
|
});
|
||||||
if (cachedResponse) {
|
if (cachedResponse) {
|
||||||
log.debug("cached", { imageUrl });
|
log.debug("cached", { imageUrl });
|
||||||
return new Response(cachedResponse.buffer, {
|
return new Response(cachedResponse.buffer.slice(), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": cachedResponse.mediaType,
|
"Content-Type": cachedResponse.mediaType,
|
||||||
},
|
},
|
||||||
@ -172,11 +172,19 @@ const GET = async (
|
|||||||
height: params.height,
|
height: params.height,
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
});
|
});
|
||||||
}, 200);
|
}, 10);
|
||||||
|
|
||||||
log.debug("not-cached", { imageUrl, resizedImage });
|
log.debug("not-cached", { imageUrl });
|
||||||
|
|
||||||
return new Response(resizedImage, {
|
cache.getThumbhash({ url: imageUrl }).then((hash) => {
|
||||||
|
if (!hash) {
|
||||||
|
cache.createThumbhash(resizedImage.slice(), imageUrl).catch((_err) => {
|
||||||
|
//
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(resizedImage.slice(), {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": mediaType,
|
"Content-Type": mediaType,
|
||||||
},
|
},
|
||||||
|
@ -48,6 +48,7 @@ export default function Greet(
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
image={doc?.meta?.image || "/placeholder.svg"}
|
image={doc?.meta?.image || "/placeholder.svg"}
|
||||||
|
thumbnail={doc?.meta?.thumbnail}
|
||||||
link={`/articles/${doc.id}`}
|
link={`/articles/${doc.id}`}
|
||||||
title={doc.name}
|
title={doc.name}
|
||||||
/>
|
/>
|
||||||
|
@ -5,7 +5,7 @@ import { getAllRecipes, Recipe } from "@lib/resource/recipes.ts";
|
|||||||
import { Grid } from "@components/Grid.tsx";
|
import { Grid } from "@components/Grid.tsx";
|
||||||
import { IconArrowLeft } from "@components/icons.tsx";
|
import { IconArrowLeft } from "@components/icons.tsx";
|
||||||
import { KMenu } from "@islands/KMenu.tsx";
|
import { KMenu } from "@islands/KMenu.tsx";
|
||||||
import { fetchQueryResource, RedirectSearchHandler } from "@islands/Search.tsx";
|
import { RedirectSearchHandler } from "@islands/Search.tsx";
|
||||||
import { parseResourceUrl, searchResource } from "@lib/search.ts";
|
import { parseResourceUrl, searchResource } from "@lib/search.ts";
|
||||||
import { SearchResult } from "@lib/types.ts";
|
import { SearchResult } from "@lib/types.ts";
|
||||||
|
|
||||||
|
259
static/thumbnails.js
Normal file
259
static/thumbnails.js
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
function thumbHashToApproximateAspectRatio(hash) {
|
||||||
|
let header = hash[3];
|
||||||
|
let hasAlpha = hash[2] & 0x80;
|
||||||
|
let isLandscape = hash[4] & 0x80;
|
||||||
|
let lx = isLandscape ? hasAlpha ? 5 : 7 : header & 7;
|
||||||
|
let ly = isLandscape ? header & 7 : hasAlpha ? 5 : 7;
|
||||||
|
return lx / ly;
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbHashToRGBA(hash) {
|
||||||
|
let { PI, min, max, cos, round } = Math;
|
||||||
|
|
||||||
|
// Read the constants
|
||||||
|
let header24 = hash[0] | (hash[1] << 8) | (hash[2] << 16);
|
||||||
|
let header16 = hash[3] | (hash[4] << 8);
|
||||||
|
let l_dc = (header24 & 63) / 63;
|
||||||
|
let p_dc = ((header24 >> 6) & 63) / 31.5 - 1;
|
||||||
|
let q_dc = ((header24 >> 12) & 63) / 31.5 - 1;
|
||||||
|
let l_scale = ((header24 >> 18) & 31) / 31;
|
||||||
|
let hasAlpha = header24 >> 23;
|
||||||
|
let p_scale = ((header16 >> 3) & 63) / 63;
|
||||||
|
let q_scale = ((header16 >> 9) & 63) / 63;
|
||||||
|
let isLandscape = header16 >> 15;
|
||||||
|
let lx = max(3, isLandscape ? hasAlpha ? 5 : 7 : header16 & 7);
|
||||||
|
let ly = max(3, isLandscape ? header16 & 7 : hasAlpha ? 5 : 7);
|
||||||
|
let a_dc = hasAlpha ? (hash[5] & 15) / 15 : 1;
|
||||||
|
let a_scale = (hash[5] >> 4) / 15;
|
||||||
|
|
||||||
|
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
|
||||||
|
let ac_start = hasAlpha ? 6 : 5;
|
||||||
|
let ac_index = 0;
|
||||||
|
let decodeChannel = (nx, ny, scale) => {
|
||||||
|
let ac = [];
|
||||||
|
for (let cy = 0; cy < ny; cy++) {
|
||||||
|
for (let cx = cy ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
|
||||||
|
ac.push(
|
||||||
|
(((hash[ac_start + (ac_index >> 1)] >> ((ac_index++ & 1) << 2)) &
|
||||||
|
15) / 7.5 - 1) * scale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ac;
|
||||||
|
};
|
||||||
|
let l_ac = decodeChannel(lx, ly, l_scale);
|
||||||
|
let p_ac = decodeChannel(3, 3, p_scale * 1.25);
|
||||||
|
let q_ac = decodeChannel(3, 3, q_scale * 1.25);
|
||||||
|
let a_ac = hasAlpha && decodeChannel(5, 5, a_scale);
|
||||||
|
|
||||||
|
// Decode using the DCT into RGB
|
||||||
|
let ratio = thumbHashToApproximateAspectRatio(hash);
|
||||||
|
let w = round(ratio > 1 ? 32 : 32 * ratio);
|
||||||
|
let h = round(ratio > 1 ? 32 / ratio : 32);
|
||||||
|
let rgba = new Uint8Array(w * h * 4), fx = [], fy = [];
|
||||||
|
for (let y = 0, i = 0; y < h; y++) {
|
||||||
|
for (let x = 0; x < w; x++, i += 4) {
|
||||||
|
let l = l_dc, p = p_dc, q = q_dc, a = a_dc;
|
||||||
|
|
||||||
|
// Precompute the coefficients
|
||||||
|
for (let cx = 0, n = max(lx, hasAlpha ? 5 : 3); cx < n; cx++) {
|
||||||
|
fx[cx] = cos(PI / w * (x + 0.5) * cx);
|
||||||
|
}
|
||||||
|
for (let cy = 0, n = max(ly, hasAlpha ? 5 : 3); cy < n; cy++) {
|
||||||
|
fy[cy] = cos(PI / h * (y + 0.5) * cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode L
|
||||||
|
for (let cy = 0, j = 0; cy < ly; cy++) {
|
||||||
|
for (
|
||||||
|
let cx = cy ? 0 : 1, fy2 = fy[cy] * 2;
|
||||||
|
cx * ly < lx * (ly - cy);
|
||||||
|
cx++, j++
|
||||||
|
) {
|
||||||
|
l += l_ac[j] * fx[cx] * fy2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode P and Q
|
||||||
|
for (let cy = 0, j = 0; cy < 3; cy++) {
|
||||||
|
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 3 - cy; cx++, j++) {
|
||||||
|
let f = fx[cx] * fy2;
|
||||||
|
p += p_ac[j] * f;
|
||||||
|
q += q_ac[j] * f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode A
|
||||||
|
if (hasAlpha) {
|
||||||
|
for (let cy = 0, j = 0; cy < 5; cy++) {
|
||||||
|
for (let cx = cy ? 0 : 1, fy2 = fy[cy] * 2; cx < 5 - cy; cx++, j++) {
|
||||||
|
a += a_ac[j] * fx[cx] * fy2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to RGB
|
||||||
|
let b = l - 2 / 3 * p;
|
||||||
|
let r = (3 * l - b + q) / 2;
|
||||||
|
let g = r - q;
|
||||||
|
rgba[i] = max(0, 255 * min(1, r));
|
||||||
|
rgba[i + 1] = max(0, 255 * min(1, g));
|
||||||
|
rgba[i + 2] = max(0, 255 * min(1, b));
|
||||||
|
rgba[i + 3] = max(0, 255 * min(1, a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { w, h, rgba };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbaToDataURL(w, h, rgba) {
|
||||||
|
let row = w * 4 + 1;
|
||||||
|
let idat = 6 + h * (5 + row);
|
||||||
|
let bytes = [
|
||||||
|
137,
|
||||||
|
80,
|
||||||
|
78,
|
||||||
|
71,
|
||||||
|
13,
|
||||||
|
10,
|
||||||
|
26,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
13,
|
||||||
|
73,
|
||||||
|
72,
|
||||||
|
68,
|
||||||
|
82,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
w >> 8,
|
||||||
|
w & 255,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
h >> 8,
|
||||||
|
h & 255,
|
||||||
|
8,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
idat >>> 24,
|
||||||
|
(idat >> 16) & 255,
|
||||||
|
(idat >> 8) & 255,
|
||||||
|
idat & 255,
|
||||||
|
73,
|
||||||
|
68,
|
||||||
|
65,
|
||||||
|
84,
|
||||||
|
120,
|
||||||
|
1,
|
||||||
|
];
|
||||||
|
let table = [
|
||||||
|
0,
|
||||||
|
498536548,
|
||||||
|
997073096,
|
||||||
|
651767980,
|
||||||
|
1994146192,
|
||||||
|
1802195444,
|
||||||
|
1303535960,
|
||||||
|
1342533948,
|
||||||
|
-306674912,
|
||||||
|
-267414716,
|
||||||
|
-690576408,
|
||||||
|
-882789492,
|
||||||
|
-1687895376,
|
||||||
|
-2032938284,
|
||||||
|
-1609899400,
|
||||||
|
-1111625188,
|
||||||
|
];
|
||||||
|
let a = 1, b = 0;
|
||||||
|
for (let y = 0, i = 0, end = row - 1; y < h; y++, end += row - 1) {
|
||||||
|
bytes.push(
|
||||||
|
y + 1 < h ? 0 : 1,
|
||||||
|
row & 255,
|
||||||
|
row >> 8,
|
||||||
|
~row & 255,
|
||||||
|
(row >> 8) ^ 255,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
for (b = (b + a) % 65521; i < end; i++) {
|
||||||
|
let u = rgba[i] & 255;
|
||||||
|
bytes.push(u);
|
||||||
|
a = (a + u) % 65521;
|
||||||
|
b = (b + a) % 65521;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bytes.push(
|
||||||
|
b >> 8,
|
||||||
|
b & 255,
|
||||||
|
a >> 8,
|
||||||
|
a & 255,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
73,
|
||||||
|
69,
|
||||||
|
78,
|
||||||
|
68,
|
||||||
|
174,
|
||||||
|
66,
|
||||||
|
96,
|
||||||
|
130,
|
||||||
|
);
|
||||||
|
for (let [start, end] of [[12, 29], [37, 41 + idat]]) {
|
||||||
|
let c = ~0;
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
c ^= bytes[i];
|
||||||
|
c = (c >>> 4) ^ table[c & 15];
|
||||||
|
c = (c >>> 4) ^ table[c & 15];
|
||||||
|
}
|
||||||
|
c = ~c;
|
||||||
|
bytes[end++] = c >>> 24;
|
||||||
|
bytes[end++] = (c >> 16) & 255;
|
||||||
|
bytes[end++] = (c >> 8) & 255;
|
||||||
|
bytes[end++] = c & 255;
|
||||||
|
}
|
||||||
|
return "data:image/png;base64," + btoa(String.fromCharCode(...bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbHashToDataURL(hash) {
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-thumb]").forEach((entry) => {
|
||||||
|
const hash = entry.getAttribute("data-thumb");
|
||||||
|
|
||||||
|
if (!hash) return;
|
||||||
|
|
||||||
|
const decodedString = atob(hash);
|
||||||
|
|
||||||
|
// Create Uint8Array from decoded string
|
||||||
|
const buffer = new Uint8Array(decodedString.length);
|
||||||
|
for (let i = 0; i < decodedString.length; i++) {
|
||||||
|
buffer[i] = decodedString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = thumbHashToRGBA(buffer);
|
||||||
|
const dataURL = rgbaToDataURL(image.w, image.h, image.rgba);
|
||||||
|
|
||||||
|
entry.style.background = `url(${dataURL})`;
|
||||||
|
entry.style.backgroundSize = "cover";
|
||||||
|
|
||||||
|
const child = entry.querySelector("img[data-thumb-img]");
|
||||||
|
if (child) {
|
||||||
|
child.style.opacity = 0;
|
||||||
|
child.style.transition = "opacity 0.3s ease";
|
||||||
|
child.addEventListener("load", () => {
|
||||||
|
child.style.opacity = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user