diff --git a/next.config.js b/next.config.js index 5af2736..f7c64e8 100644 --- a/next.config.js +++ b/next.config.js @@ -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() { diff --git a/pages/api/assetImageRedirect.js b/pages/api/assetImageRedirect.js new file mode 100644 index 0000000..0af725b --- /dev/null +++ b/pages/api/assetImageRedirect.js @@ -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; diff --git a/src/server/types/AppearanceLayer.js b/src/server/types/AppearanceLayer.js index 7379018..2750a8c 100644 --- a/src/server/types/AppearanceLayer.js +++ b/src/server/types/AppearanceLayer.js @@ -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 }; + }, }, };