outfit-images: render actual PNG and SVG layers!

This commit is contained in:
Emi Matchu 2021-01-04 05:58:19 +00:00
parent d10fe5f68f
commit e0af75d927
7 changed files with 122 additions and 13 deletions

View file

@ -9,9 +9,62 @@ const beeline = require("honeycomb-beeline")({
const { renderOutfitImage } = require("../src/server/outfit-images"); 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) { async function handle(req, res) {
const image = await renderOutfitImage(); if (!req.query.layerUrls) {
return res.status(200).setHeader("Content-Type", "image/png").send(image); 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) => { export default async (req, res) => {

View file

@ -1,17 +1,30 @@
const path = require("path");
const { createCanvas, loadImage } = require("canvas"); const { createCanvas, loadImage } = require("canvas");
async function renderOutfitImage(layerRefs) { async function renderOutfitImage(layerRefs, size) {
const canvas = createCanvas(90, 90); const canvas = createCanvas(size, size);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const image = await loadImage( const images = await Promise.all(layerRefs.map(loadImageAndSkipOnFailure));
path.join(__dirname, "../app/images/feedback-xwee.png") const loadedImages = images.filter((image) => image);
); for (const image of loadedImages) {
ctx.drawImage(image, 0, 0, 90, 90); 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 }; module.exports = { renderOutfitImage };

View file

@ -3,9 +3,52 @@ const { renderOutfitImage } = require("./outfit-images");
const { toMatchImageSnapshot } = require("jest-image-snapshot"); const { toMatchImageSnapshot } = require("jest-image-snapshot");
expect.extend({ toMatchImageSnapshot }); expect.extend({ toMatchImageSnapshot });
const originalConsoleWarn = console.warn;
describe("renderOutfitImage", () => { describe("renderOutfitImage", () => {
it("renders a test xwee", async () => { beforeEach(() => {
const image = await renderOutfitImage(); 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(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)`
);
}); });
}); });