toot medias: extract color from blurhash
All checks were successful
/ depoly (push) Successful in 1m17s
All checks were successful
/ depoly (push) Successful in 1m17s
This commit is contained in:
parent
c372ea4a92
commit
2aa2cf21da
5 changed files with 226 additions and 40 deletions
164
src/platform/blurhash.ts
Normal file
164
src/platform/blurhash.ts
Normal 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);
|
||||||
|
}
|
|
@ -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(
|
||||||
{
|
{
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue