impress-2020/src/app/components/OutfitMovieLayer.js

299 lines
9.1 KiB
JavaScript
Raw Normal View History

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;