const canvas = document.getElementById("asset-canvas"); const libraryScript = document.getElementById("canvas-movie-library"); const libraryUrl = libraryScript.getAttribute("src"); // Read the asset ID from the URL, as an extra hint of what asset we're // logging for. (This is helpful when there's a lot of assets animating!) const assetId = document.location.pathname.split("/").at(-1); const logPrefix = `[${assetId}] `.padEnd(9); // State for controlling the movie. let loadingStatus = "loading"; let playingStatus = getInitialPlayingStatus(); // State for loading the movie. let library = null; let movieClip = null; let stage = null; // State for animating the movie. let frameRequestId = null; let lastFrameTime = null; let lastLogTime = null; let numFramesSinceLastLog = 0; // State for error reporting. let hasLoggedRenderError = false; function loadImage(src) { const image = new Image(); image.crossOrigin = "anonymous"; const promise = new Promise((resolve, reject) => { image.onload = () => { resolve(image); }; image.onerror = () => { reject(new Error(`Failed to load image: ${JSON.stringify(src)}`)); }; image.src = src; }); return promise; } async function getLibrary() { if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) { throw new Error( `Movie library ${libraryUrl} 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 we could put them in preload // meta tags to get them here faster? const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/"); const manifestImages = new Map( library.properties.manifest.map(({ id, src }) => [ id, loadImage(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 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, }); } return library; } function buildMovieClip(library) { 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; } function updateStage() { 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 (!hasLoggedRenderError) { console.error(`Error rendering movie clip ${libraryUrl}`, e); // TODO: Inform user about the failure hasLoggedRenderError = true; } } } function updateCanvasDimensions() { // Set the canvas's internal dimensions to be higher, if the device has high // DPI. Scale the movie clip to match, too. const internalWidth = canvas.offsetWidth * window.devicePixelRatio; const internalHeight = canvas.offsetHeight * window.devicePixelRatio; canvas.width = internalWidth; canvas.height = internalHeight; movieClip.scaleX = internalWidth / library.properties.width; movieClip.scaleY = internalHeight / library.properties.height; } async function startMovie() { // Load the movie's library (from the JS file already run), and use it to // build a movie clip. library = await getLibrary(); movieClip = buildMovieClip(library); updateCanvasDimensions(); if (canvas.getContext("2d") == null) { console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`); // TODO: "Too many animations!" return; } stage = new window.createjs.Stage(canvas); stage.addChild(movieClip); updateStage(); loadingStatus = "loaded"; canvas.setAttribute("data-status", "loaded"); updateAnimationState(); } function updateAnimationState() { const shouldRunAnimations = loadingStatus === "loaded" && playingStatus === "playing"; if (shouldRunAnimations && frameRequestId == null) { lastFrameTime = document.timeline.currentTime; lastLogTime = document.timeline.currentTime; numFramesSinceLastLog = 0; documentHiddenSinceLastFrame = document.hidden; frameRequestId = requestAnimationFrame(onAnimationFrame); } else if (!shouldRunAnimations && frameRequestId != null) { cancelAnimationFrame(frameRequestId); lastFrameTime = null; lastLogTime = null; numFramesSinceLastLog = 0; documentHiddenSinceLastFrame = false; frameRequestId = null; } } function onAnimationFrame() { const targetFps = library.properties.fps; const msPerFrame = 1000 / targetFps; const msSinceLastFrame = document.timeline.currentTime - lastFrameTime; const msSinceLastLog = document.timeline.currentTime - lastLogTime; // If it takes too long to render a frame, cancel the movie, on the // assumption that we're riding the CPU too hard. (Some movies do this!) // // But note that, if the page is hidden (e.g. the window is not visible), // it's normal for the browser to pause animations. So, if we detected that // the document became hidden between this frame and the last, no // intervention is necesary. if (msSinceLastFrame >= 2000 && !documentHiddenSinceLastFrame) { pause(); console.warn(`Paused movie for taking too long: ${msSinceLastFrame}ms`); // TODO: Display message about low FPS, and sync up to the parent. return; } if (msSinceLastFrame >= msPerFrame) { updateStage(); lastFrameTime = document.timeline.currentTime; // If we're a little bit late to this frame, probably because the frame // rate isn't an even divisor of 60 FPS, backdate it to what the ideal time // for this frame *would* have been. (For example, without this tweak, a // 24 FPS animation like the Floating Negg Faerie actually runs at 20 FPS, // because it wants to run every 41.66ms, but a 60 FPS browser checks in // every 16.66ms, so the best it can do is 50ms. With this tweak, we can // *pretend* we ran at 41.66ms, so that the next frame timing correctly // takes the extra 9.33ms into account.) const msFrameDelay = msSinceLastFrame - msPerFrame; if (msFrameDelay < msPerFrame) { lastFrameTime -= msFrameDelay; } numFramesSinceLastLog++; } if (msSinceLastLog >= 5000) { const fps = numFramesSinceLastLog / (msSinceLastLog / 1000); console.debug(`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`); lastLogTime = document.timeline.currentTime; numFramesSinceLastLog = 0; } frameRequestId = requestAnimationFrame(onAnimationFrame); documentHiddenSinceLastFrame = document.hidden; } // If `document.hidden` becomes true at any point, log it for the next // animation frame. (The next frame will reset the state, as will starting or // stopping the animation.) document.addEventListener("visibilitychange", () => { if (document.hidden) { documentHiddenSinceLastFrame = true; } }); function play() { playingStatus = "playing"; updateAnimationState(); } function pause() { playingStatus = "paused"; updateAnimationState(); } function getInitialPlayingStatus() { const params = new URLSearchParams(document.location.search); if (params.has("playing")) { return "playing"; } else { return "paused"; } } /** * Recursively scans the given MovieClip (or child createjs node), to see if * there are any animated areas. */ 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) ); } window.addEventListener("resize", () => { updateCanvasDimensions(); // 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; }); window.addEventListener("message", ({ data }) => { // NOTE: For more sensitive messages, it's important for security to also // check the `origin` property of the incoming event. But in this case, I'm // okay with whatever site is embedding us being able to send play/pause! if (data.type === "play") { play(); } else if (data.type === "pause") { pause(); } else { throw new Error(`unexpected message: ${JSON.stringify(data)}`); } }); startMovie() .then(() => { parent.postMessage( { type: "status", status: "loaded", hasAnimations: hasAnimations(movieClip), }, document.location.origin, ); }) .catch((error) => { console.error(logPrefix, error); loadingStatus = "error"; parent.postMessage( { type: "status", status: "error" }, document.location.origin, ); // If loading the movie fails, show the fallback image instead, by moving // it out of the canvas content and into the body. document.body.appendChild(document.getElementById("fallback")); console.warn("Showing fallback image instead."); });