pmtiles-swarm 0.67.0 → 0.68.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,99 @@
4
4
  ### ✨ Features and improvements
5
5
  - _...Add new stuff here..._
6
6
 
7
+ ### 🐞 Bug fixes
8
+ - _...Add new stuff here..._
9
+
10
+ ## 0.68.1
11
+ ### ✨ Features and improvements
12
+ - **A cutline may have holes in it, which is to say it may be a real boundary.** 0.68.0 refused
13
+ any shape with interior rings, on the reasoning that the even-odd fill would treat a hole as
14
+ solid. It does not — a ray to a point inside a hole crosses the outer ring and then the inner
15
+ one, which is two crossings, which is outside — so the refusal protected against nothing and
16
+ turned away almost every boundary worth clipping to. A country is islands and enclaves and
17
+ lakes; the German cutline this was written for is ninety-three rings.
18
+
19
+ Tested rather than reasoned about, because getting it wrong fills in exactly the ground somebody
20
+ cut out.
21
+
22
+ The documentation now also says how a shapefile becomes a cutline, since that is where every
23
+ real one starts: one `ogr2ogr -t_srs EPSG:4326`, and the filename is the name the recipe uses.
24
+ With a note to simplify — a boundary drawn for surveying carries far more detail than a tile can
25
+ show, and every point of it is a segment to index and test.
26
+
27
+
28
+
29
+ ### 🐞 Bug fixes
30
+ - _...Add new stuff here..._
31
+
32
+ ## 0.68.0
33
+ ### ✨ Features and improvements
34
+ - **A source can be clipped to a shape.** `cutline` names a polygon kept under `data/cutlines/`;
35
+ `bounds` is a rectangle written straight into the recipe. Both are chosen in the stack editor,
36
+ which offers the shapes this node actually has rather than asking anybody to remember a
37
+ filename.
38
+
39
+ Most of the time this is already solved a step earlier, and the design says so: a build running
40
+ `gdalwarp -cutline … -dstnodata` writes the boundary into the archive, and `maskValues` removes
41
+ exactly those pixels. What that cannot reach is a source **this node did not build** — an
42
+ archive is content-addressed, so re-clipping one means republishing it. That is the case this is
43
+ for, and it is the federated case this project exists for.
44
+
45
+ **A rectangle is a cutline with four corners**, built through the same code rather than beside
46
+ it, so there is no second implementation to disagree with the first. A test asserts a `bounds`
47
+ and the same shape drawn as GeoJSON classify identically across a whole zoom level.
48
+
49
+ The cost is avoided rather than paid. Every tile is first classified **outside**, **inside** or
50
+ **partial**: outside skips the read entirely — no swarm round trip, no decode, no merge — inside
51
+ skips the mask entirely, and only a tile the edge actually crosses is rasterised, by scanline,
52
+ over segments found through a grid index built once when the cutline loads. For a country
53
+ boundary that is a band one tile wide; everything else is settled without touching a pixel.
54
+
55
+ It composes with the per-tile short-circuit: a clipped source still hands its bytes through
56
+ untouched where the tile is wholly inside, because there the clip provably changes nothing.
57
+
58
+ **A cutline a recipe names and this node has not got refuses the source.** Not "serve it
59
+ unclipped" — that would put back exactly the data somebody asked to remove, which is the one
60
+ failure a clip must not have. The stack reports it beside its other problems and the editor says
61
+ so on the row.
62
+
63
+ Also fixed while building it: horizontal edges were being dropped when a shape was prepared, on
64
+ the grounds that they contribute nothing to the even-odd rule. True, and they are still edges —
65
+ dropping them meant nothing noticed a rectangle's north and south sides crossing a tile, which
66
+ read as `inside` for a tile half of which was outside, and the mask was then never applied.
67
+
68
+
69
+ - **A stack that merges somewhere no longer merges everywhere.** Passthrough was decided per
70
+ recipe: one source with a mask made the whole stack a merging one, and every tile was decoded
71
+ and re-encoded — including the great majority where a single untouched source covers the ground
72
+ and its stored bytes were already the answer. `passThroughRead` now decides that per tile,
73
+ before anything is decoded, which is the point: checked afterwards it would save the encode and
74
+ not the decode.
75
+
76
+ Its own function with seventeen tests, one per condition, because a short-circuit that fires
77
+ when it should not does not fail — it serves the wrong pixels quietly, and an archive baked from
78
+ them is wrong the same way.
79
+
80
+ Masks are the subtle half, and the reason it refuses any source carrying one. A tile having a
81
+ single contributor does not make that contributor cover the tile: a mask turns pixels into
82
+ nodata, the merge fills those, and handing the stored bytes over instead would show the ground
83
+ the mask was there to remove. With masks refused, nodata has nowhere else to come from —
84
+ `decodeHeights` is arithmetic over bytes, and the only other sources of it are the parent
85
+ resample and a resize, both refused as well.
86
+
87
+ - **The catalogue page lists stacks.** A stack has no infohash and appears in no feed, so nothing
88
+ about it was discoverable: the only way to know a node served one was to be told its id.
89
+ `GET /stacks/` answers the list on the public listener, beside the tiles and TileJSON it
90
+ describes, and the page renders each with its TileJSON, XYZ template and preview — terrain
91
+ first where the stack is terrain.
92
+
93
+ Not the console's list. That one names what each source resolved to and what is missing, which
94
+ is the operator's view and names infohashes a visitor was never offered. The public one also
95
+ leaves out any stack it cannot serve — a recipe with a problem, or one wanting a codec this node
96
+ has not got — because a link that answers 501 is worse than no link.
97
+
98
+
99
+
7
100
  ### 🐞 Bug fixes
8
101
  - _...Add new stuff here..._
9
102
 
@@ -49,6 +49,8 @@ of its parts.
49
49
  - [What identifies a bake](#what-identifies-a-bake)
50
50
  - [Starting one, and watching it](#starting-one-and-watching-it)
51
51
  - [What exists now](#what-exists-now)
52
+ - [Clipping a source to a shape](#clipping-a-source-to-a-shape)
53
+ - [Finding a stack](#finding-a-stack)
52
54
  - [Syncing a stack to another node](#syncing-a-stack-to-another-node)
53
55
  - [What the offline merge got wrong](#what-the-offline-merge-got-wrong)
54
56
  - [The stack editor](#the-stack-editor)
@@ -297,6 +299,25 @@ For a request `GET /stacks/<id>/{z}/{x}/{y}.webp`:
297
299
  its bytes through untouched. No decode, no encode. This is what a stack
298
300
  degenerates to over most of the world when the top layer is dense, and it
299
301
  costs a comparison to detect.
302
+
303
+ `passThroughRead` in `src/stack-tile.js`, checked _before_ anything is
304
+ decoded — checked afterwards it would save the encode and not the decode.
305
+ Its own function with its own tests, because a short-circuit that fires when
306
+ it should not does not fail: it serves the wrong pixels quietly, and an
307
+ archive baked from them is wrong the same way.
308
+
309
+ The masks are the subtle half. A tile having one contributor does not make
310
+ that contributor cover the tile: a mask turns pixels into nodata, the merge
311
+ fills those, and passing the stored bytes through instead would show the
312
+ ground the mask was there to remove. Refusing any source carrying a mask is
313
+ what makes the rest safe — nodata has nowhere else to come from, since
314
+ `decodeHeights` is arithmetic over bytes and the only other sources of it
315
+ are the parent resample and a resize, both refused as well.
316
+
317
+ An explicit output size is refused for a different reason: the source's
318
+ pixel width is not known until it is decoded, so a resize cannot be ruled
319
+ out from here.
320
+
300
321
  6. **Decode** each contributing tile in a worker.
301
322
  7. **Mask** to a coverage mask, then apply `heightAdjustment` (elevation) or
302
323
  `opacity` (rgba). Masking comes first — the mask values are exact decoded
@@ -793,6 +814,154 @@ means re-hashing everything already buffered, and the cost of starting it empty
793
814
  is that a tile identical to one from before the interruption is stored twice.
794
815
  The archive is correct either way; it is a little larger.
795
816
 
817
+ ## Clipping a source to a shape
818
+
819
+ A source is often only meant to apply inside a boundary — a national DEM inside
820
+ its country, a survey inside its extent — and everywhere else the layer beneath
821
+ it should show through.
822
+
823
+ **Most of the time this is already solved, one step earlier.** A build that runs
824
+ `gdalwarp -cutline … -dstnodata` writes the boundary into the archive: outside
825
+ it, every pixel is the nodata value. `maskValues` then removes exactly those,
826
+ which is what the offline merge configs do and what a stack recipe does with the
827
+ same field. Nothing more is needed, and a cutline that duplicated it would be
828
+ slower and no more correct.
829
+
830
+ What that cannot reach is a source **this node did not build**. An archive is
831
+ content-addressed, so re-clipping one means republishing it — impossible for
832
+ somebody else's, and expensive for a large one whose boundary has changed. That
833
+ is the case this is for, and it is squarely the federated case this project
834
+ exists for.
835
+
836
+ Two shapes, and one of them is nearly free:
837
+
838
+ ```json
839
+ { "category": "opendtm-de", "cutline": "germany" }
840
+ { "category": "massgis", "bounds": [-73.6, 41.1, -69.8, 42.9] }
841
+ ```
842
+
843
+ `bounds` is a rectangle in WGS84, written the way every other bounds in this
844
+ project is. `cutline` names a polygon. They are the same question asked of
845
+ different shapes, so they are the same code: a rectangle is a cutline with four
846
+ corners, and treating it as one means there is no second implementation to
847
+ disagree with the first. What differs is only that a rectangle needs no file, no
848
+ index and no rasterising worth the name — which is why it is worth having even
849
+ though the polygon subsumes it.
850
+
851
+ ### Geometry by reference
852
+
853
+ A recipe names a cutline rather than carrying it. `"cutline": "germany"`
854
+ resolves to `data/cutlines/germany.geojson`, in WGS84. A recipe stays a small
855
+ document that a person can read and a peer could one day be sent; a megabyte of
856
+ coordinates inlined in one is neither. A rectangle is small enough to sit in the
857
+ recipe itself, so `bounds` does.
858
+
859
+ WGS84 on purpose. Web Mercator is arithmetic from there — no projection library,
860
+ no dependency, no CRS handling beyond refusing what is not WGS84.
861
+
862
+ ### Getting a shapefile in
863
+
864
+ A cutline usually starts life as a shapefile in a projected CRS, because that is
865
+ what `gdalwarp -cutline` wants. One `ogr2ogr` converts it, and it is the same
866
+ tool that produced the shapefile in the first place:
867
+
868
+ ```sh
869
+ ogr2ogr -t_srs EPSG:4326 \
870
+ data/cutlines/germany.geojson \
871
+ datasets/OpenDTM_DE/cutline/germany_cutline_25832.shp
872
+ ```
873
+
874
+ The name of the file is the name the recipe uses, so that one becomes
875
+ `"cutline": "germany"`. Nothing has to restart: cutlines are read when the node
876
+ starts and the console lists whatever is there.
877
+
878
+ **Simplify it.** A national boundary drawn for surveying carries far more detail
879
+ than a tile can show — Germany's is ninety-three rings and sixty-five thousand
880
+ points in its first record alone — and every one of them is a segment to index
881
+ and to test. What matters is being right to about a pixel at the deepest zoom
882
+ served, which at z14 is roughly five metres:
883
+
884
+ ```sh
885
+ ogr2ogr -t_srs EPSG:4326 -simplify 0.0001 out.geojson in.shp
886
+ ```
887
+
888
+ Holes and islands are kept, and want to be: a country is islands and enclaves
889
+ and lakes. Under the even-odd rule an interior ring needs no special handling
890
+ and gets none — a ray to a point inside one crosses the outer ring and then the
891
+ inner ring, which is two crossings, which is outside.
892
+
893
+ Edges are treated as straight lines in Mercator after their endpoints are
894
+ projected. That is what every rasteriser does, GDAL's included, and the error
895
+ over a tile's span is far below a pixel except at zooms where a tile spans a
896
+ continent — where a cutline is not the thing deciding the answer anyway.
897
+
898
+ ### Three answers, and only one of them costs anything
899
+
900
+ The cost of a clip is per pixel, and paying it per tile would make a continental
901
+ boundary unusable. It is avoided by asking a cheaper question first:
902
+
903
+ - **Outside.** No part of the tile is within the shape. The source contributes
904
+ nothing — and this is decided _before the tile is read_, so it costs no swarm
905
+ read, no decode and no merge.
906
+ - **Inside.** The tile is wholly within the shape. The clip cannot change any
907
+ pixel, so it is not applied at all.
908
+ - **Partial.** Only here is a mask rasterised, and only for the tile in hand.
909
+
910
+ For a country boundary at any useful zoom, almost every tile is one of the first
911
+ two. The third is a band one tile wide along the border.
912
+
913
+ Classification is a bounding-box test, then a look at whichever edges could
914
+ reach the tile. "Whichever" is the important word: a national boundary is tens
915
+ of thousands of segments, and testing all of them per tile would cost more than
916
+ the rasterising it is meant to avoid. The segments are indexed into a coarse
917
+ grid once, when the cutline is loaded, and a tile only ever looks at the buckets
918
+ it overlaps.
919
+
920
+ With no edge near the tile, one point decides it: a tile that no boundary
921
+ crosses is entirely on one side of it.
922
+
923
+ ### Where it applies
924
+
925
+ As a coverage mask on the decoded heights, in the same place and for the same
926
+ reason as `maskValues` — before any height adjustment, because an adjustment
927
+ would shift values out from under a comparison, and because the answer is the
928
+ same either way: nothing here.
929
+
930
+ It applies at the geometry of the tile that was **asked for**, not of the tile
931
+ that answered. A source falling back to a parent still gets clipped to the
932
+ square being served, which is the ground the pixels will end up covering.
933
+
934
+ ### What it costs the short-circuit
935
+
936
+ A clipped source cannot take the passthrough in
937
+ [Evaluating one tile](#evaluating-one-tile) — with one exception that is worth
938
+ having, because it is the common one. Where the tile is classified **inside**,
939
+ the clip provably changes nothing, and the bytes may go through untouched
940
+ exactly as if none were named. Outside, there is no contribution at all, so the
941
+ question does not arise. Only a partial tile is genuinely disqualified.
942
+
943
+ ### Refusing what cannot be honoured
944
+
945
+ A cutline named by a recipe and not present on disk is a stack problem, reported
946
+ the way an unresolvable source is: the stack is listed, and it says what is
947
+ missing. Serving a source unclipped because its cutline could not be found would
948
+ put back exactly the data somebody asked to remove, which is the one failure a
949
+ clip must not have.
950
+
951
+ ## Finding a stack
952
+
953
+ A stack has no infohash and appears in no feed, so nothing about it is
954
+ discoverable the way an archive is. `GET /stacks/` answers the list, on the
955
+ public listener beside the tiles and the TileJSON it describes, and the
956
+ catalogue page renders it.
957
+
958
+ Not the console's list. That one reports what each source resolved to, what is
959
+ missing and what cannot be served — the operator's view, naming infohashes a
960
+ visitor was never offered. The public one says what a visitor can point a map
961
+ at, and says nothing at all about a stack they cannot: a recipe with a problem,
962
+ or one asking for pixel work this node has no codec for, is left out rather than
963
+ advertised as a link that answers 501.
964
+
796
965
  ## Syncing a stack to another node
797
966
 
798
967
  The question is whether a stack can travel between nodes the way an archive
@@ -903,7 +1072,9 @@ state says what a stack is and how to add one instead. It is not conditioned on
903
1072
  having a codec either — a passthrough stack needs none, and a node without
904
1073
  `sharp` can still build and serve one.
905
1074
 
906
- What follows is the editing half, which does not exist yet.
1075
+ What follows is the editing half, which the console now does: a stack can be
1076
+ added, changed and removed there, and the source rows are edited in the order
1077
+ the file holds them.
907
1078
 
908
1079
  ### The list is shown in the file's order
909
1080
 
@@ -1103,7 +1274,7 @@ handling whatsoever.
1103
1274
  level up, and `resampleFromParent` crops the right sub-square.
1104
1275
  - **Source smaller than the output** (256px source, 512px stack) needs the
1105
1276
  other direction: fetch the four children at `z+1` and assemble them into
1106
- one grid before merging. That is the piece that does not exist yet.
1277
+ one grid before merging.
1107
1278
 
1108
1279
  **Both are implemented.** A size may be asked for in the URL —
1109
1280
  `/stacks/<id>/512/{z}/{x}/{y}.webp`, the way tileserver-gl takes one — or set
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.67.0",
3
+ "version": "0.68.1",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/api.js CHANGED
@@ -36,7 +36,7 @@ import { buildTileJson, extensionMatches, tileExtension } from './tilejson.js';
36
36
  import { SUMMARY_VERSION } from './pmtiles-probe.js';
37
37
  import { TileReadError } from './tiles.js';
38
38
  import { loadCodec } from './codec.js';
39
- import { answerStackTile, outputSize } from './stack-tile.js';
39
+ import { answerStackTile, outputFormat, outputSize } from './stack-tile.js';
40
40
  import {
41
41
  isPinned,
42
42
  needsCodec,
@@ -215,6 +215,7 @@ export function createApp({
215
215
  tiles,
216
216
  stacks,
217
217
  stackCache,
218
+ cutlines,
218
219
  bakes,
219
220
  warm,
220
221
  config,
@@ -2264,6 +2265,66 @@ export function createApp({
2264
2265
  }),
2265
2266
  );
2266
2267
 
2268
+ // The same idea one prefix over. Everything under /stacks/ is already public
2269
+ // -- the TileJSON, the tiles, the preview -- so the index of what is on
2270
+ // offer belongs beside them rather than behind the console's door.
2271
+ //
2272
+ // Not the console's list. That one reports what each source resolved to,
2273
+ // what is missing and what cannot be served, which is the operator's
2274
+ // business and names infohashes a visitor was not offered. This says what a
2275
+ // visitor can point a map at, and says nothing about a stack they cannot.
2276
+ app.get(
2277
+ '/stacks/',
2278
+ route(async (req, res) => {
2279
+ await stacks?.refresh();
2280
+ res.setHeader('access-control-allow-origin', '*');
2281
+
2282
+ const base = baseUrl(req);
2283
+ const listed = [];
2284
+ for (const stack of stacks?.list() ?? []) {
2285
+ const resolved = resolveFor(stack, req);
2286
+ // A recipe with a problem, or one asking for pixel work this node
2287
+ // cannot do, is not something to advertise: a link that answers 501 is
2288
+ // worse than no link.
2289
+ if (stacks.problems(stack.id).length > 0) continue;
2290
+ if (needsCodec(stack) && !(await loadCodec())) continue;
2291
+ if (!resolved.sources.some((source) => source.entry)) continue;
2292
+
2293
+ const coverage = stackCoverage(resolved);
2294
+ const format = outputFormat(resolved);
2295
+ const id = encodeURIComponent(stack.id);
2296
+ listed.push({
2297
+ id: stack.id,
2298
+ title: stack.title ?? stack.id,
2299
+ space: stack.space ?? 'elevation',
2300
+ minzoom: coverage.minzoom,
2301
+ maxzoom: coverage.maxzoom,
2302
+ bounds: coverage.bounds,
2303
+ format,
2304
+ encoding: stack.output?.encoding ?? null,
2305
+ sparse: stack.sparse ?? true,
2306
+ sources: resolved.sources.length,
2307
+ endpoints: {
2308
+ tileJson: `${base}/stacks/${id}/tiles.json`,
2309
+ xyz: `${base}/stacks/${id}/{z}/{x}/{y}.${tileExtension(format)}`,
2310
+ preview: `${base}/stacks/${id}/preview`,
2311
+ },
2312
+ });
2313
+ }
2314
+
2315
+ res.setHeader(
2316
+ 'etag',
2317
+ `"${crypto.createHash('sha1').update(JSON.stringify(listed)).digest('hex')}"`,
2318
+ );
2319
+ res.setHeader('cache-control', 'public, max-age=60, must-revalidate');
2320
+ res.json({
2321
+ format: 'pmtiles-swarm-stacks/1',
2322
+ generatedAt: new Date().toISOString(),
2323
+ stacks: listed,
2324
+ });
2325
+ }),
2326
+ );
2327
+
2267
2328
  // A stable handle for "the current one". Every archive is addressed by
2268
2329
  // infohash, which is right — it is what makes a tile immutable — but it
2269
2330
  // leaves nothing for a style to point at that survives a rebuild. A category
@@ -3083,6 +3144,22 @@ export function createApp({
3083
3144
  * @param {import('express').Request} req - The request, for its credential.
3084
3145
  * @returns {object} - The resolution.
3085
3146
  */
3147
+ /**
3148
+ * Cutlines a recipe names that this node does not have.
3149
+ *
3150
+ * Serving a source unclipped because its shape could not be found would put
3151
+ * back exactly the data somebody asked to remove, so the stack says so and
3152
+ * the tile path refuses the source.
3153
+ * @param {object} stack - The recipe.
3154
+ * @returns {string[]} - One line per missing cutline.
3155
+ */
3156
+ const cutlineProblems = (stack) =>
3157
+ (stack.sources ?? []).flatMap((source, index) => {
3158
+ if (!source?.cutline) return [];
3159
+ const why = cutlines?.problem(source.cutline);
3160
+ return why ? [`sources[${index}].cutline: ${why}`] : [];
3161
+ });
3162
+
3086
3163
  const resolveFor = (stack, req) =>
3087
3164
  resolveStack(stack, {
3088
3165
  archive: (hash) => catalog.get(hash) ?? null,
@@ -3108,7 +3185,11 @@ export function createApp({
3108
3185
  id: stack.id,
3109
3186
  title: stack.title ?? stack.id,
3110
3187
  space: stack.space ?? 'elevation',
3111
- problems: stacks.problems(stack.id),
3188
+ // The store's own problems, plus any cutline a source names that
3189
+ // this node has not got. Both stop the stack being servable and
3190
+ // both belong in the same list -- a stack listed as fine that
3191
+ // refuses every tile is worse than one that says why.
3192
+ problems: [...stacks.problems(stack.id), ...cutlineProblems(stack)],
3112
3193
  // Named separately from `problems` because it is not a mistake: a
3113
3194
  // recipe asking for pixel work is perfectly valid and simply cannot
3114
3195
  // be served yet. See docs/tile-stacks.md — "The codec problem".
@@ -3154,7 +3235,13 @@ export function createApp({
3154
3235
  // that need it, rather than leaving a 501 to be discovered at the first
3155
3236
  // tile. Null is a fact about this node, not about the recipes.
3156
3237
  const codec = await loadCodec();
3157
- res.json({ stacks: list, codec: codec?.name ?? null });
3238
+ res.json({
3239
+ stacks: list,
3240
+ codec: codec?.name ?? null,
3241
+ // So the editor can offer the shapes this node actually has rather
3242
+ // than asking somebody to remember a filename.
3243
+ cutlines: cutlines?.list() ?? [],
3244
+ });
3158
3245
  }),
3159
3246
  );
3160
3247
 
@@ -3541,6 +3628,7 @@ export function createApp({
3541
3628
  signal: controller.signal,
3542
3629
  size,
3543
3630
  format,
3631
+ cutlines,
3544
3632
  });
3545
3633
  } catch (error) {
3546
3634
  // The client went away mid-merge. Nothing to answer and nobody to answer
package/src/bake-jobs.js CHANGED
@@ -35,16 +35,18 @@ export class BakeManager {
35
35
  #tiles;
36
36
  #config;
37
37
  #loadCodec;
38
+ #cutlines;
38
39
  #jobs = new Map();
39
40
 
40
41
  /**
41
42
  * @param {object} deps - The library, the tile store, the config and the codec probe.
42
43
  */
43
- constructor({ library, tiles, config, loadCodec }) {
44
+ constructor({ library, tiles, config, loadCodec, cutlines }) {
44
45
  this.#library = library;
45
46
  this.#tiles = tiles;
46
47
  this.#config = config;
47
48
  this.#loadCodec = loadCodec;
49
+ this.#cutlines = cutlines;
48
50
  }
49
51
 
50
52
  /**
@@ -273,6 +275,7 @@ export class BakeManager {
273
275
  tiles: this.#tiles,
274
276
  codec,
275
277
  pixels,
278
+ cutlines: this.#cutlines,
276
279
  signal: job.controller.signal,
277
280
  format,
278
281
  }),
package/src/bake.js CHANGED
@@ -389,7 +389,7 @@ export function tileTypeFor(format) {
389
389
  * @returns {Function} - `(z, x, y) => Promise<Buffer|null>`.
390
390
  */
391
391
  export function mergeTileFor(options) {
392
- const { resolved, tiles, codec, signal, pixels } = options;
392
+ const { resolved, tiles, codec, signal, pixels, cutlines } = options;
393
393
  const format = options.format ?? outputFormat(resolved);
394
394
  const size = options.size ?? outputSize(resolved.stack);
395
395
 
@@ -406,6 +406,7 @@ export function mergeTileFor(options) {
406
406
  size,
407
407
  format,
408
408
  pixels,
409
+ cutlines,
409
410
  });
410
411
 
411
412
  // A required source that cannot be read stops the job. Baking around it