Add /api/assetImageRedirect

Okay, this is gonna be a drop-in new backend for impress-asset-images.openneo.net, to enable Classic DTI to use the same images as DTI 2020!

This will enable us to stop generating images and uploading them to S3 just for Classic's sake, so we can turn those background processes off! And the new modeling script skips that anyway, so this is an important compatibility step for the new data that went out today!
This commit is contained in:
Emi Matchu 2022-10-11 12:21:14 -07:00
parent 29d9d498bf
commit a1844f76e0
3 changed files with 274 additions and 47 deletions

View file

@ -10,6 +10,11 @@ module.exports = {
source: "/outfits/:id/v/:updatedAt/:size(150|300|600).png",
destination: "/api/outfitImage?size=:size&id=:id&updatedAt=:updatedAt",
},
{
source: "/asset-images/:type/:x1/:x2/:x3/:remoteId/:idealSize.png",
destination:
"/api/assetImageRedirect?idealSize=:idealSize&type=:type&remoteId=:remoteId",
},
];
},
async redirects() {

View file

@ -0,0 +1,105 @@
/**
* /api/assetImageRedirect takes an asset type, Neopets ID, and image size,
* and redirects to a corresponding image URL.
*
* Parameters:
* - type: "biology" or "object"
* - remoteId: The Neopets ID of the asset
* - idealSize: "600x600", "300x300", or "150x150"
*
* This is designed to be a new backend for impress-asset-images.openneo.net,
* which has URLs like: http://impress-asset-images.openneo.net/biology/000/000/000/596/600x600.png?1326426317
* (That said, we still need some of the AWS images, which will still be
* accessible at aws.impress-asset-images.openneo.net.)
*
* Note that this endpoint doesn't always respect the `idealSize` parameter very
* closely; when our best canonical image is on images.neopets.com, it's
* usually 600x600, and I don't think it's worth the negligible network savings
* on Classic DTI to do resizing work here (and add another cache layer vs just
* serving from the original CDN that's much more likely to be a cache hit!).
*/
const beeline = require("honeycomb-beeline")({
writeKey: process.env["HONEYCOMB_WRITE_KEY"],
dataset:
process.env["NODE_ENV"] === "production"
? "Dress to Impress (2020)"
: "Dress to Impress (2020, dev)",
serviceName: "impress-2020-gql-server",
});
import { gql, loadGraphqlQuery } from "../../src/server/ssr-graphql";
async function handle(req, res) {
if (!["biology", "object"].includes(req.query.type)) {
res.setHeader("Content-Type", "text/plain");
res.status(400).end(`type must be "biology" or "object"`);
return;
}
if (!["600x600", "300x300", "150x150"].includes(req.query.idealSize)) {
res.setHeader("Content-Type", "text/plain");
res.status(400).end(`idealSize must be 600x600, 300x300, or 150x150`);
return;
}
const { data, errors } = await loadGraphqlQuery({
query: gql`
query ApiAssetImageRedirect_GetImageUrl(
$type: LayerType!
$remoteId: ID!
$idealSize: LayerImageSize!
) {
appearanceLayerByRemoteId(type: $type, remoteId: $remoteId) {
imageUrlV2(idealSize: $idealSize)
}
}
`,
variables: {
type: req.query.type === "biology" ? "PET_LAYER" : "ITEM_LAYER",
remoteId: req.query.remoteId,
idealSize: "SIZE_" + parseInt(req.query.idealSize),
},
});
if (errors) {
console.error("Error loading image URL from GraphQL:");
for (const error of errors) {
console.error(error);
}
res.setHeader("Content-Type", "text/plain");
res.status(500).end(`Error loading image URL from GraphQL`);
return;
}
const layer = data.appearanceLayerByRemoteId;
if (layer == null) {
res.setHeader("Content-Type", "text/plain");
res.status(404).end(`appearance layer not found`);
return;
}
const imageUrl = layer.imageUrlV2;
if (imageUrl == null) {
res.setHeader("Content-Type", "text/plain");
res.status(404).end(`appearance layer has no image available`);
return;
}
// Cache for 5 minutes, and immediately serve stale data for an hour.
// I don't expect asset image URLs to change often, but when they do, it'll
// probably be important! And this is a pretty fast operation tbh.
res.setHeader(
"Cache-Control",
"public, max-age=300, stale-while-revalidate=3600"
);
res.setHeader("Content-Type", "image/png");
return res.redirect(imageUrl);
}
async function handleWithBeeline(req, res) {
beeline.withTrace(
{
name: "api/assetImageRedirect",
operation_name: "api/assetImageRedirect",
},
() => handle(req, res)
);
}
export default handleWithBeeline;

View file

@ -37,9 +37,28 @@ const typeDefs = gql`
This might not be available at all, if there's no official PNG, and also
DTI Classic is still converting or failed to convert it from SWF.
DEPRECATED: See imageUrlV2 instead!
"""
imageUrl(size: LayerImageSize): String
"""
This layer as a single PNG, if available.
This will sometimes be a Neopets.com URL, if there's an official PNG at the
requested size.
This might not be available at all, if there's no official PNG, and also
DTI Classic is still converting or failed to convert it from SWF.
The idealSize parameter is a hint for what size image would be best to
return (e.g. if you only need 150x150, requesting that size can make image
loading faster). Note that the endpoint might return an image that's not of
the correct size, and callers should be prepared to handle that and resize
if necessary!
"""
imageUrlV2(idealSize: LayerImageSize): String
"""
This layer as a single SVG, if available.
@ -85,70 +104,93 @@ const typeDefs = gql`
}
enum AppearanceLayerKnownGlitch {
# This glitch means that the official SWF art for this layer is known to
# contain a glitch. (It probably also affects the PNG captured by Classic
# DTI, too.)
#
# In this case, there's no correct art we _can_ show until it's converted
# to HTML5. We'll show a message explaining the situation, and automatically
# change it to be more hesitant after HTML5 conversion, because we don't
# know in advance whether the layer will be fixed during conversion.
"""
This glitch means that the official SWF art for this layer is known to
contain a glitch. (It probably also affects the PNG captured by Classic
DTI, too.)
In this case, there's no correct art we _can_ show until it's converted
to HTML5. We'll show a message explaining the situation, and automatically
change it to be more hesitant after HTML5 conversion, because we don't
know in advance whether the layer will be fixed during conversion.
"""
OFFICIAL_SWF_IS_INCORRECT
# This glitch means that, while the official manifest declares an SVG
# version of this layer, it is incorrect and does not visually match the
# PNG version that the official pet editor users.
#
# For affected layers, svgUrl will be null, regardless of the manifest.
"""
This glitch means that, while the official manifest declares an SVG
version of this layer, it is incorrect and does not visually match the
PNG version that the official pet editor users.
For affected layers, svgUrl will be null, regardless of the manifest.
"""
OFFICIAL_SVG_IS_INCORRECT
# This glitch means that the official movie JS library (or supporting data)
# for this layer is known to contain a glitch.
#
# In this case, we _could_ fall back to the PNG, but we choose not to: it
# could mislead people about how the item will appear on-site. We like our
# previews to match the real on-site appearance whenever possible! Instead,
# we show a message, asking users to send us info if they know it to be
# fixed on-site. (This could happen by our manifest getting out of date, or
# TNT replacing it with a new asset that needs re-modeling.)
"""
This glitch means that the official movie JS library (or supporting data)
for this layer is known to contain a glitch.
In this case, we _could_ fall back to the PNG, but we choose not to: it
could mislead people about how the item will appear on-site. We like our
previews to match the real on-site appearance whenever possible! Instead,
we show a message, asking users to send us info if they know it to be
fixed on-site. (This could happen by our manifest getting out of date, or
TNT replacing it with a new asset that needs re-modeling.)
"""
OFFICIAL_MOVIE_IS_INCORRECT
# This glitch means that we know the layer doesn't display correctly on
# DTI, but we're not sure why, or whether it works differently on-site. We
# show a vague apologetic message, asking users to send us info.
"""
This glitch means that we know the layer doesn't display correctly on
DTI, but we're not sure why, or whether it works differently on-site. We
show a vague apologetic message, asking users to send us info.
"""
DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN
# This glitch means that the official body ID for this asset is not correct
# (usually 0), so it will fit some pets that it shouldn't. We reflect this
# accurately on DTI, with a message to explain that it's not our error, and
# as a warning that this might not work if TNT changes it later.
"""
This glitch means that the official body ID for this asset is not correct
(usually 0), so it will fit some pets that it shouldn't. We reflect this
accurately on DTI, with a message to explain that it's not our error, and
as a warning that this might not work if TNT changes it later.
"""
OFFICIAL_BODY_ID_IS_INCORRECT
# This glitch is a hack for a bug in DTI: some items, like "Living in
# Watermelon Foreground and Background", have a background layer that's
# shared across all bodies - but it should NOT fit pets that don't have a
# corresponding body-specific foreground!
#
# The long-term fix here is to refactor our data to not use bodyId=0, and
# instead have a more robust concept of item appearance across bodies.
"""
This glitch is a hack for a bug in DTI: some items, like "Living in
Watermelon Foreground and Background", have a background layer that's
shared across all bodies - but it should NOT fit pets that don't have a
corresponding body-specific foreground!
The long-term fix here is to refactor our data to not use bodyId=0, and
instead have a more robust concept of item appearance across bodies.
"""
REQUIRES_OTHER_BODY_SPECIFIC_ASSETS
}
extend type Query {
# Return the item appearance layers with the given remoteIds. We use this
# in Support tool to bulk-add a range of layers to an item. When we can't
# find a layer with the given ID, we omit its entry from the returned list.
"""
Return the item appearance layers with the given remoteIds. We use this
in Support tool to bulk-add a range of layers to an item. When we can't
find a layer with the given ID, we omit its entry from the returned list.
"""
itemAppearanceLayersByRemoteId(remoteIds: [ID!]!): [AppearanceLayer]!
# Return the number of layers that have been converted to HTML5, optionally
# filtered by type. Cache for 30 minutes (we re-sync with Neopets every
# hour).
"""
Return the number of layers that have been converted to HTML5, optionally
filtered by type. Cache for 30 minutes (we re-sync with Neopets every
hour).
"""
numAppearanceLayersConverted(type: LayerType): Int!
@cacheControl(maxAge: 1800)
# Return the total number of layers, optionally filtered by type. Cache for
# 30 minutes (we re-sync with Neopets every hour).
"""
Return the total number of layers, optionally filtered by type. Cache for
30 minutes (we re-sync with Neopets every hour).
"""
numAppearanceLayersTotal(type: LayerType): Int! @cacheControl(maxAge: 1800)
"""
Return the appearance layer with the given type and remoteId, if any.
"""
appearanceLayerByRemoteId(type: LayerType, remoteId: ID!): AppearanceLayer
}
`;
@ -187,9 +229,6 @@ const resolvers = {
} = await loadAndCacheAssetDataFromManifest(db, layer);
// For the largest size, try to use the official Neopets PNG!
// TODO: Offer an API endpoint to resize the official Neopets PNG maybe?
// That'll be an important final step before turning off the
// Classic DTI image converters.
if (size === "SIZE_600") {
// If there's an official single-image PNG we can use, use it! This is
// what the official /customise editor uses at time of writing.
@ -238,6 +277,70 @@ const resolvers = {
`/${rid1}/${rid2}/${rid3}/${rid}/${sizeNum}x${sizeNum}.png?v2-${time}`
);
},
imageUrlV2: async ({ id }, { idealSize }, { swfAssetLoader, db }) => {
const layer = await swfAssetLoader.load(id);
const {
format,
jsAssetUrl,
pngAssetUrl,
} = await loadAndCacheAssetDataFromManifest(db, layer);
// If there's an official single-image PNG we can use, use it! This is
// what the official /customise editor uses at time of writing.
//
// NOTE: This ignores `idealSize`, and it's the case that you're most
// likely to actually trigger! This because we don't think logic to
// resize the image server-side is actually likely to be net better
// for performance in most cases, so we're not gonna build that
// until the need is demonstrated ^_^;
if (format === "lod" && !jsAssetUrl && pngAssetUrl) {
return pngAssetUrl.toString();
}
// Or, if this is a movie, we can generate the PNG ourselves.
if (format === "lod" && jsAssetUrl) {
const httpsJsAssetUrl = jsAssetUrl
.toString()
.replace(/^http:\/\//, "https://");
const sizeNum = idealSize.split("_")[1];
return (
`https://impress-2020.openneo.net/api/assetImage` +
`?libraryUrl=${encodeURIComponent(httpsJsAssetUrl)}&size=${sizeNum}`
);
}
// Otherwise, fall back to the Classic DTI image storage, which is
// generated from the SWFs (or sometimes manually overridden). It's less
// accurate, but well-tested to generally work okay, and it's the only
// image we have for assets not yet converted to HTML5.
//
// NOTE: We've stopped generating these images for new assets! This is
// mainly for old assets not yet converted to HTML5.
// If there's no image, return null. (In the development db, which isn't
// aware which assets we have images for on the DTI CDN, assume we _do_
// have the image - it's usually true, and better for testing.)
const hasImage =
layer.hasImage || process.env["DB_ENV"] === "development";
if (!hasImage) {
return null;
}
const sizeNum = idealSize.split("_")[1];
const rid = layer.remoteId;
const paddedId = rid.padStart(12, "0");
const rid1 = paddedId.slice(0, 3);
const rid2 = paddedId.slice(3, 6);
const rid3 = paddedId.slice(6, 9);
const time = Number(new Date(layer.convertedAt));
return (
`https://aws.impress-asset-images.openneo.net/${layer.type}` +
`/${rid1}/${rid2}/${rid3}/${rid}/${sizeNum}x${sizeNum}.png?v2-${time}`
);
},
svgUrl: async ({ id }, _, { db, swfAssetLoader }) => {
const layer = await swfAssetLoader.load(id);
@ -355,6 +458,20 @@ const resolvers = {
});
return count;
},
appearanceLayerByRemoteId: async (
_,
{ type, remoteId },
{ swfAssetByRemoteIdLoader }
) => {
const swfAsset = await swfAssetByRemoteIdLoader.load({
type: type === "PET_LAYER" ? "biology" : "object",
remoteId,
});
if (swfAsset == null) {
return null;
}
return { id: swfAsset.id };
},
},
};