@loaders.gl/tile-converter 5.0.0-alpha.10 → 5.0.0-alpha.12
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/README.md +211 -2
- package/bin/converter-v5.js +2 -0
- package/dist/converter.min.cjs +162 -163
- package/dist/i3s-server/bin/i3s-server.min.cjs +73 -73
- package/dist/index.cjs +1933 -2232
- package/dist/index.js +1925 -2235
- package/dist/v5/adapters.cjs +3186 -208
- package/dist/v5/adapters.d.ts +29 -2
- package/dist/v5/adapters.js +3202 -196
- package/dist/v5/archive-source.d.ts +36 -0
- package/dist/v5/browser-tileset-source.d.ts +55 -11
- package/dist/v5/browser.cjs +30 -0
- package/dist/v5/browser.d.ts +26 -2
- package/dist/v5/browser.js +45 -1
- package/dist/v5/conversion-api.d.ts +9 -0
- package/dist/v5/converter-cli.d.ts +1 -0
- package/dist/v5/converter-cli.js +1214 -0
- package/dist/v5/core.cjs +515 -50
- package/dist/v5/core.d.ts +2 -0
- package/dist/v5/core.js +515 -50
- package/dist/v5/i3s-mesh-conversion.d.ts +14 -4
- package/dist/v5/i3s-mesh-sink.d.ts +25 -0
- package/dist/v5/i3s-mesh-source-features.d.ts +13 -0
- package/dist/v5/i3s-mesh-source-material.d.ts +5 -0
- package/dist/v5/i3s-mesh-source.d.ts +30 -0
- package/dist/v5/i3s-selection-source.d.ts +70 -0
- package/dist/v5/index.cjs +20 -0
- package/dist/v5/index.d.ts +18 -2
- package/dist/v5/index.js +31 -1
- package/dist/v5/mesh-conversion.d.ts +29 -2
- package/dist/v5/mesh-features.d.ts +9 -0
- package/dist/v5/mesh-hierarchy.d.ts +47 -0
- package/dist/v5/mesh-source-features.d.ts +2 -1
- package/dist/v5/mesh-source-schema.d.ts +12 -0
- package/dist/v5/mesh-source-texture.d.ts +2 -0
- package/dist/v5/mesh-source.d.ts +13 -3
- package/dist/v5/mesh-tileset-sink.d.ts +5 -0
- package/dist/v5/mesh.d.ts +6 -1
- package/dist/v5/node-cli.d.ts +9 -0
- package/dist/v5/node-conversion.d.ts +32 -0
- package/dist/v5/node-feature-schema.d.ts +3 -0
- package/dist/v5/node-i3s-source.d.ts +41 -0
- package/dist/v5/node-source.d.ts +47 -0
- package/dist/v5/node.cjs +900 -0
- package/dist/v5/node.d.ts +6 -0
- package/dist/v5/node.js +877 -0
- package/dist/v5/point-cloud-file.d.ts +83 -0
- package/dist/v5/point-cloud-potree.d.ts +47 -0
- package/dist/v5/point-cloud-source-encoder.d.ts +4 -7
- package/dist/v5/point-cloud-spatial.d.ts +22 -0
- package/dist/v5/point-cloud-tileset-sink.d.ts +28 -0
- package/dist/v5/point-cloud-tileset.d.ts +44 -0
- package/dist/v5/point-cloud.d.ts +26 -1
- package/dist/v5/resource-fetcher.d.ts +18 -0
- package/dist/v5/resource-manifest.d.ts +10 -1
- package/dist/v5/selected-mesh-source.d.ts +53 -0
- package/dist/v5/spatial-conversion.d.ts +3 -1
- package/package.json +33 -25
- package/src/index.ts +4 -1
- package/src/v4/3d-tiles-converter/helpers/load-i3s.ts +2 -4
- package/src/v4/converter-cli.ts +8 -2
- package/src/v4/i3s-converter/helpers/load-3d-tiles.ts +3 -51
- package/src/v4/i3s-converter/helpers/node-index-document.ts +5 -2
- package/src/v4/i3s-converter/helpers/preprocess-3d-tiles.ts +1 -1
- package/src/v4/i3s-converter/json-templates/store.ts +2 -1
- package/src/v5/adapters.ts +69 -2
- package/src/v5/archive-source.ts +235 -0
- package/src/v5/browser-tileset-source.ts +418 -48
- package/src/v5/browser.ts +81 -2
- package/src/v5/conversion-api.ts +69 -9
- package/src/v5/converter-cli.ts +28 -0
- package/src/v5/core.ts +6 -0
- package/src/v5/i3s-mesh-conversion.ts +97 -19
- package/src/v5/i3s-mesh-sink.ts +351 -0
- package/src/v5/i3s-mesh-source-features.ts +279 -0
- package/src/v5/i3s-mesh-source-material.ts +157 -0
- package/src/v5/i3s-mesh-source.ts +228 -0
- package/src/v5/i3s-selection-source.ts +298 -0
- package/src/v5/index.ts +60 -2
- package/src/v5/mesh-conversion.ts +61 -11
- package/src/v5/mesh-draco.ts +47 -3
- package/src/v5/mesh-features.ts +319 -0
- package/src/v5/mesh-hierarchy.ts +648 -0
- package/src/v5/mesh-source-features.ts +73 -10
- package/src/v5/mesh-source-schema.ts +134 -0
- package/src/v5/mesh-source-texture.ts +47 -0
- package/src/v5/mesh-source.ts +99 -25
- package/src/v5/mesh-tileset-sink.ts +1 -1
- package/src/v5/mesh.ts +16 -7
- package/src/v5/node-cli.ts +197 -0
- package/src/v5/node-conversion.ts +345 -0
- package/src/v5/node-feature-schema.ts +155 -0
- package/src/v5/node-i3s-source.ts +181 -0
- package/src/v5/node-source.ts +328 -0
- package/src/v5/node.ts +16 -0
- package/src/v5/point-cloud-conversion.ts +9 -1
- package/src/v5/point-cloud-file.ts +306 -0
- package/src/v5/point-cloud-potree.ts +130 -0
- package/src/v5/point-cloud-source-encoder.ts +31 -20
- package/src/v5/point-cloud-spatial.ts +280 -0
- package/src/v5/point-cloud-tileset-sink.ts +234 -0
- package/src/v5/point-cloud-tileset.ts +137 -0
- package/src/v5/point-cloud.ts +159 -32
- package/src/v5/resource-fetcher.ts +113 -0
- package/src/v5/resource-manifest.ts +40 -5
- package/src/v5/selected-mesh-source.ts +384 -0
- package/src/v5/spatial-conversion.ts +161 -6
package/src/v5/point-cloud.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// Copyright (c) vis.gl contributors
|
|
4
4
|
|
|
5
5
|
import {Tile3DWriter, TILE3D_TYPE} from '@loaders.gl/3d-tiles';
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
Mesh,
|
|
8
|
+
MeshArrowTable,
|
|
9
|
+
MeshAttribute,
|
|
10
|
+
MeshAttributeQuantizationTransform
|
|
11
|
+
} from '@loaders.gl/schema';
|
|
7
12
|
import {convertTableToMesh} from '@loaders.gl/schema-utils';
|
|
8
13
|
import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
|
|
9
14
|
|
|
@@ -11,12 +16,28 @@ import {TileConversionError} from '@loaders.gl/tile-converter/v5/core';
|
|
|
11
16
|
export interface EncodePointCloudTileOptions {
|
|
12
17
|
/** Optional origin subtracted from absolute positions and stored as the PNTS RTC_CENTER. */
|
|
13
18
|
readonly rtcCenter?: readonly [number, number, number];
|
|
19
|
+
/** Maximum Euclidean position reconstruction error in source units; omission permits float32 rounding. */
|
|
20
|
+
readonly maxPositionError?: number;
|
|
14
21
|
/** Optional source-wide RGBA color stored as the PNTS CONSTANT_RGBA property. */
|
|
15
22
|
readonly constantRGBA?: readonly number[];
|
|
16
23
|
/** Optional per-point values indexed by the tile's `BATCH_ID` attribute. */
|
|
17
24
|
readonly batchTableJson?: Readonly<Record<string, readonly (string | number)[]>>;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
/** Encoded PNTS and measured placement information from its float32 POSITION stream. */
|
|
28
|
+
export interface EncodedPointCloudTile {
|
|
29
|
+
/** Self-contained PNTS payload. */
|
|
30
|
+
readonly pnts: ArrayBuffer;
|
|
31
|
+
/** Actual number of encoded points. */
|
|
32
|
+
readonly pointCount: number;
|
|
33
|
+
/** RTC center added to encoded positions; zero when no RTC center was selected. */
|
|
34
|
+
readonly rtcCenter: readonly [number, number, number];
|
|
35
|
+
/** Axis-aligned bounds of encoded positions before adding the RTC center. */
|
|
36
|
+
readonly localBoundingBox: readonly [readonly number[], readonly number[]];
|
|
37
|
+
/** Maximum Euclidean error after reconstructing absolute positions, in source units. */
|
|
38
|
+
readonly maximumPositionError: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/**
|
|
21
42
|
* Encodes one mesh or Mesh Arrow point batch as a PNTS resource.
|
|
22
43
|
*
|
|
@@ -34,32 +55,34 @@ export function encodePointCloudTile(
|
|
|
34
55
|
pointBatch: Mesh | MeshArrowTable,
|
|
35
56
|
options: EncodePointCloudTileOptions = {}
|
|
36
57
|
): ArrayBuffer {
|
|
58
|
+
return encodePointCloudTileWithMetadata(pointBatch, options).pnts;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Encodes a point batch and measures its decoded float32 bounds and position rounding.
|
|
63
|
+
* The optional error gate runs before PNTS serialization. Source arrays remain unchanged.
|
|
64
|
+
* @param pointBatch - One mesh or Arrow point batch.
|
|
65
|
+
* @param options - RTC center, precision gate, appearance and batch metadata.
|
|
66
|
+
* @returns PNTS bytes paired with bounds, RTC placement and maximum position error.
|
|
67
|
+
*/
|
|
68
|
+
export function encodePointCloudTileWithMetadata(
|
|
69
|
+
pointBatch: Mesh | MeshArrowTable,
|
|
70
|
+
options: EncodePointCloudTileOptions = {}
|
|
71
|
+
): EncodedPointCloudTile {
|
|
37
72
|
const mesh = 'attributes' in pointBatch ? pointBatch : convertTableToMesh(pointBatch);
|
|
38
73
|
validatePointAttributes(mesh.attributes);
|
|
39
74
|
const positionAttribute = mesh.attributes.POSITION;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'Point cloud batches must contain a three-component POSITION attribute'
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const positions = getPositionValues(positionAttribute, options.rtcCenter);
|
|
75
|
+
validatePointCloudPosition(positionAttribute);
|
|
76
|
+
const placement = encodePointPositions(positionAttribute, options);
|
|
77
|
+
const {positions} = placement;
|
|
48
78
|
const pointCount = positions.length / 3;
|
|
49
|
-
if (pointCount === 0 || !Number.isInteger(pointCount)) {
|
|
50
|
-
throw new TileConversionError(
|
|
51
|
-
'POINT_CLOUD_POSITION_COUNT_INVALID',
|
|
52
|
-
'Point cloud POSITION must contain one or more xyz triples'
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
79
|
const colors = getColorAttribute(mesh.attributes.COLOR_0 || mesh.attributes.COLOR, pointCount);
|
|
57
80
|
const constantRGBA = colors ? null : getConstantRgba(options.constantRGBA);
|
|
58
81
|
const normals = getNormalAttribute(mesh.attributes.NORMAL, pointCount);
|
|
59
82
|
const batchIds = getBatchIds(mesh.attributes.BATCH_ID, pointCount);
|
|
60
83
|
const batchTableJson = validateBatchTable(options.batchTableJson, batchIds);
|
|
61
84
|
|
|
62
|
-
|
|
85
|
+
const pnts = Tile3DWriter.encodeSync(
|
|
63
86
|
{
|
|
64
87
|
type: TILE3D_TYPE.POINT_CLOUD,
|
|
65
88
|
featureTableJson:
|
|
@@ -74,6 +97,13 @@ export function encodePointCloudTile(
|
|
|
74
97
|
},
|
|
75
98
|
{}
|
|
76
99
|
);
|
|
100
|
+
return {
|
|
101
|
+
pnts,
|
|
102
|
+
pointCount,
|
|
103
|
+
rtcCenter: placement.rtcCenter,
|
|
104
|
+
localBoundingBox: placement.localBoundingBox,
|
|
105
|
+
maximumPositionError: placement.maximumPositionError
|
|
106
|
+
};
|
|
77
107
|
}
|
|
78
108
|
|
|
79
109
|
/** Rejects attributes that this PNTS encoder would otherwise silently discard. */
|
|
@@ -114,30 +144,127 @@ function getConstantRgba(
|
|
|
114
144
|
return [constantRGBA[0], constantRGBA[1], constantRGBA[2], constantRGBA[3]];
|
|
115
145
|
}
|
|
116
146
|
|
|
117
|
-
/**
|
|
118
|
-
function
|
|
119
|
-
attribute: MeshAttribute
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
147
|
+
/** Validates packed point positions and their optional quantization descriptor. */
|
|
148
|
+
function validatePointCloudPosition(
|
|
149
|
+
attribute: MeshAttribute | undefined
|
|
150
|
+
): asserts attribute is MeshAttribute & {transform?: MeshAttributeQuantizationTransform} {
|
|
151
|
+
if (
|
|
152
|
+
!attribute ||
|
|
153
|
+
attribute.size !== 3 ||
|
|
154
|
+
attribute.byteOffset ||
|
|
155
|
+
attribute.byteStride ||
|
|
156
|
+
attribute.normalized ||
|
|
157
|
+
attribute.componentType
|
|
158
|
+
) {
|
|
159
|
+
throw new TileConversionError(
|
|
160
|
+
'POINT_CLOUD_POSITION_REQUIRED',
|
|
161
|
+
'Point cloud batches must contain packed, non-normalized three-component POSITION values'
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!attribute.value.length || attribute.value.length % 3) {
|
|
166
|
+
throw new TileConversionError(
|
|
167
|
+
'POINT_CLOUD_POSITION_COUNT_INVALID',
|
|
168
|
+
'Point cloud POSITION must contain one or more xyz triples'
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const transform = attribute.transform;
|
|
172
|
+
if (
|
|
173
|
+
transform &&
|
|
174
|
+
(transform.type !== 'quantization' ||
|
|
175
|
+
!Number.isInteger(transform.bits) ||
|
|
176
|
+
transform.bits < 1 ||
|
|
177
|
+
transform.bits > 32 ||
|
|
178
|
+
transform.origin.length !== 3 ||
|
|
179
|
+
transform.origin.some(value => !Number.isFinite(value)) ||
|
|
180
|
+
!Number.isFinite(transform.range) ||
|
|
181
|
+
transform.range < 0)
|
|
182
|
+
) {
|
|
183
|
+
throw new TileConversionError(
|
|
184
|
+
'POINT_CLOUD_POSITION_TRANSFORM_INVALID',
|
|
185
|
+
'Position quantization requires finite xyz origin, nonnegative range and 1–32 bits'
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Decodes packed point positions to doubles before applying a spatial operation. */
|
|
191
|
+
export function decodePointCloudPositions(attribute: MeshAttribute | undefined): Float64Array {
|
|
192
|
+
validatePointCloudPosition(attribute);
|
|
193
|
+
const transform = attribute.transform;
|
|
194
|
+
return Float64Array.from(attribute.value, (value, index) =>
|
|
195
|
+
transform
|
|
196
|
+
? transform.origin[index % 3] + (value / (2 ** transform.bits - 1)) * transform.range
|
|
197
|
+
: value
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Decodes source positions, rebases them and measures the actual float32 output. */
|
|
202
|
+
function encodePointPositions(
|
|
203
|
+
attribute: MeshAttribute & {transform?: MeshAttributeQuantizationTransform},
|
|
204
|
+
options: EncodePointCloudTileOptions
|
|
205
|
+
) {
|
|
206
|
+
const {rtcCenter = [0, 0, 0], maxPositionError = Infinity} = options;
|
|
207
|
+
if (rtcCenter.length !== 3 || rtcCenter.some(value => !Number.isFinite(value))) {
|
|
123
208
|
throw new TileConversionError(
|
|
124
209
|
'POINT_CLOUD_RTC_CENTER_INVALID',
|
|
125
210
|
'Point cloud RTC_CENTER must contain three finite values'
|
|
126
211
|
);
|
|
127
212
|
}
|
|
213
|
+
if (
|
|
214
|
+
options.maxPositionError !== undefined &&
|
|
215
|
+
(!Number.isFinite(maxPositionError) || maxPositionError < 0)
|
|
216
|
+
) {
|
|
217
|
+
throw new TileConversionError(
|
|
218
|
+
'POINT_CLOUD_PRECISION_INVALID',
|
|
219
|
+
'maxPositionError must be finite and nonnegative'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
128
222
|
const values = attribute.value;
|
|
129
|
-
const
|
|
223
|
+
const positions = new Float32Array(values.length);
|
|
224
|
+
const minimum = [Infinity, Infinity, Infinity];
|
|
225
|
+
const maximum = [-Infinity, -Infinity, -Infinity];
|
|
226
|
+
let maximumPositionError = 0;
|
|
130
227
|
const transform = attribute.transform;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
? transform.origin[
|
|
228
|
+
const errors = [0, 0, 0];
|
|
229
|
+
for (let point = 0; point < values.length; point += 3) {
|
|
230
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
231
|
+
const value = values[point + axis];
|
|
232
|
+
const absoluteValue = transform
|
|
233
|
+
? transform.origin[axis] + (value / (2 ** transform.bits - 1)) * transform.range
|
|
137
234
|
: value;
|
|
138
|
-
|
|
235
|
+
const relativeValue = absoluteValue - rtcCenter[axis];
|
|
236
|
+
positions[point + axis] = relativeValue;
|
|
237
|
+
const encodedValue = positions[point + axis];
|
|
238
|
+
const reconstructed = encodedValue + rtcCenter[axis];
|
|
239
|
+
if (
|
|
240
|
+
!Number.isFinite(absoluteValue) ||
|
|
241
|
+
!Number.isFinite(relativeValue) ||
|
|
242
|
+
!Number.isFinite(encodedValue) ||
|
|
243
|
+
!Number.isFinite(reconstructed)
|
|
244
|
+
) {
|
|
245
|
+
throw new TileConversionError(
|
|
246
|
+
'POINT_CLOUD_POSITION_INVALID',
|
|
247
|
+
'Point positions and their float32 reconstruction must be finite'
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
errors[axis] = reconstructed - absoluteValue;
|
|
251
|
+
minimum[axis] = Math.min(minimum[axis], encodedValue);
|
|
252
|
+
maximum[axis] = Math.max(maximum[axis], encodedValue);
|
|
253
|
+
}
|
|
254
|
+
maximumPositionError = Math.max(maximumPositionError, Math.hypot(...errors));
|
|
255
|
+
}
|
|
256
|
+
if (!Number.isFinite(maximumPositionError) || maximumPositionError > maxPositionError) {
|
|
257
|
+
throw new TileConversionError(
|
|
258
|
+
'POINT_CLOUD_PRECISION_EXCEEDED',
|
|
259
|
+
'Float32 position reconstruction exceeds maxPositionError; select a closer RTC center'
|
|
260
|
+
);
|
|
139
261
|
}
|
|
140
|
-
return
|
|
262
|
+
return {
|
|
263
|
+
positions,
|
|
264
|
+
rtcCenter: [...rtcCenter] as [number, number, number],
|
|
265
|
+
localBoundingBox: [minimum, maximum] as const,
|
|
266
|
+
maximumPositionError
|
|
267
|
+
};
|
|
141
268
|
}
|
|
142
269
|
|
|
143
270
|
/** Converts an optional mesh color attribute into the byte colors accepted by PNTS. */
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// loaders.gl
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Copyright (c) vis.gl contributors
|
|
4
|
+
|
|
5
|
+
import {TileConversionError} from './conversion-api.js';
|
|
6
|
+
|
|
7
|
+
/** One aggregate transport budget shared by selected contents and their dependencies. */
|
|
8
|
+
export interface TileConversionResourceFetcherOptions {
|
|
9
|
+
/** Maximum total response bytes, including previously inspected root bytes. */
|
|
10
|
+
readonly maxInputBytes: number;
|
|
11
|
+
/** Previously consumed input bytes, such as the inspected tileset JSON. */
|
|
12
|
+
readonly initialInputBytes?: number;
|
|
13
|
+
/** Application transport; also enables archive-backed or hermetic resolution. */
|
|
14
|
+
readonly fetcher?: typeof fetch;
|
|
15
|
+
/** Conversion cancellation, forwarded to every dependency request. */
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wraps application fetch with a shared byte budget and conversion cancellation.
|
|
21
|
+
* Each requested resource is charged, including repeated requests; HTTP(S), Blob and embedded data
|
|
22
|
+
* URLs are accepted. Response streams are canceled on overflow or failure. The complete bounded
|
|
23
|
+
* response is retained for codecs; this does not bound their decoded allocations.
|
|
24
|
+
*/
|
|
25
|
+
export function createTileConversionResourceFetcher(
|
|
26
|
+
options: TileConversionResourceFetcherOptions
|
|
27
|
+
): typeof fetch {
|
|
28
|
+
let consumed = options.initialInputBytes ?? 0;
|
|
29
|
+
if (
|
|
30
|
+
!Number.isSafeInteger(options.maxInputBytes) ||
|
|
31
|
+
options.maxInputBytes < 1 ||
|
|
32
|
+
!Number.isSafeInteger(consumed) ||
|
|
33
|
+
consumed < 0 ||
|
|
34
|
+
consumed > options.maxInputBytes
|
|
35
|
+
)
|
|
36
|
+
throw new TileConversionError(
|
|
37
|
+
'INVALID_INPUT_MEMORY_LIMIT',
|
|
38
|
+
'Resource fetch requires a positive aggregate byte budget and valid initial bytes'
|
|
39
|
+
);
|
|
40
|
+
const fetcher = options.fetcher ?? fetch;
|
|
41
|
+
return async (input, requestOptions) => {
|
|
42
|
+
options.signal?.throwIfAborted();
|
|
43
|
+
const url = new URL(input instanceof Request ? input.url : String(input));
|
|
44
|
+
if (!['http:', 'https:', 'blob:', 'data:'].includes(url.protocol))
|
|
45
|
+
throw new TileConversionError(
|
|
46
|
+
'EXTERNAL_RESOURCE_UNSUPPORTED',
|
|
47
|
+
'Conversion transport requires HTTP(S), Blob or embedded data resources'
|
|
48
|
+
);
|
|
49
|
+
const requestSignal =
|
|
50
|
+
requestOptions?.signal ?? (input instanceof Request ? input.signal : undefined);
|
|
51
|
+
const signal =
|
|
52
|
+
options.signal && requestSignal
|
|
53
|
+
? AbortSignal.any([options.signal, requestSignal])
|
|
54
|
+
: (options.signal ?? requestSignal);
|
|
55
|
+
const response = await fetcher(input, {...requestOptions, signal});
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
await response.body?.cancel();
|
|
58
|
+
throw new TileConversionError(
|
|
59
|
+
'BROWSER_SOURCE_FETCH_FAILED',
|
|
60
|
+
`Could not fetch conversion resource (${response.status})`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const reader = response.body?.getReader();
|
|
64
|
+
if (!reader)
|
|
65
|
+
throw new TileConversionError(
|
|
66
|
+
'BROWSER_SOURCE_STREAM_UNAVAILABLE',
|
|
67
|
+
'Conversion resources require readable response streams'
|
|
68
|
+
);
|
|
69
|
+
/** Cancels an injected transport stream if it does not observe the request signal itself. */
|
|
70
|
+
const cancelRead = () => {
|
|
71
|
+
void reader.cancel(signal?.reason).catch(() => {});
|
|
72
|
+
};
|
|
73
|
+
signal?.addEventListener('abort', cancelRead, {once: true});
|
|
74
|
+
const parts: Uint8Array[] = [];
|
|
75
|
+
let byteLength = 0;
|
|
76
|
+
try {
|
|
77
|
+
while (true) {
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
const {done, value} = await reader.read();
|
|
80
|
+
signal?.throwIfAborted();
|
|
81
|
+
if (done) break;
|
|
82
|
+
if (value.byteLength > options.maxInputBytes - consumed)
|
|
83
|
+
throw new TileConversionError(
|
|
84
|
+
'INPUT_RESOURCE_TOO_LARGE',
|
|
85
|
+
'Selected contents and external dependencies exceed the aggregate input byte budget'
|
|
86
|
+
);
|
|
87
|
+
consumed += value.byteLength;
|
|
88
|
+
byteLength += value.byteLength;
|
|
89
|
+
parts.push(value);
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
await reader.cancel(error);
|
|
93
|
+
throw error;
|
|
94
|
+
} finally {
|
|
95
|
+
signal?.removeEventListener('abort', cancelRead);
|
|
96
|
+
reader.releaseLock();
|
|
97
|
+
}
|
|
98
|
+
const bytes = new Uint8Array(byteLength);
|
|
99
|
+
let offset = 0;
|
|
100
|
+
for (const part of parts) {
|
|
101
|
+
bytes.set(part, offset);
|
|
102
|
+
offset += part.byteLength;
|
|
103
|
+
}
|
|
104
|
+
const boundedResponse = new Response(bytes, {
|
|
105
|
+
status: response.status,
|
|
106
|
+
statusText: response.statusText,
|
|
107
|
+
headers: response.headers
|
|
108
|
+
});
|
|
109
|
+
// Relative dependencies must resolve against the final URL after redirects.
|
|
110
|
+
Object.defineProperty(boundedResponse, 'url', {value: response.url});
|
|
111
|
+
return boundedResponse;
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -43,6 +43,17 @@ export interface ManifestBackedTileConversionSinkOptions<TResource> {
|
|
|
43
43
|
readonly sink: TileConversionSink<TResource>;
|
|
44
44
|
/** Persistence adapter for snapshots written after each resource. */
|
|
45
45
|
readonly manifestStore: TileResourceManifestStore;
|
|
46
|
+
/** Optional cap on unique checkpoint entries loaded or written; defaults to no cap. */
|
|
47
|
+
readonly maxResources?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Checks destination bytes before skipping a matching checkpoint. Return true only when
|
|
50
|
+
* stored bytes match the entry's size and fingerprint; false rewrites the resource.
|
|
51
|
+
* Without this callback the caller guarantees checkpointed destination bytes remain intact.
|
|
52
|
+
*/
|
|
53
|
+
readonly verifyResource?: (
|
|
54
|
+
entry: TileResourceManifestEntry,
|
|
55
|
+
signal?: AbortSignal
|
|
56
|
+
) => Promise<boolean>;
|
|
46
57
|
/** Stable profile-specific path or resource key. */
|
|
47
58
|
readonly getResourceId: (resource: TResource) => string;
|
|
48
59
|
/** Byte length used to detect changed content under an existing resource ID. */
|
|
@@ -62,7 +73,8 @@ export interface ManifestBackedTileConversionSinkOptions<TResource> {
|
|
|
62
73
|
* including writes skipped because the matching resource was already present. A graceful abort
|
|
63
74
|
* clears the manifest checkpoint before asking the destination to clean partial output, so the
|
|
64
75
|
* next run starts cleanly. A process interruption that does not call `abort` can resume from its
|
|
65
|
-
* last saved checkpoint.
|
|
76
|
+
* last saved checkpoint. Optional destination verification repairs missing or changed stored
|
|
77
|
+
* resources before treating them as complete. maxResources caps unique checkpoint entries.
|
|
66
78
|
*
|
|
67
79
|
* @param options - Destination, manifest store, stable IDs, byte measurement, and fingerprinting.
|
|
68
80
|
* @returns A `TileConversionSink` with resumable per-resource progress.
|
|
@@ -70,10 +82,17 @@ export interface ManifestBackedTileConversionSinkOptions<TResource> {
|
|
|
70
82
|
export async function createManifestBackedTileConversionSink<TResource>(
|
|
71
83
|
options: ManifestBackedTileConversionSinkOptions<TResource>
|
|
72
84
|
): Promise<TileConversionSink<TResource>> {
|
|
85
|
+
const maxResources = options.maxResources ?? Infinity;
|
|
86
|
+
if (maxResources !== Infinity && (!Number.isSafeInteger(maxResources) || maxResources < 0)) {
|
|
87
|
+
throw new TileConversionError(
|
|
88
|
+
'INVALID_MANIFEST_RESOURCE_LIMIT',
|
|
89
|
+
'maxResources must be a nonnegative safe integer or Infinity'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
73
92
|
const priorManifest = await options.manifestStore.load();
|
|
74
93
|
const resources = new Map<string, TileResourceManifestEntry>();
|
|
75
94
|
if (priorManifest) {
|
|
76
|
-
validateManifest(priorManifest);
|
|
95
|
+
validateManifest(priorManifest, maxResources);
|
|
77
96
|
for (const entry of priorManifest.resources) {
|
|
78
97
|
resources.set(entry.resourceId, entry);
|
|
79
98
|
}
|
|
@@ -102,6 +121,7 @@ export async function createManifestBackedTileConversionSink<TResource>(
|
|
|
102
121
|
if (writeFailed) {
|
|
103
122
|
throw writeFailure;
|
|
104
123
|
}
|
|
124
|
+
signal?.throwIfAborted();
|
|
105
125
|
const entry = createManifestEntry(options, resource);
|
|
106
126
|
const priorEntry = resources.get(entry.resourceId);
|
|
107
127
|
if (priorEntry) {
|
|
@@ -114,7 +134,16 @@ export async function createManifestBackedTileConversionSink<TResource>(
|
|
|
114
134
|
`Resource "${entry.resourceId}" changed since the saved manifest`
|
|
115
135
|
);
|
|
116
136
|
}
|
|
117
|
-
|
|
137
|
+
const verified = options.verifyResource
|
|
138
|
+
? await options.verifyResource({...priorEntry}, signal)
|
|
139
|
+
: true;
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
if (verified === true) return;
|
|
142
|
+
} else if (resources.size >= maxResources) {
|
|
143
|
+
throw new TileConversionError(
|
|
144
|
+
'MANIFEST_RESOURCE_LIMIT_EXCEEDED',
|
|
145
|
+
'Resource checkpoint exceeds maxResources'
|
|
146
|
+
);
|
|
118
147
|
}
|
|
119
148
|
|
|
120
149
|
await options.sink.write(resource, signal);
|
|
@@ -179,7 +208,7 @@ function createManifestEntry<TResource>(
|
|
|
179
208
|
}
|
|
180
209
|
|
|
181
210
|
/** Checks a loaded manifest before trusting it for restart decisions. */
|
|
182
|
-
function validateManifest(manifest: TileResourceManifest): void {
|
|
211
|
+
function validateManifest(manifest: TileResourceManifest, maxResources: number): void {
|
|
183
212
|
if (
|
|
184
213
|
typeof manifest !== 'object' ||
|
|
185
214
|
manifest === null ||
|
|
@@ -189,7 +218,13 @@ function validateManifest(manifest: TileResourceManifest): void {
|
|
|
189
218
|
) {
|
|
190
219
|
throw new TileConversionError(
|
|
191
220
|
'UNSUPPORTED_RESOURCE_MANIFEST',
|
|
192
|
-
`Unsupported or malformed resource manifest version: ${String(manifest
|
|
221
|
+
`Unsupported or malformed resource manifest version: ${String(manifest?.version)}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (manifest.resources.length > maxResources) {
|
|
225
|
+
throw new TileConversionError(
|
|
226
|
+
'MANIFEST_RESOURCE_LIMIT_EXCEEDED',
|
|
227
|
+
'Loaded manifest exceeds maxResources'
|
|
193
228
|
);
|
|
194
229
|
}
|
|
195
230
|
const resourceIds = new Set<string>();
|