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 },
+ },
+ ],
+};