maplibre-gl 3.4.0 → 3.4.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "maplibre-gl",
3
3
  "description": "BSD licensed community fork of mapbox-gl, a WebGL interactive maps library",
4
- "version": "3.4.0",
4
+ "version": "3.4.1",
5
5
  "main": "dist/maplibre-gl.js",
6
6
  "style": "dist/maplibre-gl.css",
7
7
  "license": "BSD-3-Clause",
@@ -44,7 +44,7 @@
44
44
  "@mapbox/mvt-fixtures": "^3.10.0",
45
45
  "@rollup/plugin-commonjs": "^25.0.5",
46
46
  "@rollup/plugin-json": "^6.0.1",
47
- "@rollup/plugin-node-resolve": "^15.2.2",
47
+ "@rollup/plugin-node-resolve": "^15.2.3",
48
48
  "@rollup/plugin-replace": "^5.0.3",
49
49
  "@rollup/plugin-strip": "^3.0.3",
50
50
  "@rollup/plugin-terser": "^0.4.4",
@@ -62,12 +62,12 @@
62
62
  "@types/minimist": "^1.2.3",
63
63
  "@types/murmurhash-js": "^1.0.4",
64
64
  "@types/nise": "^1.4.2",
65
- "@types/node": "^20.8.2",
65
+ "@types/node": "^20.8.3",
66
66
  "@types/offscreencanvas": "^2019.7.1",
67
67
  "@types/pixelmatch": "^5.2.4",
68
68
  "@types/pngjs": "^6.0.2",
69
69
  "@types/react": "^18.2.25",
70
- "@types/react-dom": "^18.2.10",
70
+ "@types/react-dom": "^18.2.11",
71
71
  "@types/request": "^2.48.9",
72
72
  "@types/shuffle-seed": "^1.1.0",
73
73
  "@types/window-or-global": "^1.0.4",
@@ -79,10 +79,10 @@
79
79
  "cssnano": "^6.0.1",
80
80
  "d3": "^7.8.5",
81
81
  "d3-queue": "^3.0.7",
82
- "devtools-protocol": "^0.0.1205644",
82
+ "devtools-protocol": "^0.0.1206220",
83
83
  "diff": "^5.1.0",
84
84
  "dts-bundle-generator": "^8.0.1",
85
- "eslint": "^8.50.0",
85
+ "eslint": "^8.51.0",
86
86
  "eslint-config-mourner": "^3.0.0",
87
87
  "eslint-plugin-html": "^7.1.0",
88
88
  "eslint-plugin-import": "^2.28.1",
@@ -111,10 +111,10 @@
111
111
  "postcss-cli": "^10.1.0",
112
112
  "postcss-inline-svg": "^6.0.0",
113
113
  "pretty-bytes": "^6.1.1",
114
- "puppeteer": "^21.3.6",
114
+ "puppeteer": "^21.3.8",
115
115
  "react": "^18.2.0",
116
116
  "react-dom": "^18.2.0",
117
- "rollup": "^3.29.4",
117
+ "rollup": "^4.0.2",
118
118
  "rollup-plugin-sourcemaps": "^0.6.3",
119
119
  "rw": "^1.3.3",
120
120
  "semver": "^7.5.4",
@@ -126,7 +126,7 @@
126
126
  "ts-jest": "^29.1.1",
127
127
  "ts-node": "^10.9.1",
128
128
  "tslib": "^2.6.2",
129
- "typedoc": "^0.25.1",
129
+ "typedoc": "^0.25.2",
130
130
  "typedoc-plugin-markdown": "^3.16.0",
131
131
  "typedoc-plugin-missing-exports": "^2.1.0",
132
132
  "typescript": "^5.2.2"
@@ -95,13 +95,13 @@ describe('GlyphManager', () => {
95
95
  manager.getGlyphs({'Arial Unicode MS': [0x3005]}, (err, glyphs) => {
96
96
  expect(err).toBeFalsy();
97
97
  expect(glyphs['Arial Unicode MS'][0x3005]).not.toBeNull();
98
- //Request char from Katakana range (te)
98
+ //Request char from Katakana range (te)
99
99
  manager.getGlyphs({'Arial Unicode MS': [0x30C6]}, (err, glyphs) => {
100
100
  expect(err).toBeFalsy();
101
101
  const glyph = glyphs['Arial Unicode MS'][0x30c6];
102
102
  //Ensure that te is locally generated.
103
- expect(glyph.bitmap.height).toBe(6);
104
- expect(glyph.bitmap.width).toBe(6);
103
+ expect(glyph.bitmap.height).toBe(12);
104
+ expect(glyph.bitmap.width).toBe(12);
105
105
  done();
106
106
  });
107
107
  });
@@ -110,9 +110,10 @@ describe('GlyphManager', () => {
110
110
  test('GlyphManager generates CJK PBF locally', done => {
111
111
  const manager = createGlyphManager('sans-serif');
112
112
 
113
+ // character 平
113
114
  manager.getGlyphs({'Arial Unicode MS': [0x5e73]}, (err, glyphs) => {
114
115
  expect(err).toBeFalsy();
115
- expect(glyphs['Arial Unicode MS'][0x5e73].metrics.advance).toBe(1);
116
+ expect(glyphs['Arial Unicode MS'][0x5e73].metrics.advance).toBe(0.5);
116
117
  done();
117
118
  });
118
119
  });
@@ -120,10 +121,10 @@ describe('GlyphManager', () => {
120
121
  test('GlyphManager generates Katakana PBF locally', done => {
121
122
  const manager = createGlyphManager('sans-serif');
122
123
 
123
- // Katakana letter te
124
+ // Katakana letter te
124
125
  manager.getGlyphs({'Arial Unicode MS': [0x30c6]}, (err, glyphs) => {
125
126
  expect(err).toBeFalsy();
126
- expect(glyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(1);
127
+ expect(glyphs['Arial Unicode MS'][0x30c6].metrics.advance).toBe(0.5);
127
128
  done();
128
129
  });
129
130
  });
@@ -131,10 +132,10 @@ describe('GlyphManager', () => {
131
132
  test('GlyphManager generates Hiragana PBF locally', done => {
132
133
  const manager = createGlyphManager('sans-serif');
133
134
 
134
- //Hiragana letter te
135
+ //Hiragana letter te
135
136
  manager.getGlyphs({'Arial Unicode MS': [0x3066]}, (err, glyphs) => {
136
137
  expect(err).toBeFalsy();
137
- expect(glyphs['Arial Unicode MS'][0x3066].metrics.advance).toBe(1);
138
+ expect(glyphs['Arial Unicode MS'][0x3066].metrics.advance).toBe(0.5);
138
139
  done();
139
140
  });
140
141
  });
@@ -143,7 +144,7 @@ describe('GlyphManager', () => {
143
144
 
144
145
  const manager = createGlyphManager('sans-serif');
145
146
  const drawSpy = GlyphManager.TinySDF.prototype.draw = jest.fn().mockImplementation(() => {
146
- return {data: new Uint8ClampedArray(900)} as any;
147
+ return {data: new Uint8ClampedArray(60 * 60)} as any;
147
148
  });
148
149
 
149
150
  // Katakana letter te
@@ -180,6 +180,10 @@ export class GlyphManager {
180
180
  return;
181
181
  }
182
182
 
183
+ // Client-generated glyphs are rendered at 2x texture scale,
184
+ // because CJK glyphs are more detailed than others.
185
+ const textureScale = 2;
186
+
183
187
  let tinySDF = entry.tinySDF;
184
188
  if (!tinySDF) {
185
189
  let fontWeight = '400';
@@ -191,9 +195,9 @@ export class GlyphManager {
191
195
  fontWeight = '200';
192
196
  }
193
197
  tinySDF = entry.tinySDF = new GlyphManager.TinySDF({
194
- fontSize: 24,
195
- buffer: 3,
196
- radius: 8,
198
+ fontSize: 24 * textureScale,
199
+ buffer: 3 * textureScale,
200
+ radius: 8 * textureScale,
197
201
  cutoff: 0.25,
198
202
  fontFamily,
199
203
  fontWeight
@@ -215,17 +219,20 @@ export class GlyphManager {
215
219
  * To approximately align TinySDF glyphs with server-provided glyphs, we use this baseline adjustment
216
220
  * factor calibrated to be in between DIN Pro and Arial Unicode (but closer to Arial Unicode)
217
221
  */
218
- const topAdjustment = 27;
222
+ const topAdjustment = 27.5;
223
+
224
+ const leftAdjustment = 0.5;
219
225
 
220
226
  return {
221
227
  id,
222
- bitmap: new AlphaImage({width: char.width || 30, height: char.height || 30}, char.data),
228
+ bitmap: new AlphaImage({width: char.width || 30 * textureScale, height: char.height || 30 * textureScale}, char.data),
223
229
  metrics: {
224
- width: char.glyphWidth || 24,
225
- height: char.glyphHeight || 24,
226
- left: char.glyphLeft || 0,
227
- top: char.glyphTop - topAdjustment || -8,
228
- advance: char.glyphAdvance || 24
230
+ width: char.glyphWidth / textureScale || 24,
231
+ height: char.glyphHeight / textureScale || 24,
232
+ left: (char.glyphLeft / textureScale + leftAdjustment) || 0,
233
+ top: char.glyphTop / textureScale - topAdjustment || -8,
234
+ advance: char.glyphAdvance / textureScale || 24,
235
+ isDoubleResolution: true
229
236
  }
230
237
  };
231
238
  }
@@ -1,6 +1,6 @@
1
1
  import {ImageRequest} from '../util/image_request';
2
2
  import {ResourceType} from '../util/request_manager';
3
- import {extend, isImageBitmap} from '../util/util';
3
+ import {extend, isImageBitmap, readImageUsingVideoFrame} from '../util/util';
4
4
  import {Evented} from '../util/evented';
5
5
  import {browser} from '../util/browser';
6
6
  import {offscreenCanvasSupported} from '../util/offscreen_canvas_supported';
@@ -16,6 +16,8 @@ import type {Tile} from './tile';
16
16
  import type {Callback} from '../types/callback';
17
17
  import type {RasterDEMSourceSpecification} from '@maplibre/maplibre-gl-style-spec';
18
18
  import type {ExpiryData} from '../util/ajax';
19
+ import {isOffscreenCanvasDistorted} from '../util/offscreen_canvas_distorted';
20
+ import {RGBAImage} from '../util/image';
19
21
 
20
22
  /**
21
23
  * A source containing raster DEM tiles (See the [Style Specification](https://maplibre.org/maplibre-style-spec/) for detailed documentation of options.)
@@ -54,10 +56,9 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
54
56
 
55
57
  loadTile(tile: Tile, callback: Callback<void>) {
56
58
  const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme);
57
- tile.request = ImageRequest.getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), imageLoaded.bind(this), this.map._refreshExpiredTiles);
58
-
59
+ const request = this.map._requestManager.transformRequest(url, ResourceType.Tile);
59
60
  tile.neighboringTiles = this._getNeighboringTiles(tile.tileID);
60
- function imageLoaded(err: Error, img: (HTMLImageElement | ImageBitmap) & ExpiryData) {
61
+ tile.request = ImageRequest.getImage(request, async (err: Error, img: (HTMLImageElement | ImageBitmap), expiry: ExpiryData) => {
61
62
  delete tile.request;
62
63
  if (tile.aborted) {
63
64
  tile.state = 'unloaded';
@@ -66,11 +67,9 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
66
67
  tile.state = 'errored';
67
68
  callback(err);
68
69
  } else if (img) {
69
- if (this.map._refreshExpiredTiles) tile.setExpiryData(img);
70
- delete img.cacheControl;
71
- delete img.expires;
70
+ if (this.map._refreshExpiredTiles) tile.setExpiryData(expiry);
72
71
  const transfer = isImageBitmap(img) && offscreenCanvasSupported();
73
- const rawImageData = transfer ? img : browser.getImageData(img, 1);
72
+ const rawImageData = transfer ? img : await readImageNow(img);
74
73
  const params = {
75
74
  uid: tile.uid,
76
75
  coord: tile.tileID,
@@ -85,9 +84,22 @@ export class RasterDEMTileSource extends RasterTileSource implements Source {
85
84
 
86
85
  if (!tile.actor || tile.state === 'expired') {
87
86
  tile.actor = this.dispatcher.getActor();
88
- tile.actor.send('loadDEMTile', params, done.bind(this));
87
+ tile.actor.send('loadDEMTile', params, done);
88
+ }
89
+ }
90
+ }, this.map._refreshExpiredTiles);
91
+
92
+ async function readImageNow(img: ImageBitmap | HTMLImageElement): Promise<RGBAImage | ImageData> {
93
+ if (typeof VideoFrame !== 'undefined' && isOffscreenCanvasDistorted()) {
94
+ const width = img.width + 2;
95
+ const height = img.height + 2;
96
+ try {
97
+ return new RGBAImage({width, height}, await readImageUsingVideoFrame(img, -1, -1, width, height));
98
+ } catch (e) {
99
+ // fall-back to browser canvas decoding
89
100
  }
90
101
  }
102
+ return browser.getImageData(img, 1);
91
103
  }
92
104
 
93
105
  function done(err, data) {
@@ -6,46 +6,29 @@ import type {
6
6
  WorkerDEMTileCallback,
7
7
  TileParameters
8
8
  } from './worker_source';
9
- import {isImageBitmap} from '../util/util';
9
+ import {getImageData, isImageBitmap} from '../util/util';
10
10
 
11
11
  export class RasterDEMTileWorkerSource {
12
12
  actor: Actor;
13
13
  loaded: {[_: string]: DEMData};
14
- offscreenCanvas: OffscreenCanvas;
15
- offscreenCanvasContext: OffscreenCanvasRenderingContext2D;
16
14
 
17
15
  constructor() {
18
16
  this.loaded = {};
19
17
  }
20
18
 
21
- loadTile(params: WorkerDEMTileParameters, callback: WorkerDEMTileCallback) {
19
+ async loadTile(params: WorkerDEMTileParameters, callback: WorkerDEMTileCallback) {
22
20
  const {uid, encoding, rawImageData, redFactor, greenFactor, blueFactor, baseShift} = params;
23
- // Main thread will transfer ImageBitmap if offscreen decode with OffscreenCanvas is supported, else it will transfer an already decoded image.
24
- const imagePixels = isImageBitmap(rawImageData) ? this.getImageData(rawImageData) : rawImageData as RGBAImage;
21
+ const width = rawImageData.width + 2;
22
+ const height = rawImageData.height + 2;
23
+ const imagePixels: RGBAImage = isImageBitmap(rawImageData) ?
24
+ new RGBAImage({width, height}, await getImageData(rawImageData, -1, -1, width, height)) :
25
+ rawImageData;
25
26
  const dem = new DEMData(uid, imagePixels, encoding, redFactor, greenFactor, blueFactor, baseShift);
26
27
  this.loaded = this.loaded || {};
27
28
  this.loaded[uid] = dem;
28
29
  callback(null, dem);
29
30
  }
30
31
 
31
- getImageData(imgBitmap: ImageBitmap): RGBAImage {
32
- // Lazily initialize OffscreenCanvas
33
- if (!this.offscreenCanvas || !this.offscreenCanvasContext) {
34
- // Dem tiles are typically 256x256
35
- this.offscreenCanvas = new OffscreenCanvas(imgBitmap.width, imgBitmap.height);
36
- this.offscreenCanvasContext = this.offscreenCanvas.getContext('2d', {willReadFrequently: true});
37
- }
38
-
39
- this.offscreenCanvas.width = imgBitmap.width;
40
- this.offscreenCanvas.height = imgBitmap.height;
41
-
42
- this.offscreenCanvasContext.drawImage(imgBitmap, 0, 0, imgBitmap.width, imgBitmap.height);
43
- // Insert an additional 1px padding around the image to allow backfilling for neighboring data.
44
- const imgData = this.offscreenCanvasContext.getImageData(-1, -1, imgBitmap.width + 2, imgBitmap.height + 2);
45
- this.offscreenCanvasContext.clearRect(0, 0, this.offscreenCanvas.width, this.offscreenCanvas.height);
46
- return new RGBAImage({width: imgData.width, height: imgData.height}, imgData.data);
47
- }
48
-
49
32
  removeTile(params: TileParameters) {
50
33
  const loaded = this.loaded,
51
34
  uid = params.uid;
@@ -725,6 +725,9 @@ export class Style extends Evented {
725
725
 
726
726
  this.stylesheet = nextState;
727
727
 
728
+ // reset serialization field, to be populated only when needed
729
+ this._serializedLayers = null;
730
+
728
731
  return true;
729
732
  }
730
733
 
@@ -1,14 +1,15 @@
1
1
  import type {AlphaImage} from '../util/image';
2
2
 
3
- /**
4
- * The glyph's metrices
5
- */
6
3
  export type GlyphMetrics = {
7
4
  width: number;
8
5
  height: number;
9
6
  left: number;
10
7
  top: number;
11
8
  advance: number;
9
+ /**
10
+ * isDoubleResolution = true for 48px textures
11
+ */
12
+ isDoubleResolution?: boolean;
12
13
  };
13
14
 
14
15
  /**
@@ -274,10 +274,12 @@ export function getGlyphQuads(
274
274
  builtInOffset = [0, 0];
275
275
  }
276
276
 
277
+ const textureScale = positionedGlyph.metrics.isDoubleResolution ? 2 : 1;
278
+
277
279
  const x1 = (positionedGlyph.metrics.left - rectBuffer) * positionedGlyph.scale - halfAdvance + builtInOffset[0];
278
280
  const y1 = (-positionedGlyph.metrics.top - rectBuffer) * positionedGlyph.scale + builtInOffset[1];
279
- const x2 = x1 + textureRect.w * positionedGlyph.scale / pixelRatio;
280
- const y2 = y1 + textureRect.h * positionedGlyph.scale / pixelRatio;
281
+ const x2 = x1 + textureRect.w / textureScale * positionedGlyph.scale / pixelRatio;
282
+ const y2 = y1 + textureRect.h / textureScale * positionedGlyph.scale / pixelRatio;
281
283
 
282
284
  const tl = new Point(x1, y1);
283
285
  const tr = new Point(x2, y1);
@@ -288,6 +288,23 @@ describe('Map', () => {
288
288
 
289
289
  });
290
290
 
291
+ test('setStyle back to the first style should work', done => {
292
+ const redStyle = {version: 8 as const, sources: {}, layers: [
293
+ {id: 'background', type: 'background' as const, paint: {'background-color': 'red'}},
294
+ ]};
295
+ const blueStyle = {version: 8 as const, sources: {}, layers: [
296
+ {id: 'background', type: 'background' as const, paint: {'background-color': 'blue'}},
297
+ ]};
298
+ const map = createMap({style: redStyle});
299
+ map.setStyle(blueStyle);
300
+ map.once('style.load', () => {
301
+ map.setStyle(redStyle);
302
+ const serializedStyle = map.style.serialize();
303
+ expect(serializedStyle.layers[0].paint['background-color']).toBe('red');
304
+ done();
305
+ });
306
+ });
307
+
291
308
  test('style transform overrides unmodified map transform', done => {
292
309
  const map = new Map({container: window.document.createElement('div')} as any as MapOptions);
293
310
  map.transform.lngRange = [-120, 140];
@@ -0,0 +1,13 @@
1
+ import {isOffscreenCanvasDistorted} from './offscreen_canvas_distorted';
2
+ import {Canvas} from 'canvas';
3
+ import {offscreenCanvasSupported} from './offscreen_canvas_supported';
4
+
5
+ test('normal operation does not mangle canvas', () => {
6
+ const OffscreenCanvas = (window as any).OffscreenCanvas = jest.fn((width:number, height: number) => {
7
+ return new Canvas(width, height);
8
+ });
9
+ expect(offscreenCanvasSupported()).toBeTruthy();
10
+ OffscreenCanvas.mockClear();
11
+ expect(isOffscreenCanvasDistorted()).toBeFalsy();
12
+ expect(OffscreenCanvas).toHaveBeenCalledTimes(1);
13
+ });
@@ -0,0 +1,39 @@
1
+ import {offscreenCanvasSupported} from './offscreen_canvas_supported';
2
+
3
+ let offscreenCanvasDistorted: boolean;
4
+
5
+ /**
6
+ * Some browsers don't return the exact pixels from a canvas to prevent user fingerprinting (see #3185).
7
+ * This function writes pixels to an OffscreenCanvas and reads them back using getImageData, returning false
8
+ * if they don't match.
9
+ *
10
+ * @returns true if the browser supports OffscreenCanvas but it distorts getImageData results, false otherwise.
11
+ */
12
+ export function isOffscreenCanvasDistorted(): boolean {
13
+ if (offscreenCanvasDistorted == null) {
14
+ offscreenCanvasDistorted = false;
15
+ if (offscreenCanvasSupported()) {
16
+ const size = 5;
17
+ const canvas = new OffscreenCanvas(size, size);
18
+ const context = canvas.getContext('2d', {willReadFrequently: true});
19
+ if (context) {
20
+ // fill each pixel with an RGB value that should make the byte at index i equal to i (except alpha channel):
21
+ // [0, 1, 2, 255, 4, 5, 6, 255, 8, 9, 10, 255, ...]
22
+ for (let i = 0; i < size * size; i++) {
23
+ const base = i * 4;
24
+ context.fillStyle = `rgb(${base},${base + 1},${base + 2})`;
25
+ context.fillRect(i % size, Math.floor(i / size), 1, 1);
26
+ }
27
+ const data = context.getImageData(0, 0, size, size).data;
28
+ for (let i = 0; i < size * size * 4; i++) {
29
+ if (i % 4 !== 3 && data[i] !== i) {
30
+ offscreenCanvasDistorted = true;
31
+ break;
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+
38
+ return offscreenCanvasDistorted || false;
39
+ }
@@ -1,5 +1,6 @@
1
1
  import Point from '@mapbox/point-geometry';
2
- import {arraysIntersect, asyncAll, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isClosedPolygon, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, uniqueId, wrap} from './util';
2
+ import {arraysIntersect, asyncAll, bezier, clamp, clone, deepEqual, easeCubicInOut, extend, filterObject, findLineIntersection, isClosedPolygon, isCounterClockwise, isPowerOfTwo, keysDifference, mapObject, nextPowerOfTwo, parseCacheControl, pick, readImageDataUsingOffscreenCanvas, readImageUsingVideoFrame, uniqueId, wrap} from './util';
3
+ import {Canvas} from 'canvas';
3
4
 
4
5
  describe('util', () => {
5
6
  expect(easeCubicInOut(0)).toBe(0);
@@ -346,3 +347,172 @@ describe('util findLineIntersection', () => {
346
347
  expect(intersection).toBeNull();
347
348
  });
348
349
  });
350
+
351
+ describe('util readImageUsingVideoFrame', () => {
352
+ let format = 'RGBA';
353
+ const frame = {
354
+ get format() {
355
+ return format;
356
+ },
357
+ copyTo: jest.fn(buf => {
358
+ buf.set(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]).subarray(0, buf.length));
359
+ return Promise.resolve();
360
+ }),
361
+ close: jest.fn(),
362
+ };
363
+ (window as any).VideoFrame = jest.fn(() => frame);
364
+ const canvas = document.createElement('canvas');
365
+ canvas.width = canvas.height = 2;
366
+
367
+ beforeEach(() => {
368
+ format = 'RGBA';
369
+ frame.copyTo.mockClear();
370
+ frame.close.mockReset();
371
+ });
372
+
373
+ test('copy RGB', async () => {
374
+ format = 'RGBA';
375
+ const result = await readImageUsingVideoFrame(canvas, 0, 0, 2, 2);
376
+ expect(result).toHaveLength(4 * 4);
377
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
378
+ layout: [{offset: 0, stride: 8}],
379
+ rect: {x: 0, y: 0, width: 2, height: 2}
380
+ });
381
+ expect(result).toEqual(new Uint8ClampedArray([
382
+ 1, 2, 3, 4, 5, 6, 7, 8,
383
+ 9, 10, 11, 12, 13, 14, 15, 16
384
+ ]));
385
+ expect(frame.close).toHaveBeenCalledTimes(1);
386
+ });
387
+
388
+ test('flip BRG', async () => {
389
+ format = 'BGRX';
390
+ const result = await readImageUsingVideoFrame(canvas, 0, 0, 2, 2);
391
+ expect(result).toEqual(new Uint8ClampedArray([
392
+ 3, 2, 1, 4, 7, 6, 5, 8,
393
+ 11, 10, 9, 12, 15, 14, 13, 16
394
+ ]));
395
+ expect(frame.close).toHaveBeenCalledTimes(1);
396
+ });
397
+
398
+ test('ignore bad format', async () => {
399
+ format = 'OTHER';
400
+ await expect(readImageUsingVideoFrame(canvas, 0, 0, 2, 2)).rejects.toThrow();
401
+ expect(frame.close).toHaveBeenCalledTimes(1);
402
+ });
403
+
404
+ describe('layout/rect', () => {
405
+ beforeEach(() => {
406
+ (window as any).VideoFrame = jest.fn(() => frame);
407
+ canvas.width = canvas.height = 3;
408
+ });
409
+
410
+ test('full rectangle', async () => {
411
+ await readImageUsingVideoFrame(canvas, 0, 0, 3, 3);
412
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
413
+ layout: [{offset: 0, stride: 12}],
414
+ rect: {x: 0, y: 0, width: 3, height: 3}
415
+ });
416
+ });
417
+
418
+ test('top left', async () => {
419
+ await readImageUsingVideoFrame(canvas, 0, 0, 2, 2);
420
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
421
+ layout: [{offset: 0, stride: 8}],
422
+ rect: {x: 0, y: 0, width: 2, height: 2}
423
+ });
424
+ });
425
+
426
+ test('top right', async () => {
427
+ await readImageUsingVideoFrame(canvas, 1, 0, 2, 2);
428
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
429
+ layout: [{offset: 0, stride: 8}],
430
+ rect: {x: 1, y: 0, width: 2, height: 2}
431
+ });
432
+ });
433
+
434
+ test('bottom left', async () => {
435
+ await readImageUsingVideoFrame(canvas, 0, 1, 2, 2);
436
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
437
+ layout: [{offset: 0, stride: 8}],
438
+ rect: {x: 0, y: 1, width: 2, height: 2}
439
+ });
440
+ });
441
+
442
+ test('bottom right', async () => {
443
+ await readImageUsingVideoFrame(canvas, 1, 1, 2, 2);
444
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
445
+ layout: [{offset: 0, stride: 8}],
446
+ rect: {x: 1, y: 1, width: 2, height: 2}
447
+ });
448
+ });
449
+
450
+ test('middle', async () => {
451
+ await readImageUsingVideoFrame(canvas, 1, 1, 1, 1);
452
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
453
+ layout: [{offset: 0, stride: 4}],
454
+ rect: {x: 1, y: 1, width: 1, height: 1}
455
+ });
456
+ });
457
+
458
+ test('extend past on all sides', async () => {
459
+ await readImageUsingVideoFrame(canvas, -1, -1, 5, 5);
460
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
461
+ layout: [{offset: 4 * 5 + 4, stride: 4 * 5}],
462
+ rect: {x: 0, y: 0, width: 3, height: 3}
463
+ });
464
+ });
465
+
466
+ test('overhang top left', async () => {
467
+ await readImageUsingVideoFrame(canvas, -1, -1, 2, 2);
468
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
469
+ layout: [{offset: 4 * 2 + 4, stride: 4 * 2}],
470
+ rect: {x: 0, y: 0, width: 1, height: 1}
471
+ });
472
+ });
473
+
474
+ test('overhang top right', async () => {
475
+ await readImageUsingVideoFrame(canvas, 2, -1, 2, 2);
476
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
477
+ layout: [{offset: 4 * 2, stride: 4 * 2}],
478
+ rect: {x: 2, y: 0, width: 1, height: 1}
479
+ });
480
+ });
481
+
482
+ test('overhang bottom left', async () => {
483
+ await readImageUsingVideoFrame(canvas, -1, 2, 2, 2);
484
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
485
+ layout: [{offset: 4, stride: 4 * 2}],
486
+ rect: {x: 0, y: 2, width: 1, height: 1}
487
+ });
488
+ });
489
+
490
+ test('overhang bottom right', async () => {
491
+ await readImageUsingVideoFrame(canvas, 2, 2, 2, 2);
492
+ expect(frame.copyTo).toHaveBeenCalledWith(expect.anything(), {
493
+ layout: [{offset: 0, stride: 4 * 2}],
494
+ rect: {x: 2, y: 2, width: 1, height: 1}
495
+ });
496
+ });
497
+ });
498
+ });
499
+
500
+ describe('util readImageDataUsingOffscreenCanvas', () => {
501
+ test('reads pixels from image', async () => {
502
+ (window as any).OffscreenCanvas = Canvas;
503
+ const image = new Canvas(2, 2);
504
+ const context = image.getContext('2d');
505
+ context.fillStyle = 'rgb(10,0,0)';
506
+ context.fillRect(0, 0, 1, 1);
507
+ context.fillStyle = 'rgb(0,20,0)';
508
+ context.fillRect(1, 0, 1, 1);
509
+ context.fillStyle = 'rgb(0,0,30)';
510
+ context.fillRect(0, 1, 1, 1);
511
+ context.fillStyle = 'rgb(40,40,40)';
512
+ context.fillRect(1, 1, 1, 1);
513
+ expect([...await readImageDataUsingOffscreenCanvas(image as any, 0, 0, 2, 2)]).toEqual([
514
+ 10, 0, 0, 255, 0, 20, 0, 255,
515
+ 0, 0, 30, 255, 40, 40, 40, 255,
516
+ ]);
517
+ });
518
+ });