impress/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js
Emi Matchu 56fe5e4889 Oops, fix bug in recent movie-pausing bugfix!
Oh right, this previously logic was silly: we can't count on the
*interval itself* to be reliably resetting the FPS counter state,
because the interval might not be firing!

I think this fix worked when I tried brief tests, but didn't work when
I did an (accidental) longer test, because the browser switched to a
more aggressive throttle mode, and the previous mode was close enough
on the resets for it to be fine, whereas this time the FPS counter
state got way too old.

Now, we reset the FPS counter state *exactly* when the page comes back.
2024-06-12 17:14:16 -07:00

503 lines
17 KiB
JavaScript

import React from "react";
import LRU from "lru-cache";
import { Box, Grid, useToast } from "@chakra-ui/react";
import { loadImage, logAndCapture, safeImageUrl } from "../util";
import usePreferArchive from "./usePreferArchive";
// Import EaselJS and TweenJS as strings to run in a global context!
// The bundled scripts are built to attach themselves to `window.createjs`, and
// `window.createjs` is where the Neopets movie libraries expects to find them!
//
// TODO: Is there a nicer way to do this within esbuild? Would be nice to have
// builds of these libraries that just play better in the first place...
import easelSource from "easeljs/lib/easeljs.min.js";
import tweenSource from "tweenjs/lib/tweenjs.min.js";
new Function(easelSource).call(window);
new Function(tweenSource).call(window);
function OutfitMovieLayer({
libraryUrl,
width,
height,
placeholderImageUrl = null,
isPaused = false,
onLoad = null,
onError = null,
onLowFps = null,
canvasProps = {},
}) {
const [preferArchive] = usePreferArchive();
const [stage, setStage] = React.useState(null);
const [library, setLibrary] = React.useState(null);
const [movieClip, setMovieClip] = React.useState(null);
const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
const canvasRef = React.useRef(null);
const hasShownErrorMessageRef = React.useRef(false);
const toast = useToast();
// 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;
const callOnLoadIfNotYetCalled = React.useCallback(() => {
setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
if (!alreadyHasCalledOnLoad && onLoad) {
onLoad();
}
return true;
});
}, [onLoad]);
const updateStage = React.useCallback(() => {
if (!stage) {
return;
}
try {
stage.update();
} catch (e) {
// If rendering the frame fails, log it and proceed. If it's an
// animation, then maybe the next frame will work? Also alert the user,
// just as an FYI. (This is pretty uncommon, so I'm not worried about
// being noisy!)
if (!hasShownErrorMessageRef.current) {
console.error(`Error rendering movie clip ${libraryUrl}`);
logAndCapture(e);
toast({
status: "warning",
title:
"Hmm, we're maybe having trouble playing one of these animations.",
description:
"If it looks wrong, try pausing and playing, or reloading the " +
"page. Sorry!",
duration: 10000,
isClosable: true,
});
// We do this via a ref, not state, because I want to guarantee that
// future calls see the new value. With state, React's effects might
// not happen in the right order for it to work!
hasShownErrorMessageRef.current = true;
}
}
}, [stage, toast, libraryUrl]);
// This effect gives us a `stage` corresponding to the canvas element.
React.useLayoutEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
if (canvas.getContext("2d") == null) {
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
toast({
status: "warning",
title: "Oops, too many animations!",
description:
`Your device is out of memory, so we can't show any more ` +
`animations. Try removing some items, or using another device.`,
duration: null,
isClosable: true,
});
return;
}
setStage((stage) => {
if (stage && stage.canvas === canvas) {
return stage;
}
return new window.createjs.Stage(canvas);
});
return () => {
setStage(null);
if (canvas) {
// There's a Safari bug where it doesn't reliably garbage-collect
// canvas data. Clean it up ourselves, rather than leaking memory over
// time! https://stackoverflow.com/a/52586606/107415
// https://bugs.webkit.org/show_bug.cgi?id=195325
canvas.width = 0;
canvas.height = 0;
}
};
}, [libraryUrl, toast]);
// This effect gives us the `library` and `movieClip`, based on the incoming
// `libraryUrl`.
React.useEffect(() => {
let canceled = false;
const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
movieLibraryPromise
.then((library) => {
if (canceled) {
return;
}
setLibrary(library);
const movieClip = buildMovieClip(library, libraryUrl);
setMovieClip(movieClip);
})
.catch((e) => {
console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
if (onError) {
onError(e);
}
});
return () => {
canceled = true;
movieLibraryPromise.cancel();
setLibrary(null);
setMovieClip(null);
};
}, [libraryUrl, preferArchive, onError]);
// 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.
updateStage();
// This is when we trigger `onLoad`: once we're actually showing it!
callOnLoadIfNotYetCalled();
setMovieIsLoaded(true);
return () => stage.removeChild(movieClip);
}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
// This effect updates the `stage` according to the `library`'s framerate,
// but only if there's actual animation to do - i.e., there's more than one
// frame to show, and we're not paused.
React.useEffect(() => {
if (!stage || !movieClip || !library) {
return;
}
if (isPaused || !hasAnimations(movieClip)) {
return;
}
const targetFps = library.properties.fps;
let lastFpsLoggedAtInMs = performance.now();
let numFramesSinceLastLogged = 0;
const intervalId = setInterval(() => {
const now = performance.now();
const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
const roundedFps = Math.round(fps * 100) / 100;
// If the page is visible, render the next frame, and track that we did.
// And if it's been 2 seconds since the last time we logged the FPS,
// compute and log the FPS during those two seconds. (Checking the page
// visibility is both an optimization to avoid rendering the movie, but
// also makes "low FPS" tracking more accurate: browsers already throttle
// intervals when the page is hidden, so a low FPS is *expected*, and
// wouldn't indicate a performance problem like a low FPS normally would.)
if (!document.hidden) {
updateStage();
numFramesSinceLastLogged++;
if (timeSinceLastFpsLoggedAtInSec > 2) {
console.debug(
`[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`,
);
if (onLowFps && fps < 2) {
onLowFps(fps);
}
lastFpsLoggedAtInMs = now;
numFramesSinceLastLogged = 0;
}
}
}, 1000 / targetFps);
const onVisibilityChange = () => {
// When the page switches from hidden to visible, reset the FPS counter
// state, to start counting from When Visibility Came Back, rather than
// from when we last counted, which could be a long time ago.
if (!document.hidden) {
lastFpsLoggedAtInMs = performance.now();
numFramesSinceLastLogged = 0;
console.debug(
`[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`,
);
} else {
console.debug(
`[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`,
);
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
clearInterval(intervalId);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
// 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;
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
return (
<Grid templateAreas="single-shared-area">
<canvas
ref={canvasRef}
width={internalWidth}
height={internalHeight}
style={{
width: width,
height: height,
gridArea: "single-shared-area",
}}
data-is-loaded={movieIsLoaded}
{...canvasProps}
/>
{/* While the movie is loading, we show our image version as a
* placeholder, because it generally loads much faster.
* TODO: Show a loading indicator for this partially-loaded state? */}
{placeholderImageUrl && (
<Box
as="img"
src={safeImageUrl(placeholderImageUrl)}
width={width}
height={height}
gridArea="single-shared-area"
opacity={movieIsLoaded ? 0 : 1}
transition="opacity 0.2s"
onLoad={callOnLoadIfNotYetCalled}
/>
)}
</Grid>
);
}
function loadScriptTag(src) {
let script;
let canceled = false;
let resolved = false;
const scriptTagPromise = new Promise((resolve, reject) => {
script = document.createElement("script");
script.onload = () => {
if (canceled) return;
resolved = true;
resolve(script);
};
script.onerror = (e) => {
if (canceled) return;
reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
};
script.src = src;
document.body.appendChild(script);
});
scriptTagPromise.cancel = () => {
if (resolved) return;
script.src = "";
canceled = true;
};
return scriptTagPromise;
}
const MOVIE_LIBRARY_CACHE = new LRU(10);
export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
const cancelableResourcePromises = [];
const cancelAllResources = () =>
cancelableResourcePromises.forEach((p) => p.cancel());
// Most of the logic for `loadMovieLibrary` is inside this async function.
// But we want to attach more fields to the promise before returning it; so
// we declare this async function separately, then call it, then edit the
// returned promise!
const createMovieLibraryPromise = async () => {
// First, check the LRU cache. This will enable us to quickly return movie
// libraries, without re-loading and re-parsing and re-executing.
const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
if (cachedLibrary) {
return cachedLibrary;
}
// Then, load the script tag. (Make sure we set it up to be cancelable!)
const scriptPromise = loadScriptTag(
safeImageUrl(librarySrc, { preferArchive }),
);
cancelableResourcePromises.push(scriptPromise);
await scriptPromise;
// 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!
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
throw new Error(
`Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`,
);
}
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(librarySrcDir + "/" + src, {
crossOrigin: "anonymous",
preferArchive,
}),
]),
);
// Wait for the images, and make sure they're cancelable while we do.
const manifestImagePromises = manifestImages.values();
cancelableResourcePromises.push(...manifestImagePromises);
await Promise.all(manifestImagePromises);
// Finally, once we have the images loaded, the library object expects us to
// mutate it (!) to give it the actual image and sprite sheet objects from
// the loaded images. That's how the MovieClip's internal JS objects will
// access the loaded data!
const images = composition.getImages();
for (const [id, image] of manifestImages.entries()) {
images[id] = await image;
}
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,
});
}
MOVIE_LIBRARY_CACHE.set(librarySrc, library);
return library;
};
const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
// When any part of the movie library fails, we also cancel the other
// resources ourselves, to avoid stray throws for resources that fail after
// the parent catches the initial failure. We re-throw the initial failure
// for the parent to handle, though!
cancelAllResources();
throw e;
});
// To cancel a `loadMovieLibrary`, cancel all of the resource promises we
// load as part of it. That should effectively halt the async function above
// (anything not yet loaded will stop loading), and ensure that stray
// failures don't trigger uncaught promise rejection warnings.
movieLibraryPromise.cancel = cancelAllResources;
return movieLibraryPromise;
}
export function buildMovieClip(library, libraryUrl) {
let constructorName;
try {
const fileName = decodeURI(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 (
// Some nodes have simple animation frames.
createjsNode.totalFrames > 1 ||
// Tweens are a form of animation that can happen separately from frames.
// They expect timer ticks to happen, and they change the scene accordingly.
createjsNode?.timeline?.tweens?.length >= 1 ||
// And some nodes have _children_ that are animated.
(createjsNode.children || []).some(hasAnimations)
);
}
export default OutfitMovieLayer;