Add blurhash previews to make (re)loading more entertaining
This commit is contained in:
parent
60ed786e22
commit
5e48ffbf34
7 changed files with 178 additions and 8 deletions
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<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(() => {
|
||||
|
|
@ -122,7 +168,7 @@ export default function ViewerPanel({className, style}: {className?: string, sty
|
|||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<div className={className}
|
||||
style={{
|
||||
|
|
@ -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%',
|
||||
}}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
77
src/io/image_hashes.ts
Normal file
77
src/io/image_hashes.ts
Normal file
|
|
@ -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};
|
||||
}
|
||||
|
|
@ -35,6 +35,12 @@ export interface State {
|
|||
extruderColors?: string[],
|
||||
},
|
||||
|
||||
|
||||
preview?: {
|
||||
thumbhash?: string,
|
||||
blurhash?: string,
|
||||
},
|
||||
|
||||
view: {
|
||||
logs?: boolean,
|
||||
extruderPickerVisibility?: 'editing' | 'exporting',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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),
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue