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/jest-dom": "^5.17.0",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"chroma-js": "^3.1.2",
|
"chroma-js": "^3.1.2",
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
|
|
@ -20,6 +21,7 @@
|
||||||
"primereact": "^10.8.5",
|
"primereact": "^10.8.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"thumbhash": "^0.1.1",
|
||||||
"uuid": "^11.0.3",
|
"uuid": "^11.0.3",
|
||||||
"uzip": "^0.20201231.0"
|
"uzip": "^0.20201231.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
// Portions of this file are Copyright 2021 Google LLC, and licensed under GPL2+. See COPYING.
|
// 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 { ModelContext } from './contexts';
|
||||||
import { Toast } from 'primereact/toast';
|
import { Toast } from 'primereact/toast';
|
||||||
|
import { blurHashToImage, imageToBlurhash, imageToThumbhash, thumbHashToImage } from '../io/image_hashes';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
|
|
@ -59,6 +60,51 @@ export default function ViewerPanel({className, style}: {className?: string, sty
|
||||||
const axesViewerRef = useRef<any>();
|
const axesViewerRef = useRef<any>();
|
||||||
const toastRef = useRef<Toast>(null);
|
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]) {
|
for (const ref of [modelViewerRef, axesViewerRef]) {
|
||||||
const otherRef = ref === modelViewerRef ? axesViewerRef : modelViewerRef;
|
const otherRef = ref === modelViewerRef ? axesViewerRef : modelViewerRef;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -122,7 +168,7 @@ export default function ViewerPanel({className, style}: {className?: string, sty
|
||||||
window.removeEventListener('mouseup', onMouseUp);
|
window.removeEventListener('mouseup', onMouseUp);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}
|
<div className={className}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -134,11 +180,36 @@ export default function ViewerPanel({className, style}: {className?: string, sty
|
||||||
...(style ?? {})
|
...(style ?? {})
|
||||||
}}>
|
}}>
|
||||||
<Toast ref={toastRef} position='top-right' />
|
<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
|
<model-viewer
|
||||||
orientation="0deg -90deg 0deg"
|
orientation="0deg -90deg 0deg"
|
||||||
class="main-viewer"
|
class="main-viewer"
|
||||||
src={state.output?.displayFileURL ?? state.output?.outFileURL ?? ''}
|
src={modelUri}
|
||||||
style={{
|
style={{
|
||||||
|
transition: 'opacity 0.5s',
|
||||||
|
opacity: loaded ? 1 : 0,
|
||||||
|
position: 'absolute',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
}}
|
}}
|
||||||
|
|
@ -173,8 +244,8 @@ export default function ViewerPanel({className, style}: {className?: string, sty
|
||||||
min-camera-orbit="auto 0deg auto"
|
min-camera-orbit="auto 0deg auto"
|
||||||
orbit-sensitivity="5"
|
orbit-sensitivity="5"
|
||||||
interaction-prompt="none"
|
interaction-prompt="none"
|
||||||
disable-zoom
|
|
||||||
camera-controls="false"
|
camera-controls="false"
|
||||||
|
disable-zoom
|
||||||
disable-tap
|
disable-tap
|
||||||
disable-pan
|
disable-pan
|
||||||
ref={axesViewerRef}
|
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[],
|
extruderColors?: string[],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
preview?: {
|
||||||
|
thumbhash?: string,
|
||||||
|
blurhash?: string,
|
||||||
|
},
|
||||||
|
|
||||||
view: {
|
view: {
|
||||||
logs?: boolean,
|
logs?: boolean,
|
||||||
extruderPickerVisibility?: 'editing' | 'exporting',
|
extruderPickerVisibility?: 'editing' | 'exporting',
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,8 @@ async function decompressString(compressedInput: string): Promise<string> {
|
||||||
export function encodeStateParamsAsFragment(state: State) {
|
export function encodeStateParamsAsFragment(state: State) {
|
||||||
const json = JSON.stringify({
|
const json = JSON.stringify({
|
||||||
params: state.params,
|
params: state.params,
|
||||||
view: state.view
|
view: state.view,
|
||||||
|
preview: state.preview,
|
||||||
});
|
});
|
||||||
// return encodeURIComponent(json);
|
// return encodeURIComponent(json);
|
||||||
return compressString(json);
|
return compressString(json);
|
||||||
|
|
@ -73,7 +74,7 @@ export async function readStateFromFragment(): Promise<State | null> {
|
||||||
// Backwards compatibility
|
// Backwards compatibility
|
||||||
obj = JSON.parse(decodeURIComponent(serialized));
|
obj = JSON.parse(decodeURIComponent(serialized));
|
||||||
}
|
}
|
||||||
const {params, view} = obj;
|
const {params, view, preview} = obj;
|
||||||
return {
|
return {
|
||||||
params: {
|
params: {
|
||||||
activePath: validateString(params?.activePath, () => defaultSourcePath),
|
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'),
|
exportFormat3D: validateStringEnum(params?.exportFormat3D, Object.keys(VALID_EXPORT_FORMATS_3D), s => 'glb'),
|
||||||
extruderColors: validateArray(params?.extruderColors, validateString, () => undefined as any as []),
|
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: {
|
view: {
|
||||||
logs: validateBoolean(view?.logs),
|
logs: validateBoolean(view?.logs),
|
||||||
extruderPickerVisibility: validateStringEnum(view?.extruderPickerVisibility, ['editing', 'exporting'], s => undefined),
|
extruderPickerVisibility: validateStringEnum(view?.extruderPickerVisibility, ['editing', 'exporting'], s => undefined),
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ import { fetchSource } from '../utils';
|
||||||
|
|
||||||
export const defaultSourcePath = '/playground.scad';
|
export const defaultSourcePath = '/playground.scad';
|
||||||
export const defaultModelColor = '#f9d72c';
|
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'];
|
type Mode = State['view']['layout']['mode'];
|
||||||
const mode: Mode = window.matchMedia("(min-width: 768px)").matches
|
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');
|
if (source) throw new Error('Cannot provide source when state is provided');
|
||||||
initialState = state;
|
initialState = state;
|
||||||
} else {
|
} else {
|
||||||
let content, path, url;
|
let content, path, url, blurhash;
|
||||||
if (source) {
|
if (source) {
|
||||||
content = source.content;
|
content = source.content;
|
||||||
path = source.path;
|
path = source.path;
|
||||||
url = source.url;
|
url = source.url;
|
||||||
|
blurhash = source.blurhash;
|
||||||
} else {
|
} else {
|
||||||
content = defaultScad;
|
content = defaultScad;
|
||||||
path = defaultSourcePath;
|
path = defaultSourcePath;
|
||||||
|
blurhash = defaultBlurhash;
|
||||||
}
|
}
|
||||||
let activePath = path ?? (url && new URL(url).pathname.split('/').pop()) ?? defaultSourcePath;
|
let activePath = path ?? (url && new URL(url).pathname.split('/').pop()) ?? defaultSourcePath;
|
||||||
initialState = {
|
initialState = {
|
||||||
|
|
@ -46,6 +49,7 @@ export async function createInitialState(state: State | null, source?: {content?
|
||||||
|
|
||||||
color: defaultModelColor,
|
color: defaultModelColor,
|
||||||
},
|
},
|
||||||
|
preview: blurhash ? {blurhash} : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,11 @@ export class Model {
|
||||||
s.lastCheckerRun = undefined;
|
s.lastCheckerRun = undefined;
|
||||||
s.output = undefined;
|
s.output = undefined;
|
||||||
s.export = undefined;
|
s.export = undefined;
|
||||||
|
s.preview = undefined;
|
||||||
|
s.currentRunLogs = undefined;
|
||||||
|
s.error = undefined;
|
||||||
|
s.is2D = undefined;
|
||||||
|
console.log('Opened file:', path);
|
||||||
}
|
}
|
||||||
})) {
|
})) {
|
||||||
this.processSource();
|
this.processSource();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue