Show image preview while movie loads

Movies often have a lot of assets, which are more likely to be cache misses, and take script time to render! So the time until the user sees something is often huge.

Here, we start loading our PNG image at the same time. This is a filesize loading increase, but even in slow connections, it's generally worth it as a _sharp_ improvement in time until you get to see something!

One noteworthy UI weakness here is that we don't show _any_ loading indicator while the image is visible and the movie is still loading. This makes sense from a practical standpoint, but could be a problem when a movie takes a particularly long amount of time. I also want to be cognizant of whether the blink-of-content ever gets annoying! (We could make it fade out 🤔)
This commit is contained in:
Emi Matchu 2021-06-20 11:54:22 -07:00
parent 92001d514a
commit 6652e66af1
2 changed files with 105 additions and 59 deletions

View file

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import LRU from "lru-cache"; import LRU from "lru-cache";
import { useToast } from "@chakra-ui/react"; import { Box, Grid, useToast } from "@chakra-ui/react";
import { loadImage, logAndCapture, safeImageUrl } from "../util"; import { loadImage, logAndCapture, safeImageUrl } from "../util";
@ -14,6 +14,7 @@ function OutfitMovieLayer({
libraryUrl, libraryUrl,
width, width,
height, height,
placeholderImageUrl = null,
isPaused = false, isPaused = false,
onLoad = null, onLoad = null,
onLowFps = null, onLowFps = null,
@ -21,6 +22,8 @@ function OutfitMovieLayer({
const [stage, setStage] = React.useState(null); const [stage, setStage] = React.useState(null);
const [library, setLibrary] = React.useState(null); const [library, setLibrary] = React.useState(null);
const [movieClip, setMovieClip] = React.useState(null); const [movieClip, setMovieClip] = React.useState(null);
const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
const canvasRef = React.useRef(null); const canvasRef = React.useRef(null);
const hasShownErrorMessageRef = React.useRef(false); const hasShownErrorMessageRef = React.useRef(false);
const toast = useToast(); const toast = useToast();
@ -30,6 +33,15 @@ function OutfitMovieLayer({
const internalWidth = width * window.devicePixelRatio; const internalWidth = width * window.devicePixelRatio;
const internalHeight = height * window.devicePixelRatio; const internalHeight = height * window.devicePixelRatio;
const callOnLoadIfNotYetCalled = React.useCallback(() => {
setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
if (!alreadyHasCalledOnLoad) {
onLoad();
}
return true;
});
}, [onLoad]);
const updateStage = React.useCallback(() => { const updateStage = React.useCallback(() => {
if (!stage) { if (!stage) {
return; return;
@ -147,12 +159,11 @@ function OutfitMovieLayer({
updateStage(); updateStage();
// This is when we trigger `onLoad`: once we're actually showing it! // This is when we trigger `onLoad`: once we're actually showing it!
if (onLoad) { callOnLoadIfNotYetCalled();
onLoad(); setMovieIsLoaded(true);
}
return () => stage.removeChild(movieClip); return () => stage.removeChild(movieClip);
}, [stage, updateStage, movieClip, onLoad]); }, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
// This effect updates the `stage` according to the `library`'s framerate, // This effect updates the `stage` according to the `library`'s framerate,
// but only if there's actual animation to do - i.e., there's more than one // but only if there's actual animation to do - i.e., there's more than one
@ -221,12 +232,27 @@ function OutfitMovieLayer({
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]); }, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
return ( return (
<Grid templateAreas="single-shared-area">
{!movieIsLoaded && (
// While the movie is loading, we show our image version as a
// placeholder, because it generally loads much faster.
// TODO: Show a loading indicator for this partially-loaded state?
<Box
as="img"
src={safeImageUrl(placeholderImageUrl)}
width={width}
height={height}
gridArea="single-shared-area"
onLoad={callOnLoadIfNotYetCalled}
/>
)}
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={internalWidth} width={internalWidth}
height={internalHeight} height={internalHeight}
style={{ width: width, height: height }} style={{ width: width, height: height, gridArea: "single-shared-area" }}
/> />
</Grid>
); );
} }

View file

@ -254,6 +254,9 @@ export function OutfitLayers({
{layer.canvasMovieLibraryUrl ? ( {layer.canvasMovieLibraryUrl ? (
<OutfitMovieLayer <OutfitMovieLayer
libraryUrl={layer.canvasMovieLibraryUrl} libraryUrl={layer.canvasMovieLibraryUrl}
placeholderImageUrl={getBestImageUrlForLayer(layer, {
hiResMode,
})}
width={canvasSize} width={canvasSize}
height={canvasSize} height={canvasSize}
isPaused={isPaused} isPaused={isPaused}
@ -362,68 +365,85 @@ export function usePreloadLayers(layers) {
// HACK: Don't clear the preview when we have zero layers, because it // HACK: Don't clear the preview when we have zero layers, because it
// usually means the parent is still loading data. I feel like this isn't // usually means the parent is still loading data. I feel like this isn't
// the right abstraction, though... // the right abstraction, though...
if (loadedLayers.length > 0 && layers.length === 0) { if (layers.length === 0) {
return;
}
// If the layers already match, we can ignore extra effect triggers.
if (!loading) {
return; return;
} }
let canceled = false; let canceled = false;
setError(null); setError(null);
setLayersHaveAnimations(false);
const loadAssets = async () => { const loadAssets = async () => {
const assetPromises = layers.map((layer) => { const minimalAssetPromises = [];
const imageAssetPromises = [];
const movieAssetPromises = [];
for (const layer of layers) {
const imageAssetPromise = loadImage(
getBestImageUrlForLayer(layer, { hiResMode })
).then((image) => ({
type: "image",
image,
}));
imageAssetPromises.push(imageAssetPromise);
if (layer.canvasMovieLibraryUrl) { if (layer.canvasMovieLibraryUrl) {
return loadMovieLibrary(layer.canvasMovieLibraryUrl).then( // Start preloading the movie. But we won't block on it! The blocking
(library) => ({ // request will still be the image, which we'll show as a
// placeholder, which should usually be noticeably faster!
const movieAssetPromise = loadMovieLibrary(
layer.canvasMovieLibraryUrl
).then((library) => ({
type: "movie", type: "movie",
library, library,
libraryUrl: layer.canvasMovieLibraryUrl, libraryUrl: layer.canvasMovieLibraryUrl,
}) }));
movieAssetPromises.push(movieAssetPromise);
// The minimal asset for the movie case is *either* the image *or*
// the movie, because we can start rendering when either is ready.
minimalAssetPromises.push(
Promise.race([imageAssetPromise, movieAssetPromise])
); );
} else { } else {
return loadImage(getBestImageUrlForLayer(layer, { hiResMode })).then( minimalAssetPromises.push(imageAssetPromise);
(image) => ({ }
type: "image",
image,
})
);
} }
});
let assets; // When the minimal assets have loaded, we can say the layers have
try { // loaded, and allow the UI to start showing them!
assets = await Promise.all(assetPromises); Promise.all(minimalAssetPromises)
} catch (e) { .then(() => {
if (canceled) return;
setLoadedLayers(layers);
})
.catch((e) => {
if (canceled) return; if (canceled) return;
console.error("Error preloading outfit layers", e); console.error("Error preloading outfit layers", e);
assetPromises.forEach((p) => {
if (p.cancel) {
p.cancel();
}
});
setError(e); setError(e);
return;
}
// Cancel any remaining promises, if cancelable.
minimalAssetPromises.forEach((p) => p.cancel && p.cancel());
movieAssetPromises.forEach((p) => p.cancel && p.cancel());
});
// As the movie assets come in, check them for animations, to decide
// whether to show the Play/Pause button.
const checkHasAnimations = (asset) => {
if (canceled) return; if (canceled) return;
let assetHasAnimations;
let newLayersHaveAnimations;
try { try {
newLayersHaveAnimations = assets assetHasAnimations = getHasAnimationsForMovieAsset(asset);
.filter((a) => a.type === "movie")
.some(getHasAnimationsForMovieAsset);
} catch (e) { } catch (e) {
console.error("Error testing layers for animations", e); console.error("Error testing layers for animations", e);
setError(e); setError(e);
return; return;
} }
setLayersHaveAnimations(newLayersHaveAnimations); setLayersHaveAnimations(
setLoadedLayers(layers); (alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations
);
};
movieAssetPromises.forEach((p) => p.then(checkHasAnimations));
}; };
loadAssets(); loadAssets();
@ -431,7 +451,7 @@ export function usePreloadLayers(layers) {
return () => { return () => {
canceled = true; canceled = true;
}; };
}, [layers, loadedLayers.length, loading, hiResMode]); }, [layers, hiResMode]);
return { loading, error, loadedLayers, layersHaveAnimations }; return { loading, error, loadedLayers, layersHaveAnimations };
} }