outfit-images: render actual PNG and SVG layers!
This commit is contained in:
parent
d10fe5f68f
commit
e0af75d927
7 changed files with 122 additions and 13 deletions
|
@ -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) => {
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
After Width: | Height: | Size: 203 KiB |
Binary file not shown.
After Width: | Height: | Size: 120 KiB |
|
@ -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 };
|
||||
|
|
|
@ -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)`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue