pmtiles-swarm 0.72.0 → 0.73.0
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 +113 -0
- package/docs/running-as-a-service.md +6 -0
- package/docs/tile-stacks.md +226 -1
- package/package.json +1 -1
- package/src/api.js +10 -5
- package/src/auth.js +4 -1
- package/src/bake-jobs.js +61 -0
- package/src/catalog.js +1 -1
- package/src/config.js +5 -5
- package/src/cutline.js +153 -10
- package/src/cutlines.js +34 -2
- package/src/elevation.js +125 -16
- package/src/engines/composite.js +13 -2
- package/src/engines/libtorrent.js +6 -1
- package/src/engines/webtorrent.js +4 -0
- package/src/feed.js +1 -1
- package/src/hooks.js +8 -3
- package/src/incomplete.js +8 -2
- package/src/library.js +3 -0
- package/src/mutable.js +6 -0
- package/src/origin.js +0 -1
- package/src/pixels.js +5 -1
- package/src/pmtiles-probe.js +6 -0
- package/src/pmtiles-scan.js +2 -2
- package/src/pmtiles-write.js +16 -4
- package/src/prewarm.js +5 -1
- package/src/publisher.js +3 -1
- package/src/rate-limits.js +4 -1
- package/src/rgba.js +4 -1
- package/src/savepath.js +1 -0
- package/src/seeding.js +4 -1
- package/src/sources.js +12 -6
- package/src/stack-cache.js +2 -1
- package/src/stack-tile.js +38 -7
- package/src/stacks.js +35 -3
- package/src/subscriptions.js +2 -0
- package/src/systemd.js +37 -0
- package/src/tilejson.js +0 -1
- package/src/torrent-create.js +0 -1
- package/src/traffic-stats.js +8 -7
- package/src/watch.js +2 -1
- package/src/web/index.html +88 -1
- package/tools/tile-bench.mjs +18 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,119 @@
|
|
|
7
7
|
### 🐞 Bug fixes
|
|
8
8
|
- _...Add new stuff here..._
|
|
9
9
|
|
|
10
|
+
## 0.73.0
|
|
11
|
+
### ✨ Features and improvements
|
|
12
|
+
- **A source can fade in at the edge of its shape instead of stopping dead.** Where a
|
|
13
|
+
high-resolution local DEM meets a coarser global one the two disagree - by their survey and by
|
|
14
|
+
their vertical datum - and the merge took the upper one outright, so the step between them read
|
|
15
|
+
as a wall under a hillshade. `feather` on a source, in pixels, ramps its weight in from its
|
|
16
|
+
`cutline` or its `bounds`.
|
|
17
|
+
|
|
18
|
+
The step left is the height difference divided by the feather, which makes the number
|
|
19
|
+
predictable: two sources 40 m apart, faded over 16 pixels, step 2.5 m a pixel instead of 40 m at
|
|
20
|
+
once. It also hides a vertical datum disagreement, which otherwise wants a hand-tuned
|
|
21
|
+
`heightAdjustment` to correct and is the same wall by another cause.
|
|
22
|
+
|
|
23
|
+
Three things worth knowing. The ramp runs inward only, because a cutline says where a source's
|
|
24
|
+
data is good and spreading it outward would answer for ground the recipe just excluded. A
|
|
25
|
+
feathered layer over a hole stands alone, because fading into nothing would erode the source by
|
|
26
|
+
the width of its own feather exactly where it is the only cover. And RGBA needed no merge change
|
|
27
|
+
at all - alpha is already the weight that space composites with.
|
|
28
|
+
|
|
29
|
+
Not yet for a mask edge, which is where most seams actually come from. A cutline is known in
|
|
30
|
+
full, so the ramp beside a tile costs a few extra rasterised rows; a mask edge lives in the
|
|
31
|
+
source's own pixels and needs its neighbours read and decoded. docs/tile-stacks.md says what that
|
|
32
|
+
would take.
|
|
33
|
+
- **Smoothing is in the stack editor, and bounded.** `gaussianBlurSigma` was honoured, documented
|
|
34
|
+
and reachable only by hand-editing `stacks.json`. It now sits under the output settings beside
|
|
35
|
+
Resampling, where it belongs - the two describe the same operation - and is hidden for an RGBA
|
|
36
|
+
stack, which never enters elevation space. Zero is left out of the recipe rather than written
|
|
37
|
+
down.
|
|
38
|
+
|
|
39
|
+
Validated as well as offered. The sigma a recipe names is multiplied by how many zoom levels a
|
|
40
|
+
source was upscaled, so the cost of a typo grows with the kernel and the distance at once: `50`
|
|
41
|
+
at a six-level upscale is a 900-pixel kernel and eight seconds for one 512px tile, on an endpoint
|
|
42
|
+
anybody can ask. `8` is the most it takes now, which is already past useful - these archives were
|
|
43
|
+
built with `1.5`.
|
|
44
|
+
|
|
45
|
+
The export dialog says what the recipe will write - encoding, format, size, resampling, smoothing
|
|
46
|
+
- because an export is hours of work and every setting that decides the result lives in the stack
|
|
47
|
+
rather than in that dialog.
|
|
48
|
+
- **An export says so when it cannot use the machine it was given.** `stacks.bakeConcurrency` sizes
|
|
49
|
+
how many tiles are merged at once and how many threads do their arithmetic. It does not size the
|
|
50
|
+
one that binds: decoding and encoding a tile is sharp, sharp does that on libuv's thread pool, and
|
|
51
|
+
that pool holds four threads unless the environment says otherwise. Raising `bakeConcurrency` past
|
|
52
|
+
four moved where the merges queued rather than how many ran, so the export stayed slow and the
|
|
53
|
+
cores stayed idle with nothing logged and nothing failing.
|
|
54
|
+
|
|
55
|
+
Measured on sixteen cores, merging 512px tiles from three sources into lossless WebP: 26 tiles/s
|
|
56
|
+
at the default pool of 4, 41 at 8, 51 at 16, 54 at 32. It cannot be set from inside the process -
|
|
57
|
+
the pool is built before any of this code runs, and setting `process.env.UV_THREADPOOL_SIZE` at
|
|
58
|
+
startup measurably does nothing - so a bake now warns once when `bakeConcurrency` is larger than
|
|
59
|
+
the pool, naming the variable and the value to give it. The systemd unit in the guide sets it,
|
|
60
|
+
and docs/tile-stacks.md has the numbers under "Giving a bake the whole machine".
|
|
61
|
+
|
|
62
|
+
The batch is not the problem, which is worth writing down because it looks like it should be:
|
|
63
|
+
replacing it with a sliding window that retires in order measured 12% better on a large pool and
|
|
64
|
+
worse on the default one.
|
|
65
|
+
|
|
66
|
+
`init --systemd` writes `Environment=UV_THREADPOOL_SIZE` into the unit, set from
|
|
67
|
+
`stacks.bakeConcurrency`, for the same reason it derives `ReadWritePaths`: it is decidable from
|
|
68
|
+
the configuration and silent when wrong. An existing unit does not gain it by upgrading - re-run
|
|
69
|
+
`pmtiles-swarm init --systemd`, or add the line to `[Service]` by hand.
|
|
70
|
+
|
|
71
|
+
Both the unit it writes and the warning ask for `min(bakeConcurrency, cores)` rather than
|
|
72
|
+
`bakeConcurrency`, because past the core count a thread has nowhere to run and the measurements
|
|
73
|
+
flatten. A node merging 32 tiles at once on twelve cores wants twelve threads and is not
|
|
74
|
+
misconfigured; the first version of this would have told its operator otherwise.
|
|
75
|
+
|
|
76
|
+
### 🐞 Bug fixes
|
|
77
|
+
- **The smoothing was drawing a grid at tile boundaries.** `blurHeights` reads a neighbourhood and
|
|
78
|
+
a tile server has one tile, so it clamped at the edge - and two adjacent tiles then computed their
|
|
79
|
+
shared boundary from different data. On a slope they disagreed by tens of metres, which under a
|
|
80
|
+
hillshade is a faint line at every tile edge.
|
|
81
|
+
|
|
82
|
+
An upscaled source turns out to have the pixels already. The blur only runs where the tile came
|
|
83
|
+
from a parent, and that parent was read in full, so `resampleFromParent` now takes a `margin`,
|
|
84
|
+
the blur runs over the larger raster, and `cropMargin` throws the border away. At a six-level
|
|
85
|
+
upscale a 27-pixel border is 0.42 parent pixels - already in the array. Two adjacent children of
|
|
86
|
+
one parent, at `gaussianBlurSigma: 1.5`, stepping beyond what the same two unblurred tiles step:
|
|
87
|
+
|
|
88
|
+
upscale 2 (sigma 3) 31.73 m -> -0.05 m
|
|
89
|
+
upscale 4 (sigma 6) 20.14 m -> 0.00 m
|
|
90
|
+
upscale 6 (sigma 9) 6.15 m -> 0.00 m
|
|
91
|
+
upscale 8 (sigma 12) 0.43 m -> 0.00 m
|
|
92
|
+
|
|
93
|
+
Worst at a moderate upscale rather than an extreme one, which is not the intuition: eight levels
|
|
94
|
+
up the sub-region is two parent pixels across and there is little for a clamped edge to get
|
|
95
|
+
wrong. It costs 10-15%, and the border is capped at a quarter of the tile.
|
|
96
|
+
|
|
97
|
+
Anything built with a sigma has this baked in, here and in the offline merge - `gaussian_filter`
|
|
98
|
+
defaults to `mode='reflect'` over one tile's array. Re-exporting is what removes it.
|
|
99
|
+
|
|
100
|
+
## 0.72.1
|
|
101
|
+
### 🐞 Bug fixes
|
|
102
|
+
- **A settings save could set something that was not a setting.** `saveConfig` rejected a key it
|
|
103
|
+
did not recognise by asking `key in DEFAULTS`, and `in` walks the prototype chain: every object
|
|
104
|
+
inherits `__proto__`, `constructor` and `toString`, so all three passed as known settings. The
|
|
105
|
+
assignment underneath then did what those names mean rather than what a setting means -
|
|
106
|
+
`config.__proto__ = {...}` replaces the running config's prototype - and the result was written
|
|
107
|
+
to the config file. `Object.hasOwn` asks the question that was meant.
|
|
108
|
+
|
|
109
|
+
Reachable only by an admin token, which is a token that can change any setting anyway, so this
|
|
110
|
+
is a gate that was not doing its job rather than a way in. Found by reading through what
|
|
111
|
+
`security/detect-object-injection` had to say, which is what it is switched on for.
|
|
112
|
+
- **A date placeholder that was nearly right took exponentially long to reject.** `expandTemplate`
|
|
113
|
+
read a `{...}` group as a date pattern by testing it against `^[YMDymd]+([-_./ ]?[YMDymd]+)*$`.
|
|
114
|
+
With the separator optional, a run of field letters can be divided between the group and the `+`
|
|
115
|
+
in front of it in every possible way, so a group that is all field letters and one character that
|
|
116
|
+
cannot match has to try all of them before saying no: 24 characters took 100ms, and each two
|
|
117
|
+
after that doubled it.
|
|
118
|
+
|
|
119
|
+
Requiring the separator inside the group accepts exactly the same set of patterns - a run can
|
|
120
|
+
only be matched one way now - and rejects the near miss immediately. The template is config
|
|
121
|
+
rather than anything a stranger sends, so this was a source of surprise rather than a way in.
|
|
122
|
+
|
|
10
123
|
## 0.72.0
|
|
11
124
|
### ✨ Features and improvements
|
|
12
125
|
- **An unfinished export is picked up when the node starts.** The checkpoint was always there;
|
|
@@ -213,6 +213,12 @@ WorkingDirectory=/var/lib/pmtiles-swarm
|
|
|
213
213
|
ExecStart=/var/lib/pmtiles-swarm/node_modules/.bin/pmtiles-swarm \
|
|
214
214
|
--config /etc/pmtiles-swarm/swarm.config.json
|
|
215
215
|
|
|
216
|
+
# How many tiles a stack export can decode or encode at once. Four unless this
|
|
217
|
+
# says otherwise, whatever stacks.bakeConcurrency is set to. `init --systemd`
|
|
218
|
+
# writes this from that setting; if you keep the unit by hand, keep the two in
|
|
219
|
+
# step. See docs/tile-stacks.md - "Giving a bake the whole machine".
|
|
220
|
+
Environment=UV_THREADPOOL_SIZE=16
|
|
221
|
+
|
|
216
222
|
# Required. The console's Save & Restart exits and expects to be brought back;
|
|
217
223
|
# see below.
|
|
218
224
|
Restart=always
|
package/docs/tile-stacks.md
CHANGED
|
@@ -225,7 +225,7 @@ it differs from the snake_case rio-rgbify-merge uses.
|
|
|
225
225
|
| `output.nodata` | Height an uncovered pixel encodes as. Elevation space only. |
|
|
226
226
|
| `output.tileSize` | 256 or 512, the stack's default. A URL overrides it per request; with neither, the largest source decides. |
|
|
227
227
|
| `resampling` | `nearest`, `bilinear`, `cubic` or `lanczos`. |
|
|
228
|
-
| `gaussianBlurSigma` | Multiplied by the zoom distance of an upscaled parent. `0` disables.
|
|
228
|
+
| `gaussianBlurSigma` | Multiplied by the zoom distance of an upscaled parent. `0` disables, `8` is the most it takes. |
|
|
229
229
|
| `boundsSource` | Index into `sources` whose bounds become the stack's. Omit for the union. |
|
|
230
230
|
| `bounds` | Explicit `[w, s, e, n]`. Wins over `boundsSource`. |
|
|
231
231
|
| `minzoom` / `maxzoom` | Clamps. Default to the min and **max** over the sources. |
|
|
@@ -242,6 +242,7 @@ it differs from the snake_case rio-rgbify-merge uses.
|
|
|
242
242
|
| `baseVal` / `interval` | Mapbox decode offset and step. Default `-10000` and `0.1`. |
|
|
243
243
|
| `maskValues` | Decoded heights meaning "no data here". Elevation space only. |
|
|
244
244
|
| `heightAdjustment` | Metres, added **after** masking. Elevation space only. |
|
|
245
|
+
| `feather` | Pixels to fade in over at the edge of the source's shape. Needs a `cutline` or `bounds`. Max 64. |
|
|
245
246
|
| `opacity` | `0`–`1`, scales the source alpha. RGBA space only. |
|
|
246
247
|
| `blend` | `normal`, `multiply`, `screen`, `overlay`, `darken`, `lighten`. RGBA space only. |
|
|
247
248
|
|
|
@@ -711,6 +712,57 @@ tiles. Zero is as fast as it can go, which is right for a node baking and doing
|
|
|
711
712
|
nothing else. On a node that is also serving maps it is the direct trade: how
|
|
712
713
|
long the bake takes against how much of the machine it takes while it runs.
|
|
713
714
|
|
|
715
|
+
### Giving a bake the whole machine
|
|
716
|
+
|
|
717
|
+
`stacks.bakeConcurrency` sizes two things: how many tiles are merged at once,
|
|
718
|
+
and how many threads `src/pixels.js` starts to do their arithmetic on. It does
|
|
719
|
+
not size the third thing, and the third thing is the one that binds.
|
|
720
|
+
|
|
721
|
+
Decoding a source tile and encoding the result are sharp, and sharp does that
|
|
722
|
+
work on libuv's thread pool. That pool holds **four** threads unless the
|
|
723
|
+
environment says otherwise, and it is built before any of this code runs — so
|
|
724
|
+
setting `process.env.UV_THREADPOOL_SIZE` from inside the process is too late and
|
|
725
|
+
measurably does nothing. Raising `bakeConcurrency` past four therefore moves
|
|
726
|
+
where the merges queue rather than how many of them run: they queue at the
|
|
727
|
+
decode instead of at the arithmetic, and the machine sits mostly idle looking
|
|
728
|
+
like a bake that is simply slow.
|
|
729
|
+
|
|
730
|
+
On sixteen cores, merging 512px tiles from three sources into lossless WebP:
|
|
731
|
+
|
|
732
|
+
| `UV_THREADPOOL_SIZE` | tiles/s |
|
|
733
|
+
| -------------------- | ------- |
|
|
734
|
+
| 4 (the default) | 26.2 |
|
|
735
|
+
| 8 | 40.9 |
|
|
736
|
+
| 16 | 51.1 |
|
|
737
|
+
| 32 | 54.2 |
|
|
738
|
+
|
|
739
|
+
So set it in the environment the node starts in, alongside `bakeConcurrency`:
|
|
740
|
+
|
|
741
|
+
```ini
|
|
742
|
+
Environment=UV_THREADPOOL_SIZE=16
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
Past the core count it flattens, because at that point the cores are the limit
|
|
746
|
+
and that is the right place for the limit to be — so what the pool wants is
|
|
747
|
+
`min(bakeConcurrency, cores)`, and both `init --systemd` and the warning below
|
|
748
|
+
work that out rather than echoing `bakeConcurrency` back. A node merging 32
|
|
749
|
+
tiles at once on twelve cores wants twelve threads and is not misconfigured.
|
|
750
|
+
|
|
751
|
+
A bake warns once when the pool is smaller than that, because the failure is
|
|
752
|
+
otherwise invisible: nothing errors, nothing logs, the export is just several
|
|
753
|
+
times slower than the machine can manage.
|
|
754
|
+
|
|
755
|
+
`sharp.concurrency()` is a different knob — how many threads libvips uses
|
|
756
|
+
_within_ one operation — and it is left alone. A 512px tile is too small to
|
|
757
|
+
split usefully, and the measurements above move by under 2% whether it is 1 or 16.
|
|
758
|
+
|
|
759
|
+
The batch is not the problem, which is worth writing down because it looks like
|
|
760
|
+
it should be. `bakeStack` merges a batch, waits for all of it, then writes it in
|
|
761
|
+
order, so nothing merges while anything writes and a batch costs the slowest
|
|
762
|
+
tile in it. Replacing that with a sliding window that retires in order was
|
|
763
|
+
measured at 12% on a large pool and _slower_ on the default one, which is not
|
|
764
|
+
worth the second thing to get right.
|
|
765
|
+
|
|
714
766
|
### What identifies a bake
|
|
715
767
|
|
|
716
768
|
`bakeRevision` is the recipe's revision and what each source resolved to,
|
|
@@ -962,6 +1014,179 @@ missing. Serving a source unclipped because its cutline could not be found would
|
|
|
962
1014
|
put back exactly the data somebody asked to remove, which is the one failure a
|
|
963
1015
|
clip must not have.
|
|
964
1016
|
|
|
1017
|
+
## Feathering a seam
|
|
1018
|
+
|
|
1019
|
+
_Built for a cutline and for `bounds`. A mask edge is not feathered yet, and
|
|
1020
|
+
the last section says what that still needs._
|
|
1021
|
+
|
|
1022
|
+
Smoothing hides the terracing **inside** an upscaled area. It does nothing about
|
|
1023
|
+
the artefact the original discussion actually named — "artefacts at tiles and
|
|
1024
|
+
country borders when multiple DEMs overlap and do not have the same resolution".
|
|
1025
|
+
That is a different thing: a step where one dataset stops and another resumes,
|
|
1026
|
+
and no amount of blurring one side of it will help.
|
|
1027
|
+
|
|
1028
|
+
Today the handover is exact. `paintHeights` walks the layers in order and takes
|
|
1029
|
+
whatever the upper one has:
|
|
1030
|
+
|
|
1031
|
+
```js
|
|
1032
|
+
if (!Number.isNaN(value)) result[i] = value;
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
A pixel is one dataset or the other. Where a high-resolution local DEM ends, the
|
|
1036
|
+
next pixel is a global 30 m one upscaled six levels, and the two disagree — by
|
|
1037
|
+
their vertical datum, by their resampling, by simply being different surveys.
|
|
1038
|
+
Under a hillshade that reads as a wall.
|
|
1039
|
+
|
|
1040
|
+
### Where the seams actually are
|
|
1041
|
+
|
|
1042
|
+
Three geometries, and they are not equally hard.
|
|
1043
|
+
|
|
1044
|
+
**A mask hole.** `maskValues` and `maskColors` turn nodata into `NaN`, and this
|
|
1045
|
+
is where most seams come from — a dataset that covers part of the world carries
|
|
1046
|
+
a fill value everywhere else, and masking it is what lets the layer underneath
|
|
1047
|
+
show through. The edge is data-dependent: it is wherever masked pixels meet
|
|
1048
|
+
unmasked ones, discovered per tile, at whatever resolution the source is being
|
|
1049
|
+
read at.
|
|
1050
|
+
|
|
1051
|
+
**A missing tile.** A sparse archive simply has no tile outside its extent, so
|
|
1052
|
+
the source contributes nothing and the boundary lands exactly on a tile edge —
|
|
1053
|
+
a 512-pixel straight line, which is the most visible seam of the three and the
|
|
1054
|
+
cheapest to detect. Nothing needs decoding to find it.
|
|
1055
|
+
|
|
1056
|
+
**A cutline.** Geometric, known analytically, and already computed per pixel by
|
|
1057
|
+
`coverageMask`. The easiest case and, going by how these recipes are actually
|
|
1058
|
+
written, the rarest.
|
|
1059
|
+
|
|
1060
|
+
### What feathering is
|
|
1061
|
+
|
|
1062
|
+
`coverage` becomes a weight rather than a switch — `Float32Array` in 0..1 rather
|
|
1063
|
+
than `Uint8Array` in {0,1} — and the merge blends instead of replacing:
|
|
1064
|
+
|
|
1065
|
+
```js
|
|
1066
|
+
result[i] = result[i] * (1 - w) + value * w;
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
The weight ramps from 0 to 1 over `feather` pixels inward from the edge, so a
|
|
1070
|
+
local DEM fades into the global one across a band instead of stepping into it.
|
|
1071
|
+
That also buys the thing nobody asks for by name: two DEMs on different geoids
|
|
1072
|
+
sit metres apart, and today the only cure is a hand-tuned `heightAdjustment`.
|
|
1073
|
+
Feathered, a metre or two of bias becomes a ramp nobody sees.
|
|
1074
|
+
|
|
1075
|
+
`NaN` still has to mean "nothing here" — a weight of 0 and a height of `NaN` are
|
|
1076
|
+
the same statement, and only one of them can be written into a `Float32Array` of
|
|
1077
|
+
heights. So the weight travels beside the layer, not inside it.
|
|
1078
|
+
|
|
1079
|
+
### The border underneath it, which is built
|
|
1080
|
+
|
|
1081
|
+
Any operation with a radius needs pixels beyond the tile it is filling in, and a
|
|
1082
|
+
tile server has one tile. `blurHeights` used to handle that by clamping at the
|
|
1083
|
+
edge, so two adjacent tiles computed their shared boundary from different data
|
|
1084
|
+
and did not agree.
|
|
1085
|
+
|
|
1086
|
+
The fix is that an upscaled source already has the pixels. The blur only runs
|
|
1087
|
+
where `parentZ < z`, and `resampleFromParent` is sampling a sub-region of a
|
|
1088
|
+
parent tile that was read in full — so it now takes a `margin` and returns
|
|
1089
|
+
`size + 2r` a side, the blur runs over that, and `cropMargin` throws the border
|
|
1090
|
+
away. At a six-level upscale a 512px output tile is 8×8 parent pixels, so a
|
|
1091
|
+
27-pixel output border is **0.42 parent pixels**: already in the array.
|
|
1092
|
+
|
|
1093
|
+
Two adjacent children of one parent, `gaussianBlurSigma: 1.5`, measuring how far
|
|
1094
|
+
their shared edge steps beyond what the same two unblurred tiles step:
|
|
1095
|
+
|
|
1096
|
+
| upscale | sigma | border | clamped | bordered |
|
|
1097
|
+
| ------- | ----- | ------ | ------- | -------- |
|
|
1098
|
+
| 2 | 3 | 9 px | 31.73 m | −0.05 m |
|
|
1099
|
+
| 4 | 6 | 18 px | 20.14 m | 0.00 m |
|
|
1100
|
+
| 6 | 9 | 27 px | 6.15 m | 0.00 m |
|
|
1101
|
+
| 8 | 12 | 36 px | 0.43 m | 0.00 m |
|
|
1102
|
+
|
|
1103
|
+
Worst at a **moderate** upscale rather than an extreme one, which is not the
|
|
1104
|
+
intuition. At eight levels the sub-region is two parent pixels across and the
|
|
1105
|
+
tile is nearly flat, so there is little for a clamped edge to get wrong; at two
|
|
1106
|
+
levels there is real gradient at the tile edge and clamping repeats it.
|
|
1107
|
+
|
|
1108
|
+
It costs 10–15% of the resample and the blur together. The border is capped at a
|
|
1109
|
+
quarter of the tile — past that it buys nothing the radius has not already used,
|
|
1110
|
+
and a sigma wanting more is smoothing the tile into itself, where the tile
|
|
1111
|
+
boundary is not what is wrong with the result.
|
|
1112
|
+
|
|
1113
|
+
The offline merge still has this. `scipy.ndimage.gaussian_filter` defaults to
|
|
1114
|
+
`mode='reflect'` and is handed one tile's array, so an archive built with a
|
|
1115
|
+
sigma carries the seam.
|
|
1116
|
+
|
|
1117
|
+
### Where the margin comes from for the feather
|
|
1118
|
+
|
|
1119
|
+
The border above solves the upscaled case, which is the one the blur needed.
|
|
1120
|
+
Feathering has two more.
|
|
1121
|
+
|
|
1122
|
+
**A missing tile costs nothing.** Whether a neighbour exists is a directory
|
|
1123
|
+
lookup, not a decode. A source that vanishes at a tile edge can ramp down to 0
|
|
1124
|
+
approaching that edge using only knowledge of which neighbours are absent.
|
|
1125
|
+
|
|
1126
|
+
**A mask hole at native resolution is the expensive one.** There the edge is in
|
|
1127
|
+
the source's own pixels at full resolution, with no parent to widen the window
|
|
1128
|
+
into, so the margin genuinely means reading the neighbouring tiles — up to eight
|
|
1129
|
+
reads and eight decodes for that source, on that tile. That is the case to
|
|
1130
|
+
bound, to make opt-in, and to measure before believing.
|
|
1131
|
+
|
|
1132
|
+
### What it does
|
|
1133
|
+
|
|
1134
|
+
`feather` on a source, in pixels, `0` and absent meaning the edge is a switch as
|
|
1135
|
+
before. It fades that source in over that many pixels measured inward from its
|
|
1136
|
+
cutline or its `bounds`:
|
|
1137
|
+
|
|
1138
|
+
```json
|
|
1139
|
+
{ "archive": "swissalti", "cutline": "switzerland", "feather": 16 }
|
|
1140
|
+
```
|
|
1141
|
+
|
|
1142
|
+
The step left at the seam is the height difference divided by the feather, which
|
|
1143
|
+
is what makes the number predictable: two sources 40 m apart at the border, faded
|
|
1144
|
+
over 16 pixels, step 2.5 m a pixel instead of 40 m at once. Sixty-four is the
|
|
1145
|
+
most it takes — past that the ramp does not reach full weight anywhere inside a
|
|
1146
|
+
256px tile, and the source is being turned down rather than blended in.
|
|
1147
|
+
|
|
1148
|
+
Three things fall out of the implementation and are worth stating.
|
|
1149
|
+
|
|
1150
|
+
**The ramp runs inward only.** A pixel outside the shape stays at 0. A cutline is
|
|
1151
|
+
a statement about where a source's data is good, and spreading the ramp outward
|
|
1152
|
+
would answer for ground the recipe just said this source does not cover.
|
|
1153
|
+
|
|
1154
|
+
**A feathered layer over a hole stands alone.** Weight is how much of what is
|
|
1155
|
+
underneath shows through, so with nothing underneath there is nothing to show —
|
|
1156
|
+
and fading into nothing would erode the source by the width of its own feather
|
|
1157
|
+
exactly where it is the only thing covering that ground.
|
|
1158
|
+
|
|
1159
|
+
**RGBA needed no merge change at all.** Alpha is already the weight that space
|
|
1160
|
+
composites with, so the feather multiplies it and `over` does what it always did.
|
|
1161
|
+
|
|
1162
|
+
The distance is exact Euclidean, by Felzenszwalb and Huttenlocher's lower
|
|
1163
|
+
envelope — linear in the tile's width, and chosen over a chamfer approximation
|
|
1164
|
+
because a chamfer's error is largest on diagonals and a national boundary is
|
|
1165
|
+
mostly diagonals.
|
|
1166
|
+
|
|
1167
|
+
### What it does not do yet
|
|
1168
|
+
|
|
1169
|
+
**A mask edge.** `maskValues` and `maskColors` are where most seams actually come
|
|
1170
|
+
from, and they are not feathered. The reason is the margin: a cutline is known in
|
|
1171
|
+
full, so the ramp beside a tile can be computed by rasterising a few extra rows,
|
|
1172
|
+
while a mask edge lives in the source's own pixels and the ramp needs the
|
|
1173
|
+
neighbouring tiles to be read and decoded — up to eight of them, for that source,
|
|
1174
|
+
on that tile.
|
|
1175
|
+
|
|
1176
|
+
That is the piece with a real cost attached, and the one to measure before
|
|
1177
|
+
building. Two things make it more tractable than it looks: in a bake the tiles
|
|
1178
|
+
are walked in Hilbert order, which is chosen for locality, so a cache of decoded
|
|
1179
|
+
source tiles would be hit far more often than missed; and the ramp is smooth by
|
|
1180
|
+
construction, so it can be computed from the source's _parent_ at half resolution
|
|
1181
|
+
and upscaled, which turns eight reads at this zoom into a handful at the one
|
|
1182
|
+
above, shared between four children.
|
|
1183
|
+
|
|
1184
|
+
**A source that vanishes at a tile edge.** A sparse archive simply has no tile
|
|
1185
|
+
outside its extent, so the seam lands exactly on a tile boundary — the most
|
|
1186
|
+
visible of the three and the cheapest to detect, since whether a neighbour exists
|
|
1187
|
+
is a directory lookup rather than a decode. It ramps down toward an absent
|
|
1188
|
+
neighbour, and nothing else has to happen.
|
|
1189
|
+
|
|
965
1190
|
## Finding a stack
|
|
966
1191
|
|
|
967
1192
|
A stack has no infohash and appears in no feed, so nothing about it is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmtiles-swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.73.0",
|
|
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
|
@@ -84,10 +84,11 @@ function route(handler) {
|
|
|
84
84
|
* Progress has its own route already: `runningAdds()` feeds `/api/adds`, the
|
|
85
85
|
* console polls it, and `DELETE /api/adds` cancels the ones that can be.
|
|
86
86
|
* @param {object} res - The response to answer.
|
|
87
|
-
* @param {
|
|
88
|
-
* @param {
|
|
89
|
-
* @param {
|
|
90
|
-
* @param {string}
|
|
87
|
+
* @param {object} options - The add itself, and what to say about it.
|
|
88
|
+
* @param {Function} options.start - Called with `{onValidated}`; returns the add's promise.
|
|
89
|
+
* @param {object} options.accepted - Fields describing the source, for the 202 body.
|
|
90
|
+
* @param {string} options.message - What the 202 tells the caller is now happening.
|
|
91
|
+
* @param {string} options.what - Prefixed log tag and source, for a failure nobody is waiting on.
|
|
91
92
|
* @returns {Promise<void>} - Resolves once the response has been sent.
|
|
92
93
|
*/
|
|
93
94
|
async function acceptAdd(res, { start, accepted, message, what }) {
|
|
@@ -154,7 +155,6 @@ async function acceptAdd(res, { start, accepted, message, what }) {
|
|
|
154
155
|
* worse, because it makes the shape of the fragment depend on what happened to
|
|
155
156
|
* be available, so every reader has to handle both anyway. One shape,
|
|
156
157
|
* `URLSearchParams` reads it, and a magnet survives the round trip exactly.
|
|
157
|
-
*
|
|
158
158
|
* @param {string} url - The URL a style points at.
|
|
159
159
|
* @param {object} handles - `{torrent, magnet}`, either of which may be absent.
|
|
160
160
|
* @returns {string} - The URL, with a fragment if there is anything to put in one.
|
|
@@ -205,6 +205,11 @@ function sourceUrlFor(category, newest, base) {
|
|
|
205
205
|
return withSwarmHandles(url, { torrent, magnet });
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Builds the HTTP surface over everything the node has already started.
|
|
210
|
+
* @param {object} parts - The node's live pieces.
|
|
211
|
+
* @returns {object} - The Express app.
|
|
212
|
+
*/
|
|
208
213
|
export function createApp({
|
|
209
214
|
library,
|
|
210
215
|
catalog,
|
package/src/auth.js
CHANGED
|
@@ -338,7 +338,10 @@ export function createAuth(config) {
|
|
|
338
338
|
/** Whether any credential is configured. */
|
|
339
339
|
enabled,
|
|
340
340
|
|
|
341
|
-
/**
|
|
341
|
+
/**
|
|
342
|
+
* Whether a password login is possible, as opposed to only a token.
|
|
343
|
+
* @returns {boolean} - True when a password or a hash is set.
|
|
344
|
+
*/
|
|
342
345
|
get passwordLoginEnabled() {
|
|
343
346
|
return Boolean(settings().password || settings().passwordHash);
|
|
344
347
|
},
|
package/src/bake-jobs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import {
|
|
4
5
|
assertBakeable,
|
|
@@ -58,6 +59,64 @@ export function workDirFor(job, config = {}) {
|
|
|
58
59
|
/** A bake that has finished is kept this long, so the console can report it. */
|
|
59
60
|
const KEEP_FINISHED_MS = 10 * 60 * 1000;
|
|
60
61
|
|
|
62
|
+
/** libuv's own default, which is what is in force when nothing says otherwise. */
|
|
63
|
+
const DEFAULT_THREADPOOL = 4;
|
|
64
|
+
|
|
65
|
+
/** Said once per process rather than once per bake. */
|
|
66
|
+
let saidAboutThreads = false;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the pool has to hold for the cores to stay busy.
|
|
70
|
+
*
|
|
71
|
+
* Not `bakeConcurrency`: past the core count another thread has nowhere to run,
|
|
72
|
+
* and the measurements flatten there. A node told to merge 32 tiles at once on
|
|
73
|
+
* twelve cores wants twelve, and telling its operator to ask for 32 would be
|
|
74
|
+
* asking them to fix something that is not broken.
|
|
75
|
+
* @param {number} concurrency - What the bake was told to run.
|
|
76
|
+
* @param {number} cores - How many the machine has.
|
|
77
|
+
* @returns {number} - The pool worth having.
|
|
78
|
+
*/
|
|
79
|
+
function poolWorthHaving(concurrency, cores) {
|
|
80
|
+
return Math.max(1, Math.min(concurrency, cores));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Says so when the machine cannot actually run the bake as wide as it was told
|
|
85
|
+
* to.
|
|
86
|
+
*
|
|
87
|
+
* Decoding and encoding a tile is sharp, and sharp does that work on libuv's
|
|
88
|
+
* thread pool, which holds four threads unless the environment says otherwise.
|
|
89
|
+
* `bakeConcurrency` sizes the batch and the pixel pool above that, so raising
|
|
90
|
+
* it past four buys nothing on its own: the merges queue at the decode instead
|
|
91
|
+
* of at the arithmetic, and the machine sits idle looking like a bake that is
|
|
92
|
+
* simply slow. It cannot be fixed from in here — the pool is built before any
|
|
93
|
+
* of this runs — so the only thing to do is say it plainly and name the fix.
|
|
94
|
+
* See docs/tile-stacks.md — "Giving a bake the whole machine".
|
|
95
|
+
* @param {number} concurrency - What the bake was told to run.
|
|
96
|
+
* @param {number} [cores] - How many the machine has. Injectable, so the rule
|
|
97
|
+
* can be tested at a size rather than at whatever the test machine happens
|
|
98
|
+
* to have -- on four cores or fewer there is nothing to be short of, and a
|
|
99
|
+
* test asserting otherwise fails on the smallest runners and passes here.
|
|
100
|
+
* @returns {void}
|
|
101
|
+
*/
|
|
102
|
+
export function sayIfThreadStarved(
|
|
103
|
+
concurrency,
|
|
104
|
+
cores = os.availableParallelism(),
|
|
105
|
+
) {
|
|
106
|
+
if (saidAboutThreads) return;
|
|
107
|
+
const pool = Number(process.env.UV_THREADPOOL_SIZE) || DEFAULT_THREADPOOL;
|
|
108
|
+
const wanted = poolWorthHaving(concurrency, cores);
|
|
109
|
+
if (pool >= wanted) return;
|
|
110
|
+
saidAboutThreads = true;
|
|
111
|
+
console.warn(
|
|
112
|
+
`[bake] bakeConcurrency is ${concurrency} but UV_THREADPOOL_SIZE is ` +
|
|
113
|
+
`${pool}, so at most ${pool} tiles can be decoded or encoded at once. ` +
|
|
114
|
+
`Set UV_THREADPOOL_SIZE=${wanted} in the environment the node starts ` +
|
|
115
|
+
'in — it cannot be set from inside the process.',
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The exports a node is running, and the ones it has just finished. */
|
|
61
120
|
export class BakeManager {
|
|
62
121
|
#library;
|
|
63
122
|
#tiles;
|
|
@@ -315,6 +374,7 @@ export class BakeManager {
|
|
|
315
374
|
const pixels = codec
|
|
316
375
|
? new PixelWorker({ size: this.#concurrency() })
|
|
317
376
|
: null;
|
|
377
|
+
if (codec) sayIfThreadStarved(this.#concurrency());
|
|
318
378
|
try {
|
|
319
379
|
await this.#merge(
|
|
320
380
|
job,
|
|
@@ -357,6 +417,7 @@ export class BakeManager {
|
|
|
357
417
|
* @param {string} workDir - Where the unfinished work lives.
|
|
358
418
|
* @param {string} destination - Where the archive goes.
|
|
359
419
|
* @param {string} format - The output format.
|
|
420
|
+
* @param {object} options - `signal` to stop early, and how often to checkpoint.
|
|
360
421
|
* @returns {Promise<void>} - Resolves when the file is written.
|
|
361
422
|
*/
|
|
362
423
|
async #merge(
|
package/src/catalog.js
CHANGED
|
@@ -8,7 +8,6 @@ import path from 'node:path';
|
|
|
8
8
|
* dozens, the whole thing is human-readable and hand-editable, and it avoids a
|
|
9
9
|
* native dependency in a project that already asks a lot of the install.
|
|
10
10
|
* Writes go through a temp file and a rename, so a crash cannot truncate it.
|
|
11
|
-
*
|
|
12
11
|
* @typedef {object} CatalogEntry
|
|
13
12
|
* @property {string} infoHash - Hex v1 infohash. The catalog's primary key.
|
|
14
13
|
* @property {string} name - Archive filename.
|
|
@@ -99,6 +98,7 @@ export function newerFirst(a, b) {
|
|
|
99
98
|
return String(b.createdAt ?? '').localeCompare(String(a.createdAt ?? ''));
|
|
100
99
|
}
|
|
101
100
|
|
|
101
|
+
/** What the node knows about every archive it holds. */
|
|
102
102
|
export class Catalog {
|
|
103
103
|
#file;
|
|
104
104
|
#entries = new Map();
|
package/src/config.js
CHANGED
|
@@ -538,16 +538,13 @@ function merge(base, override) {
|
|
|
538
538
|
// `libtorrent`, which would otherwise alter the defaults themselves.
|
|
539
539
|
const out = {};
|
|
540
540
|
for (const [key, value] of Object.entries(base ?? {})) {
|
|
541
|
-
// eslint-disable-next-line security/detect-object-injection -- keys come from DEFAULTS
|
|
542
541
|
out[key] = clone(value);
|
|
543
542
|
}
|
|
544
543
|
for (const [key, value] of Object.entries(override ?? {})) {
|
|
545
544
|
if (value === undefined) continue;
|
|
546
|
-
// eslint-disable-next-line security/detect-object-injection -- keys come from a config file the operator controls
|
|
547
545
|
out[key] =
|
|
548
546
|
value && typeof value === 'object' && !Array.isArray(value)
|
|
549
|
-
?
|
|
550
|
-
merge(base[key] ?? {}, value)
|
|
547
|
+
? merge(base[key] ?? {}, value)
|
|
551
548
|
: value;
|
|
552
549
|
}
|
|
553
550
|
return out;
|
|
@@ -926,7 +923,10 @@ export async function saveConfig(config, updates, configPath) {
|
|
|
926
923
|
);
|
|
927
924
|
|
|
928
925
|
for (const [key] of changing) {
|
|
929
|
-
|
|
926
|
+
// `in` would walk the prototype chain, which says yes to `__proto__` and
|
|
927
|
+
// to `constructor` — and the assignment below then swaps the config's
|
|
928
|
+
// prototype rather than setting a setting.
|
|
929
|
+
if (!Object.hasOwn(DEFAULTS, key)) {
|
|
930
930
|
throw new Error(`unknown setting: ${key}`);
|
|
931
931
|
}
|
|
932
932
|
if (guarded.has(key)) {
|