diff --git a/api/assetProxy.js b/api/assetProxy.js index dda53a8..1ab3b7f 100644 --- a/api/assetProxy.js +++ b/api/assetProxy.js @@ -6,7 +6,7 @@ const streamPipeline = util.promisify(stream.pipeline); const VALID_URL_PATTERNS = [ /^http:\/\/images\.neopets\.com\/items\/[a-zA-Z0-9_ -]+\.gif$/, - /^http:\/\/pets\.neopets\.com\/cp\/[a-z0-9]+\/[0-9]+\/[0-9]+\.png$/, + /^http:\/\/images\.neopets\.com\/cp\/(bio|items)\/data\/[0-9]{3}\/[0-9]{3}\/[0-9]{3}\/[a-f0-9_]+\/[0-9]+\.svg$/, ]; export default async (req, res) => { @@ -34,6 +34,13 @@ export default async (req, res) => { ); res.status(proxyRes.status); + + res.setHeader("Content-Length", proxyRes.headers.get("Content-Length")); + res.setHeader("Content-Type", proxyRes.headers.get("Content-Type")); + res.setHeader("Cache-Control", proxyRes.headers.get("Cache-Control")); + res.setHeader("ETag", proxyRes.headers.get("ETag")); + res.setHeader("Last-Modified", proxyRes.headers.get("Last-Modified")); + streamPipeline(proxyRes.body, res); }; diff --git a/src/app/OutfitPreview.js b/src/app/OutfitPreview.js index b3deead..0f17678 100644 --- a/src/app/OutfitPreview.js +++ b/src/app/OutfitPreview.js @@ -65,7 +65,7 @@ export function OutfitLayers({ loading, visibleLayers, doAnimations = false }) { > finishes preloading and // applies the src to the underlying . @@ -132,4 +132,12 @@ function FullScreenCenter({ children }) { ); } +function getBestImageUrlForLayer(layer) { + if (layer.svgUrl) { + return `/api/assetProxy?url=${encodeURIComponent(layer.svgUrl)}`; + } else { + return layer.imageUrl; + } +} + export default OutfitPreview; diff --git a/src/app/useOutfitAppearance.js b/src/app/useOutfitAppearance.js index 4b06567..d18283f 100644 --- a/src/app/useOutfitAppearance.js +++ b/src/app/useOutfitAppearance.js @@ -93,6 +93,7 @@ export const itemAppearanceFragment = gql` fragment ItemAppearanceForOutfitPreview on ItemAppearance { layers { id + svgUrl imageUrl(size: SIZE_600) zone { id @@ -111,6 +112,7 @@ export const petAppearanceFragment = gql` id layers { id + svgUrl imageUrl(size: SIZE_600) zone { id diff --git a/src/app/useOutfitState.js b/src/app/useOutfitState.js index bdd0c72..d20c79b 100644 --- a/src/app/useOutfitState.js +++ b/src/app/useOutfitState.js @@ -188,8 +188,8 @@ function parseOutfitUrl() { colorId: urlParams.get("color"), emotion: urlParams.get("emotion") || "HAPPY", genderPresentation: urlParams.get("genderPresentation") || "FEMININE", - wornItemIds: urlParams.getAll("objects[]"), - closetedItemIds: urlParams.getAll("closet[]"), + wornItemIds: new Set(urlParams.getAll("objects[]")), + closetedItemIds: new Set(urlParams.getAll("closet[]")), }; } diff --git a/src/server/index.js b/src/server/index.js index 6795234..91e2220 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -65,6 +65,14 @@ const typeDefs = gql` id: ID! zone: Zone! imageUrl(size: LayerImageSize): String + + """ + This layer as a single SVG, if available. + + This might not be available if the asset isn't converted yet by Neopets, + or if it's not as simple as a single SVG (e.g. animated). + """ + svgUrl: String } type Zone { @@ -210,6 +218,42 @@ const resolvers = { `/${rid1}/${rid2}/${rid3}/${rid}/${sizeNum}x${sizeNum}.png?v2-${time}` ); }, + svgUrl: async (layer) => { + const manifest = await neopets.loadAssetManifest(layer.url); + if (!manifest) { + console.debug("expected manifest to exist, but it did not"); + return null; + } + + if (manifest.assets.length !== 1) { + console.debug( + "expected 1 asset in manifest, but found %d", + manifest.assets.length + ); + return null; + } + + const asset = manifest.assets[0]; + if (asset.format !== "vector") { + console.debug( + 'expected asset format "vector", but found %s', + asset.format + ); + return null; + } + + if (asset.assetData.length !== 1) { + console.debug( + "expected 1 datum in asset, but found %d", + asset.assetData.length + ); + return null; + } + + const assetDatum = asset.assetData[0]; + const url = new URL(assetDatum.path, "http://images.neopets.com"); + return url.toString(); + }, }, Zone: { label: async (zone, _, { zoneTranslationLoader }) => { diff --git a/src/server/neopets.js b/src/server/neopets.js index 945e1f9..f99c411 100644 --- a/src/server/neopets.js +++ b/src/server/neopets.js @@ -1,12 +1,15 @@ const fetch = require("node-fetch"); async function loadPetData(petName) { - const res = await fetch( + const url = `http://www.neopets.com/amfphp/json.php/CustomPetService.getViewerData` + - `/${petName}` - ); + `/${petName}`; + const res = await fetch(url); if (!res.ok) { - throw new Error(`neopets.com returned: ${res.statusText}`); + throw new Error( + `for pet data, neopets.com returned: ` + + `${res.status} ${res.statusText}. (${url})` + ); } const json = await res.json(); @@ -17,4 +20,40 @@ async function loadPetData(petName) { return json; } -module.exports = { loadPetData }; +async function loadAssetManifest(swfUrl) { + const manifestUrl = convertSwfUrlToManifestUrl(swfUrl); + const res = await fetch(manifestUrl); + if (res.status === 404) { + return null; + } else if (!res.ok) { + throw new Error( + `for asset manifest, images.neopets.com returned: ` + + `${res.status} ${res.statusText}. (${manifestUrl})` + ); + } + + const json = await res.json(); + return { + assets: json["cpmanifest"]["assets"].map((asset) => ({ + format: asset["format"], + assetData: asset["asset_data"].map((assetDatum) => ({ + path: assetDatum["url"], + })), + })), + }; +} + +const SWF_URL_PATTERN = /^http:\/\/images\.neopets\.com\/cp\/(.+?)\/swf\/(.+?)\.swf$/; + +function convertSwfUrlToManifestUrl(swfUrl) { + const match = swfUrl.match(SWF_URL_PATTERN); + if (!match) { + throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`); + } + + const [_, type, folders] = match; + + return `http://images.neopets.com/cp/${type}/data/${folders}/manifest.json`; +} + +module.exports = { loadPetData, loadAssetManifest }; diff --git a/src/server/query-tests/PetAppearance.test.js b/src/server/query-tests/PetAppearance.test.js index ea23c40..c4bc806 100644 --- a/src/server/query-tests/PetAppearance.test.js +++ b/src/server/query-tests/PetAppearance.test.js @@ -15,6 +15,7 @@ describe("PetAppearance", () => { layers { id imageUrl(size: SIZE_600) + svgUrl zone { depth } diff --git a/src/server/query-tests/__snapshots__/PetAppearance.test.js.snap b/src/server/query-tests/__snapshots__/PetAppearance.test.js.snap index aed0832..5d48a66 100644 --- a/src/server/query-tests/__snapshots__/PetAppearance.test.js.snap +++ b/src/server/query-tests/__snapshots__/PetAppearance.test.js.snap @@ -7,6 +7,7 @@ Object { Object { "id": "5995", "imageUrl": "https://impress-asset-images.s3.amazonaws.com/biology/000/000/007/7941/600x600.png?v2-0", + "svgUrl": "http://images.neopets.com/cp/bio/data/000/000/007/7941_2c4cc4b846/7941.svg", "zone": Object { "depth": 18, }, @@ -14,6 +15,7 @@ Object { Object { "id": "5996", "imageUrl": "https://impress-asset-images.s3.amazonaws.com/biology/000/000/007/7942/600x600.png?v2-0", + "svgUrl": "http://images.neopets.com/cp/bio/data/000/000/007/7942_2eab06fd7b/7942.svg", "zone": Object { "depth": 7, }, @@ -21,6 +23,7 @@ Object { Object { "id": "6000", "imageUrl": "https://impress-asset-images.s3.amazonaws.com/biology/000/000/007/7946/600x600.png?v2-0", + "svgUrl": "http://images.neopets.com/cp/bio/data/000/000/007/7946_0348dad587/7946.svg", "zone": Object { "depth": 40, }, @@ -28,6 +31,7 @@ Object { Object { "id": "16467", "imageUrl": "https://impress-asset-images.s3.amazonaws.com/biology/000/000/024/24008/600x600.png?v2-0", + "svgUrl": "http://images.neopets.com/cp/bio/data/000/000/024/24008_a05fe9876a/24008.svg", "zone": Object { "depth": 34, }, @@ -35,6 +39,7 @@ Object { Object { "id": "19784", "imageUrl": "https://impress-asset-images.s3.amazonaws.com/biology/000/000/028/28892/600x600.png?v2-1313418652000", + "svgUrl": "http://images.neopets.com/cp/bio/data/000/000/028/28892_a8e3a8b430/28892.svg", "zone": Object { "depth": 37, }, @@ -42,6 +47,7 @@ Object { Object { "id": "178150", "imageUrl": "https://impress-asset-images.s3.amazonaws.com/biology/000/000/036/36887/600x600.png?v2-1354240708000", + "svgUrl": "http://images.neopets.com/cp/bio/data/000/000/036/36887_a335fbba09/36887.svg", "zone": Object { "depth": 38, },