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:
parent
92001d514a
commit
6652e66af1
2 changed files with 105 additions and 59 deletions
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
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";
|
||||
|
||||
|
@ -14,6 +14,7 @@ function OutfitMovieLayer({
|
|||
libraryUrl,
|
||||
width,
|
||||
height,
|
||||
placeholderImageUrl = null,
|
||||
isPaused = false,
|
||||
onLoad = null,
|
||||
onLowFps = null,
|
||||
|
@ -21,6 +22,8 @@ function OutfitMovieLayer({
|
|||
const [stage, setStage] = React.useState(null);
|
||||
const [library, setLibrary] = 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 hasShownErrorMessageRef = React.useRef(false);
|
||||
const toast = useToast();
|
||||
|
@ -30,6 +33,15 @@ function OutfitMovieLayer({
|
|||
const internalWidth = width * window.devicePixelRatio;
|
||||
const internalHeight = height * window.devicePixelRatio;
|
||||
|
||||
const callOnLoadIfNotYetCalled = React.useCallback(() => {
|
||||
setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
|
||||
if (!alreadyHasCalledOnLoad) {
|
||||
onLoad();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [onLoad]);
|
||||
|
||||
const updateStage = React.useCallback(() => {
|
||||
if (!stage) {
|
||||
return;
|
||||
|
@ -147,12 +159,11 @@ function OutfitMovieLayer({
|
|||
updateStage();
|
||||
|
||||
// This is when we trigger `onLoad`: once we're actually showing it!
|
||||
if (onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
callOnLoadIfNotYetCalled();
|
||||
setMovieIsLoaded(true);
|
||||
|
||||
return () => stage.removeChild(movieClip);
|
||||
}, [stage, updateStage, movieClip, onLoad]);
|
||||
}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
|
||||
|
||||
// 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
|
||||
|
@ -221,12 +232,27 @@ function OutfitMovieLayer({
|
|||
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={internalWidth}
|
||||
height={internalHeight}
|
||||
style={{ width: width, height: height }}
|
||||
/>
|
||||
<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
|
||||
ref={canvasRef}
|
||||
width={internalWidth}
|
||||
height={internalHeight}
|
||||
style={{ width: width, height: height, gridArea: "single-shared-area" }}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -254,6 +254,9 @@ export function OutfitLayers({
|
|||
{layer.canvasMovieLibraryUrl ? (
|
||||
<OutfitMovieLayer
|
||||
libraryUrl={layer.canvasMovieLibraryUrl}
|
||||
placeholderImageUrl={getBestImageUrlForLayer(layer, {
|
||||
hiResMode,
|
||||
})}
|
||||
width={canvasSize}
|
||||
height={canvasSize}
|
||||
isPaused={isPaused}
|
||||
|
@ -362,68 +365,85 @@ export function usePreloadLayers(layers) {
|
|||
// 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
|
||||
// the right abstraction, though...
|
||||
if (loadedLayers.length > 0 && layers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the layers already match, we can ignore extra effect triggers.
|
||||
if (!loading) {
|
||||
if (layers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
setError(null);
|
||||
setLayersHaveAnimations(false);
|
||||
|
||||
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) {
|
||||
return loadMovieLibrary(layer.canvasMovieLibraryUrl).then(
|
||||
(library) => ({
|
||||
type: "movie",
|
||||
library,
|
||||
libraryUrl: layer.canvasMovieLibraryUrl,
|
||||
})
|
||||
// Start preloading the movie. But we won't block on it! The blocking
|
||||
// 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",
|
||||
library,
|
||||
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 {
|
||||
return loadImage(getBestImageUrlForLayer(layer, { hiResMode })).then(
|
||||
(image) => ({
|
||||
type: "image",
|
||||
image,
|
||||
})
|
||||
);
|
||||
minimalAssetPromises.push(imageAssetPromise);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let assets;
|
||||
try {
|
||||
assets = await Promise.all(assetPromises);
|
||||
} catch (e) {
|
||||
if (canceled) return;
|
||||
console.error("Error preloading outfit layers", e);
|
||||
assetPromises.forEach((p) => {
|
||||
if (p.cancel) {
|
||||
p.cancel();
|
||||
}
|
||||
// When the minimal assets have loaded, we can say the layers have
|
||||
// loaded, and allow the UI to start showing them!
|
||||
Promise.all(minimalAssetPromises)
|
||||
.then(() => {
|
||||
if (canceled) return;
|
||||
setLoadedLayers(layers);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (canceled) return;
|
||||
console.error("Error preloading outfit layers", e);
|
||||
setError(e);
|
||||
|
||||
// Cancel any remaining promises, if cancelable.
|
||||
minimalAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
movieAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
});
|
||||
setError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canceled) return;
|
||||
// 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;
|
||||
let assetHasAnimations;
|
||||
try {
|
||||
assetHasAnimations = getHasAnimationsForMovieAsset(asset);
|
||||
} catch (e) {
|
||||
console.error("Error testing layers for animations", e);
|
||||
setError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
let newLayersHaveAnimations;
|
||||
try {
|
||||
newLayersHaveAnimations = assets
|
||||
.filter((a) => a.type === "movie")
|
||||
.some(getHasAnimationsForMovieAsset);
|
||||
} catch (e) {
|
||||
console.error("Error testing layers for animations", e);
|
||||
setError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
setLayersHaveAnimations(newLayersHaveAnimations);
|
||||
setLoadedLayers(layers);
|
||||
setLayersHaveAnimations(
|
||||
(alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations
|
||||
);
|
||||
};
|
||||
movieAssetPromises.forEach((p) => p.then(checkHasAnimations));
|
||||
};
|
||||
|
||||
loadAssets();
|
||||
|
@ -431,7 +451,7 @@ export function usePreloadLayers(layers) {
|
|||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [layers, loadedLayers.length, loading, hiResMode]);
|
||||
}, [layers, hiResMode]);
|
||||
|
||||
return { loading, error, loadedLayers, layersHaveAnimations };
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue