diff --git a/src/app/WardrobePage/index.js b/src/app/WardrobePage/index.js index 17e8da7..d9afa3b 100644 --- a/src/app/WardrobePage/index.js +++ b/src/app/WardrobePage/index.js @@ -67,7 +67,6 @@ function WardrobePage() { pose={outfitState.pose} appearanceId={outfitState.appearanceId} wornItemIds={outfitState.wornItemIds} - engine="canvas" onChangeHasAnimations={setHasAnimations} /> } diff --git a/src/app/components/OutfitCanvas.js b/src/app/components/OutfitCanvas.js deleted file mode 100644 index 6add8e1..0000000 --- a/src/app/components/OutfitCanvas.js +++ /dev/null @@ -1,648 +0,0 @@ -import React from "react"; - -import { safeImageUrl } from "../util"; - -const EaselContext = React.createContext({ - stage: null, - addResizeListener: () => {}, - removeResizeListener: () => {}, -}); - -function OutfitCanvas({ - children, - width, - height, - pauseMovieLayers = true, - onChangeHasAnimations = null, -}) { - const [stage, setStage] = React.useState(null); - const resizeListenersRef = React.useRef([]); - const canvasRef = React.useRef(null); - - // These fields keep track of whether there's something animating or awaiting - // animation. We use this to decide whether to enable or disable our - // `requestAnimationFrame` calls in `useRAFTicker`, as a performance - // optimization. (It's not so great to dive into an RAF callback at 60fps if - // there's nothing going on!) - const [hasAnimatedChildren, setHasAnimatedChildren] = React.useState(false); - const [isTweeningChildren, setIsTweeningChildren] = React.useState(false); - const [numChildrenAwaitingDraw, setNumChildrenAwaitingDraw] = React.useState( - 0 - ); - - const { loading } = useEaselDependenciesLoader(); - - // Set the canvas's internal dimensions to be higher, if the device has high - // DPI like retina. But we'll keep the layout width/height as expected! - const internalWidth = width * window.devicePixelRatio; - const internalHeight = height * window.devicePixelRatio; - - React.useLayoutEffect(() => { - if (loading) { - return; - } - - const stage = new window.createjs.Stage(canvasRef.current); - setStage(stage); - }, [loading]); - - // Cache any cache groups whose children aren't doing a fade-in/out tween, - // and uncache any whose children are. We call this when tweens start and - // stop. - const onTweenStateChange = React.useCallback(() => { - let isTweeningAnyChild = false; - for (const childOrCacheGroup of stage.children) { - if (childOrCacheGroup.DTI_isCacheGroup) { - const cacheGroup = childOrCacheGroup; - const isTweening = cacheGroup.children.some((c) => - window.createjs.Tween.hasActiveTweens(c) - ); - if (isTweening) { - cacheGroup.uncache(); - isTweeningAnyChild = true; - } else { - cacheGroup.cache(0, 0, internalWidth, internalHeight); - } - } else { - const child = childOrCacheGroup; - const isTweening = window.createjs.Tween.hasActiveTweens(child); - if (isTweening) { - isTweeningAnyChild = true; - } - } - } - - setIsTweeningChildren(isTweeningAnyChild); - }, [internalWidth, internalHeight, stage]); - - const reorganizeChildren = React.useCallback(() => { - // First, to simplify, let's clean out all of the main children, and any - // caching group containers they might be in. This will empty the stage. - // (This isn't like, _great_ to do re perf, but it only happens on - // add/remove, and we don't update yet, and it simplifies the algo a lot.) - // - // NOTE: We copy the arrays below, because mutating them while iterating - // causes elements to get lost! - const children = []; - for (const childOrCacheGroup of [...stage.children]) { - if (childOrCacheGroup.DTI_isCacheGroup) { - const cacheGroup = childOrCacheGroup; - for (const child of [...cacheGroup.children]) { - children.push(child); - cacheGroup.removeChild(child); - } - stage.removeChild(cacheGroup); - } else { - const child = childOrCacheGroup; - children.push(child); - stage.removeChild(child); - } - } - - // Sort the children in zIndex order. - children.sort((a, b) => a.DTI_zIndex - b.DTI_zIndex); - - // Now, re-insert the children into the stage, while making a point of - // grouping adjacent non-animated assets into cache group containers. - let lastCacheGroup = null; - for (const child of children) { - stage.addChild(child); - if (child.DTI_hasAnimations) { - stage.addChild(child); - lastCacheGroup = null; - } else { - if (!lastCacheGroup) { - lastCacheGroup = new window.createjs.Container(); - lastCacheGroup.DTI_isCacheGroup = true; - stage.addChild(lastCacheGroup); - } - - lastCacheGroup.addChild(child); - } - } - - // Finally, cache the cache groups! This will flatten them into a single - // bitmap, so that these adjacent static layers can render ~instantly on - // each frame, instead of spending time compositing all of them together. - // Doesn't seem like a big deal, but helps a lot for squeezing out that - // last oomph of performance! - for (const childOrCacheGroup of stage.children) { - if (childOrCacheGroup.DTI_isCacheGroup) { - childOrCacheGroup.cache(0, 0, internalWidth, internalHeight); - } - } - - // Check whether any of the children have animations. Either way, call the - // onChangeHasAnimations callback to let the parent know. - const hasAnimations = stage.children.some((c) => c.DTI_hasAnimations); - setHasAnimatedChildren(hasAnimations); - if (onChangeHasAnimations) { - onChangeHasAnimations(hasAnimations); - } - }, [stage, onChangeHasAnimations, internalWidth, internalHeight]); - - const addChild = React.useCallback( - (child, zIndex, { afterFirstDraw = null } = {}) => { - // Save this child's z-index and animation-ness for future use. (We could - // recompute the animation one at any time, it's just a cached value!) - child.DTI_zIndex = zIndex; - child.DTI_hasAnimations = createjsNodeHasAnimations(child); - - // Add the child, then reorganize the children to get them sorted and - // grouped. - stage.addChild(child); - reorganizeChildren(); - - // Finally, add a one-time listener to trigger `afterFirstDraw`. - if (afterFirstDraw) { - stage.on( - "drawend", - () => { - setNumChildrenAwaitingDraw((num) => num - 1); - afterFirstDraw(); - }, - null, - true - ); - } - - setNumChildrenAwaitingDraw((num) => num + 1); - }, - [stage, reorganizeChildren] - ); - - const removeChild = React.useCallback( - (child) => { - // Remove the child, then reorganize the children in case this affects - // grouping for caching. (Note that the child's parent might not be the - // stage; it might be part of a caching group.) - child.parent.removeChild(child); - reorganizeChildren(); - }, - [reorganizeChildren] - ); - - const addResizeListener = React.useCallback((handler) => { - resizeListenersRef.current.push(handler); - }, []); - const removeResizeListener = React.useCallback((handler) => { - resizeListenersRef.current = resizeListenersRef.current.filter( - (h) => h !== handler - ); - }, []); - - const onTick = React.useCallback((event) => stage.update(event), [stage]); - - // When the canvas resizes, resize all the layers. - React.useEffect(() => { - for (const handler of resizeListenersRef.current) { - handler(); - } - }, [stage, width, height]); - - // When it's time to pause/unpause the movie layers, we implement this by - // disabling/enabling passing ticks along to the children. We don't stop - // playing the ticks altogether though, because we do want our fade-in/out - // transitions to keep playing! - React.useEffect(() => { - if (stage) { - stage.tickOnUpdate = !pauseMovieLayers; - } - }, [stage, pauseMovieLayers]); - - const isAnimatingRightNow = - isTweeningChildren || - (hasAnimatedChildren && !pauseMovieLayers) || - numChildrenAwaitingDraw > 0; - useRAFTicker(isAnimatingRightNow, onTick); - - if (loading) { - return null; - } - - return ( - - - {stage && children} - - ); -} - -/** - * useRAFTicker calls `onTick` on every animation frame, when `isEnabled` is - * true. It uses `requestAnimationFrame` to do this. - * - * It passes to `onTick` an object with a key `delta`, which represents the - * time in milliseconds since the last tick. This is compatible with EaselJS's - * Ticker, and passing this to `stage.update()` will enable animations to - * update at the correct framerate. - */ -function useRAFTicker(isEnabled, onTick) { - React.useEffect(() => { - if (!isEnabled) { - return; - } - - console.info("[OutfitCanvas] Starting animation ticker"); - - let canceled = false; - let lastTime = performance.now(); - function tick(time) { - const delta = time - lastTime; - lastTime = time; - - onTick({ delta }); - - if (!canceled) { - requestAnimationFrame(tick); - } - } - requestAnimationFrame(tick); - - return () => { - console.info("[OutfitCanvas] Stopping animation ticker"); - // Let the next scheduled frame finish, then stop. - canceled = true; - }; - }, [isEnabled, onTick]); -} - -export function OutfitCanvasImage({ src, zIndex }) { - const { - canvasRef, - addChild, - removeChild, - onTweenStateChange, - addResizeListener, - removeResizeListener, - } = React.useContext(EaselContext); - - React.useEffect(() => { - let image; - let bitmap; - let tween; - let canceled = false; - - function setBitmapSize() { - bitmap.scaleX = canvasRef.current.width / image.width; - bitmap.scaleY = canvasRef.current.height / image.height; - } - - async function addBitmap() { - image = await loadImage(src); - if (canceled) { - return; - } - - bitmap = new window.createjs.Bitmap(image); - - // We're gonna fade in! Wait for the first frame to draw, to make the - // timing smooth, but yeah here we go! - bitmap.alpha = 0; - tween = window.createjs.Tween.get(bitmap, { paused: true }).to( - { alpha: 1 }, - 200 - ); - const startFadeIn = () => { - // NOTE: You must cache bitmaps to apply filters to them, and caching - // doesn't work until the first draw. - bitmap.cache(0, 0, image.width, image.height); - tween.paused = false; - onTweenStateChange(); - }; - tween.on("complete", onTweenStateChange); - - setBitmapSize(); - addChild(bitmap, zIndex, { afterFirstDraw: startFadeIn }); - addResizeListener(setBitmapSize); - } - - function removeBitmap() { - removeResizeListener(setBitmapSize); - removeChild(bitmap); - } - - addBitmap(); - - return () => { - canceled = true; - if (bitmap) { - // Reverse the fade-in into a fade-out, then remove the bitmap. - tween.reversed = true; - tween.setPosition(0); - tween.paused = false; - tween.on("complete", removeBitmap, null, true); - onTweenStateChange(); - } - }; - }, [ - src, - zIndex, - canvasRef, - addChild, - removeChild, - addResizeListener, - removeResizeListener, - onTweenStateChange, - ]); - - return null; -} - -export function OutfitCanvasMovie({ librarySrc, zIndex }) { - const { - canvasRef, - addChild, - removeChild, - onTweenStateChange, - addResizeListener, - removeResizeListener, - } = React.useContext(EaselContext); - - React.useEffect(() => { - let library; - let movieClip; - let tween; - let canceled = false; - - function updateSize() { - movieClip.scaleX = canvasRef.current.width / library.properties.width; - movieClip.scaleY = canvasRef.current.height / library.properties.height; - } - - async function addMovieClip() { - try { - library = await loadCanvasMovieLibrary(librarySrc); - } catch (e) { - console.error("Error loading movie library", librarySrc, e); - return; - } - if (canceled) { - return; - } - - let constructorName; - try { - const fileName = librarySrc.split("/").pop(); - const fileNameWithoutExtension = fileName.split(".")[0]; - constructorName = fileNameWithoutExtension.replace(/[ -]/g, ""); - if (constructorName.match(/^[0-9]/)) { - constructorName = "_" + constructorName; - } - } catch (e) { - console.error( - `Movie librarySrc %s did not match expected format: %o`, - JSON.stringify(librarySrc), - e - ); - return; - } - - const LibraryMovieClipConstructor = library[constructorName]; - if (!LibraryMovieClipConstructor) { - console.error( - `Expected JS movie library %s to contain a constructor named ` + - `%s, but it did not: %o`, - JSON.stringify(librarySrc), - JSON.stringify(constructorName), - library - ); - return; - } - movieClip = new LibraryMovieClipConstructor(); - - // For actual animated movies, we cache their appearance then update - // every time they advance a frame, so that they aren't recomputing - // things while we perform 60FPS fade transitions. - // - // For static assets, we go even further: we cache their appearance once, - // then never touch it again, even disabling the entire tick event for - // its entire remaining lifetime! (This is a surprisingly good perf win: - // static assets are often complex with a big sprite tree, and not having - // to walk it has a measurable impact on simulated low-power CPUs.) - movieClip.cache( - 0, - 0, - library.properties.width, - library.properties.height - ); - if (createjsNodeHasAnimations(movieClip)) { - movieClip.on("tick", () => { - movieClip.updateCache(); - }); - } else { - movieClip.tickEnabled = false; - } - - // We're gonna fade in! Wait for the first frame to draw, to make the - // timing smooth, but yeah here we go! - movieClip.alpha = 0; - tween = window.createjs.Tween.get(movieClip, { paused: true }).to( - { alpha: 1 }, - 200 - ); - const startFadeIn = () => { - tween.paused = false; - onTweenStateChange(); - }; - tween.on("complete", onTweenStateChange); - - // Get it actually running! We need to set framerate _after_ adding it - // to the stage, to overwrite the stage's defaults. - updateSize(); - addChild(movieClip, zIndex, { afterFirstDraw: startFadeIn }); - movieClip.framerate = library.properties.fps; - - addResizeListener(updateSize); - } - - function removeMovieClip() { - removeResizeListener(updateSize); - removeChild(movieClip); - } - - addMovieClip(); - - return () => { - canceled = true; - if (movieClip) { - // Reverse the fade-in into a fade-out, then remove the bitmap. - tween.reversed = true; - tween.setPosition(0); - tween.paused = false; - tween.on("complete", removeMovieClip, null, true); - onTweenStateChange(); - } - }; - }, [ - librarySrc, - zIndex, - canvasRef, - addChild, - removeChild, - addResizeListener, - removeResizeListener, - onTweenStateChange, - ]); - - return null; -} - -/** - * useEaselDependenciesLoader loads the CreateJS scripts we use in OutfitCanvas. - * We load it as part of OutfitCanvas, but callers can also use this to preload - * the scripts and track loading progress. - */ -export function useEaselDependenciesLoader() { - // NOTE: I couldn't find an official NPM source for this that worked with - // Webpack, and I didn't want to rely on random people's ports, and I - // couldn't get a bundled version to work quite right. So we load - // createjs async! - const easelLoading = useScriptTag( - "https://code.createjs.com/1.0.0/easeljs.min.js" - ); - const tweenLoading = useScriptTag( - "https://code.createjs.com/1.0.0/tweenjs.min.js" - ); - - return { loading: easelLoading || tweenLoading }; -} - -function useScriptTag(src) { - const [loading, setLoading] = React.useState(true); - - React.useEffect(() => { - const existingScript = document.querySelector( - `script[src=${CSS.escape(src)}]` - ); - if (existingScript) { - setLoading(false); - return; - } - - let canceled = false; - loadScriptTag(src).then(() => { - if (!canceled) { - setLoading(false); - } - }); - - return () => { - canceled = true; - setLoading(true); - }; - }, [src, setLoading]); - - return loading; -} - -export function loadImage(url) { - const image = new Image(); - const promise = new Promise((resolve, reject) => { - image.onload = () => resolve(image); - image.onerror = (e) => reject(e); - image.src = url; - }); - promise.cancel = () => { - image.src = ""; - }; - return promise; -} - -export async function loadCanvasMovieLibrary(librarySrc) { - // These library JS files are interesting in their operation. It seems like - // the idea is, it pushes an object to a global array, and you need to snap - // it up and see it at the end of the array! And I don't really see a way to - // like, get by a name or ID that we know by this point. So, here we go, just - // try to grab it once it arrives! - // - // I'm not _sure_ this method is reliable, but it seems to be stable so far - // in Firefox for me. The things I think I'm observing are: - // - Script execution order should match insert order, - // - Onload execution order should match insert order, - // - BUT, script executions might be batched before onloads. - // - So, each script grabs the _first_ composition from the list, and - // deletes it after grabbing. That way, it serves as a FIFO queue! - // I'm not suuure this is happening as I'm expecting, vs I'm just not seeing - // the race anymore? But fingers crossed! - await loadScriptTag(safeImageUrl(librarySrc)); - const [compositionId, composition] = Object.entries( - window.AdobeAn.compositions - )[0]; - if (Object.keys(window.AdobeAn.compositions).length > 1) { - console.warn( - `Grabbing composition ${compositionId}, but there are >1 here: `, - Object.keys(window.AdobeAn.compositions).length - ); - } - delete window.AdobeAn.compositions[compositionId]; - const library = composition.getLibrary(); - - // One more loading step as part of loading this library is loading the - // images it uses for sprites. - // - // TODO: I guess the manifest has these too, so if we could use our DB cache - // to get the manifest to us faster, then we could avoid a network RTT - // on the critical path by preloading these images before the JS file - // even gets to us? - const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/"); - const manifestImages = new Map( - library.properties.manifest.map(({ id, src }) => [ - id, - loadImage(safeImageUrl(librarySrcDir + "/" + src)), - ]) - ); - await Promise.all(manifestImages.values()); - - // Finally, once we have the images loaded, the library object expects us to - // mutate it (!) to give it the actual sprite sheet objects based on the - // loaded images. That's how the MovieClip's objects will access the loaded - // versions! - const spriteSheets = composition.getSpriteSheet(); - for (const { name, frames } of library.ssMetadata) { - const image = await manifestImages.get(name); - spriteSheets[name] = new window.createjs.SpriteSheet({ - images: [image], - frames, - }); - } - - return library; -} - -function loadScriptTag(src) { - return new Promise((resolve, reject) => { - const script = document.createElement("script"); - script.onload = () => resolve(script); - script.onerror = (e) => reject(e); - script.src = src; - document.body.appendChild(script); - }); -} - -function createjsNodeHasAnimations(createjsNode) { - return ( - createjsNode.totalFrames > 1 || - (createjsNode.children || []).some(createjsNodeHasAnimations) - ); -} - -export default OutfitCanvas; diff --git a/src/app/components/OutfitMovieLayer.js b/src/app/components/OutfitMovieLayer.js new file mode 100644 index 0000000..aa807d3 --- /dev/null +++ b/src/app/components/OutfitMovieLayer.js @@ -0,0 +1,298 @@ +import React from "react"; + +import { safeImageUrl } from "../util"; + +function OutfitMovieLayer({ libraryUrl, width, height, isPaused = false }) { + const [stage, setStage] = React.useState(null); + const [library, setLibrary] = React.useState(null); + const [movieClip, setMovieClip] = React.useState(null); + const canvasRef = React.useRef(null); + + const loadingDeps = useEaselDependenciesLoader(); + + // Set the canvas's internal dimensions to be higher, if the device has high + // DPI like retina. But we'll keep the layout width/height as expected! + const internalWidth = width * window.devicePixelRatio; + const internalHeight = height * window.devicePixelRatio; + + // This effect gives us a `stage` corresponding to the canvas element. + React.useLayoutEffect(() => { + if (loadingDeps || !canvasRef.current) { + return; + } + + setStage((stage) => { + if (stage && stage.canvas === canvasRef.current) { + return stage; + } + + return new window.createjs.Stage(canvasRef.current); + }); + return () => setStage(null); + }, [loadingDeps]); + + // This effect gives us the `library` and `movieClip`, based on the incoming + // `libraryUrl`. + React.useEffect(() => { + if (loadingDeps) { + return; + } + + let canceled = false; + + loadMovieLibrary(libraryUrl) + .then((library) => { + if (canceled) { + return; + } + + setLibrary(library); + + const movieClip = buildMovieClip(library, libraryUrl); + setMovieClip(movieClip); + }) + .catch((e) => { + console.error("Error loading outfit movie layer", e); + }); + + return () => { + canceled = true; + setLibrary(null); + setMovieClip(null); + }; + }, [loadingDeps, libraryUrl]); + + // This effect puts the `movieClip` on the `stage`, when both are ready. + React.useEffect(() => { + if (!stage || !movieClip) { + return; + } + + stage.addChild(movieClip); + + // Render the movie's first frame. If it's animated and we're not paused, + // then another effect will perform subsequent updates. + stage.update(); + + return () => stage.removeChild(movieClip); + }, [stage, movieClip]); + + // 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 + // frame to show, and we're not paused. + React.useEffect(() => { + if (!stage || !movieClip || !library) { + return; + } + + if (isPaused || !hasAnimations(movieClip)) { + return; + } + + const intervalId = setInterval( + () => stage.update(), + 1000 / library.properties.fps + ); + return () => clearInterval(intervalId); + }, [stage, movieClip, library, isPaused]); + + // This effect keeps the `movieClip` scaled correctly, based on the canvas + // size and the `library`'s natural size declaration. (If the canvas size + // changes on window resize, then this will keep us responsive, so long as + // the parent updates our width/height props on window resize!) + React.useEffect(() => { + if (!stage || !movieClip || !library) { + return; + } + + movieClip.scaleX = internalWidth / library.properties.width; + movieClip.scaleY = internalHeight / library.properties.height; + + // Ensure that we update here, in case the movie is paused or not animated. + stage.update(); + }, [stage, library, movieClip, internalWidth, internalHeight]); + + return ( + + ); +} + +/** + * useEaselDependenciesLoader loads the CreateJS scripts we use in OutfitCanvas. + * We load it as part of OutfitCanvas, but callers can also use this to preload + * the scripts and track loading progress. + */ +export function useEaselDependenciesLoader() { + // NOTE: I couldn't find an official NPM source for this that worked with + // Webpack, and I didn't want to rely on random people's ports, and I + // couldn't get a bundled version to work quite right. So we load + // createjs async! + const loadingEaselJS = useScriptTag( + "https://code.createjs.com/1.0.0/easeljs.min.js" + ); + const loadingTweenJS = useScriptTag( + "https://code.createjs.com/1.0.0/tweenjs.min.js" + ); + const loadingDeps = loadingEaselJS || loadingTweenJS; + + return loadingDeps; +} + +function useScriptTag(src) { + const [loading, setLoading] = React.useState(true); + + React.useEffect(() => { + const existingScript = document.querySelector( + `script[src=${CSS.escape(src)}]` + ); + if (existingScript) { + setLoading(false); + return; + } + + let canceled = false; + loadScriptTag(src).then(() => { + if (!canceled) { + setLoading(false); + } + }); + + return () => { + canceled = true; + setLoading(true); + }; + }, [src, setLoading]); + + return loading; +} + +function loadScriptTag(src) { + return new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.onload = () => resolve(script); + script.onerror = (e) => reject(e); + script.src = src; + document.body.appendChild(script); + }); +} + +export async function loadMovieLibrary(librarySrc) { + // These library JS files are interesting in their operation. It seems like + // the idea is, it pushes an object to a global array, and you need to snap + // it up and see it at the end of the array! And I don't really see a way to + // like, get by a name or ID that we know by this point. So, here we go, just + // try to grab it once it arrives! + // + // I'm not _sure_ this method is reliable, but it seems to be stable so far + // in Firefox for me. The things I think I'm observing are: + // - Script execution order should match insert order, + // - Onload execution order should match insert order, + // - BUT, script executions might be batched before onloads. + // - So, each script grabs the _first_ composition from the list, and + // deletes it after grabbing. That way, it serves as a FIFO queue! + // I'm not suuure this is happening as I'm expecting, vs I'm just not seeing + // the race anymore? But fingers crossed! + await loadScriptTag(safeImageUrl(librarySrc)); + const [compositionId, composition] = Object.entries( + window.AdobeAn.compositions + )[0]; + if (Object.keys(window.AdobeAn.compositions).length > 1) { + console.warn( + `Grabbing composition ${compositionId}, but there are >1 here: `, + Object.keys(window.AdobeAn.compositions).length + ); + } + delete window.AdobeAn.compositions[compositionId]; + const library = composition.getLibrary(); + + // One more loading step as part of loading this library is loading the + // images it uses for sprites. + // + // TODO: I guess the manifest has these too, so if we could use our DB cache + // to get the manifest to us faster, then we could avoid a network RTT + // on the critical path by preloading these images before the JS file + // even gets to us? + const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/"); + const manifestImages = new Map( + library.properties.manifest.map(({ id, src }) => [ + id, + loadImage(safeImageUrl(librarySrcDir + "/" + src)), + ]) + ); + await Promise.all(manifestImages.values()); + + // Finally, once we have the images loaded, the library object expects us to + // mutate it (!) to give it the actual sprite sheet objects based on the + // loaded images. That's how the MovieClip's objects will access the loaded + // versions! + const spriteSheets = composition.getSpriteSheet(); + for (const { name, frames } of library.ssMetadata) { + const image = await manifestImages.get(name); + spriteSheets[name] = new window.createjs.SpriteSheet({ + images: [image], + frames, + }); + } + + return library; +} + +export function loadImage(url) { + const image = new Image(); + const promise = new Promise((resolve, reject) => { + image.onload = () => resolve(image); + image.onerror = (e) => reject(e); + image.src = url; + }); + promise.cancel = () => { + image.src = ""; + }; + return promise; +} + +export function buildMovieClip(library, libraryUrl) { + let constructorName; + try { + const fileName = libraryUrl.split("/").pop(); + const fileNameWithoutExtension = fileName.split(".")[0]; + constructorName = fileNameWithoutExtension.replace(/[ -]/g, ""); + if (constructorName.match(/^[0-9]/)) { + constructorName = "_" + constructorName; + } + } catch (e) { + throw new Error( + `Movie libraryUrl ${JSON.stringify( + libraryUrl + )} did not match expected format: ${e.message}` + ); + } + + const LibraryMovieClipConstructor = library[constructorName]; + if (!LibraryMovieClipConstructor) { + throw new Error( + `Expected JS movie library ${libraryUrl} to contain a constructor ` + + `named ${constructorName}, but it did not: ${Object.keys(library)}` + ); + } + const movieClip = new LibraryMovieClipConstructor(); + + return movieClip; +} + +/** + * Recursively scans the given MovieClip (or child createjs node), to see if + * there are any animated areas. + */ +export function hasAnimations(createjsNode) { + return ( + createjsNode.totalFrames > 1 || + (createjsNode.children || []).some(hasAnimations) + ); +} + +export default OutfitMovieLayer; diff --git a/src/app/components/OutfitPreview.js b/src/app/components/OutfitPreview.js index c0daaef..a72b1ab 100644 --- a/src/app/components/OutfitPreview.js +++ b/src/app/components/OutfitPreview.js @@ -4,15 +4,15 @@ import { WarningIcon } from "@chakra-ui/icons"; import { css, cx } from "emotion"; import { CSSTransition, TransitionGroup } from "react-transition-group"; -import OutfitCanvas, { - OutfitCanvasImage, - OutfitCanvasMovie, +import OutfitMovieLayer, { + buildMovieClip, + hasAnimations, loadImage, - loadCanvasMovieLibrary, + loadMovieLibrary, useEaselDependenciesLoader, -} from "./OutfitCanvas"; +} from "./OutfitMovieLayer"; import HangerSpinner from "./HangerSpinner"; -import { useLocalStorage } from "../util"; +import { safeImageUrl, useLocalStorage } from "../util"; import useOutfitAppearance from "./useOutfitAppearance"; /** @@ -39,7 +39,6 @@ function OutfitPreview({ placeholder, loadingDelayMs, spinnerVariant, - engine = "images", onChangeHasAnimations = null, }) { const { loading, error, visibleLayers } = useOutfitAppearance({ @@ -50,9 +49,18 @@ function OutfitPreview({ wornItemIds, }); - const { loading: loading2, error: error2, loadedLayers } = usePreloadLayers( - visibleLayers - ); + const { + loading: loading2, + error: error2, + loadedLayers, + layersHaveAnimations, + } = usePreloadLayers(visibleLayers); + + const [isPaused] = useLocalStorage("DTIOutfitIsPaused", true); + + React.useEffect(() => { + onChangeHasAnimations(layersHaveAnimations); + }, [layersHaveAnimations, onChangeHasAnimations]); if (error || error2) { return ( @@ -73,9 +81,9 @@ function OutfitPreview({ placeholder={placeholder} loadingDelayMs={loadingDelayMs} spinnerVariant={spinnerVariant} - engine={engine} onChangeHasAnimations={onChangeHasAnimations} doTransitions + isPaused={isPaused} /> ); } @@ -91,8 +99,8 @@ export function OutfitLayers({ loadingDelayMs = 500, spinnerVariant = "overlay", doTransitions = false, - engine = "images", onChangeHasAnimations = null, + isPaused = true, }) { const containerRef = React.useRef(null); const [canvasSize, setCanvasSize] = React.useState(0); @@ -103,8 +111,6 @@ export function OutfitLayers({ const { loading: loadingEasel } = useEaselDependenciesLoader(); const loadingAnything = loading || loadingEasel; - const [isPaused] = useLocalStorage("DTIOutfitIsPaused", true); - // When we start in a loading state, or re-enter a loading state, start the // loading delay timer. React.useEffect(() => { @@ -154,95 +160,72 @@ export function OutfitLayers({ )} - { - // TODO: A bit of a mess in here! Extract these out? - engine === "canvas" ? ( - !loadingEasel && ( - - - {visibleLayers.map((layer) => - layer.canvasMovieLibraryUrl ? ( - - ) : ( - - ) - )} - + + {visibleLayers.map((layer) => ( + + + {layer.canvasMovieLibraryUrl ? ( + + ) : ( + finishes preloading and + // applies the src to the underlying . + className={cx( + css` + object-fit: contain; + max-width: 100%; + max-height: 100%; + + &.do-animations { + animation: fade-in 0.2s; + } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + `, + doTransitions && "do-animations" + )} + // This sets up the cache to not need to reload images during + // download! + // TODO: Re-enable this once we get our change into Chakra + // main. For now, this will make Downloads a bit slower, which + // is fine! + // crossOrigin="Anonymous" + /> + )} - ) - ) : ( - - {visibleLayers.map((layer) => ( - - - finishes preloading and - // applies the src to the underlying . - className={cx( - css` - object-fit: contain; - max-width: 100%; - max-height: 100%; - - &.do-animations { - animation: fade-in 0.2s; - } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } - `, - doTransitions && "do-animations" - )} - // This sets up the cache to not need to reload images during - // download! - // TODO: Re-enable this once we get our change into Chakra - // main. For now, this will make Downloads a bit slower, which - // is fine! - // crossOrigin="Anonymous" - /> - - - ))} - - ) - } + + ))} + { const assetPromises = layers.map((layer) => { if (layer.canvasMovieLibraryUrl) { - return loadCanvasMovieLibrary(layer.canvasMovieLibraryUrl); + return loadMovieLibrary(layer.canvasMovieLibraryUrl).then( + (library) => ({ + type: "movie", + library, + libraryUrl: layer.canvasMovieLibraryUrl, + }) + ); } else { - return loadImage(getBestImageUrlForLayer(layer)); + return loadImage(getBestImageUrlForLayer(layer)).then((image) => ({ + type: "image", + image, + })); } }); + + let assets; try { - // TODO: Load in one at a time, under a loading spinner & delay? - await Promise.all(assetPromises); + assets = await Promise.all(assetPromises); } catch (e) { if (canceled) return; console.error("Error preloading outfit layers", e); @@ -352,7 +346,14 @@ export function usePreloadLayers(layers) { } if (canceled) return; + + const newLayersHaveAnimations = assets.some( + (a) => + a.type === "movie" && + hasAnimations(buildMovieClip(a.library, a.libraryUrl)) + ); setLoadedLayers(layers); + setLayersHaveAnimations(newLayersHaveAnimations); }; loadAssets(); @@ -362,7 +363,7 @@ export function usePreloadLayers(layers) { }; }, [layers, loadedLayers.length, loading]); - return { loading, error, loadedLayers }; + return { loading, error, loadedLayers, layersHaveAnimations }; } export default OutfitPreview; diff --git a/src/stories/OutfitCanvas.stories.js b/src/stories/OutfitCanvas.stories.js deleted file mode 100644 index 7f8a2ad..0000000 --- a/src/stories/OutfitCanvas.stories.js +++ /dev/null @@ -1,94 +0,0 @@ -import React from "react"; - -import OutfitCanvas, { - OutfitCanvasImage, - OutfitCanvasMovie, -} from "../app/components/OutfitCanvas"; - -export default { - title: "Dress to Impress/OutfitCanvas", - component: OutfitCanvas, - argTypes: { - paused: { - name: "Paused", - }, - pet: { - name: "Pet", - control: { - type: "radio", - options: ["None", "Blue Acara"], - }, - }, - items: { - name: "Items", - control: { - type: "multi-select", - options: ["Bubbles In Water Foreground"], - }, - }, - }, -}; - -// NOTE: We don't bother with assetProxy here, because we only run Storybook -// locally, and localhost isn't subject to the same mixed content rules. -// So this is noticeably faster! - -const Template = (args) => ( - - {args.pet === "Blue Acara" && ( - <> - - - - - - - - )} - {args.items.includes("Bubbles In Water Foreground") && ( - - )} - -); - -export const BlueAcara = Template.bind({}); -BlueAcara.args = { - pet: "Blue Acara", - items: [], - paused: false, -}; - -export const BubblesOnWaterForeground = Template.bind({}); -BubblesOnWaterForeground.args = { - pet: "None", - items: ["Bubbles In Water Foreground"], - paused: false, -}; - -export const BlueAcaraWithForeground = Template.bind({}); -BlueAcaraWithForeground.args = { - pet: "Blue Acara", - items: ["Bubbles In Water Foreground"], - paused: false, -}; diff --git a/src/stories/OutfitLayers.stories.js b/src/stories/OutfitLayers.stories.js new file mode 100644 index 0000000..af15ba4 --- /dev/null +++ b/src/stories/OutfitLayers.stories.js @@ -0,0 +1,113 @@ +import React from "react"; +import { Box } from "@chakra-ui/core"; + +import { OutfitLayers } from "../app/components/OutfitPreview"; + +export default { + title: "Dress to Impress/OutfitLayers", + component: OutfitLayers, + argTypes: { + paused: { + name: "Paused", + }, + pet: { + name: "Pet", + control: { + type: "radio", + options: ["None", "Blue Acara"], + }, + }, + items: { + name: "Items", + control: { + type: "multi-select", + options: ["Bubbles On Water Foreground"], + }, + }, + }, +}; + +const Template = (args) => { + const layers = []; + + if (args.pet === "Blue Acara") { + layers.push(...LAYERS.BlueAcara); + } + + if (args.items.includes("Bubbles On Water Foreground")) { + layers.push(...LAYERS.BubblesOnWaterForeground); + } + + return ( + + + + + + + ); +}; + +export const BlueAcara = Template.bind({}); +BlueAcara.args = { + pet: "Blue Acara", + items: [], + paused: false, +}; + +export const BubblesOnWaterForeground = Template.bind({}); +BubblesOnWaterForeground.args = { + pet: "None", + items: ["Bubbles On Water Foreground"], + paused: false, +}; + +const LAYERS = { + BlueAcara: [ + { + id: "1795", + svgUrl: + "http://images.neopets.com/cp/bio/data/000/000/002/2426_898928db88/2426.svg", + zone: { id: "5", depth: 7 }, + }, + { + id: "1794", + svgUrl: + "http://images.neopets.com/cp/bio/data/000/000/002/2425_501f596cef/2425.svg", + zone: { id: "15", depth: 18 }, + }, + { + id: "22101", + svgUrl: + "http://images.neopets.com/cp/bio/data/000/000/032/32185_dc8f076ae3/32185.svg", + zone: { id: "30", depth: 34 }, + }, + { + id: "1797", + svgUrl: + "http://images.neopets.com/cp/bio/data/000/000/002/2428_991dcdedc7/2428.svg", + zone: { id: "33", depth: 37 }, + }, + { + id: "1798", + svgUrl: + "http://images.neopets.com/cp/bio/data/000/000/002/2430_87edccba4c/2430.svg", + zone: { id: "34", depth: 38 }, + }, + { + id: "1796", + svgUrl: + "http://images.neopets.com/cp/bio/data/000/000/002/2427_f12853f18a/2427.svg", + zone: { id: "38", depth: 42 }, + }, + ], + + BubblesOnWaterForeground: [ + { + id: "468155", + canvasMovieLibraryUrl: + "http://images.neopets.com/cp/items/data/000/000/564/564507_fc3216b9b8/all-item_foreground_lower.js", + zone: { id: "45", depth: 50 }, + }, + ], +};