pmtiles-swarm 0.73.0 β†’ 0.74.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 CHANGED
@@ -7,6 +7,52 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.74.0
11
+ ### ✨ Features and improvements
12
+ - **The holes a mask leaves are feathered now, not just a cutline's edge.** `feather` faded a source
13
+ in at its `cutline` or `bounds` and did nothing about `maskValues` or `maskColors` - which is
14
+ where most of these recipes actually stop, and why the field looked like it did nothing.
15
+
16
+ A tile cannot measure that edge alone: the hole may continue past its border and nothing inside it
17
+ says so. Carrying the edge outward, which is the obvious answer, changes the seam by nothing at
18
+ all - measured across four coastline geometries, to the metre - because it only adds holes where
19
+ the edge pixel is already one and the distance is already zero. The tile that gets it wrong is
20
+ the tile with no hole in it, and no rule applied to its own pixels can invent one.
21
+
22
+ So the mask is read from the source's parent, which covers this tile and its three siblings and
23
+ sees past every one of their borders. Four parents cover any tile's surroundings, each is shared
24
+ by four children, and they are read only for a source that both masks and fades. The parent
25
+ supplies the border; the middle is the tile's own pixels, because a ramp measured entirely against
26
+ half-resolution pixels climbs in two-pixel steps, which is the terracing the fade exists to
27
+ remove. A 1000 m drop over a 16 px fade, stepping along the edge two tiles share:
28
+
29
+ coastline crossing at 45Β° 1000 m unfeathered -> 63 m
30
+ crossing steeply 1000 m -> 0 m
31
+ parallel, just past the seam 0 m -> 125 m
32
+ parallel, right on the seam 0 m -> 125 m
33
+
34
+ What is left is bounded by the parent's resolution rather than by the height difference - two
35
+ steps of the ramp, or `2 / feather` of the drop - and shrinks as the feather widens.
36
+
37
+ The field is on every source in the editor now rather than appearing only once a clip is set,
38
+ with a note where a source neither masks nor clips and it would do nothing.
39
+
40
+ ## 0.73.1
41
+ ### 🐞 Bug fixes
42
+ - **The console could not start at all in 0.73.0.** A string literal in the export dialog's output
43
+ summary lost its escape, so the console's module failed to parse - and a module that does not
44
+ parse does not run one line of itself. Every page rendered its static markup and then sat on
45
+ "connecting…" for ever, with one `SyntaxError` in the browser console and nothing anywhere else.
46
+
47
+ Nothing was in a position to catch it. eslint does not read HTML, prettier reports the file clean
48
+ whether or not the script inside it is valid, and the tests that do read that script match
49
+ patterns in its text rather than running it. So 320 KB of JavaScript shipped without anything
50
+ having asked whether it parses.
51
+
52
+ `test/web-parse.test.js` now hands every inline script in every page to `node --check` - the same
53
+ engine that will run it, so there is no second grammar to disagree with. Verified against this
54
+ exact fault: the test fails on it and names the line, where prettier and eslint both pass.
55
+
10
56
  ## 0.73.0
11
57
  ### ✨ Features and improvements
12
58
  - **A source can fade in at the edge of its shape instead of stopping dead.** Where a
@@ -1114,29 +1114,64 @@ The offline merge still has this. `scipy.ndimage.gaussian_filter` defaults to
1114
1114
  `mode='reflect'` and is handed one tile's array, so an archive built with a
1115
1115
  sigma carries the seam.
1116
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.
1117
+ ### Where the margin comes from for a mask edge
1118
+
1119
+ A cutline is known in full, so the ramp beside a tile costs a few extra
1120
+ rasterised rows. A mask edge is not: it lives in the source's own decoded
1121
+ pixels, and a tile cannot see whether the hole continues past its own border.
1122
+
1123
+ That is not a boundary condition to be chosen well, which is the first thing
1124
+ worth writing down because it looks like one. Carrying the edge outward β€” the
1125
+ obvious fix β€” adds holes only where the edge pixel is already a hole, and there
1126
+ the distance is already zero. Measured against four coastline geometries it
1127
+ changes the seam by nothing at all, to the metre. The tile that gets it wrong is
1128
+ the tile with **no hole in it**, and no rule applied to its own pixels can
1129
+ invent one.
1130
+
1131
+ So the mask is read from the source's **parent**, which covers this tile and its
1132
+ three siblings and therefore sees past every one of their borders. Four parents
1133
+ cover any tile's surroundings, each is shared by four children, and they are
1134
+ read only for a source that both masks and fades.
1135
+
1136
+ The parent supplies the border only. Its pixels are half this tile's, so a ramp
1137
+ measured entirely against them climbs in two-pixel steps β€” terracing, which is
1138
+ the artefact the fade exists to remove β€” and the middle is overwritten with the
1139
+ tile's own pixels, which are exact.
1140
+
1141
+ A 1000 m drop faded over 16 px, measuring the step along the edge two tiles
1142
+ share:
1143
+
1144
+ | coastline at the seam | no fade | tile alone | with parents |
1145
+ | ---------------------------- | ------- | ---------- | ------------ |
1146
+ | crossing at 45Β° | 1000 m | 1000 m | 63 m |
1147
+ | crossing steeply | 1000 m | 938 m | 0 m |
1148
+ | parallel, just past the seam | 0 m | 438 m | 125 m |
1149
+ | parallel, on the seam | 0 m | 938 m | 125 m |
1150
+
1151
+ "tile alone" is the version that measures inside the tile and is why it is not
1152
+ the version that shipped: it moves the wall off the coastline, where it is at
1153
+ least geographically meaningful, and onto the tile grid.
1154
+
1155
+ What is left is bounded by the parent's resolution rather than by the height
1156
+ difference β€” two steps of the ramp, or `2 / feather` of the drop, and it shrinks
1157
+ as the feather widens. Reading the eight neighbours at native resolution instead
1158
+ would make it exact, at nine times the reads and decodes for that source; the
1159
+ extra precision is finer than a sixteen-pixel ramp can show.
1160
+
1161
+ **A source that vanishes at a tile edge is still open.** A sparse archive has no
1162
+ tile outside its extent, so the seam lands exactly on a tile boundary. It is the
1163
+ cheapest of the three to detect β€” whether a neighbour exists is a directory
1164
+ lookup rather than a decode β€” and it is not done.
1131
1165
 
1132
1166
  ### What it does
1133
1167
 
1134
1168
  `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`:
1169
+ before. It fades that source in over that many pixels measured inward from
1170
+ wherever it stops β€” the holes `maskValues` and `maskColors` leave, and the edge
1171
+ of a `cutline` or `bounds`:
1137
1172
 
1138
1173
  ```json
1139
- { "archive": "swissalti", "cutline": "switzerland", "feather": 16 }
1174
+ { "archive": "swissalti", "maskValues": [0], "feather": 16 }
1140
1175
  ```
1141
1176
 
1142
1177
  The step left at the seam is the height difference divided by the feather, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.73.0",
3
+ "version": "0.74.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/elevation.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { cropMask, featherMask } from './cutline.js';
2
+
1
3
  /**
2
4
  * Merging terrain tiles, in metres rather than in pixels.
3
5
  *
@@ -691,6 +693,46 @@ export function mergeElevation(contributions, options) {
691
693
  if (contribution.coverage[i] <= 0) heights[i] = Number.NaN;
692
694
  }
693
695
  }
696
+
697
+ // The edge a mask left, which is the one most of these recipes have.
698
+ // Measured on a grid that reaches past this tile, because the pixels that
699
+ // decide how far a hole is may be in the next one -- `gather` reads the
700
+ // source's parents for exactly this. Without them there is nothing to
701
+ // measure against and the edge stays as it was, rather than being faded
702
+ // against a hole this tile cannot see.
703
+ if (contribution.neighbourhood) {
704
+ const { known, margin } = contribution.neighbourhood;
705
+ const side = size + margin * 2;
706
+
707
+ // The parent is half this tile's resolution, so a ramp measured entirely
708
+ // against it climbs in two-pixel steps -- terracing, which is the
709
+ // artefact this whole feature exists to remove. So the parent supplies
710
+ // the border only, and the middle is overwritten with this tile's own
711
+ // pixels, which are exact. Distances inside the tile are what the ramp
712
+ // is mostly made of; only the last few pixels of its reach are
713
+ // approximated, and there a pixel of error is a pixel of a sixteen-pixel
714
+ // fade.
715
+ const grid = Uint8Array.from(known);
716
+ for (let row = 0; row < size; row += 1) {
717
+ const from = row * size;
718
+ const to = (row + margin) * side + margin;
719
+ for (let column = 0; column < size; column += 1) {
720
+ grid[to + column] = Number.isNaN(heights[from + column]) ? 0 : 1;
721
+ }
722
+ }
723
+
724
+ const ramp = cropMask(featherMask(grid, side, margin), size, margin);
725
+ // The narrower of the two where a cutline has ramped as well: each is a
726
+ // separate statement about how much of this source belongs here, and
727
+ // both have to hold.
728
+ if (contribution.coverage) {
729
+ for (let i = 0; i < ramp.length; i += 1) {
730
+ ramp[i] = Math.min(ramp[i], contribution.coverage[i]);
731
+ }
732
+ }
733
+ contribution.coverage = ramp;
734
+ }
735
+
694
736
  return heights;
695
737
  });
696
738
 
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The edge a mask leaves, measured across the tile boundary.
3
+ *
4
+ * `maskValues` and `maskColors` turn a source's nodata into holes, and where a
5
+ * high-resolution source ends the merge steps straight down to whatever is
6
+ * underneath. Feathering that edge means knowing how far each pixel is from
7
+ * the hole β€” and a tile cannot answer that on its own, because the hole may
8
+ * continue past its border and nothing inside the tile says so.
9
+ *
10
+ * That is not a boundary condition to be chosen well. Carrying the edge
11
+ * outward adds holes only where the edge pixel is already one, where the
12
+ * distance is already zero, so it changes nothing β€” measured, to the metre.
13
+ * The tile that gets it wrong is the tile with no hole in it at all, and no
14
+ * rule applied to its own pixels can invent one.
15
+ *
16
+ * So the mask is read from the source's **parent**, which covers this tile and
17
+ * its three siblings and therefore sees past every one of their borders. Four
18
+ * parents cover any tile's surroundings, and each is shared by four children.
19
+ *
20
+ * The parent supplies the border only. Its pixels are half this tile's, so a
21
+ * ramp measured entirely against them climbs in two-pixel steps -- terracing,
22
+ * which is the artefact the fade exists to remove -- and the middle is
23
+ * overwritten with the tile's own. What is left is a seam bounded by the
24
+ * parent's resolution rather than by the height difference: two steps of the
25
+ * ramp, against the whole drop unfeathered. See docs/tile-stacks.md --
26
+ * "Feathering a seam".
27
+ */
28
+
29
+ /**
30
+ * Which parent tiles cover this tile and the border around it.
31
+ *
32
+ * At most four: the tile occupies one quadrant of its parent, so a border
33
+ * around it reaches past two of that parent's sides and into the two
34
+ * neighbours and the one diagonal.
35
+ * @param {object} tile - z, x, y of the tile being built.
36
+ * @param {number} size - Pixels per side of that tile.
37
+ * @param {number} feather - The border wanted, in those pixels.
38
+ * @returns {object|null} - `{z, tiles, west, north, per}`, or null at z0.
39
+ */
40
+ export function parentsFor(tile, size, feather) {
41
+ const z = tile.z - 1;
42
+ if (z < 0 || !(feather > 0)) return null;
43
+
44
+ // In units of one parent tile: this tile is half of one a side, and the
45
+ // border is a fraction of that again.
46
+ const per = feather / (2 * size);
47
+ const west = tile.x / 2 - per;
48
+ const east = (tile.x + 1) / 2 + per;
49
+ const north = tile.y / 2 - per;
50
+ const south = (tile.y + 1) / 2 + per;
51
+
52
+ const span = 2 ** z;
53
+ const tiles = [];
54
+ for (let row = Math.floor(north); row <= Math.floor(south); row += 1) {
55
+ for (
56
+ let column = Math.floor(west);
57
+ column <= Math.floor(east);
58
+ column += 1
59
+ ) {
60
+ // Off the top or bottom of the world there is no tile and never was.
61
+ // Off the side there is: the map wraps, and a source that covers the
62
+ // antimeridian covers both halves of it.
63
+ if (row < 0 || row >= span) continue;
64
+ tiles.push({
65
+ z,
66
+ x: ((column % span) + span) % span,
67
+ y: row,
68
+ column,
69
+ row,
70
+ });
71
+ }
72
+ }
73
+ return { z, tiles, west, north, per };
74
+ }
75
+
76
+ /**
77
+ * A padded grid saying where the source has data, at the output's resolution.
78
+ *
79
+ * Sampled nearest from the parents rather than interpolated: this is a yes or
80
+ * no about each pixel, and a half-yes between a hole and the ground beside it
81
+ * is not a smaller hole, it is a wrong answer about which one the pixel is.
82
+ * @param {object} layout - What `parentsFor` returned.
83
+ * @param {Map} known - Parent key `"x,y"` to a Uint8Array, 1 where it has data.
84
+ * @param {number} size - Pixels per side of the tile being built.
85
+ * @param {number} feather - The border, in those pixels.
86
+ * @param {number} parentSize - Pixels per side of a parent.
87
+ * @returns {Uint8Array} - `size + 2 * feather` a side, 1 where there is data.
88
+ */
89
+ export function paddedKnown(layout, known, size, feather, parentSize) {
90
+ const side = size + feather * 2;
91
+ const out = new Uint8Array(side * side);
92
+ // One output pixel is this much of a parent tile.
93
+ const step = 1 / (2 * size);
94
+
95
+ for (let row = 0; row < side; row += 1) {
96
+ // The middle of the pixel, in parent-tile units.
97
+ const y = layout.north + (row + 0.5) * step;
98
+ const parentRow = Math.floor(y);
99
+ for (let column = 0; column < side; column += 1) {
100
+ const x = layout.west + (column + 0.5) * step;
101
+ const parentColumn = Math.floor(x);
102
+ const grid = known.get(`${parentColumn},${parentRow}`);
103
+ // A parent nothing could be read from is ground this source does not
104
+ // cover, which is what a hole means anyway.
105
+ if (!grid) continue;
106
+ const px = Math.min(
107
+ parentSize - 1,
108
+ Math.floor((x - parentColumn) * parentSize),
109
+ );
110
+ const py = Math.min(
111
+ parentSize - 1,
112
+ Math.floor((y - parentRow) * parentSize),
113
+ );
114
+ out[row * side + column] = grid[py * parentSize + px];
115
+ }
116
+ }
117
+ return out;
118
+ }
package/src/stack-tile.js CHANGED
@@ -1,9 +1,13 @@
1
1
  import {
2
2
  assembleChildren,
3
+ decodeHeights,
3
4
  encodeHeights,
4
5
  fillNodata,
6
+ maskColors,
7
+ maskHeights,
5
8
  mergeElevation,
6
9
  } from './elevation.js';
10
+ import { paddedKnown, parentsFor } from './mask-edge.js';
7
11
  import {
8
12
  INSIDE,
9
13
  OUTSIDE,
@@ -428,14 +432,105 @@ async function gather({ reads, z, x, y, tiles, codec, signal, size, rgba }) {
428
432
 
429
433
  contributions.push({
430
434
  source: read.source.source,
435
+ // Carried so a feathered source can go back for its parents, which is
436
+ // the one thing the merge needs that is not already in this raster.
437
+ infoHash: read.source.entry?.infoHash,
431
438
  parentZ: read.found.parentZ,
432
439
  raster,
433
440
  });
434
441
  }
435
442
 
443
+ await readMaskEdges({ contributions, z, x, y, size, tiles, codec, signal });
436
444
  return { contributors, contributions };
437
445
  }
438
446
 
447
+ /**
448
+ * Whether a source's recipe leaves holes a feather would have to fade.
449
+ * @param {object} recipe - One source out of a recipe.
450
+ * @returns {boolean} - True when it masks anything.
451
+ */
452
+ function masksAnything(recipe) {
453
+ return Boolean(recipe?.maskValues?.length || recipe?.maskColors?.length);
454
+ }
455
+
456
+ /**
457
+ * Reads the parents a feathered source needs to see past its own tile.
458
+ *
459
+ * Only for a source that both fades and masks. A cutline's ramp is measured
460
+ * from a shape this node holds in full and needs nothing read; a mask's is
461
+ * measured from pixels, and the pixels that decide it are partly in the
462
+ * neighbouring tiles. See src/mask-edge.js for why the parent rather than
463
+ * those neighbours.
464
+ *
465
+ * A parent that cannot be read is left out rather than failing the tile. It
466
+ * means the ramp is measured against less than it could be, which is a slightly
467
+ * wrong edge -- and a tile that will not draw at all is worse than one whose
468
+ * coastline fades over fifteen pixels instead of sixteen.
469
+ * @param {object} options - The contributions, where they are, and what to read with.
470
+ * @returns {Promise<void>} - Resolves once every feathered source has its grid.
471
+ */
472
+ async function readMaskEdges({
473
+ contributions,
474
+ z,
475
+ x,
476
+ y,
477
+ size,
478
+ tiles,
479
+ codec,
480
+ signal,
481
+ }) {
482
+ if (!codec) return;
483
+ await Promise.all(
484
+ contributions.map(async (contribution) => {
485
+ if (!contribution) return;
486
+ const recipe = contribution.source ?? {};
487
+ const feather = featherFor(recipe);
488
+ if (!feather || !masksAnything(recipe)) return;
489
+
490
+ const grid = contribution.raster?.width ?? size;
491
+ const layout = parentsFor({ z, x, y }, grid, feather);
492
+ if (!layout) return;
493
+
494
+ const known = new Map();
495
+ let parentSize = grid;
496
+ await Promise.all(
497
+ layout.tiles.map(async (parent) => {
498
+ const tile = await tiles
499
+ .getTile(contribution.infoHash, parent.z, parent.x, parent.y, {
500
+ signal,
501
+ })
502
+ .catch(() => null);
503
+ if (!tile?.data) return;
504
+ const raster = await codec
505
+ .decode(Buffer.from(tile.data), { channels: 3 })
506
+ .catch(() => null);
507
+ if (!raster) return;
508
+
509
+ // The same two masks the merge applies, asked of the parent. A ramp
510
+ // measured against a different idea of where the holes are would fade
511
+ // toward ground that is not a hole.
512
+ const heights = decodeHeights(raster, recipe);
513
+ maskHeights(heights, recipe.maskValues);
514
+ maskColors(heights, raster, recipe.maskColors);
515
+
516
+ const flags = new Uint8Array(heights.length);
517
+ for (let i = 0; i < flags.length; i += 1) {
518
+ flags[i] = Number.isNaN(heights[i]) ? 0 : 1;
519
+ }
520
+ parentSize = raster.width;
521
+ known.set(`${parent.column},${parent.row}`, flags);
522
+ }),
523
+ );
524
+
525
+ if (known.size === 0) return;
526
+ contribution.neighbourhood = {
527
+ known: paddedKnown(layout, known, grid, feather, parentSize),
528
+ margin: feather,
529
+ };
530
+ }),
531
+ );
532
+ }
533
+
439
534
  /**
440
535
  * Merges what the sources gave into one encoded tile.
441
536
  * @param {object} options - Everything the merge needs.
package/src/stacks.js CHANGED
@@ -137,9 +137,16 @@ export function validateStack(stack) {
137
137
  }
138
138
  // A fade with no edge to fade at. Refused rather than ignored: it reads as
139
139
  // a source that blends into what is under it, and it does nothing at all.
140
- if (source?.feather && !source.cutline && !source.bounds) {
140
+ // A mask is an edge as much as a cutline is -- the hole it leaves is where
141
+ // most of these recipes actually stop.
142
+ const fades =
143
+ source?.cutline ||
144
+ source?.bounds ||
145
+ source?.maskValues?.length ||
146
+ source?.maskColors?.length;
147
+ if (source?.feather && !fades) {
141
148
  problems.push(
142
- `sources[${index}].feather needs a cutline or bounds to fade at`,
149
+ `sources[${index}].feather needs a cutline, bounds, or a mask to fade at`,
143
150
  );
144
151
  }
145
152
  if (source?.encoding === 'custom' && !hasUsableEncoding(source)) {
@@ -7672,19 +7672,23 @@ Every piece is hashed against the ` +
7672
7672
  </label>`
7673
7673
  : ''
7674
7674
  }
7675
- ${
7676
- source.cutline || source.bounds
7677
- ? `<label class="choice"
7678
- title="Fade this source in over this many pixels at the edge of its shape, instead of stopping dead. Where a high-resolution source meets a coarser one the two disagree β€” by their survey and by their vertical datum β€” and the step between them reads as a wall under a hillshade. The drop is divided by this: 40 m across a 8 px feather is 5 m a pixel. 0 is a hard edge.">
7679
- Fade in
7680
- <input type="number" min="0" max="64" step="1" style="width:4.5rem"
7681
- placeholder="0"
7682
- data-stack-field="feather" data-stack-index="${index}"
7683
- value="${source.feather ?? ''}" />
7684
- <span class="sub">px</span>
7685
- </label>`
7686
- : ''
7687
- }
7675
+ <label class="choice"
7676
+ title="Fade this source in over this many pixels wherever it stops β€” the holes a mask leaves, and the edge of a cutline or box. Where a high-resolution source meets a coarser one the two disagree, by their survey and by their vertical datum, and the step between them reads as a wall under a hillshade. The drop is divided by this: 40 m across an 8 px fade is 5 m a pixel. 0 is a hard edge.">
7677
+ Fade in
7678
+ <input type="number" min="0" max="64" step="1" style="width:4.5rem"
7679
+ placeholder="0"
7680
+ data-stack-field="feather" data-stack-index="${index}"
7681
+ value="${source.feather ?? ''}" />
7682
+ <span class="sub">px${
7683
+ source.feather &&
7684
+ !source.cutline &&
7685
+ !source.bounds &&
7686
+ !(source.maskValues ?? []).length &&
7687
+ !(source.maskColors ?? []).length
7688
+ ? ' β€” nothing to fade at yet: mask something, or clip it'
7689
+ : ''
7690
+ }</span>
7691
+ </label>
7688
7692
  ${
7689
7693
  source.cutline && !(stackCutlines ?? []).includes(source.cutline)
7690
7694
  ? `<span class="bad">no cutline called
@@ -7767,10 +7771,6 @@ Every piece is hashed against the ` +
7767
7771
  if (source) {
7768
7772
  delete source.cutline;
7769
7773
  delete source.bounds;
7770
- // A fade needs an edge to fade at, and the recipe validation says
7771
- // so -- leaving it behind would make the source unsaveable with
7772
- // nothing on screen to explain why.
7773
- if (!event.target.value) delete source.feather;
7774
7774
  if (event.target.value === '__bounds') {
7775
7775
  source.bounds = [-180, -85, 180, 85];
7776
7776
  } else if (event.target.value) {
@@ -7946,9 +7946,9 @@ Every piece is hashed against the ` +
7946
7946
  const output = stack?.output ?? {};
7947
7947
  const rgba = stack?.space === 'rgba';
7948
7948
  const parts = [
7949
- rgba ? 'rgba' : (output.encoding ?? 'the sources' encoding'),
7949
+ rgba ? 'rgba' : (output.encoding ?? "the sources' encoding"),
7950
7950
  output.format ?? "the sources' format",
7951
- output.tileSize ? `${output.tileSize} px` : 'the sources' tile size',
7951
+ output.tileSize ? `${output.tileSize} px` : "the sources' tile size",
7952
7952
  ];
7953
7953
  if (!rgba) {
7954
7954
  parts.push(`${stack?.resampling ?? 'bilinear'} resampling`);