diff --git a/src/app/components/OutfitMovieLayer.js b/src/app/components/OutfitMovieLayer.js
index 3ec8371..fed69cd 100644
--- a/src/app/components/OutfitMovieLayer.js
+++ b/src/app/components/OutfitMovieLayer.js
@@ -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 (
-
+
+ {!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?
+
+ )}
+
+
);
}
diff --git a/src/app/components/OutfitPreview.js b/src/app/components/OutfitPreview.js
index 723ad8f..db70539 100644
--- a/src/app/components/OutfitPreview.js
+++ b/src/app/components/OutfitPreview.js
@@ -254,6 +254,9 @@ export function OutfitLayers({
{layer.canvasMovieLibraryUrl ? (
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 };
}