From 2aa2cf21da3ed75a6b6e998cee2dcc89cf179761 Mon Sep 17 00:00:00 2001 From: thislight Date: Mon, 11 Nov 2024 16:53:34 +0800 Subject: [PATCH] toot medias: extract color from blurhash --- src/platform/blurhash.ts | 164 ++++++++++++++++++++++++++ src/timelines/MediaAttachmentGrid.tsx | 5 +- src/timelines/RegularToot.tsx | 90 ++++++++------ src/timelines/TootList.tsx | 1 + src/timelines/toot.module.css | 6 +- 5 files changed, 226 insertions(+), 40 deletions(-) create mode 100644 src/platform/blurhash.ts diff --git a/src/platform/blurhash.ts b/src/platform/blurhash.ts new file mode 100644 index 0000000..77a7d71 --- /dev/null +++ b/src/platform/blurhash.ts @@ -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); +} diff --git a/src/timelines/MediaAttachmentGrid.tsx b/src/timelines/MediaAttachmentGrid.tsx index 682767a..3f7147c 100644 --- a/src/timelines/MediaAttachmentGrid.tsx +++ b/src/timelines/MediaAttachmentGrid.tsx @@ -20,6 +20,7 @@ import { } from "@solid-primitives/resize-observer"; import { useStore } from "@nanostores/solid"; import { $settings } from "../settings/stores"; +import { averageColorHex } from "../platform/blurhash"; type ElementSize = { width: number; height: number }; @@ -120,7 +121,9 @@ const MediaAttachmentGrid: Component<{ // we may need better tool to manage the performance impact // before using this. See #37. // 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( { diff --git a/src/timelines/RegularToot.tsx b/src/timelines/RegularToot.tsx index cfb963c..ffcae6a 100644 --- a/src/timelines/RegularToot.tsx +++ b/src/timelines/RegularToot.tsx @@ -6,6 +6,7 @@ import { Show, createRenderEffect, createEffect, + createMemo, } from "solid-js"; import tootStyle from "./toot.module.css"; import { formatRelative } from "date-fns"; @@ -29,13 +30,13 @@ import { Divider } from "@suid/material"; import cardStyle from "../material/cards.module.css"; import Button from "../material/Button.js"; import MediaAttachmentGrid from "./MediaAttachmentGrid.js"; -import { FastAverageColor } from "fast-average-color"; import Color from "colorjs.io"; import { useDateFnLocale } from "../platform/i18n"; import { canShare, share } from "../platform/share"; import { makeAcctText, useDefaultSession } from "../masto/clients"; import TootContent from "./toot-components/TootContent"; import BoostIcon from "./toot-components/BoostIcon"; +import { averageColorHex } from "../platform/blurhash"; type TootActionGroupProps = { onRetoot?: (value: T) => void; @@ -205,31 +206,44 @@ export function TootPreviewCard(props: { } }); - const onImgLoad = (event: Event & { currentTarget: HTMLImageElement }) => { - // TODO: better extraction algorithm - // I'd like to use a pattern panel and match one in the panel from the extracted color - const fac = new FastAverageColor(); - const result = fac.getColor(event.currentTarget); - if (result.error) { - console.error(result.error); - fac.destroy(); + const imgAverageColor = createMemo(() => { + if (!props.src.image) return; + return new Color(averageColorHex(props.src.blurhash)); + }); + + const prefersWhiteText = createMemo(() => { + const oc = imgAverageColor(); + 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; } - root.style.setProperty("--tutu-color-surface", result.hex); - const focusSurface = result.isDark - ? new Color(result.hex).darken(0.2) - : new Color(result.hex).lighten(0.2); - root.style.setProperty("--tutu-color-surface-d", focusSurface.toString()); - const textColor = result.isDark ? "white" : "black"; - const secondaryTextColor = new Color(textColor); - secondaryTextColor.alpha = 0.75; - root.style.setProperty("--tutu-color-on-surface", textColor); - root.style.setProperty( - "--tutu-color-secondary-text-on-surface", - secondaryTextColor.toString(), - ); - fac.destroy(); - }; + return useWhiteText ? "white" : "black"; + }); + + const secondaryTextColor = createMemo(() => { + const tcn = textColorName(); + if (!tcn) return; + const tc = new Color(tcn); + tc.alpha = 0.75; + return tc; + }); return ( = (props) => {
- { - createRenderEffect(() => { - e.innerHTML = resolveCustomEmoji( - status().account.displayName, - toot().emojis, - ); - }); - }} - > + { + createRenderEffect(() => { + e.innerHTML = resolveCustomEmoji( + status().account.displayName, + toot().emojis, + ); + }); + }} + > boosts
diff --git a/src/timelines/TootList.tsx b/src/timelines/TootList.tsx index 85a0541..7ef86da 100644 --- a/src/timelines/TootList.tsx +++ b/src/timelines/TootList.tsx @@ -183,6 +183,7 @@ const TootList: Component<{ return ( { + console.error(err); return

Oops: {String(err)}

; }} > diff --git a/src/timelines/toot.module.css b/src/timelines/toot.module.css index d196189..cb3c70d 100644 --- a/src/timelines/toot.module.css +++ b/src/timelines/toot.module.css @@ -135,6 +135,7 @@ position: relative; >img { + background-color: #eeeeee; max-width: 100%; height: auto; } @@ -260,11 +261,8 @@ &:hover, &: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)); - border-color: transparent; + border: none; } } }