diff --git a/api/assetImage.js b/api/assetImage.js
new file mode 100644
index 0000000..6aaa1fc
--- /dev/null
+++ b/api/assetImage.js
@@ -0,0 +1,154 @@
+/**
+ * /api/assetImage renders a canvas movie to PNG! To do this, we use a headless
+ * Chromium browser, which renders a special page in the webapp and screenshots
+ * the displayed canvas.
+ *
+ * This is, of course, a relatively heavyweight operation: it's always gonna be
+ * a bit slow, and consume significant RAM. So, caching is going to be
+ * important, so that we're not calling this all the time and overloading the
+ * endpoint!
+ */
+const beeline = require("honeycomb-beeline")({
+ writeKey: process.env["HONEYCOMB_WRITE_KEY"],
+ dataset:
+ process.env["NODE_ENV"] === "production"
+ ? "Dress to Impress (2020)"
+ : "Dress to Impress (2020, dev)",
+ serviceName: "impress-2020-gql-server",
+});
+
+const { chromium } = require("playwright");
+
+// To render the image, we load the /internal/assetImage page in the web app,
+// a simple page specifically designed for this API endpoint!
+const ASSET_IMAGE_PAGE_BASE_URL = process.env.VERCEL_URL
+ ? `https://${process.env.VERCEL_URL}/internal/assetImage`
+ : process.env.NODE_ENV === "development"
+ ? "http://localhost:3000/internal/assetImage"
+ : "https://impress-2020.openneo.net/internal/assetImage";
+
+// TODO: What are the perf implications of sharing one browser instance, with
+// multiple pages open? This feels optimal to me from the *obvious*
+// perspective, but I do wonder whether e.g. there are surprise
+// implications from sharing a browser instance, or if too many pages in
+// parallel will be a problem for our API endpoint.
+let BROWSER;
+async function getBrowser() {
+ if (!BROWSER) {
+ BROWSER = await chromium.launch();
+ }
+ return BROWSER;
+}
+
+async function handle(req, res) {
+ const { libraryUrl } = req.query;
+ if (!libraryUrl) {
+ return reject(res, "libraryUrl is required");
+ }
+
+ if (!isNeopetsUrl(libraryUrl)) {
+ return reject(
+ res,
+ `libraryUrl must be an HTTPS Neopets URL, but was: ${libraryUrl}`
+ );
+ }
+
+ let imageBuffer;
+ try {
+ imageBuffer = await loadAndScreenshotImage(libraryUrl);
+ } catch (e) {
+ console.error(e);
+ return reject(res, `Could not load image: ${e.message}`, 500);
+ }
+
+ // TODO: Compress the image?
+
+ // Send a long-term cache header, to avoid running this any more than we have
+ // to! If we make a big change, we'll flush the cache or add a version param.
+ res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
+ res.setHeader("Content-Type", "image/png");
+ return res.send(imageBuffer);
+}
+
+async function loadAndScreenshotImage(libraryUrl) {
+ const assetImagePageUrl = new URL(ASSET_IMAGE_PAGE_BASE_URL);
+ assetImagePageUrl.search = new URLSearchParams({ libraryUrl }).toString();
+
+ console.debug("Opening browser page");
+ const browser = await getBrowser();
+ const page = await browser.newPage();
+ console.debug("Page opened, navigating to: " + assetImagePageUrl.toString());
+
+ await page.goto(assetImagePageUrl.toString());
+ console.debug("Page loaded, awaiting image");
+
+ // Start looking for the loaded canvas, *and* for an error message.
+ // When either one displays, we proceed, either by returning the image if
+ // present, or raising the error if present.
+ const imageBufferPromise = screenshotImageFromPage(page);
+ const errorMessagePromise = readErrorMessageFromPage(page);
+ const firstResultFromPage = await Promise.any([
+ imageBufferPromise.then((imageBuffer) => ({ imageBuffer })),
+ errorMessagePromise.then((errorMessage) => ({ errorMessage })),
+ ]);
+
+ if (firstResultFromPage.errorMessage) {
+ throw new Error(firstResultFromPage.errorMessage);
+ } else if (firstResultFromPage.imageBuffer) {
+ return firstResultFromPage.imageBuffer;
+ } else {
+ throw new Error(
+ `Assertion error: Promise.any did not return an errorMessage or an imageBuffer: ` +
+ `${JSON.stringify(Object.keys(firstResultFromPage))}`
+ );
+ }
+}
+
+async function screenshotImageFromPage(page) {
+ await page.waitForSelector("#asset-image-canvas[data-is-loaded=true]", {
+ timeout: 10000,
+ });
+ const canvas = await page.$("#asset-image-canvas[data-is-loaded=true]");
+ console.debug("Image loaded, taking screenshot");
+
+ const imageBuffer = await canvas.screenshot({
+ omitBackground: true,
+ });
+ console.debug(`Screenshot captured, size: ${imageBuffer.length}`);
+
+ return imageBuffer;
+}
+
+async function readErrorMessageFromPage(page) {
+ await page.waitForSelector("#asset-image-error-message", {
+ timeout: 10000,
+ });
+ const errorMessageContainer = await page.$("#asset-image-error-message");
+ const errorMessage = await errorMessageContainer.innerText();
+ return errorMessage;
+}
+
+function isNeopetsUrl(urlString) {
+ let url;
+ try {
+ url = new URL(urlString);
+ } catch (e) {
+ return false;
+ }
+
+ return url.origin === "https://images.neopets.com";
+}
+
+function reject(res, message, status = 400) {
+ res.setHeader("Content-Type", "text/plain");
+ return res.status(status).send(message);
+}
+
+async function handleWithBeeline(req, res) {
+ beeline.withTrace(
+ { name: "api/assetImage", operation_name: "api/assetImage" },
+ () => handle(req, res)
+ );
+}
+
+export default handleWithBeeline;
diff --git a/src/app/InternalAssetImagePage.js b/src/app/InternalAssetImagePage.js
index 8eaf5ae..2d3aa1b 100644
--- a/src/app/InternalAssetImagePage.js
+++ b/src/app/InternalAssetImagePage.js
@@ -2,6 +2,8 @@ import React from "react";
import { Box, Center } from "@chakra-ui/react";
import { useLocation } from "react-router-dom";
import * as Sentry from "@sentry/react";
+import { Global, css } from "@emotion/react";
+
import OutfitMovieLayer from "./components/OutfitMovieLayer";
/**
@@ -20,6 +22,15 @@ function InternalAssetImagePage() {
>
+
);
}
diff --git a/src/app/components/OutfitMovieLayer.js b/src/app/components/OutfitMovieLayer.js
index 7c3bd91..720e9ad 100644
--- a/src/app/components/OutfitMovieLayer.js
+++ b/src/app/components/OutfitMovieLayer.js
@@ -285,7 +285,7 @@ function loadScriptTag(src) {
};
script.onerror = (e) => {
if (canceled) return;
- reject(e);
+ reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
};
script.src = src;
document.body.appendChild(script);