diff --git a/api/outfitImage.js b/api/outfitImage.js index a21d80b..3938c5d 100644 --- a/api/outfitImage.js +++ b/api/outfitImage.js @@ -9,9 +9,62 @@ const beeline = require("honeycomb-beeline")({ const { renderOutfitImage } = require("../src/server/outfit-images"); +const VALID_LAYER_URLS = [ + /^https:\/\/impress-asset-images\.s3\.amazonaws\.com\/(biology|object)\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[0-9]+\/150x150\.png$/, + /^http:\/\/images\.neopets\.com\/cp\/(biology|object)\/data\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[a-zA-Z0-9_]+\/[a-zA-Z0-9_]+\.svg$/, +]; + async function handle(req, res) { - const image = await renderOutfitImage(); - return res.status(200).setHeader("Content-Type", "image/png").send(image); + if (!req.query.layerUrls) { + return res + .status(400) + .setHeader("Content-Type", "text/plain") + .send(`Missing required parameter: layerUrls`); + } + + const layerUrls = req.query.layerUrls.split(","); + + for (const layerUrl of layerUrls) { + if (!VALID_LAYER_URLS.some((pattern) => layerUrl.match(pattern))) { + return res + .status(400) + .setHeader("Content-Type", "text/plain") + .send(`Unexpected layer URL format: ${layerUrl}`); + } + } + + let imageResult; + try { + imageResult = await renderOutfitImage(layerUrls, 150); + } catch (e) { + console.error(e); + return res + .status(400) + .setHeader("Content-Type", "text/plain") + .send(`Error rendering image: ${e.message}`); + } + + const { image, status } = imageResult; + + if (status === "success") { + // On success, we use very aggressive caching, on the assumption that + // layers are ~immutable too, and that our rendering algorithm will almost + // never change in a way that requires pushing changes. If it does, we + // should add a cache-buster to the URL! + res + .status(200) + .setHeader("Cache-Control", "public, max-age=604800, immutable"); + } else { + // On partial failure, we still send the image, but with a 500 status. We + // send a long-lived cache header, but in such a way that the user can + // refresh the page to try again. (`private` means the CDN won't cache it, + // and we don't send `immutable`, which would save it even across reloads.) + // The 500 won't really affect the client, which will still show the image + // without feedback to the user - but it's a helpful debugging hint. + res.status(500).setHeader("Cache-Control", "private, max-age=604800"); + } + + return res.setHeader("Content-Type", "image/png").send(image); } export default async (req, res) => { diff --git a/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-a-test-xwee-1-snap.png b/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-a-test-xwee-1-snap.png deleted file mode 100644 index 1f0c376..0000000 Binary files a/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-a-test-xwee-1-snap.png and /dev/null differ diff --git a/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-the-moon-and-stars-background-and-green-leaf-string-lights-as-png-1-snap.png b/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-the-moon-and-stars-background-and-green-leaf-string-lights-as-png-1-snap.png new file mode 100644 index 0000000..1f94699 Binary files /dev/null and b/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-the-moon-and-stars-background-and-green-leaf-string-lights-as-png-1-snap.png differ diff --git a/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-the-moon-and-stars-background-and-green-leaf-string-lights-as-svg-1-snap.png b/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-the-moon-and-stars-background-and-green-leaf-string-lights-as-svg-1-snap.png new file mode 100644 index 0000000..ec01367 Binary files /dev/null and b/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-renders-the-moon-and-stars-background-and-green-leaf-string-lights-as-svg-1-snap.png differ diff --git a/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-skips-network-failures-and-logs-an-error-1-snap.png b/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-skips-network-failures-and-logs-an-error-1-snap.png new file mode 100644 index 0000000..1268bd8 Binary files /dev/null and b/src/server/__image_snapshots__/outfit-images-test-js-render-outfit-image-skips-network-failures-and-logs-an-error-1-snap.png differ diff --git a/src/server/outfit-images.js b/src/server/outfit-images.js index 0f699c8..6e84588 100644 --- a/src/server/outfit-images.js +++ b/src/server/outfit-images.js @@ -1,17 +1,30 @@ -const path = require("path"); - const { createCanvas, loadImage } = require("canvas"); -async function renderOutfitImage(layerRefs) { - const canvas = createCanvas(90, 90); +async function renderOutfitImage(layerRefs, size) { + const canvas = createCanvas(size, size); const ctx = canvas.getContext("2d"); - const image = await loadImage( - path.join(__dirname, "../app/images/feedback-xwee.png") - ); - ctx.drawImage(image, 0, 0, 90, 90); + const images = await Promise.all(layerRefs.map(loadImageAndSkipOnFailure)); + const loadedImages = images.filter((image) => image); + for (const image of loadedImages) { + ctx.drawImage(image, 0, 0, size, size); + } - return canvas.toBuffer(); + return { + image: canvas.toBuffer(), + status: + loadedImages.length === layerRefs.length ? "success" : "partial-failure", + }; +} + +async function loadImageAndSkipOnFailure(url) { + try { + const image = await loadImage(url); + return image; + } catch (e) { + console.warn(`Error loading layer, skipping: ${e.message}. (${url})`); + return null; + } } module.exports = { renderOutfitImage }; diff --git a/src/server/outfit-images.test.js b/src/server/outfit-images.test.js index 7c3ba57..cb782c2 100644 --- a/src/server/outfit-images.test.js +++ b/src/server/outfit-images.test.js @@ -3,9 +3,52 @@ const { renderOutfitImage } = require("./outfit-images"); const { toMatchImageSnapshot } = require("jest-image-snapshot"); expect.extend({ toMatchImageSnapshot }); +const originalConsoleWarn = console.warn; + describe("renderOutfitImage", () => { - it("renders a test xwee", async () => { - const image = await renderOutfitImage(); + beforeEach(() => { + console.warn = jest.fn(); + }); + + afterEach(() => { + console.warn = originalConsoleWarn; + }); + + it("renders the Moon and Stars Background and Green Leaf String Lights, as PNG", async () => { + const image = await renderOutfitImage( + [ + "https://impress-asset-images.s3.amazonaws.com/object/000/000/006/6829/600x600.png", + "https://impress-asset-images.s3.amazonaws.com/object/000/000/036/36414/600x600.png", + ], + 600 + ); expect(image).toMatchImageSnapshot(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("renders the Moon and Stars Background and Green Leaf String Lights, as SVG", async () => { + const image = await renderOutfitImage( + [ + "http://images.neopets.com/cp/items/data/000/000/006/6829_1707e50385/6829.svg", + "http://images.neopets.com/cp/items/data/000/000/036/36414_1e2aaab4ad/36414.svg", + ], + 600 + ); + expect(image).toMatchImageSnapshot(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it("skips network failures, and logs an error", async () => { + const image = await renderOutfitImage( + [ + "https://impress-asset-images.s3.amazonaws.com/object/000/000/006/6829/600x600.png", + "https://impress-asset-images.s3.amazonaws.com/object/000/000/000/00000000/600x600.png", // fake URL + ], + 600 + ); + expect(image).toMatchImageSnapshot(); + expect(console.warn).toHaveBeenCalledWith( + `Error loading layer, skipping: Server responded with 403. (https://impress-asset-images.s3.amazonaws.com/object/000/000/000/00000000/600x600.png)` + ); }); });