From 5e48ffbf343bb1e6020cf0bac103b174609df7e3 Mon Sep 17 00:00:00 2001 From: ochafik Date: Tue, 31 Dec 2024 16:20:08 +0000 Subject: [PATCH] Add blurhash previews to make (re)loading more entertaining --- package.json | 2 + src/components/ViewerPanel.tsx | 79 ++++++++++++++++++++++++++++++++-- src/io/image_hashes.ts | 77 +++++++++++++++++++++++++++++++++ src/state/app-state.ts | 6 +++ src/state/fragment-state.ts | 9 +++- src/state/initial-state.ts | 8 +++- src/state/model.ts | 5 +++ 7 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 src/io/image_hashes.ts diff --git a/package.json b/package.json index 09719c0..6d96b03 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "blurhash": "^2.0.5", "chroma-js": "^3.1.2", "debug": "^4.4.0", "jszip": "^3.10.1", @@ -20,6 +21,7 @@ "primereact": "^10.8.5", "react": "^18.3.1", "react-dom": "^18.3.1", + "thumbhash": "^0.1.1", "uuid": "^11.0.3", "uzip": "^0.20201231.0" }, diff --git a/src/components/ViewerPanel.tsx b/src/components/ViewerPanel.tsx index 2832e3d..9d08604 100644 --- a/src/components/ViewerPanel.tsx +++ b/src/components/ViewerPanel.tsx @@ -1,8 +1,9 @@ // Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING. -import { CSSProperties, useContext, useEffect, useRef, useState } from 'react'; +import { CSSProperties, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { ModelContext } from './contexts'; import { Toast } from 'primereact/toast'; +import { blurHashToImage, imageToBlurhash, imageToThumbhash, thumbHashToImage } from '../io/image_hashes'; declare global { namespace JSX { @@ -59,6 +60,51 @@ export default function ViewerPanel({className, style}: {className?: string, sty const axesViewerRef = useRef(); const toastRef = useRef(null); + const [loadedUri, setLoadedUri] = useState(); + + const [cachedImageHash, setCachedImageHash] = useState<{hash: string, uri: string} | undefined>(undefined); + + const modelUri = state.output?.displayFileURL ?? state.output?.outFileURL ?? ''; + const loaded = loadedUri === modelUri; + + if (state?.preview) { + let {hash, uri} = cachedImageHash ?? {}; + if (state.preview.blurhash && hash !== state.preview.blurhash) { + hash = state.preview.blurhash; + uri = blurHashToImage(hash, 100, 100); + setCachedImageHash({hash, uri}); + } else if (state.preview.thumbhash && hash !== state.preview.thumbhash) { + hash = state.preview.thumbhash; + uri = thumbHashToImage(hash); + setCachedImageHash({hash, uri}); + } + } else if (cachedImageHash) { + setCachedImageHash(undefined); + } + + const onLoad = useCallback(async (e: any) => { + setLoadedUri(modelUri); + console.log('onLoad', e); + + if (!modelViewerRef.current) return; + + const uri = await modelViewerRef.current.toDataURL('image/png', 0.5); + const preview = {blurhash: await imageToBlurhash(uri)}; + // const preview = {thumbhash: await imageToThumbhash(uri)}; + console.log(preview); + + model?.mutate(s => s.preview = preview); + }, [model, modelUri, setLoadedUri, modelViewerRef.current]); + + useEffect(() => { + if (!modelViewerRef.current) return; + + const element = modelViewerRef.current; + element.addEventListener('load', onLoad); + return () => element.removeEventListener('load', onLoad); + }, [modelViewerRef.current, onLoad]); + + for (const ref of [modelViewerRef, axesViewerRef]) { const otherRef = ref === modelViewerRef ? axesViewerRef : modelViewerRef; useEffect(() => { @@ -122,7 +168,7 @@ export default function ViewerPanel({className, style}: {className?: string, sty window.removeEventListener('mouseup', onMouseUp); }; }); - + return (
+ + + {!loaded && cachedImageHash && + + } + { + return new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => resolve(img); + img.onerror = (...args) => reject(args); + img.src = src; + }); +} + +export async function imageToBlurhash(imageUrl: string): Promise { + const {rgba, w, h} = await getImageThumbnail(imageUrl, {maxSize: 100, opaque: true}); + const parts = 9; + // const parts = 6; + return encodeBlurHash(new Uint8ClampedArray(rgba), w, h, parts, parts); +} + +export async function imageToThumbhash(imagePath: string): Promise { + const {rgba, w, h} = await getImageThumbnail(imagePath, {maxSize: 100, opaque: false}); + const hash = rgbaToThumbHash(w, h, rgba); + return btoa(String.fromCharCode(...hash)); +} + +export function blurHashToImage(hash: string, width: number, height: number): string { + const pixels = decodeBlurHash(hash, width, height); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d")!; + const imageData = ctx.createImageData(width, height); + imageData.data.set(pixels); + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); +} + +export function thumbHashToImage(hash: string): string { + const {w: width, h: height, rgba} = thumbHashToRGBA(new Uint8Array([...atob(hash)].map(c => c.charCodeAt(0)))); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d")!; + const imageData = ctx.createImageData(width, height); + imageData.data.set(rgba); + ctx.putImageData(imageData, 0, 0); + return canvas.toDataURL("image/png"); +} + +async function getImageThumbnail(imageUrl: string, {maxSize, opaque}: {maxSize: number, opaque: boolean}): Promise<{rgba: Uint8Array, w: number, h: number}> { + const image = await loadImage(imageUrl); + const width = image.width; + const height = image.height; + + const scale = Math.min(maxSize / width, maxSize / height); + const resizedWidth = Math.floor(width * scale); + const resizedHeight = Math.floor(height * scale); + + const canvas = document.createElement("canvas"); + canvas.width = resizedWidth; + canvas.height = resizedHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Could not get canvas context"); + + if (opaque) { + ctx.fillStyle = 'white'; + ctx.fillRect(0, 0, resizedWidth, resizedHeight); + } + ctx.drawImage(image, 0, 0, resizedWidth, resizedHeight); + + const imageData = ctx.getImageData(0, 0, resizedWidth, resizedHeight); + const rgba = new Uint8Array(imageData.data.buffer); + return {rgba, w: resizedWidth, h: resizedHeight}; +} diff --git a/src/state/app-state.ts b/src/state/app-state.ts index 3d4f8c7..e2e609f 100644 --- a/src/state/app-state.ts +++ b/src/state/app-state.ts @@ -35,6 +35,12 @@ export interface State { extruderColors?: string[], }, + + preview?: { + thumbhash?: string, + blurhash?: string, + }, + view: { logs?: boolean, extruderPickerVisibility?: 'editing' | 'exporting', diff --git a/src/state/fragment-state.ts b/src/state/fragment-state.ts index 8e146e7..b3eb3c6 100644 --- a/src/state/fragment-state.ts +++ b/src/state/fragment-state.ts @@ -42,7 +42,8 @@ async function decompressString(compressedInput: string): Promise { export function encodeStateParamsAsFragment(state: State) { const json = JSON.stringify({ params: state.params, - view: state.view + view: state.view, + preview: state.preview, }); // return encodeURIComponent(json); return compressString(json); @@ -73,7 +74,7 @@ export async function readStateFromFragment(): Promise { // Backwards compatibility obj = JSON.parse(decodeURIComponent(serialized)); } - const {params, view} = obj; + const {params, view, preview} = obj; return { params: { activePath: validateString(params?.activePath, () => defaultSourcePath), @@ -85,6 +86,10 @@ export async function readStateFromFragment(): Promise { exportFormat3D: validateStringEnum(params?.exportFormat3D, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'glb'), extruderColors: validateArray(params?.extruderColors, validateString, () => undefined as any as []), }, + preview: preview ? { + thumbhash: preview.thumbhash ? validateString(preview.thumbhash) : undefined, + blurhash: preview.blurhash ? validateString(preview.blurhash) : undefined, + } : undefined, view: { logs: validateBoolean(view?.logs), extruderPickerVisibility: validateStringEnum(view?.extruderPickerVisibility, ['editing', 'exporting'], s => undefined), diff --git a/src/state/initial-state.ts b/src/state/initial-state.ts index 4a858cb..f242503 100644 --- a/src/state/initial-state.ts +++ b/src/state/initial-state.ts @@ -6,8 +6,9 @@ import { fetchSource } from '../utils'; export const defaultSourcePath = '/playground.scad'; export const defaultModelColor = '#f9d72c'; +const defaultBlurhash = "|KSPX^%3~qtjMx$lR*x]t7n,R%xuxbM{WBt7ayfk_3bY9FnAt8XOxanjNF%fxbMyIn%3t7NFoLaeoeV[WBo{xar^IoS1xbxcR*S0xbofRjV[j[kCNGofxaWBNHW-xasDR*WTkBxuWBM{s:t7bYahRjfkozWUadofbIW:jZ"; -export async function createInitialState(state: State | null, source?: {content?: string, path?: string, url?: string}): Promise { +export async function createInitialState(state: State | null, source?: {content?: string, path?: string, url?: string, blurhash?: string}): Promise { type Mode = State['view']['layout']['mode']; const mode: Mode = window.matchMedia("(min-width: 768px)").matches @@ -18,14 +19,16 @@ export async function createInitialState(state: State | null, source?: {content? if (source) throw new Error('Cannot provide source when state is provided'); initialState = state; } else { - let content, path, url; + let content, path, url, blurhash; if (source) { content = source.content; path = source.path; url = source.url; + blurhash = source.blurhash; } else { content = defaultScad; path = defaultSourcePath; + blurhash = defaultBlurhash; } let activePath = path ?? (url && new URL(url).pathname.split('/').pop()) ?? defaultSourcePath; initialState = { @@ -46,6 +49,7 @@ export async function createInitialState(state: State | null, source?: {content? color: defaultModelColor, }, + preview: blurhash ? {blurhash} : undefined, }; } diff --git a/src/state/model.ts b/src/state/model.ts index 30cf50e..4f0ab1c 100644 --- a/src/state/model.ts +++ b/src/state/model.ts @@ -142,6 +142,11 @@ export class Model { s.lastCheckerRun = undefined; s.output = undefined; s.export = undefined; + s.preview = undefined; + s.currentRunLogs = undefined; + s.error = undefined; + s.is2D = undefined; + console.log('Opened file:', path); } })) { this.processSource();