From 32c501c76301c69639eb412fac80f488f65ad3fb Mon Sep 17 00:00:00 2001 From: Blayne Chard Date: Mon, 19 Feb 2024 15:56:14 +1300 Subject: [PATCH] feat: move to query parameters for pipeline selection (#3136) #### Motivation inline parameters in the URL were hard to parse and did not allow for future expansion of pipeline configuration, by moving to a query parameter the pipline can be configured easier and has opportunities for expansion later. #### Modification switches from a inline url to a query parameter for pipline selection #### Checklist _If not applicable, provide explanation of why._ - [ ] Tests updated - [ ] Docs updated - [ ] Issue linked in Title --- packages/_infra/src/edge/index.ts | 3 +- .../config-loader/src/json/tiff.config.ts | 32 +++------- packages/config/src/config/tile.set.output.ts | 23 ++++++++ packages/config/src/config/tile.set.ts | 20 +++++-- packages/config/src/index.ts | 1 + packages/config/src/memory/memory.config.ts | 24 +++----- .../lambda-tiler/src/cli/render.preview.ts | 2 +- packages/lambda-tiler/src/index.ts | 2 +- .../src/routes/__tests__/xyz.test.ts | 53 +++++++++++++---- packages/lambda-tiler/src/routes/preview.ts | 33 ++++------- .../src/routes/tile.style.json.ts | 5 +- .../src/routes/tile.xyz.raster.ts | 59 ++++--------------- packages/lambda-tiler/src/util/validate.ts | 57 +++++++++++++++--- packages/landing/src/url.ts | 2 + packages/server/src/route.layers.ts | 6 +- packages/shared/src/imagery.url.ts | 5 +- 16 files changed, 190 insertions(+), 137 deletions(-) create mode 100644 packages/config/src/config/tile.set.output.ts diff --git a/packages/_infra/src/edge/index.ts b/packages/_infra/src/edge/index.ts index 6932ec175..ac2e11557 100644 --- a/packages/_infra/src/edge/index.ts +++ b/packages/_infra/src/edge/index.ts @@ -103,7 +103,7 @@ export class EdgeStack extends cdk.Stack { forwardedValues: { /** Forward all query strings but do not use them for caching */ queryString: true, - queryStringCacheKeys: ['config', 'exclude'].map(encodeURIComponent), + queryStringCacheKeys: ['config', 'exclude', 'pipeline'].map(encodeURIComponent), }, lambdaFunctionAssociations: [], }, @@ -118,6 +118,7 @@ export class EdgeStack extends cdk.Stack { 'exclude', 'tileMatrix', 'style', + 'pipeline', // Deprecated single character query params for style and projection 's', 'p', diff --git a/packages/config-loader/src/json/tiff.config.ts b/packages/config-loader/src/json/tiff.config.ts index 99f4126d9..0509cc556 100644 --- a/packages/config-loader/src/json/tiff.config.ts +++ b/packages/config-loader/src/json/tiff.config.ts @@ -2,6 +2,8 @@ import { ConfigImagery, ConfigProviderMemory, ConfigTileSetRaster, + DefaultColorRampOutput, + DefaultTerrainRgbOutput, ImageryDataType, sha256base58, TileSetType, @@ -446,29 +448,11 @@ export async function initConfigFromUrls( const elevationTileSet: ConfigTileSetRaster = { id: 'ts_elevation', name: 'elevation', - title: 'Basemaps', + title: 'Elevation Basemap', category: 'Basemaps', type: TileSetType.Raster, layers: [], - outputs: [ - { - title: 'TerrainRGB', - name: 'terrain-rgb', - pipeline: [{ type: 'terrain-rgb' }], - output: { - type: 'webp', - lossless: true, - background: { r: 1, g: 134, b: 160, alpha: 1 }, - resizeKernel: { in: 'nearest', out: 'nearest' }, - }, - }, - { - title: 'Color ramp', - name: 'color-ramp', - pipeline: [{ type: 'color-ramp' }], - output: { type: 'webp' }, - }, - ], + outputs: [DefaultTerrainRgbOutput, DefaultColorRampOutput], }; provider.put(aerialTileSet); @@ -489,11 +473,13 @@ export async function initConfigFromUrls( elevationTileSet.layers.push(existingLayer); } existingLayer[cfg.projection] = cfg.id; - provider.put(elevationTileSet); - tileSets.push(elevationTileSet); + if (!provider.objects.has(elevationTileSet.id)) { + provider.put(elevationTileSet); + tileSets.push(elevationTileSet); + } } } - // FIXME: this should return all the tile sets that were created + // FIXME: tileSet should be removed now that we are returning all tilesets return { tileSet: aerialTileSet, tileSets, imagery: configs }; } diff --git a/packages/config/src/config/tile.set.output.ts b/packages/config/src/config/tile.set.output.ts new file mode 100644 index 000000000..c115363f3 --- /dev/null +++ b/packages/config/src/config/tile.set.output.ts @@ -0,0 +1,23 @@ +import { ConfigTileSetRasterOutput } from './tile.set.js'; + +export const DefaultTerrainRgbOutput: ConfigTileSetRasterOutput = { + title: 'TerrainRGB', + name: 'terrain-rgb', + pipeline: [{ type: 'terrain-rgb' }], + output: { + // terrain rgb cannot be resampled after it has been made + lossless: true, + // Zero encoded as a TerrainRGB + background: { r: 1, g: 134, b: 160, alpha: 1 }, + resizeKernel: { in: 'nearest', out: 'nearest' }, + }, +} as const; + +export const DefaultColorRampOutput: ConfigTileSetRasterOutput = { + title: 'Color ramp', + name: 'color-ramp', + pipeline: [{ type: 'color-ramp' }], + output: { + background: { r: 1, g: 134, b: 160, alpha: 1 }, + }, +} as const; diff --git a/packages/config/src/config/tile.set.ts b/packages/config/src/config/tile.set.ts index e438851b9..a67cae5be 100644 --- a/packages/config/src/config/tile.set.ts +++ b/packages/config/src/config/tile.set.ts @@ -92,10 +92,20 @@ export interface ConfigTileSetRasterOutput { */ pipeline?: ConfigRasterPipeline[]; - /** Raster output format */ - output: { - /** Output file format to use */ - type: ImageFormat; + /** + * Raster output format + * if none is provided it is assumed to be a RGBA output allowing all image formats + */ + output?: { + /** + * Allowed output file format to use + * + * Will default to all image formats + * if "lossless" is set then lossless image formats + * + * @default ImageFormat[] - All Image formats + */ + type?: ImageFormat[]; /** * should the output be lossless * @@ -111,7 +121,7 @@ export interface ConfigTileSetRasterOutput { /** * When scaling tiles in the rendering process what kernel to use * - * will fall back to {@link ConfigTileSetRaster.background} if not defined + * will fall back to {@link ConfigTileSetRaster.background} if not defined */ resizeKernel?: { in: TileResizeKernel; out: TileResizeKernel }; }; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 396cbcadc..676de77f5 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -22,6 +22,7 @@ export { TileResizeKernel, TileSetType, } from './config/tile.set.js'; +export { DefaultColorRampOutput, DefaultTerrainRgbOutput } from './config/tile.set.output.js'; export { ConfigVectorStyle, Layer, Sources, StyleJson } from './config/vector.style.js'; export { ConfigBundled, ConfigProviderMemory } from './memory/memory.config.js'; export { standardizeLayerName } from './name.convertor.js'; diff --git a/packages/config/src/memory/memory.config.ts b/packages/config/src/memory/memory.config.ts index f8e35a280..02aa2ebd9 100644 --- a/packages/config/src/memory/memory.config.ts +++ b/packages/config/src/memory/memory.config.ts @@ -10,6 +10,7 @@ import { ConfigPrefix } from '../config/prefix.js'; import { ConfigProvider } from '../config/provider.js'; import { ConfigLayer, ConfigTileSet, ConfigTileSetRaster, TileSetType } from '../config/tile.set.js'; import { ConfigVectorStyle } from '../config/vector.style.js'; +import { DefaultColorRampOutput, DefaultTerrainRgbOutput } from '../index.js'; import { standardizeLayerName } from '../name.convertor.js'; interface DuplicatedImagery { @@ -144,10 +145,15 @@ export class ConfigProviderMemory extends BasemapsConfigProvider { const layerByName = new Map(); // Set all layers as minZoom:32 for (const l of layers) { + // Ignore any tileset that has defined pipelines + const tileSet = this.objects.get(this.TileSet.id(l.name)) as ConfigTileSetRaster; + if (tileSet.outputs) continue; + const newLayer = { ...l, minZoom: 32 }; delete newLayer.maxZoom; // max zoom not needed when minzoom is 32 layerByName.set(newLayer.name, { ...layerByName.get(l.name), ...newLayer }); } + const allTileset: ConfigTileSet = { type: TileSetType.Raster, id: 'ts_all', @@ -180,14 +186,7 @@ export class ConfigProviderMemory extends BasemapsConfigProvider { // FIXME: should we store output types here if (i.bands?.length === 1) { - existing.outputs = [ - { - title: 'Color ramp', - name: 'color-ramp', - pipeline: [{ type: 'color-ramp' }], - output: { type: 'webp' }, - }, - ]; + existing.outputs = [DefaultTerrainRgbOutput, DefaultColorRampOutput]; } } // The latest imagery overwrite the earlier ones. @@ -219,14 +218,7 @@ export class ConfigProviderMemory extends BasemapsConfigProvider { // FIXME: should we store output types here if (i.bands?.length === 1) { - ts.outputs = [ - { - title: 'Color ramp', - name: 'color-ramp', - pipeline: [{ type: 'color-ramp' }], - output: { type: 'webp' }, - }, - ]; + ts.outputs = [DefaultTerrainRgbOutput, DefaultColorRampOutput]; } return ts; } diff --git a/packages/lambda-tiler/src/cli/render.preview.ts b/packages/lambda-tiler/src/cli/render.preview.ts index 36151ce04..4a3674c42 100644 --- a/packages/lambda-tiler/src/cli/render.preview.ts +++ b/packages/lambda-tiler/src/cli/render.preview.ts @@ -39,7 +39,7 @@ async function main(): Promise { tileSet, location, z, - output: { title: outputFormat, output: { type: outputFormat }, name: 'rgba' }, + output: { title: outputFormat, output: { type: [outputFormat] }, name: 'rgba' }, }); const previewFile = fsa.toUrl(`./z${z}_${location.lon}_${location.lat}.${outputFormat}`); await fsa.write(previewFile, Buffer.from(res.body, 'base64')); diff --git a/packages/lambda-tiler/src/index.ts b/packages/lambda-tiler/src/index.ts index 7c08faf17..559ffd2e8 100644 --- a/packages/lambda-tiler/src/index.ts +++ b/packages/lambda-tiler/src/index.ts @@ -73,7 +73,7 @@ handler.router.get('/v1/tiles/:tileSet/:tileMatrix/style/:styleName.json', style handler.router.get('/v1/tiles/:tileSet/:tileMatrix/tile.json', tileJsonGet); // Tiles -handler.router.get('/v1/tiles/:tileSet/:tileMatrix/:z(^\\d+)/:x(^\\d+)/:y(^\\d+):tileType', tileXyzGet); +handler.router.get('/v1/tiles/:tileSet/:tileMatrix/:z/:x/:y.:tileType', tileXyzGet); // Preview handler.router.get('/v1/preview/:tileSet/:tileMatrix/:z/:lon/:lat', tilePreviewGet); diff --git a/packages/lambda-tiler/src/routes/__tests__/xyz.test.ts b/packages/lambda-tiler/src/routes/__tests__/xyz.test.ts index 6355e4a25..029c57ab3 100644 --- a/packages/lambda-tiler/src/routes/__tests__/xyz.test.ts +++ b/packages/lambda-tiler/src/routes/__tests__/xyz.test.ts @@ -6,7 +6,7 @@ import { LogConfig } from '@basemaps/shared'; import { round } from '@basemaps/test/build/rounding.js'; import { FakeData } from '../../__tests__/config.data.js'; -import { Api, mockRequest } from '../../__tests__/xyz.util.js'; +import { Api, mockRequest, mockUrlRequest } from '../../__tests__/xyz.util.js'; import { handler } from '../../index.js'; import { ConfigLoader } from '../../util/config.loader.js'; import { Etag } from '../../util/etag.js'; @@ -53,8 +53,8 @@ describe('/v1/tiles', () => { const request = mockRequest('/v1/tiles/aerial/3857/0/0/0.webp', 'get', Api.header); const res = await handler.router.handle(request); - console.log(res.statusDescription); - assert.equal(res.status, 200); + + assert.equal(res.status, 200, res.statusDescription); assert.equal(res.header('content-type'), 'image/webp'); assert.equal(res.header('eTaG'), 'fakeEtag'); // o(res.body).equals(rasterMockBuffer.toString('base64')); @@ -68,13 +68,13 @@ describe('/v1/tiles', () => { it(`should 200 with empty ${fmt} if a tile is out of bounds`, async (t) => { t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); - const res = await handler.router.handle( - mockRequest(`/v1/tiles/aerial/global-mercator/0/0/0.${fmt}`, 'get', Api.header), - ); - assert.equal(res.status, 200); + const request = mockRequest(`/v1/tiles/aerial/global-mercator/0/0/0.${fmt}`, 'get', Api.header); + const res = await handler.router.handle(request); + assert.equal(res.status, 200, res.statusDescription); assert.equal(res.header('content-type'), `image/${fmt}`); assert.notEqual(res.header('etag'), undefined); assert.equal(res.header('cache-control'), 'public, max-age=604800, stale-while-revalidate=86400'); + assert.deepEqual(request.logContext['pipeline'], 'rgba'); }); }); @@ -114,7 +114,7 @@ describe('/v1/tiles', () => { const req = mockRequest('/v1/tiles/🦄 🌈/global-mercator/0/0/0.png', 'get', Api.header); assert.equal(req.path, '/v1/tiles/%F0%9F%A6%84%20%F0%9F%8C%88/global-mercator/0/0/0.png'); const res = await handler.router.handle(req); - assert.equal(res.status, 200); + assert.equal(res.status, 200, res.statusDescription); assert.equal(res.header('content-type'), 'image/png'); assert.notEqual(res.header('etag'), undefined); assert.equal(res.header('cache-control'), 'public, max-age=604800, stale-while-revalidate=86400'); @@ -129,21 +129,52 @@ describe('/v1/tiles', () => { }); }); + it('should 404 if pipelines are defined but one is not requested', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + const elevation = FakeData.tileSetRaster('elevation'); + + elevation.outputs = [{ title: 'Terrain RGB', name: 'terrain-rgb', output: { lossless: true } }]; + config.put(elevation); + + const request = mockRequest('/v1/tiles/elevation/3857/11/2022/1283.webp', 'get', Api.header); + + const res = await handler.router.handle(request); + + assert.equal(res.status, 404, res.statusDescription); + }); + it('should generate a terrain-rgb 11/2022/1283 in webp', async (t) => { t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); const elevation = FakeData.tileSetRaster('elevation'); - elevation.outputs = [{ title: 'Terrain RGB', name: 'terrain-rgb', output: { type: 'webp', lossless: true } }]; + elevation.outputs = [{ title: 'Terrain RGB', name: 'terrain-rgb', output: { lossless: true } }]; config.put(elevation); - const request = mockRequest('/v1/tiles/elevation/3857/11/2022/1283-terrain-rgb.webp', 'get', Api.header); + const request = mockUrlRequest('/v1/tiles/elevation/3857/11/2022/1283.webp', '?pipeline=terrain-rgb', Api.header); const res = await handler.router.handle(request); - assert.equal(res.status, 200); + assert.equal(res.status, 200, res.statusDescription); // Validate the session information has been set correctly assert.deepEqual(request.logContext['xyz'], { x: 2022, y: 1283, z: 11 }); + assert.deepEqual(request.logContext['pipeline'], 'terrain-rgb'); assert.deepEqual(round(request.logContext['location']), { lat: -41.44272638, lon: 175.51757812 }); }); + + it('should validate lossless if pipelines are defined but one is not requested', async (t) => { + t.mock.method(ConfigLoader, 'getDefaultConfig', () => Promise.resolve(config)); + + const elevation = FakeData.tileSetRaster('elevation'); + + elevation.outputs = [{ title: 'Terrain RGB', name: 'terrain-rgb', output: { lossless: true } }]; + config.put(elevation); + + // JPEG is not lossless + const res = await handler.router.handle( + mockUrlRequest('/v1/tiles/elevation/3857/11/2022/1283.jpeg', '?pipeline=terrain-rgb', Api.header), + ); + assert.equal(res.status, 400, res.statusDescription); + }); }); diff --git a/packages/lambda-tiler/src/routes/preview.ts b/packages/lambda-tiler/src/routes/preview.ts index 0f6d45c05..4c396e56a 100644 --- a/packages/lambda-tiler/src/routes/preview.ts +++ b/packages/lambda-tiler/src/routes/preview.ts @@ -1,6 +1,6 @@ import { ConfigTileSetRaster, ConfigTileSetRasterOutput } from '@basemaps/config'; import { Bounds, LatLon, Projection, TileMatrixSet } from '@basemaps/geo'; -import { CompositionTiff, Tiler } from '@basemaps/tiler'; +import { CompositionTiff, TileMakerContext, Tiler } from '@basemaps/tiler'; import { SharpOverlay, TileMakerSharp } from '@basemaps/tiler-sharp'; import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda'; import sharp from 'sharp'; @@ -9,13 +9,7 @@ import { ConfigLoader } from '../util/config.loader.js'; import { Etag } from '../util/etag.js'; import { NotModified } from '../util/response.js'; import { Validate } from '../util/validate.js'; -import { - DefaultBackground, - DefaultResizeKernel, - getTileSetOutput, - isArchiveTiff, - TileXyzRaster, -} from './tile.xyz.raster.js'; +import { DefaultBackground, DefaultResizeKernel, isArchiveTiff, TileXyzRaster } from './tile.xyz.raster.js'; export interface PreviewGet { Params: { @@ -54,7 +48,6 @@ export async function tilePreviewGet(req: LambdaHttpRequest): Promis req.set('projection', tileMatrix.projection.code); // TODO we should detect the format based off the "Accept" header and maybe default back to webp - const location = Validate.getLocation(req.params.lon, req.params.lat); if (location == null) return new LambdaHttpResponse(404, 'Preview location not found'); req.set('location', location); @@ -71,11 +64,11 @@ export async function tilePreviewGet(req: LambdaHttpRequest): Promis // Only raster previews are supported if (tileSet.type !== 'raster') return new LambdaHttpResponse(404, 'Preview invalid tile set type'); - const outputFormat = req.params.outputType; + const outputFormat = req.params.outputType ?? 'webp'; - const tileOutput = getTileSetOutput(tileSet, outputFormat); + const tileOutput = Validate.pipeline(tileSet, outputFormat, req.query.get('pipeline')); if (tileOutput == null) return new LambdaHttpResponse(404, `Output format: ${outputFormat} not found`); - req.set('extension', tileOutput.output.type); + req.set('extension', tileOutput.output?.type); req.set('pipeline', tileOutput.name ?? 'rgba'); return renderPreview(req, { tileSet, tileMatrix, location, output: tileOutput, z }); @@ -148,13 +141,13 @@ export async function renderPreview(req: LambdaHttpRequest, ctx: PreviewRenderCo } const tileOutput = ctx.output; - const tileContext = { + const tileContext: TileMakerContext = { layers: compositions, pipeline: tileOutput.pipeline, - format: tileOutput.output.type, - lossless: tileOutput.output.lossless, - background: tileOutput.output.background ?? ctx.tileSet.background ?? DefaultBackground, - resizeKernel: tileOutput.output.resizeKernel ?? ctx.tileSet.resizeKernel ?? DefaultResizeKernel, + format: tileOutput.output?.type?.[0] ?? 'webp', // default to the first output format if defined or webp + lossless: tileOutput.output?.lossless, + background: tileOutput.output?.background ?? ctx.tileSet.background ?? DefaultBackground, + resizeKernel: tileOutput.output?.resizeKernel ?? ctx.tileSet.resizeKernel ?? DefaultResizeKernel, }; // Load all the tiff tiles and resize/them into the correct locations @@ -169,7 +162,7 @@ export async function renderPreview(req: LambdaHttpRequest, ctx: PreviewRenderCo img.composite(overlays); req.timer.start('compose:compress'); - const buf = await TilerSharp.toImage(ctx.output.output.type, img, ctx.output.output.lossless); + const buf = await TilerSharp.toImage(tileContext.format, img, tileContext.lossless); req.timer.end('compose:compress'); req.set('layersUsed', overlays.length); @@ -177,10 +170,10 @@ export async function renderPreview(req: LambdaHttpRequest, ctx: PreviewRenderCo const response = new LambdaHttpResponse(200, 'ok'); response.header(HttpHeader.ETag, cacheKey); response.header(HttpHeader.CacheControl, 'public, max-age=604800, stale-while-revalidate=86400'); - response.buffer(buf, 'image/' + ctx.output.output.type); + response.buffer(buf, 'image/' + tileContext.format); const shortLocation = [ctx.location.lon.toFixed(7), ctx.location.lat.toFixed(7)].join('_'); - const suggestedFileName = `preview_${ctx.tileSet.name}_z${ctx.z}_${shortLocation}-${ctx.output.name}.${ctx.output.output.type}`; + const suggestedFileName = `preview_${ctx.tileSet.name}_z${ctx.z}_${shortLocation}-${ctx.output.name}.${tileContext.format}`; response.header('Content-Disposition', `inline; filename=\"${suggestedFileName}\"`); return response; diff --git a/packages/lambda-tiler/src/routes/tile.style.json.ts b/packages/lambda-tiler/src/routes/tile.style.json.ts index 0a0883699..95a62a0b2 100644 --- a/packages/lambda-tiler/src/routes/tile.style.json.ts +++ b/packages/lambda-tiler/src/routes/tile.style.json.ts @@ -73,8 +73,11 @@ export async function tileSetToStyle( const [tileFormat] = Validate.getRequestedFormats(req) ?? ['webp']; if (tileFormat == null) return new LambdaHttpResponse(400, 'Invalid image format'); + const pipeline = Validate.pipeline(tileSet, tileFormat, req.query.get('pipeline')); + const pipelineName = pipeline?.name === 'rgba' ? undefined : pipeline?.name; + const configLocation = ConfigLoader.extract(req); - const query = toQueryString({ config: configLocation, api: apiKey, ...getFilters(req) }); + const query = toQueryString({ config: configLocation, api: apiKey, ...getFilters(req), pipeline: pipelineName }); const tileUrl = (Env.get(Env.PublicUrlBase) ?? '') + diff --git a/packages/lambda-tiler/src/routes/tile.xyz.raster.ts b/packages/lambda-tiler/src/routes/tile.xyz.raster.ts index 9966ae87d..6099f0527 100644 --- a/packages/lambda-tiler/src/routes/tile.xyz.raster.ts +++ b/packages/lambda-tiler/src/routes/tile.xyz.raster.ts @@ -1,7 +1,7 @@ -import { ConfigTileSetRaster, ConfigTileSetRasterOutput, getAllImagery } from '@basemaps/config'; -import { Bounds, Epsg, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; +import { ConfigTileSetRaster, getAllImagery } from '@basemaps/config'; +import { Bounds, Epsg, ImageFormat, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; import { Cotar, Env, stringToUrlFolder, Tiff } from '@basemaps/shared'; -import { getImageFormat, Tiler } from '@basemaps/tiler'; +import { Tiler } from '@basemaps/tiler'; import { TileMakerSharp } from '@basemaps/tiler-sharp'; import { HttpHeader, LambdaHttpRequest, LambdaHttpResponse } from '@linzjs/lambda'; import pLimit from 'p-limit'; @@ -11,7 +11,7 @@ import { Etag } from '../util/etag.js'; import { filterLayers } from '../util/filter.js'; import { NotFound, NotModified } from '../util/response.js'; import { CoSources } from '../util/source.cache.js'; -import { TileXyz } from '../util/validate.js'; +import { TileXyz, Validate } from '../util/validate.js'; const LoadingQueue = pLimit(Env.getNumber(Env.TiffConcurrency, 25)); @@ -119,9 +119,8 @@ export const TileXyzRaster = { }, async tile(req: LambdaHttpRequest, tileSet: ConfigTileSetRaster, xyz: TileXyz): Promise { - const tileOutput = getTileSetOutput(tileSet, xyz.tileType); + const tileOutput = Validate.pipeline(tileSet, xyz.tileType, xyz.pipeline); if (tileOutput == null) return NotFound(); - req.set('extension', tileOutput.output.type); req.set('pipeline', tileOutput.name); const assetPaths = await this.getAssetsForTile(req, tileSet, xyz); @@ -136,10 +135,10 @@ export const TileXyzRaster = { const res = await TileComposer.compose({ layers, pipeline: tileOutput.pipeline, - format: tileOutput.output.type, - lossless: tileOutput.output.lossless, - background: tileOutput.output.background ?? tileSet.background ?? DefaultBackground, - resizeKernel: tileOutput.output.resizeKernel ?? tileSet.resizeKernel ?? DefaultResizeKernel, + format: xyz.tileType as ImageFormat, + lossless: tileOutput.output?.lossless, + background: tileOutput.output?.background ?? tileSet.background ?? DefaultBackground, + resizeKernel: tileOutput.output?.resizeKernel ?? tileSet.resizeKernel ?? DefaultResizeKernel, metrics: req.timer, }); @@ -149,45 +148,7 @@ export const TileXyzRaster = { const response = new LambdaHttpResponse(200, 'ok'); response.header(HttpHeader.ETag, cacheKey); response.header(HttpHeader.CacheControl, 'public, max-age=604800, stale-while-revalidate=86400'); - response.buffer(res.buffer, 'image/' + tileOutput.output.type); + response.buffer(res.buffer, 'image/' + xyz.tileType); return response; }, }; - -/** - * Lookup the raster configuration pipeline for a output tile type - * - * Defaults to standard image format output if no outputs are defined on the tileset - */ -export function getTileSetOutput( - tileSet: ConfigTileSetRaster, - tileType?: string | null, -): ConfigTileSetRasterOutput | null { - if (tileSet.outputs != null) { - // Default to the first output if no extension given - if (tileType == null) return tileSet.outputs[0]; - // const expectedTarget = `` - for (const out of tileSet.outputs) { - const targetName = `${out.name}.${out.output.type}`; - if (targetName === tileType) return out; - } - - // Find the first matching imagery type - for (const out of tileSet.outputs) { - if (out.output.type === tileType) return out; - } - return null; - } - - const img = getImageFormat(tileType ?? 'webp'); - if (img == null) return null; - return { - title: `RGBA ${tileType}`, - name: 'rgba', - output: { - type: img, - lossless: img === 'png' ? true : false, - background: tileSet.background, - }, - } as ConfigTileSetRasterOutput; -} diff --git a/packages/lambda-tiler/src/util/validate.ts b/packages/lambda-tiler/src/util/validate.ts index 2109c32ff..307afbc9e 100644 --- a/packages/lambda-tiler/src/util/validate.ts +++ b/packages/lambda-tiler/src/util/validate.ts @@ -1,3 +1,4 @@ +import { ConfigTileSetRaster, ConfigTileSetRasterOutput } from '@basemaps/config'; import { ImageFormat, LatLon, Projection, TileMatrixSet, TileMatrixSets } from '@basemaps/geo'; import { Const, isValidApiKey, truncateApiKey } from '@basemaps/shared'; import { getImageFormat } from '@basemaps/tiler'; @@ -14,6 +15,8 @@ export interface TileXyz { tileMatrix: TileMatrixSet; /** Output tile format */ tileType: string; + /** Optional processing pipeline to use */ + pipeline?: string | null; } export interface TileMatrixRequest { @@ -90,12 +93,6 @@ export const Validate = { if (req.params.tileType == null) throw new LambdaHttpResponse(404, 'Tile extension not found'); - // trim ".webp" to "webp" and "-terrain-rgb.webp" to "terrain-rgb.webp" - // so that it is easier to match latter - if (req.params.tileType.startsWith('.') || req.params.tileType.startsWith('-')) { - req.params.tileType = req.params.tileType.slice(1); - } - req.set('extension', req.params.tileType); if (isNaN(z) || z > tileMatrix.maxZoom || z < 0) throw new LambdaHttpResponse(404, `Zoom not found: ${z}`); @@ -104,7 +101,16 @@ export const Validate = { if (isNaN(x) || x < 0 || x > zoom.matrixWidth) throw new LambdaHttpResponse(404, `X not found: ${x}`); if (isNaN(y) || y < 0 || y > zoom.matrixHeight) throw new LambdaHttpResponse(404, `Y not found: ${y}`); - const xyzData = { tile: { x, y, z }, tileSet: req.params.tileSet, tileMatrix, tileType: req.params.tileType }; + const pipeline = req.query.get('pipeline'); + if (pipeline) req.set('pipeline', pipeline); + + const xyzData = { + tile: { x, y, z }, + tileSet: req.params.tileSet, + tileMatrix, + tileType: req.params.tileType, + pipeline: req.query.get('pipeline'), + }; req.set('xyz', xyzData.tile); const latLon = Projection.tileCenterToLatLon(tileMatrix, xyzData.tile); @@ -112,4 +118,41 @@ export const Validate = { return xyzData; }, + + /** + * Lookup the raster configuration pipeline for a output tile type + * + * Defaults to standard image format output if no outputs are defined on the tileset + */ + pipeline(tileSet: ConfigTileSetRaster, tileType: string, pipeline?: string | null): ConfigTileSetRasterOutput | null { + if (pipeline != null && pipeline !== 'rgba') { + if (tileSet.outputs == null) throw new LambdaHttpResponse(404, 'TileSet has no pipelines'); + const output = tileSet.outputs.find((f) => f.name === pipeline); + if (output == null) throw new LambdaHttpResponse(404, `TileSet has no pipeline named "${pipeline}"`); + + // If lossless mode is needed validate that its either WebP or PNG + if (output.output?.lossless) { + if (tileType === 'webp' || tileType === 'png') return output; + throw new LambdaHttpResponse(400, 'Lossless output is required for pipeline:' + pipeline); + } + return output; + } + // If the tileset has pipelines defined the user MUST specify which one + if (tileSet.outputs) { + throw new LambdaHttpResponse(404, 'TileSet needs pipeline: ' + tileSet.outputs.map((f) => f.name)); + } + + // Generate a default RGBA configuration + const img = getImageFormat(tileType ?? 'webp'); + if (img == null) return null; + return { + title: `RGBA ${tileType}`, + name: 'rgba', + output: { + type: [img], + lossless: img === 'png' ? true : false, + background: tileSet.background, + }, + } as ConfigTileSetRasterOutput; + }, }; diff --git a/packages/landing/src/url.ts b/packages/landing/src/url.ts index 3b4d498cb..e56f57a25 100644 --- a/packages/landing/src/url.ts +++ b/packages/landing/src/url.ts @@ -29,6 +29,7 @@ export interface TileUrlParams { layerId: string; style?: string | null; config?: string | null; + pipeline?: string | null; date?: FilterDate; } @@ -66,6 +67,7 @@ export const WindowUrl = { const queryParams = new URLSearchParams(); if (Config.ApiKey != null && Config.ApiKey !== '') queryParams.set('api', Config.ApiKey); if (params.config != null) queryParams.set('config', ensureBase58(params.config)); + if (params.pipeline != null) queryParams.set('pipeline', params.pipeline); if (params.date?.before != null) queryParams.set('date[before]', params.date.before); if (params.urlType === MapOptionType.Style) { diff --git a/packages/server/src/route.layers.ts b/packages/server/src/route.layers.ts index bfc334d29..d84052d9a 100644 --- a/packages/server/src/route.layers.ts +++ b/packages/server/src/route.layers.ts @@ -18,6 +18,7 @@ export async function createLayersHtml(mem: BasemapsConfigProvider): Promise