toot medias: extract color from blurhash
All checks were successful
/ depoly (push) Successful in 1m17s

This commit is contained in:
thislight 2024-11-11 16:53:34 +08:00
parent c372ea4a92
commit 2aa2cf21da
No known key found for this signature in database
GPG key ID: FCFE5192241CCD4E
5 changed files with 226 additions and 40 deletions

164
src/platform/blurhash.ts Normal file
View file

@ -0,0 +1,164 @@
/*
Blurhash toolkit.
base83 decoder/encoder is copied from
https://github.com/woltapp/blurhash/blob/master/TypeScript/src/base83.ts,
which is MIT Licensed: https://github.com/woltapp/blurhash?tab=MIT-1-ov-file#readme
*/
const digitCharacters = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"a",
"b",
"c",
"d",
"e",
"f",
"g",
"h",
"i",
"j",
"k",
"l",
"m",
"n",
"o",
"p",
"q",
"r",
"s",
"t",
"u",
"v",
"w",
"x",
"y",
"z",
"#",
"$",
"%",
"*",
"+",
",",
"-",
".",
":",
";",
"=",
"?",
"@",
"[",
"]",
"^",
"_",
"{",
"|",
"}",
"~",
];
function decode83(str: string) {
let value = 0;
for (let i = 0; i < str.length; i++) {
const c = str[i];
const digit = digitCharacters.indexOf(c);
value = value * 83 + digit;
}
return value;
}
function encode83(n: number, length: number): string {
var result = "";
for (let i = 1; i <= length; i++) {
let digit = (Math.floor(n) / Math.pow(83, length - i)) % 83;
result += digitCharacters[Math.floor(digit)];
}
return result;
}
/* toColorHex() is modified from
https://www.xaymar.com/articles/2020/12/08/fastest-uint8array-to-hex-string-conversion-in-javascript/,
licensed BSD-3. */
// Pre-Init
const LUT_HEX_4b = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"A",
"B",
"C",
"D",
"E",
"F",
];
const LUT_HEX_8b = new Array(0x100);
for (let n = 0; n < 0x100; n++) {
LUT_HEX_8b[n] = `${LUT_HEX_4b[(n >>> 4) & 0xf]}${LUT_HEX_4b[n & 0xf]}`;
}
// End Pre-Init
function toColorHex(buffer: Uint8ClampedArray): `#${string}` {
let out = "#";
for (let idx = 0, edx = buffer.length; idx < edx; idx++) {
out += LUT_HEX_8b[buffer[idx]];
}
return out as `#${string}`;
}
export function averageColor(blurhash: string) {
const v = decode83(blurhash.substring(2, 6)); // 24-bit RGB
return [v >> 16, (v >> 8) & 255, v & 255] as const;
}
export function averageColorHex(blurhash: string) : `#${string}` {
const [r, g, b] = averageColor(blurhash);
const buf = new Uint8ClampedArray(3);
buf[0] = r;
buf[1] = g;
buf[2] = b;
return toColorHex(buf);
}

View file

@ -20,6 +20,7 @@ import {
} from "@solid-primitives/resize-observer"; } from "@solid-primitives/resize-observer";
import { useStore } from "@nanostores/solid"; import { useStore } from "@nanostores/solid";
import { $settings } from "../settings/stores"; import { $settings } from "../settings/stores";
import { averageColorHex } from "../platform/blurhash";
type ElementSize = { width: number; height: number }; type ElementSize = { width: number; height: number };
@ -120,7 +121,9 @@ const MediaAttachmentGrid: Component<{
// we may need better tool to manage the performance impact // we may need better tool to manage the performance impact
// before using this. See #37. // before using this. See #37.
// TODO: use fast average color to extract accent color // TODO: use fast average color to extract accent color
const accentColor = item.meta?.colors?.accent; const accentColor =
item.meta?.colors?.accent ??
(item.blurhash ? averageColorHex(item.blurhash) : undefined);
return Object.assign( return Object.assign(
{ {

View file

@ -6,6 +6,7 @@ import {
Show, Show,
createRenderEffect, createRenderEffect,
createEffect, createEffect,
createMemo,
} from "solid-js"; } from "solid-js";
import tootStyle from "./toot.module.css"; import tootStyle from "./toot.module.css";
import { formatRelative } from "date-fns"; import { formatRelative } from "date-fns";
@ -29,13 +30,13 @@ import { Divider } from "@suid/material";
import cardStyle from "../material/cards.module.css"; import cardStyle from "../material/cards.module.css";
import Button from "../material/Button.js"; import Button from "../material/Button.js";
import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js";
import { FastAverageColor } from "fast-average-color";
import Color from "colorjs.io"; import Color from "colorjs.io";
import { useDateFnLocale } from "../platform/i18n"; import { useDateFnLocale } from "../platform/i18n";
import { canShare, share } from "../platform/share"; import { canShare, share } from "../platform/share";
import { makeAcctText, useDefaultSession } from "../masto/clients"; import { makeAcctText, useDefaultSession } from "../masto/clients";
import TootContent from "./toot-components/TootContent"; import TootContent from "./toot-components/TootContent";
import BoostIcon from "./toot-components/BoostIcon"; import BoostIcon from "./toot-components/BoostIcon";
import { averageColorHex } from "../platform/blurhash";
type TootActionGroupProps<T extends mastodon.v1.Status> = { type TootActionGroupProps<T extends mastodon.v1.Status> = {
onRetoot?: (value: T) => void; onRetoot?: (value: T) => void;
@ -205,31 +206,44 @@ export function TootPreviewCard(props: {
} }
}); });
const onImgLoad = (event: Event & { currentTarget: HTMLImageElement }) => { const imgAverageColor = createMemo(() => {
// TODO: better extraction algorithm if (!props.src.image) return;
// I'd like to use a pattern panel and match one in the panel from the extracted color return new Color(averageColorHex(props.src.blurhash));
const fac = new FastAverageColor(); });
const result = fac.getColor(event.currentTarget);
if (result.error) { const prefersWhiteText = createMemo(() => {
console.error(result.error); const oc = imgAverageColor();
fac.destroy(); if (!oc) return;
const colorWhite = new Color("white");
return colorWhite.luminance / oc.luminance > 3.5;
});
const focusSurfaceColor = createMemo(() => {
const oc = imgAverageColor();
if (!oc) return;
if (prefersWhiteText()) {
return new Color(oc).darken(0.2);
} else {
return new Color(oc).lighten(0.2);
}
});
const textColorName = createMemo(() => {
const useWhiteText = prefersWhiteText();
if (typeof useWhiteText === "undefined") {
return; return;
} }
root.style.setProperty("--tutu-color-surface", result.hex); return useWhiteText ? "white" : "black";
const focusSurface = result.isDark });
? new Color(result.hex).darken(0.2)
: new Color(result.hex).lighten(0.2); const secondaryTextColor = createMemo(() => {
root.style.setProperty("--tutu-color-surface-d", focusSurface.toString()); const tcn = textColorName();
const textColor = result.isDark ? "white" : "black"; if (!tcn) return;
const secondaryTextColor = new Color(textColor); const tc = new Color(tcn);
secondaryTextColor.alpha = 0.75; tc.alpha = 0.75;
root.style.setProperty("--tutu-color-on-surface", textColor); return tc;
root.style.setProperty( });
"--tutu-color-secondary-text-on-surface",
secondaryTextColor.toString(),
);
fac.destroy();
};
return ( return (
<a <a
@ -238,12 +252,18 @@ export function TootPreviewCard(props: {
href={props.src.url} href={props.src.url}
target="_blank" target="_blank"
referrerPolicy="unsafe-url" referrerPolicy="unsafe-url"
style={{
"--tutu-color-surface": imgAverageColor()?.toString(),
"--tutu-color-surface-d": focusSurfaceColor()?.toString(),
"--tutu-color-on-surface": textColorName(),
"--tutu-color-secondary-text-on-surface":
secondaryTextColor()?.toString(),
}}
> >
<Show when={props.src.image}> <Show when={props.src.image}>
<img <img
crossOrigin="anonymous" crossOrigin="anonymous"
src={props.src.image!} src={props.src.image!}
onLoad={onImgLoad}
width={props.src.width || undefined} width={props.src.width || undefined}
height={props.src.height || undefined} height={props.src.height || undefined}
loading="lazy" loading="lazy"
@ -373,16 +393,16 @@ const RegularToot: Component<TootCardProps> = (props) => {
<Show when={!!status().reblog}> <Show when={!!status().reblog}>
<div class={tootStyle.tootRetootGrp}> <div class={tootStyle.tootRetootGrp}>
<BoostIcon /> <BoostIcon />
<Body2 <Body2
ref={(e: { innerHTML: string }) => { ref={(e: { innerHTML: string }) => {
createRenderEffect(() => { createRenderEffect(() => {
e.innerHTML = resolveCustomEmoji( e.innerHTML = resolveCustomEmoji(
status().account.displayName, status().account.displayName,
toot().emojis, toot().emojis,
); );
}); });
}} }}
></Body2> ></Body2>
<span>boosts</span> <span>boosts</span>
</div> </div>
</Show> </Show>

View file

@ -183,6 +183,7 @@ const TootList: Component<{
return ( return (
<ErrorBoundary <ErrorBoundary
fallback={(err, reset) => { fallback={(err, reset) => {
console.error(err);
return <p>Oops: {String(err)}</p>; return <p>Oops: {String(err)}</p>;
}} }}
> >

View file

@ -135,6 +135,7 @@
position: relative; position: relative;
>img { >img {
background-color: #eeeeee;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
@ -260,11 +261,8 @@
&:hover, &:hover,
&:focus-visible { &:focus-visible {
/* TODO: the goal is to use the media's accent color as the outline */
/* but our infra is not prepared for this. The average color thing is slow */
/* and we need further managing to control its performance impact. */
outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d)); outline: 8px solid var(--media-color-accent, var(--tutu-color-surface-d));
border-color: transparent; border: none;
} }
} }
} }