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:
parent
29d9d498bf
commit
a1844f76e0
3 changed files with 274 additions and 47 deletions
|
@ -10,6 +10,11 @@ module.exports = {
|
||||||
source: "/outfits/:id/v/:updatedAt/:size(150|300|600).png",
|
source: "/outfits/:id/v/:updatedAt/:size(150|300|600).png",
|
||||||
destination: "/api/outfitImage?size=:size&id=:id&updatedAt=:updatedAt",
|
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() {
|
async redirects() {
|
||||||
|
|
105
pages/api/assetImageRedirect.js
Normal file
105
pages/api/assetImageRedirect.js
Normal 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;
|
|
@ -37,9 +37,28 @@ const typeDefs = gql`
|
||||||
|
|
||||||
This might not be available at all, if there's no official PNG, and also
|
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.
|
DTI Classic is still converting or failed to convert it from SWF.
|
||||||
|
|
||||||
|
DEPRECATED: See imageUrlV2 instead!
|
||||||
"""
|
"""
|
||||||
imageUrl(size: LayerImageSize): String
|
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.
|
This layer as a single SVG, if available.
|
||||||
|
|
||||||
|
@ -85,70 +104,93 @@ const typeDefs = gql`
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AppearanceLayerKnownGlitch {
|
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
|
This glitch means that the official SWF art for this layer is known to
|
||||||
# DTI, too.)
|
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
|
In this case, there's no correct art we _can_ show until it's converted
|
||||||
# change it to be more hesitant after HTML5 conversion, because we don't
|
to HTML5. We'll show a message explaining the situation, and automatically
|
||||||
# know in advance whether the layer will be fixed during conversion.
|
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
|
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
|
This glitch means that, while the official manifest declares an SVG
|
||||||
# PNG version that the official pet editor users.
|
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.
|
|
||||||
|
For affected layers, svgUrl will be null, regardless of the manifest.
|
||||||
|
"""
|
||||||
OFFICIAL_SVG_IS_INCORRECT
|
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.
|
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
|
In this case, we _could_ fall back to the PNG, but we choose not to: it
|
||||||
# previews to match the real on-site appearance whenever possible! Instead,
|
could mislead people about how the item will appear on-site. We like our
|
||||||
# we show a message, asking users to send us info if they know it to be
|
previews to match the real on-site appearance whenever possible! Instead,
|
||||||
# fixed on-site. (This could happen by our manifest getting out of date, or
|
we show a message, asking users to send us info if they know it to be
|
||||||
# TNT replacing it with a new asset that needs re-modeling.)
|
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
|
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
|
This glitch means that we know the layer doesn't display correctly on
|
||||||
# show a vague apologetic message, asking users to send us info.
|
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
|
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
|
This glitch means that the official body ID for this asset is not correct
|
||||||
# accurately on DTI, with a message to explain that it's not our error, and
|
(usually 0), so it will fit some pets that it shouldn't. We reflect this
|
||||||
# as a warning that this might not work if TNT changes it later.
|
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
|
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
|
This glitch is a hack for a bug in DTI: some items, like "Living in
|
||||||
# shared across all bodies - but it should NOT fit pets that don't have a
|
Watermelon Foreground and Background", have a background layer that's
|
||||||
# corresponding body-specific foreground!
|
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.
|
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
|
REQUIRES_OTHER_BODY_SPECIFIC_ASSETS
|
||||||
}
|
}
|
||||||
|
|
||||||
extend type Query {
|
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
|
Return the item appearance layers with the given remoteIds. We use this
|
||||||
# find a layer with the given ID, we omit its entry from the returned list.
|
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]!
|
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
|
Return the number of layers that have been converted to HTML5, optionally
|
||||||
# hour).
|
filtered by type. Cache for 30 minutes (we re-sync with Neopets every
|
||||||
|
hour).
|
||||||
|
"""
|
||||||
numAppearanceLayersConverted(type: LayerType): Int!
|
numAppearanceLayersConverted(type: LayerType): Int!
|
||||||
@cacheControl(maxAge: 1800)
|
@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)
|
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);
|
} = await loadAndCacheAssetDataFromManifest(db, layer);
|
||||||
|
|
||||||
// For the largest size, try to use the official Neopets PNG!
|
// 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 (size === "SIZE_600") {
|
||||||
// If there's an official single-image PNG we can use, use it! This is
|
// 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.
|
// 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}`
|
`/${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 }) => {
|
svgUrl: async ({ id }, _, { db, swfAssetLoader }) => {
|
||||||
const layer = await swfAssetLoader.load(id);
|
const layer = await swfAssetLoader.load(id);
|
||||||
|
|
||||||
|
@ -355,6 +458,20 @@ const resolvers = {
|
||||||
});
|
});
|
||||||
return count;
|
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 };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue