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 }; }