Skip to content

Commit

Permalink
Add blurhash previews to make (re)loading more entertaining
Browse files Browse the repository at this point in the history
  • Loading branch information
ochafik committed Dec 31, 2024
1 parent 60ed786 commit 5e48ffb
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 8 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
79 changes: 75 additions & 4 deletions src/components/ViewerPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -59,6 +60,51 @@ export default function ViewerPanel({className, style}: {className?: string, sty
const axesViewerRef = useRef<any>();
const toastRef = useRef<Toast>(null);

const [loadedUri, setLoadedUri] = useState<string | undefined>();

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(() => {
Expand Down Expand Up @@ -122,7 +168,7 @@ export default function ViewerPanel({className, style}: {className?: string, sty
window.removeEventListener('mouseup', onMouseUp);
};
});

return (
<div className={className}
style={{
Expand All @@ -134,11 +180,36 @@ export default function ViewerPanel({className, style}: {className?: string, sty
...(style ?? {})
}}>
<Toast ref={toastRef} position='top-right' />
<style>
{`
@keyframes pulse {
0% { opacity: 0.4; }
50% { opacity: 0.7; }
100% { opacity: 0.4; }
}
`}
</style>

{!loaded && cachedImageHash &&
<img
src={cachedImageHash.uri}
style={{
animation: 'pulse 1.5s ease-in-out infinite',
position: 'absolute',
pointerEvents: 'none',
width: '100%',
height: '100%'
}} />
}

<model-viewer
orientation="0deg -90deg 0deg"
class="main-viewer"
src={state.output?.displayFileURL ?? state.output?.outFileURL ?? ''}
src={modelUri}
style={{
transition: 'opacity 0.5s',
opacity: loaded ? 1 : 0,
position: 'absolute',
width: '100%',
height: '100%',
}}
Expand Down Expand Up @@ -173,8 +244,8 @@ export default function ViewerPanel({className, style}: {className?: string, sty
min-camera-orbit="auto 0deg auto"
orbit-sensitivity="5"
interaction-prompt="none"
disable-zoom
camera-controls="false"
disable-zoom
disable-tap
disable-pan
ref={axesViewerRef}
Expand Down
77 changes: 77 additions & 0 deletions src/io/image_hashes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.

import { rgbaToThumbHash, thumbHashToRGBA } from 'thumbhash'
import { decode as decodeBlurHash, encode as encodeBlurHash } from "blurhash";

async function loadImage(src: string): Promise<HTMLImageElement> {
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<string> {
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<string> {
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};
}
6 changes: 6 additions & 0 deletions src/state/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export interface State {
extruderColors?: string[],
},


preview?: {
thumbhash?: string,
blurhash?: string,
},

view: {
logs?: boolean,
extruderPickerVisibility?: 'editing' | 'exporting',
Expand Down
9 changes: 7 additions & 2 deletions src/state/fragment-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ async function decompressString(compressedInput: string): Promise<string> {
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);
Expand Down Expand Up @@ -73,7 +74,7 @@ export async function readStateFromFragment(): Promise<State | null> {
// Backwards compatibility
obj = JSON.parse(decodeURIComponent(serialized));
}
const {params, view} = obj;
const {params, view, preview} = obj;
return {
params: {
activePath: validateString(params?.activePath, () => defaultSourcePath),
Expand All @@ -85,6 +86,10 @@ export async function readStateFromFragment(): Promise<State | null> {
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),
Expand Down
8 changes: 6 additions & 2 deletions src/state/initial-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<State> {
export async function createInitialState(state: State | null, source?: {content?: string, path?: string, url?: string, blurhash?: string}): Promise<State> {

type Mode = State['view']['layout']['mode'];
const mode: Mode = window.matchMedia("(min-width: 768px)").matches
Expand All @@ -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 = {
Expand All @@ -46,6 +49,7 @@ export async function createInitialState(state: State | null, source?: {content?

color: defaultModelColor,
},
preview: blurhash ? {blurhash} : undefined,
};
}

Expand Down
5 changes: 5 additions & 0 deletions src/state/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down

0 comments on commit 5e48ffb

Please sign in to comment.