simplify canvas code, just use separate elements

Previously I tried to be clever and pre-optimize by putting all the layers onto one canvas… I think this probably helped by batching their paints, but it made fades less smooth by not taking advantage of native CSS transitions, and it made us dip into JS way more often than necessary.

Here, I take the simpler approach: just layers of <img> and <canvas> tags, with each animated layer on its own canvas, and letting the browser handle transitions and compositing, and separate `setInterval` timers to manage their framerates.

I have a suspicion that batching the paints could help performance more, but honestly, maybe that batching is already happening somehow, because things look pretty great on my big-screen stress test now; and so if it _is_ relevant, I want to wait and see after testing on low-power devices.
This commit is contained in:
Emi Matchu 2020-10-08 04:13:47 -07:00
parent 3e147ec5b4
commit 1875931a48
6 changed files with 520 additions and 851 deletions

View file

@ -67,7 +67,6 @@ function WardrobePage() {
pose={outfitState.pose}
appearanceId={outfitState.appearanceId}
wornItemIds={outfitState.wornItemIds}
engine="canvas"
onChangeHasAnimations={setHasAnimations}
/>
}

View file

@ -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 (
<EaselContext.Provider
value={{
canvasRef,
addChild,
removeChild,
addResizeListener,
removeResizeListener,
onTweenStateChange,
stage, // Not used, but available for debugging.
}}
>
<canvas
ref={canvasRef}
width={internalWidth}
height={internalHeight}
style={{
width: width + "px",
height: height + "px",
}}
/>
{stage && children}
</EaselContext.Provider>
);
}
/**
* 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;

View file

@ -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 (
<canvas
ref={canvasRef}
width={internalWidth}
height={internalHeight}
style={{ width: width, height: height }}
/>
);
}
/**
* 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;

View file

@ -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,36 +160,6 @@ export function OutfitLayers({
</Box>
</FullScreenCenter>
)}
{
// TODO: A bit of a mess in here! Extract these out?
engine === "canvas" ? (
!loadingEasel && (
<FullScreenCenter>
<OutfitCanvas
width={canvasSize}
height={canvasSize}
pauseMovieLayers={isPaused}
onChangeHasAnimations={onChangeHasAnimations}
>
{visibleLayers.map((layer) =>
layer.canvasMovieLibraryUrl ? (
<OutfitCanvasMovie
key={layer.id}
librarySrc={layer.canvasMovieLibraryUrl}
zIndex={layer.zone.depth}
/>
) : (
<OutfitCanvasImage
key={layer.id}
src={getBestImageUrlForLayer(layer)}
zIndex={layer.zone.depth}
/>
)
)}
</OutfitCanvas>
</FullScreenCenter>
)
) : (
<TransitionGroup enter={false} exit={doTransitions}>
{visibleLayers.map((layer) => (
<CSSTransition
@ -203,6 +179,14 @@ export function OutfitLayers({
timeout={200}
>
<FullScreenCenter zIndex={layer.zone.depth}>
{layer.canvasMovieLibraryUrl ? (
<OutfitMovieLayer
libraryUrl={layer.canvasMovieLibraryUrl}
width={canvasSize}
height={canvasSize}
isPaused={isPaused}
/>
) : (
<img
src={getBestImageUrlForLayer(layer)}
alt=""
@ -237,12 +221,11 @@ export function OutfitLayers({
// is fine!
// crossOrigin="Anonymous"
/>
)}
</FullScreenCenter>
</CSSTransition>
))}
</TransitionGroup>
)
}
<FullScreenCenter
zIndex="9000"
// This is similar to our Delay util component, but Delay disappears
@ -297,7 +280,7 @@ export function FullScreenCenter({ children, ...otherProps }) {
function getBestImageUrlForLayer(layer) {
if (layer.svgUrl) {
return `/api/assetProxy?url=${encodeURIComponent(layer.svgUrl)}`;
return safeImageUrl(layer.svgUrl);
} else {
return layer.imageUrl;
}
@ -311,6 +294,7 @@ function getBestImageUrlForLayer(layer) {
export function usePreloadLayers(layers) {
const [error, setError] = React.useState(null);
const [loadedLayers, setLoadedLayers] = React.useState([]);
const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
// NOTE: This condition would need to change if we started loading one at a
// time, or if the error case would need to show a partial state!
@ -335,14 +319,24 @@ export function usePreloadLayers(layers) {
const loadAssets = async () => {
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;

View file

@ -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) => (
<OutfitCanvas width={300} height={300} pauseMovieLayers={args.paused}>
{args.pet === "Blue Acara" && (
<>
<OutfitCanvasImage
src="http://images.neopets.com/cp/bio/data/000/000/002/2426_898928db88/2426.svg"
zIndex={10}
/>
<OutfitCanvasImage
src="http://images.neopets.com/cp/bio/data/000/000/002/2425_501f596cef/2425.svg"
zIndex={20}
/>
<OutfitCanvasImage
src="http://images.neopets.com/cp/bio/data/000/000/002/2427_f12853f18a/2427.svg"
zIndex={30}
/>
<OutfitCanvasImage
src="http://images.neopets.com/cp/bio/data/000/000/032/32185_dc8f076ae3/32185.svg"
zIndex={40}
/>
<OutfitCanvasImage
src="http://images.neopets.com/cp/bio/data/000/000/002/2428_991dcdedc7/2428.svg"
zIndex={50}
/>
<OutfitCanvasImage
src="http://images.neopets.com/cp/bio/data/000/000/002/2430_87edccba4c/2430.svg"
zIndex={60}
/>
</>
)}
{args.items.includes("Bubbles In Water Foreground") && (
<OutfitCanvasMovie
librarySrc="http://images.neopets.com/cp/items/data/000/000/564/564507_fc3216b9b8/all-item_foreground_lower.js"
zIndex={100}
/>
)}
</OutfitCanvas>
);
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,
};

View file

@ -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 (
<Box width="100%" position="relative">
<Box paddingBottom="100%" />
<Box position="absolute" top="0" left="0" right="0" bottom="0">
<OutfitLayers visibleLayers={layers} isPaused={args.paused} />
</Box>
</Box>
);
};
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 },
},
],
};