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 { 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) => {
|
||||||
|
|
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");
|
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 };
|
||||||
|
|
|
@ -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)`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue