Generate outfit images by ID alone
Not using this anywhere in-app yet! But might swap it into the user outfits page, and use it to server-side-render social sharing meta tags! Also eyeing this as a way to replace our nearly 1TB of outfit image S3 storage, and save $20/mo…
This commit is contained in:
parent
8f495d8302
commit
9722addd3f
2 changed files with 129 additions and 14 deletions
|
@ -1,3 +1,26 @@
|
||||||
|
/**
|
||||||
|
* /api/outfitImage returns an image of an outfit!
|
||||||
|
*
|
||||||
|
* Parameters:
|
||||||
|
* - size: Must be 150 or 300, to indicate the image size you'd like back.
|
||||||
|
* - layerUrls: A comma-separated list of URLs to render, in order from
|
||||||
|
* bottom to top. This is a sorta "independent" render mode,
|
||||||
|
* not bound to any saved outfit. The URLs must match a known
|
||||||
|
* layer URL format.
|
||||||
|
* - id: Instead of `layerUrls`, you can instead provide an outfit ID, which
|
||||||
|
* will load the outfit data and render it directly.
|
||||||
|
* - updatedAt: If you provide an `id`, you must also provide `updatedAt`:
|
||||||
|
* the UNIX timestamp for when the outfit was last updated. This
|
||||||
|
* has no effect on output, but is very important for caching:
|
||||||
|
* we always return a long-term cache header, so our CDN cache
|
||||||
|
* will likely cache the requested URL forever. That way, outfit
|
||||||
|
* images will cache long-term, unless they're updated and the
|
||||||
|
* user requests a new URL. (This _does_ mean this API can no
|
||||||
|
* longer be used for simple embeds in e.g. petpages that
|
||||||
|
* auto-update to the latest version of the image… but I don't
|
||||||
|
* actually know if anyone does that? If we need a
|
||||||
|
* latest-version API, we can build that as a separate case.)
|
||||||
|
*/
|
||||||
const beeline = require("honeycomb-beeline")({
|
const beeline = require("honeycomb-beeline")({
|
||||||
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
|
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
|
||||||
dataset:
|
dataset:
|
||||||
|
@ -7,7 +30,15 @@ const beeline = require("honeycomb-beeline")({
|
||||||
serviceName: "impress-2020-gql-server",
|
serviceName: "impress-2020-gql-server",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { renderOutfitImage } = require("../src/server/outfit-images");
|
import fetch from "node-fetch";
|
||||||
|
import gql from "graphql-tag";
|
||||||
|
import { print as graphqlPrint } from "graphql/language/printer";
|
||||||
|
|
||||||
|
import { renderOutfitImage } from "../src/server/outfit-images";
|
||||||
|
import getVisibleLayers, {
|
||||||
|
petAppearanceFragmentForGetVisibleLayers,
|
||||||
|
itemAppearanceFragmentForGetVisibleLayers,
|
||||||
|
} from "../src/shared/getVisibleLayers";
|
||||||
|
|
||||||
const VALID_LAYER_URLS = [
|
const VALID_LAYER_URLS = [
|
||||||
/^https:\/\/(impress-asset-images\.openneo\.net|impress-asset-images\.s3\.amazonaws\.com)\/(biology|object)\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[0-9]+\/(150|300)x(150|300)\.png(\?[a-zA-Z0-9_-]+)?$/,
|
/^https:\/\/(impress-asset-images\.openneo\.net|impress-asset-images\.s3\.amazonaws\.com)\/(biology|object)\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[0-9]+\/(150|300)x(150|300)\.png(\?[a-zA-Z0-9_-]+)?$/,
|
||||||
|
@ -15,21 +46,40 @@ const VALID_LAYER_URLS = [
|
||||||
];
|
];
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
if (!req.query.layerUrls) {
|
|
||||||
res.setHeader("Content-Type", "text/plain");
|
|
||||||
return res.status(400).send(`Missing required parameter: layerUrls`);
|
|
||||||
}
|
|
||||||
const layerUrls = req.query.layerUrls.split(",");
|
|
||||||
|
|
||||||
const size = parseInt(req.query.size);
|
const size = parseInt(req.query.size);
|
||||||
if (size !== 150 && size !== 300) {
|
if (size !== 150 && size !== 300) {
|
||||||
res.setHeader("Content-Type", "text/plain");
|
return reject(res, `Size must be 150 or 300`);
|
||||||
return res.status(400).send(`Size must be 150 or 300`);
|
}
|
||||||
|
|
||||||
|
let layerUrls;
|
||||||
|
if (req.query.layerUrls) {
|
||||||
|
layerUrls = req.query.layerUrls.split(",");
|
||||||
|
} else if (req.query.id) {
|
||||||
|
if (!req.query.updatedAt) {
|
||||||
|
return reject(
|
||||||
|
res,
|
||||||
|
`updatedAt parameter is required, when id parameter is provided`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outfitId = req.query.id;
|
||||||
|
try {
|
||||||
|
layerUrls = await loadLayerUrlsForSavedOutfit(outfitId, size);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return reject(
|
||||||
|
res,
|
||||||
|
`Error loading data for outfit ${outfitId}: ${e.message}`,
|
||||||
|
500
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return reject(res, `Missing required parameter: layerUrls`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const layerUrl of layerUrls) {
|
for (const layerUrl of layerUrls) {
|
||||||
if (!VALID_LAYER_URLS.some((pattern) => layerUrl.match(pattern))) {
|
if (!VALID_LAYER_URLS.some((pattern) => layerUrl.match(pattern))) {
|
||||||
return res.status(400).send(`Unexpected layer URL format: ${layerUrl}`);
|
return reject(res, `Unexpected layer URL format: ${layerUrl}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,8 +88,7 @@ async function handle(req, res) {
|
||||||
imageResult = await renderOutfitImage(layerUrls, size);
|
imageResult = await renderOutfitImage(layerUrls, size);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
res.setHeader("Content-Type", "text/plain");
|
return reject(res, `Error rendering image: ${e.message}`);
|
||||||
return res.status(400).send(`Error rendering image: ${e.message}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { image, status } = imageResult;
|
const { image, status } = imageResult;
|
||||||
|
@ -49,6 +98,8 @@ async function handle(req, res) {
|
||||||
// layers are ~immutable too, and that our rendering algorithm will almost
|
// layers are ~immutable too, and that our rendering algorithm will almost
|
||||||
// never change in a way that requires pushing changes. If it does, we
|
// never change in a way that requires pushing changes. If it does, we
|
||||||
// should add a cache-buster to the URL!
|
// should add a cache-buster to the URL!
|
||||||
|
//
|
||||||
|
// TODO: Maybe verify that there's a timestamp param in the ?id case?
|
||||||
res.setHeader("Cache-Control", "public, max-age=604800, immutable");
|
res.setHeader("Cache-Control", "public, max-age=604800, immutable");
|
||||||
res.status(200);
|
res.status(200);
|
||||||
} else {
|
} else {
|
||||||
|
@ -66,6 +117,66 @@ async function handle(req, res) {
|
||||||
return res.send(image);
|
return res.send(image);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GRAPHQL_ENDPOINT =
|
||||||
|
process.env.NODE_ENV === "development"
|
||||||
|
? "http://localhost:3000/api/graphql"
|
||||||
|
: "https://impress-2020.openneo.net/api/graphql";
|
||||||
|
|
||||||
|
// NOTE: Unlike in-app views, we only load PNGs here. We expect this to
|
||||||
|
// generally perform better, and be pretty reliable now that TNT is
|
||||||
|
// generating canonical PNGs for every layer!
|
||||||
|
const GRAPHQL_QUERY = gql`
|
||||||
|
query ApiOutfitImage($outfitId: ID!, $size: LayerImageSize) {
|
||||||
|
outfit(id: $outfitId) {
|
||||||
|
petAppearance {
|
||||||
|
layers {
|
||||||
|
imageUrl(size: $size)
|
||||||
|
}
|
||||||
|
...PetAppearanceForGetVisibleLayers
|
||||||
|
}
|
||||||
|
itemAppearances {
|
||||||
|
layers {
|
||||||
|
imageUrl(size: $size)
|
||||||
|
}
|
||||||
|
...ItemAppearanceForGetVisibleLayers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${petAppearanceFragmentForGetVisibleLayers}
|
||||||
|
${itemAppearanceFragmentForGetVisibleLayers}
|
||||||
|
`;
|
||||||
|
const GRAPHQL_QUERY_STRING = graphqlPrint(GRAPHQL_QUERY);
|
||||||
|
|
||||||
|
async function loadLayerUrlsForSavedOutfit(outfitId, size) {
|
||||||
|
const { errors, data } = await fetch(GRAPHQL_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: GRAPHQL_QUERY_STRING,
|
||||||
|
variables: { outfitId, size: `SIZE_${size}` },
|
||||||
|
}),
|
||||||
|
}).then((res) => res.json());
|
||||||
|
|
||||||
|
if (errors && errors.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`GraphQL Error: ${errors.map((e) => e.message).join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { petAppearance, itemAppearances } = data.outfit;
|
||||||
|
const visibleLayers = getVisibleLayers(petAppearance, itemAppearances);
|
||||||
|
return visibleLayers
|
||||||
|
.sort((a, b) => a.depth - b.depth)
|
||||||
|
.map((layer) => layer.imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(res, message, status = 400) {
|
||||||
|
res.setHeader("Content-Type", "text/plain");
|
||||||
|
return res.status(status).send(message);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleWithBeeline(req, res) {
|
async function handleWithBeeline(req, res) {
|
||||||
beeline.withTrace(
|
beeline.withTrace(
|
||||||
{ name: "api/outfitImage", operation_name: "api/outfitImage" },
|
{ name: "api/outfitImage", operation_name: "api/outfitImage" },
|
||||||
|
|
|
@ -92,6 +92,8 @@ function getVisibleLayers(petAppearance, itemAppearances) {
|
||||||
return visibleLayers;
|
return visibleLayers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: The web client could save bandwidth by applying @client to the `depth`
|
||||||
|
// field, because it already has zone depths cached.
|
||||||
export const itemAppearanceFragmentForGetVisibleLayers = gql`
|
export const itemAppearanceFragmentForGetVisibleLayers = gql`
|
||||||
fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
|
fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
|
||||||
id
|
id
|
||||||
|
@ -99,7 +101,7 @@ export const itemAppearanceFragmentForGetVisibleLayers = gql`
|
||||||
id
|
id
|
||||||
zone {
|
zone {
|
||||||
id
|
id
|
||||||
depth @client
|
depth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
restrictedZones {
|
restrictedZones {
|
||||||
|
@ -108,6 +110,8 @@ export const itemAppearanceFragmentForGetVisibleLayers = gql`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
// TODO: The web client could save bandwidth by applying @client to the `depth`
|
||||||
|
// field, because it already has zone depths cached.
|
||||||
export const petAppearanceFragmentForGetVisibleLayers = gql`
|
export const petAppearanceFragmentForGetVisibleLayers = gql`
|
||||||
fragment PetAppearanceForGetVisibleLayers on PetAppearance {
|
fragment PetAppearanceForGetVisibleLayers on PetAppearance {
|
||||||
id
|
id
|
||||||
|
@ -116,7 +120,7 @@ export const petAppearanceFragmentForGetVisibleLayers = gql`
|
||||||
id
|
id
|
||||||
zone {
|
zone {
|
||||||
id
|
id
|
||||||
depth @client
|
depth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
restrictedZones {
|
restrictedZones {
|
||||||
|
|
Loading…
Reference in a new issue