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";
 | 
			
		||||
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(
 | 
			
		||||
      {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<T extends mastodon.v1.Status> = {
 | 
			
		||||
  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 (
 | 
			
		||||
    <a
 | 
			
		||||
| 
						 | 
				
			
			@ -238,12 +252,18 @@ export function TootPreviewCard(props: {
 | 
			
		|||
      href={props.src.url}
 | 
			
		||||
      target="_blank"
 | 
			
		||||
      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}>
 | 
			
		||||
        <img
 | 
			
		||||
          crossOrigin="anonymous"
 | 
			
		||||
          src={props.src.image!}
 | 
			
		||||
          onLoad={onImgLoad}
 | 
			
		||||
          width={props.src.width || undefined}
 | 
			
		||||
          height={props.src.height || undefined}
 | 
			
		||||
          loading="lazy"
 | 
			
		||||
| 
						 | 
				
			
			@ -373,16 +393,16 @@ const RegularToot: Component<TootCardProps> = (props) => {
 | 
			
		|||
        <Show when={!!status().reblog}>
 | 
			
		||||
          <div class={tootStyle.tootRetootGrp}>
 | 
			
		||||
            <BoostIcon />
 | 
			
		||||
              <Body2
 | 
			
		||||
                ref={(e: { innerHTML: string }) => {
 | 
			
		||||
                  createRenderEffect(() => {
 | 
			
		||||
                    e.innerHTML = resolveCustomEmoji(
 | 
			
		||||
                      status().account.displayName,
 | 
			
		||||
                      toot().emojis,
 | 
			
		||||
                    );
 | 
			
		||||
                  });
 | 
			
		||||
                }}
 | 
			
		||||
              ></Body2>
 | 
			
		||||
            <Body2
 | 
			
		||||
              ref={(e: { innerHTML: string }) => {
 | 
			
		||||
                createRenderEffect(() => {
 | 
			
		||||
                  e.innerHTML = resolveCustomEmoji(
 | 
			
		||||
                    status().account.displayName,
 | 
			
		||||
                    toot().emojis,
 | 
			
		||||
                  );
 | 
			
		||||
                });
 | 
			
		||||
              }}
 | 
			
		||||
            ></Body2>
 | 
			
		||||
            <span>boosts</span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </Show>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -183,6 +183,7 @@ const TootList: Component<{
 | 
			
		|||
  return (
 | 
			
		||||
    <ErrorBoundary
 | 
			
		||||
      fallback={(err, reset) => {
 | 
			
		||||
        console.error(err);
 | 
			
		||||
        return <p>Oops: {String(err)}</p>;
 | 
			
		||||
      }}
 | 
			
		||||
    >
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue