use thumbhash instead

This commit is contained in:
grngxd 2025-08-01 10:46:58 +01:00
parent 972c8930cd
commit ae74906217
5 changed files with 107 additions and 18 deletions

View file

@ -6,27 +6,30 @@
"dependencies": {
"@types/aos": "^3.0.7",
"aos": "^3.0.0-beta.6",
"ky": "^1.8.1",
"buffer": "^6.0.3",
"fast-blurhash": "^1.1.4",
"ky": "^1.8.2",
"nanostores": "^1.0.1",
"tailwind-scrollbar": "^4.0.2",
"thumbhash": "^0.1.1",
},
"devDependencies": {
"@builder.io/qwik": "^1.14.1",
"@builder.io/qwik-city": "^1.14.1",
"@builder.io/qwik": "^1.15.0",
"@builder.io/qwik-city": "^1.15.0",
"@eslint/js": "latest",
"@tailwindcss/vite": "^4.0.0",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "20.14.11",
"eslint": "9.25.1",
"eslint-plugin-qwik": "^1.14.1",
"eslint-plugin-qwik": "^1.15.0",
"globals": "16.0.0",
"prettier": "3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"tailwindcss": "^4.1.11",
"typescript": "5.4.5",
"typescript-eslint": "8.26.1",
"undici": "*",
"undici": "^7.12.0",
"vite": "5.3.5",
"vite-tsconfig-paths": "^4.2.1",
"vite-tsconfig-paths": "^4.3.2",
},
},
},
@ -323,12 +326,16 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@ -475,6 +482,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-blurhash": ["fast-blurhash@1.1.4", "", {}, "sha512-xeH121M027hgWHHhHWYYjUmMKl8vCH3PPkXk439ixsP8Bvb/r3UFqg12oMSToD/aSAw8EE6XiTdfZ6M5jaLfzg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@ -545,6 +554,8 @@
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imagetools-core": ["imagetools-core@7.1.0", "", {}, "sha512-8Aa4NecBBGmTkaAUjcuRYgTPKHCsBEWYmCnvKCL6/bxedehtVVFyZPdXe8DD0Nevd6UWBq85ifUaJ8498lgqNQ=="],
@ -915,6 +926,8 @@
"tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="],
"thumbhash": ["thumbhash@0.1.1", "", {}, "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],

View file

@ -44,8 +44,11 @@
"dependencies": {
"@types/aos": "^3.0.7",
"aos": "^3.0.0-beta.6",
"buffer": "^6.0.3",
"fast-blurhash": "^1.1.4",
"ky": "^1.8.2",
"nanostores": "^1.0.1",
"tailwind-scrollbar": "^4.0.2"
"tailwind-scrollbar": "^4.0.2",
"thumbhash": "^0.1.1"
}
}

View file

@ -0,0 +1,54 @@
import { component$, QwikIntrinsicElements, useSignal, useTask$ } from "@builder.io/qwik";
import { thumbHashToRGBA } from "thumbhash";
export default component$<QwikIntrinsicElements['img'] & {
hash: string;
width: number;
height: number;
}>(({ hash, width, height, class: cls, ...otherProps }) => {
const dataUrl = useSignal<string | null>(null);
useTask$(() => {
if (!hash) return;
// Decode base64 to Uint8Array (browser-safe)
const binary = atob(hash);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
try {
const image = thumbHashToRGBA(bytes);
console.log("pixels", image);
const canvas = document.createElement("canvas");
canvas.width = image.w;
canvas.height = image.h;
const ctx = canvas.getContext("2d");
if (!ctx) {
console.error("failed to get canvas context");
return;
}
const imageData = ctx.createImageData(image.w, image.h);
imageData.data.set(image.rgba);
ctx.putImageData(imageData, 0, 0);
ctx.drawImage(canvas, 0, 0);
dataUrl.value = canvas.toDataURL("image/png");
} catch (e) {
console.error("ThumbHash decode error:", e);
}
});
return dataUrl.value ? (
<><img
src={dataUrl.value}
width={width}
height={height}
class={["object-cover", cls]}
alt="blurhash placeholder"
{...otherProps}
/></>
) : null;
});

View file

@ -5,6 +5,9 @@ export type StereoFile = {
Size: number;
CreatedAt: string;
Mime: string;
Hash?: string;
Width?: number;
Height?: number;
}
export type StereoUser = {

View file

@ -1,8 +1,10 @@
/* eslint-disable qwik/jsx-img */
/* eslint-disable qwik/no-use-visible-task */
import { component$, Signal, useSignal, useTask$, useVisibleTask$ } from "@builder.io/qwik";
import { routeLoader$, type DocumentHead } from "@builder.io/qwik-city";
import Actionbar from "~/components/dashboard/Actionbar";
import Settings from "~/components/dashboard/Settings";
import Thumbhash from "~/components/dashboard/Thumbhash";
import Titlebar from "~/components/dashboard/Titlebar";
import { useNanostore$ } from "~/hooks/nanostores";
// import Dropzone from "~/components/Dropzone";
@ -52,16 +54,30 @@ const Files = component$(() => {
else fileType.value = "other";
});
const loaded = useSignal(false);
return (
<div class="w-full h-max object-cover flex-grow relative overflow-clip">
{fileType.value === "image" && (
// eslint-disable-next-line qwik/jsx-img
<img
width={400}
src={`/api/${file.ID}`}
alt={file.Name}
class="w-full min-h-30 object-cover flex-grow hover:scale-[102.5%] transition-all duration-500"
/>
<div
style={{
aspectRatio: file.Width && file.Height ? `${file.Width} / ${file.Height}` : "16 / 9",
width: "100%",
position: "relative",
overflow: "hidden",
}}
>
{file.Hash && !loaded.value && (
<Thumbhash hash={file.Hash} width={file.Width || 0} height={file.Height || 0} class="absolute w-full h-full" />
)}
<img
width={400}
src={`/api/${file.ID}`}
alt={file.Name}
class="w-full min-h-30 object-cover flex-grow hover:scale-[102.5%] transition-all duration-500"
onLoad$={() => loaded.value = true}
/>
</div>
)}
{fileType.value === "video" && (
<video
@ -181,7 +197,7 @@ const Files = component$(() => {
{hasMore.value && (
<div
ref={sentinel}
class="absolute bottom-0 left-0 h-56 flex items-center justify-center w-full"
class="absolute bottom-0 left-0 h-1/2 flex items-center justify-center w-full"
>
{/* {loadingMore.value && (
loading spinner?