From 6652e66af185655d6fb95b9f262cdcccf0d70612 Mon Sep 17 00:00:00 2001 From: Matchu Date: Sun, 20 Jun 2021 11:54:22 -0700 Subject: [PATCH] Show image preview while movie loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 🤔) --- src/app/components/OutfitMovieLayer.js | 48 +++++++--- src/app/components/OutfitPreview.js | 116 +++++++++++++++---------- 2 files changed, 105 insertions(+), 59 deletions(-) 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 }; }