@ksp-gonogo/kerbcast 1.4.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 +186 -0
- package/LICENSE +369 -0
- package/README.md +112 -0
- package/dist/__generated__/types.d.ts +529 -0
- package/dist/__generated__/types.js +50 -0
- package/dist/client.d.ts +433 -0
- package/dist/client.js +821 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/noise.d.ts +34 -0
- package/dist/noise.js +274 -0
- package/dist/panZoom.d.ts +190 -0
- package/dist/panZoom.js +302 -0
- package/dist/testing/index.d.ts +217 -0
- package/dist/testing/index.js +606 -0
- package/package.json +57 -0
- package/src/__generated__/types.ts +488 -0
- package/src/client.test.ts +1673 -0
- package/src/client.ts +1219 -0
- package/src/domStubs.test.ts +49 -0
- package/src/index.ts +29 -0
- package/src/noise.test.ts +340 -0
- package/src/noise.ts +317 -0
- package/src/panZoom.test.ts +404 -0
- package/src/panZoom.ts +405 -0
- package/src/testing/index.ts +689 -0
- package/src/testing.test.ts +156 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, beforeEach, it, expect } from "vitest";
|
|
2
|
+
import { installDomStubs } from "./testing/index";
|
|
3
|
+
|
|
4
|
+
// Tests confirm the stubs install cleanly and expose the minimum surface
|
|
5
|
+
// that kerbcast component tests rely on.
|
|
6
|
+
|
|
7
|
+
describe("installDomStubs", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
installDomStubs();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("installs idempotently (second call does not throw)", () => {
|
|
13
|
+
expect(() => installDomStubs()).not.toThrow();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("ResizeObserver is constructible after install", () => {
|
|
17
|
+
const ro = new ResizeObserver(() => {});
|
|
18
|
+
expect(ro).toBeDefined();
|
|
19
|
+
expect(typeof ro.observe).toBe("function");
|
|
20
|
+
expect(typeof ro.unobserve).toBe("function");
|
|
21
|
+
expect(typeof ro.disconnect).toBe("function");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("MediaStream is constructible", () => {
|
|
25
|
+
const ms = new MediaStream();
|
|
26
|
+
expect(ms).toBeDefined();
|
|
27
|
+
expect(typeof ms.getTracks).toBe("function");
|
|
28
|
+
expect(Array.isArray(ms.getTracks())).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("captureStream returns a MediaStream-shaped object", () => {
|
|
32
|
+
const canvas = document.createElement("canvas");
|
|
33
|
+
const stream = (canvas as HTMLCanvasElement & { captureStream(fps?: number): MediaStream }).captureStream(30);
|
|
34
|
+
expect(stream).toBeDefined();
|
|
35
|
+
expect(typeof stream.getTracks).toBe("function");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("HTMLMediaElement.play resolves without throwing", async () => {
|
|
39
|
+
const video = document.createElement("video");
|
|
40
|
+
await expect(video.play()).resolves.toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("window.matchMedia returns a non-matching MediaQueryList", () => {
|
|
44
|
+
const mql = window.matchMedia("(prefers-color-scheme: dark)");
|
|
45
|
+
expect(mql).toBeDefined();
|
|
46
|
+
expect(mql.matches).toBe(false);
|
|
47
|
+
expect(typeof mql.addEventListener).toBe("function");
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Re-export every type the typeshare codegen emits from the Rust
|
|
2
|
+
// protocol module. New code should prefer the higher-level
|
|
3
|
+
// `KerbcastClient` re-exported below, but the underlying wire types
|
|
4
|
+
// stay available for consumers that want to roll their own transport.
|
|
5
|
+
export * from "./__generated__/types";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
KerbcastClient,
|
|
9
|
+
BrowserKerbcastTransport,
|
|
10
|
+
type BrowserKerbcastTransportOptions,
|
|
11
|
+
type InboundVideoStats,
|
|
12
|
+
type KerbcastCameraHandle,
|
|
13
|
+
type KerbcastClientConfig,
|
|
14
|
+
type KerbcastConnectionState,
|
|
15
|
+
type KerbcastClientEvents,
|
|
16
|
+
type KerbcastCameraEvents,
|
|
17
|
+
type KerbcastTransport,
|
|
18
|
+
type KerbcastPeer,
|
|
19
|
+
type KerbcastDataChannel,
|
|
20
|
+
type DiscoveredCamera,
|
|
21
|
+
type NoiseConfig,
|
|
22
|
+
} from "./client";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
PanZoomController,
|
|
26
|
+
type PanZoomCommandSink,
|
|
27
|
+
type PanZoomBounds,
|
|
28
|
+
type PanZoomControllerOptions,
|
|
29
|
+
} from "./panZoom";
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline-level tests for the stall presentation in `noise.ts`:
|
|
3
|
+
*
|
|
4
|
+
* - a source that stalls after delivering frames gets the static ramped in
|
|
5
|
+
* over STALL_RAMP_MS on top of the held last frame (layer alpha 0 -> 1);
|
|
6
|
+
* - the first decoded frame drops the static immediately (no ramp out);
|
|
7
|
+
* - `showStatic: false` freezes the last frame with no static at all;
|
|
8
|
+
* - a source that never decoded a frame keeps the immediate full-static
|
|
9
|
+
* "waiting" look;
|
|
10
|
+
* - stall transitions surface through `onStallChange`.
|
|
11
|
+
*
|
|
12
|
+
* jsdom has no canvas/captureStream/requestVideoFrameCallback, so the test
|
|
13
|
+
* installs a recording 2d-context fake, captures the rAF draw loop and the
|
|
14
|
+
* rVFC frame callback, and drives time through a mocked `performance.now`.
|
|
15
|
+
* Draw records carry the `globalAlpha` in effect at call time, which is the
|
|
16
|
+
* opacity mechanic the ramp is implemented with.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
20
|
+
import { tryCreateNoisePipeline } from "./noise";
|
|
21
|
+
|
|
22
|
+
interface DrawRecord {
|
|
23
|
+
/*
|
|
24
|
+
* getContext call order in the pipeline: 0 = the noise scratch canvas,
|
|
25
|
+
* 1 = the OUTPUT canvas (what the processed stream shows), 2 = the
|
|
26
|
+
* held-last-frame canvas (created lazily at stall onset, receives the
|
|
27
|
+
* snapshot copy).
|
|
28
|
+
*/
|
|
29
|
+
ctx: number;
|
|
30
|
+
kind: "drawImage" | "fillRect";
|
|
31
|
+
src?: unknown;
|
|
32
|
+
alpha: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The output canvas is the second 2d context the pipeline requests. */
|
|
36
|
+
const OUTPUT_CTX = 1;
|
|
37
|
+
|
|
38
|
+
interface StallEnv {
|
|
39
|
+
setNow(t: number): void;
|
|
40
|
+
/** Deliver one decoded frame (fires the captured rVFC callback). */
|
|
41
|
+
fireFrame(): void;
|
|
42
|
+
/** Run one draw-loop iteration; returns the OUTPUT-canvas records it made. */
|
|
43
|
+
draw(): DrawRecord[];
|
|
44
|
+
putImageData: ReturnType<typeof vi.fn>;
|
|
45
|
+
restore(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function installStallEnv(): StallEnv {
|
|
49
|
+
let now = 0;
|
|
50
|
+
const records: DrawRecord[] = [];
|
|
51
|
+
const putImageData = vi.fn();
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* Each canvas gets its own recording context (tagged by creation order) so
|
|
55
|
+
* tests can tell an output-canvas composite from the stall-onset snapshot
|
|
56
|
+
* copy into the held-frame canvas.
|
|
57
|
+
*/
|
|
58
|
+
let ctxCount = 0;
|
|
59
|
+
const makeCtx = () => {
|
|
60
|
+
const id = ctxCount++;
|
|
61
|
+
const ctx = {
|
|
62
|
+
globalAlpha: 1,
|
|
63
|
+
fillStyle: "",
|
|
64
|
+
drawImage: (src: unknown) => {
|
|
65
|
+
records.push({ ctx: id, kind: "drawImage", src, alpha: ctx.globalAlpha });
|
|
66
|
+
},
|
|
67
|
+
fillRect: () => {
|
|
68
|
+
records.push({ ctx: id, kind: "fillRect", alpha: ctx.globalAlpha });
|
|
69
|
+
},
|
|
70
|
+
createImageData: (w: number, h: number) => ({
|
|
71
|
+
data: new Uint8ClampedArray(w * h * 4),
|
|
72
|
+
width: w,
|
|
73
|
+
height: h,
|
|
74
|
+
}),
|
|
75
|
+
putImageData,
|
|
76
|
+
};
|
|
77
|
+
return ctx;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const origGetContext = HTMLCanvasElement.prototype.getContext;
|
|
81
|
+
// @ts-expect-error -- jsdom augmentation
|
|
82
|
+
HTMLCanvasElement.prototype.getContext = vi.fn(() => makeCtx());
|
|
83
|
+
// @ts-expect-error -- jsdom augmentation
|
|
84
|
+
HTMLCanvasElement.prototype.captureStream = vi.fn(
|
|
85
|
+
() => ({}) as MediaStream,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
let rafCb: FrameRequestCallback | null = null;
|
|
89
|
+
const rafSpy = vi
|
|
90
|
+
.spyOn(globalThis, "requestAnimationFrame")
|
|
91
|
+
.mockImplementation((cb) => {
|
|
92
|
+
rafCb = cb;
|
|
93
|
+
return 1;
|
|
94
|
+
});
|
|
95
|
+
const cancelSpy = vi
|
|
96
|
+
.spyOn(globalThis, "cancelAnimationFrame")
|
|
97
|
+
.mockImplementation(() => {});
|
|
98
|
+
|
|
99
|
+
let vfcCb: (() => void) | null = null;
|
|
100
|
+
const proto = HTMLVideoElement.prototype as HTMLVideoElement & {
|
|
101
|
+
requestVideoFrameCallback?: (cb: () => void) => number;
|
|
102
|
+
cancelVideoFrameCallback?: (id: number) => void;
|
|
103
|
+
};
|
|
104
|
+
proto.requestVideoFrameCallback = (cb: () => void) => {
|
|
105
|
+
vfcCb = cb;
|
|
106
|
+
return 1;
|
|
107
|
+
};
|
|
108
|
+
proto.cancelVideoFrameCallback = () => {};
|
|
109
|
+
|
|
110
|
+
const origPlay = HTMLMediaElement.prototype.play;
|
|
111
|
+
HTMLMediaElement.prototype.play = () => Promise.resolve();
|
|
112
|
+
const origPause = HTMLMediaElement.prototype.pause;
|
|
113
|
+
HTMLMediaElement.prototype.pause = () => {};
|
|
114
|
+
|
|
115
|
+
const nowSpy = vi.spyOn(performance, "now").mockImplementation(() => now);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
setNow(t: number) {
|
|
119
|
+
now = t;
|
|
120
|
+
},
|
|
121
|
+
fireFrame() {
|
|
122
|
+
vfcCb?.();
|
|
123
|
+
},
|
|
124
|
+
draw() {
|
|
125
|
+
const start = records.length;
|
|
126
|
+
rafCb?.(now);
|
|
127
|
+
return records.slice(start).filter((r) => r.ctx === OUTPUT_CTX);
|
|
128
|
+
},
|
|
129
|
+
putImageData,
|
|
130
|
+
restore() {
|
|
131
|
+
HTMLCanvasElement.prototype.getContext = origGetContext;
|
|
132
|
+
// @ts-expect-error -- cleanup
|
|
133
|
+
delete HTMLCanvasElement.prototype.captureStream;
|
|
134
|
+
delete proto.requestVideoFrameCallback;
|
|
135
|
+
delete proto.cancelVideoFrameCallback;
|
|
136
|
+
HTMLMediaElement.prototype.play = origPlay;
|
|
137
|
+
HTMLMediaElement.prototype.pause = origPause;
|
|
138
|
+
rafSpy.mockRestore();
|
|
139
|
+
cancelSpy.mockRestore();
|
|
140
|
+
nowSpy.mockRestore();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fakeStream(): MediaStream {
|
|
146
|
+
return { getTracks: () => [] } as unknown as MediaStream;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Last drawImage of a draw: the noise-canvas composite. */
|
|
150
|
+
function noiseDraw(records: DrawRecord[]): DrawRecord | undefined {
|
|
151
|
+
return [...records].reverse().find((r) => r.kind === "drawImage");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
afterEach(() => {
|
|
155
|
+
vi.restoreAllMocks();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("noise pipeline stall presentation", () => {
|
|
159
|
+
it("ramps static in over the held last frame after a stall", () => {
|
|
160
|
+
const env = installStallEnv();
|
|
161
|
+
try {
|
|
162
|
+
const onStallChange = vi.fn();
|
|
163
|
+
const pipeline = tryCreateNoisePipeline(fakeStream(), 0.05, {
|
|
164
|
+
onStallChange,
|
|
165
|
+
});
|
|
166
|
+
expect(pipeline).not.toBeNull();
|
|
167
|
+
|
|
168
|
+
// Frames delivering: no stall.
|
|
169
|
+
env.setNow(1000);
|
|
170
|
+
env.fireFrame();
|
|
171
|
+
env.setNow(1100);
|
|
172
|
+
env.draw();
|
|
173
|
+
expect(onStallChange).not.toHaveBeenCalled();
|
|
174
|
+
|
|
175
|
+
// Stall onset (>500 ms since the last decoded frame): static layer
|
|
176
|
+
// starts at alpha 0 over the restored last frame.
|
|
177
|
+
env.setNow(1601);
|
|
178
|
+
let records = env.draw();
|
|
179
|
+
expect(onStallChange).toHaveBeenLastCalledWith(true);
|
|
180
|
+
// First drawImage restores the held frame at full opacity.
|
|
181
|
+
const restore = records.find((r) => r.kind === "drawImage");
|
|
182
|
+
expect(restore?.alpha).toBe(1);
|
|
183
|
+
expect(restore?.src).toBeInstanceOf(HTMLCanvasElement);
|
|
184
|
+
// Scrim + static composite at the ramp position (0 at onset).
|
|
185
|
+
expect(records.find((r) => r.kind === "fillRect")?.alpha).toBe(0);
|
|
186
|
+
expect(noiseDraw(records)?.alpha).toBe(0);
|
|
187
|
+
|
|
188
|
+
// Halfway through the 5 s ramp.
|
|
189
|
+
env.setNow(1601 + 2500);
|
|
190
|
+
records = env.draw();
|
|
191
|
+
expect(records.find((r) => r.kind === "fillRect")?.alpha).toBeCloseTo(0.5);
|
|
192
|
+
expect(noiseDraw(records)?.alpha).toBeCloseTo(0.5);
|
|
193
|
+
|
|
194
|
+
// Fully ramped (and clamped thereafter).
|
|
195
|
+
env.setNow(1601 + 7000);
|
|
196
|
+
records = env.draw();
|
|
197
|
+
expect(records.find((r) => r.kind === "fillRect")?.alpha).toBe(1);
|
|
198
|
+
expect(noiseDraw(records)?.alpha).toBe(1);
|
|
199
|
+
|
|
200
|
+
// One stall, one event.
|
|
201
|
+
expect(onStallChange).toHaveBeenCalledTimes(1);
|
|
202
|
+
pipeline?.destroy();
|
|
203
|
+
} finally {
|
|
204
|
+
env.restore();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("drops the static immediately when frames resume", () => {
|
|
209
|
+
const env = installStallEnv();
|
|
210
|
+
try {
|
|
211
|
+
const onStallChange = vi.fn();
|
|
212
|
+
const pipeline = tryCreateNoisePipeline(fakeStream(), 0.05, {
|
|
213
|
+
onStallChange,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
env.setNow(1000);
|
|
217
|
+
env.fireFrame();
|
|
218
|
+
env.setNow(2000);
|
|
219
|
+
env.draw(); // stalled, mid-ramp
|
|
220
|
+
expect(onStallChange).toHaveBeenLastCalledWith(true);
|
|
221
|
+
|
|
222
|
+
// A decoded frame lands: the very next draw is back to the live path,
|
|
223
|
+
// full-opacity degrade static, no scrim, no held-frame restore.
|
|
224
|
+
env.fireFrame();
|
|
225
|
+
const records = env.draw();
|
|
226
|
+
expect(onStallChange).toHaveBeenLastCalledWith(false);
|
|
227
|
+
expect(records.some((r) => r.kind === "fillRect")).toBe(false);
|
|
228
|
+
expect(noiseDraw(records)?.alpha).toBe(1);
|
|
229
|
+
pipeline?.destroy();
|
|
230
|
+
} finally {
|
|
231
|
+
env.restore();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("showStatic: false freezes the last frame with no static", () => {
|
|
236
|
+
const env = installStallEnv();
|
|
237
|
+
try {
|
|
238
|
+
const onStallChange = vi.fn();
|
|
239
|
+
const pipeline = tryCreateNoisePipeline(fakeStream(), 0.05, {
|
|
240
|
+
showStatic: false,
|
|
241
|
+
onStallChange,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
env.setNow(1000);
|
|
245
|
+
env.fireFrame();
|
|
246
|
+
env.setNow(1601);
|
|
247
|
+
env.putImageData.mockClear();
|
|
248
|
+
const records = env.draw();
|
|
249
|
+
|
|
250
|
+
// The stall is still detected and reported (consumers drive their own
|
|
251
|
+
// staleness chrome from it) ...
|
|
252
|
+
expect(onStallChange).toHaveBeenLastCalledWith(true);
|
|
253
|
+
// ... but the draw only restores the held frame: no scrim, no noise.
|
|
254
|
+
expect(records).toHaveLength(1);
|
|
255
|
+
expect(records[0]?.kind).toBe("drawImage");
|
|
256
|
+
expect(records[0]?.alpha).toBe(1);
|
|
257
|
+
expect(env.putImageData).not.toHaveBeenCalled();
|
|
258
|
+
|
|
259
|
+
// Runtime re-enable brings the static back on the next draw.
|
|
260
|
+
pipeline?.setShowStatic(true);
|
|
261
|
+
env.setNow(1700);
|
|
262
|
+
env.draw();
|
|
263
|
+
expect(env.putImageData).toHaveBeenCalled();
|
|
264
|
+
pipeline?.destroy();
|
|
265
|
+
} finally {
|
|
266
|
+
env.restore();
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("keeps the immediate full-static look for a source that never decoded", () => {
|
|
271
|
+
const env = installStallEnv();
|
|
272
|
+
try {
|
|
273
|
+
const onStallChange = vi.fn();
|
|
274
|
+
const pipeline = tryCreateNoisePipeline(fakeStream(), 0.05, {
|
|
275
|
+
onStallChange,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// No frame ever fires; past the stall window the waiting look is the
|
|
279
|
+
// hard full-static cut (black + static at alpha 1), not a ramp.
|
|
280
|
+
env.setNow(601);
|
|
281
|
+
const records = env.draw();
|
|
282
|
+
expect(onStallChange).toHaveBeenLastCalledWith(true);
|
|
283
|
+
expect(records.find((r) => r.kind === "fillRect")?.alpha).toBe(1);
|
|
284
|
+
expect(noiseDraw(records)?.alpha).toBe(1);
|
|
285
|
+
// Nothing restored under it: the only drawImage is the noise canvas.
|
|
286
|
+
expect(records.filter((r) => r.kind === "drawImage")).toHaveLength(1);
|
|
287
|
+
pipeline?.destroy();
|
|
288
|
+
} finally {
|
|
289
|
+
env.restore();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("showStatic: false skips noise on the sourceless path", () => {
|
|
294
|
+
const env = installStallEnv();
|
|
295
|
+
try {
|
|
296
|
+
const pipeline = tryCreateNoisePipeline(null, 1.0, { showStatic: false });
|
|
297
|
+
expect(pipeline).not.toBeNull();
|
|
298
|
+
|
|
299
|
+
env.putImageData.mockClear();
|
|
300
|
+
const records = env.draw();
|
|
301
|
+
|
|
302
|
+
/* Sourceless + showStatic off: only the black fillRect, no noise composite. */
|
|
303
|
+
expect(records).toHaveLength(1);
|
|
304
|
+
expect(records[0]?.kind).toBe("fillRect");
|
|
305
|
+
expect(env.putImageData).not.toHaveBeenCalled();
|
|
306
|
+
|
|
307
|
+
/* Re-enable at runtime: noise appears on the next draw. */
|
|
308
|
+
pipeline?.setShowStatic(true);
|
|
309
|
+
env.draw();
|
|
310
|
+
expect(env.putImageData).toHaveBeenCalled();
|
|
311
|
+
|
|
312
|
+
pipeline?.destroy();
|
|
313
|
+
} finally {
|
|
314
|
+
env.restore();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("clears the stall state on source swap", () => {
|
|
319
|
+
const env = installStallEnv();
|
|
320
|
+
try {
|
|
321
|
+
const onStallChange = vi.fn();
|
|
322
|
+
const pipeline = tryCreateNoisePipeline(fakeStream(), 0.05, {
|
|
323
|
+
onStallChange,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
env.setNow(1000);
|
|
327
|
+
env.fireFrame();
|
|
328
|
+
env.setNow(2000);
|
|
329
|
+
env.draw();
|
|
330
|
+
expect(onStallChange).toHaveBeenLastCalledWith(true);
|
|
331
|
+
|
|
332
|
+
// Going sourceless ends the stall (sourceless static takes over).
|
|
333
|
+
pipeline?.setSource(null);
|
|
334
|
+
expect(onStallChange).toHaveBeenLastCalledWith(false);
|
|
335
|
+
pipeline?.destroy();
|
|
336
|
+
} finally {
|
|
337
|
+
env.restore();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
});
|