@pbvision/partner-sdk 0.1.13 → 0.1.15

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.
Files changed (3) hide show
  1. package/README.md +367 -20
  2. package/package.json +1 -1
  3. package/src/index.js +453 -59
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # PB Vision Partner Documentation <!-- omit in toc -->
2
2
 
3
+ For policies, pricing, and partner responsibilities refer to our [API Partner Guide](https://help.pb.vision/en/help/articles/2793895-pb-vision-api-partner-guide).
4
+
3
5
  The easiest way to use the PB Vision AI Engine is to simply share a link to
4
6
  your video file. Our AI usually works on each video for about 30 minutes for a
5
7
  standard length game, so we'll notify your servers when the results are ready.
@@ -12,9 +14,16 @@ standard length game, so we'll notify your servers when the results are ready.
12
14
  - [Send Videos](#send-videos)
13
15
  - [Option 1: PBV downloads your video from a URL](#option-1-pbv-downloads-your-video-from-a-url)
14
16
  - [Option 2: Upload your video](#option-2-upload-your-video)
15
- - [Video Editors](#video-editors)
17
+ - [Option 3: Pre-allocate a video ID (record/upload elsewhere)](#option-3-pre-allocate-a-video-id-recordupload-elsewhere)
18
+ - [Video Metadata](#video-metadata)
19
+ - [Auto-tagging players](#auto-tagging-players)
20
+ - [Folders](#folders)
21
+ - [Recording with the PB Vision app](#recording-with-the-pb-vision-app)
22
+ - [Video Editors and Viewers](#video-editors-and-viewers)
16
23
  - [After Video Processing is Done](#after-video-processing-is-done)
17
24
  - [Callback Data](#callback-data)
25
+ - [Fetching Insights by Video ID](#fetching-insights-by-video-id)
26
+ - [Player Identification](#player-identification)
18
27
  - [Video Guidelines](#video-guidelines)
19
28
  - [Reference Guide](#reference-guide)
20
29
 
@@ -80,13 +89,12 @@ Next, tell us to download and work on the video. You can do this using our SDK:
80
89
  import { PBVision } from '@pbvision/partner-sdk';
81
90
 
82
91
  const pbv = new PBVision(YOUR_API_KEY, { useProdServer: true });
83
- // you can omit this metadata, or provide some or all of this object—whatever you'd like!
84
- const optionalMetadata = {
85
- userEmails: [], // these users will have the video in their PB Vision library
92
+ const metadata = {
93
+ userEmails: ['player1@example.com', 'player2@example.com'],
86
94
  name: 'Dink Championship 2025',
87
- gameStartEpoch: 1711393200, // when the game was played
95
+ gameStartEpoch: 1711393200,
88
96
  };
89
- await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL, optionalMetadata);
97
+ const { vid } = await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL, metadata);
90
98
  ```
91
99
 
92
100
  Alternatively, you can just use `curl`:
@@ -107,24 +115,278 @@ You can directly upload your video from a file using the SDK too:
107
115
  import { PBVision } from '@pbvision/partner-sdk';
108
116
 
109
117
  const pbv = new PBVision(YOUR_API_KEY, { useProdServer: true });
110
- // you can omit this metadata, or provide some or all of this object—whatever you'd like!
111
- const optionalMetadata = {
112
- userEmails: [], // these users will have the video in their PB Vision library
113
- name: 'Dink Championship 2025',
114
- gameStartEpoch: 1711393200, // when the game was played
118
+ const metadata = {
119
+ userEmails: ['player1@example.com'],
120
+ name: 'My Game',
121
+ gameStartEpoch: 1711393200,
115
122
  };
116
- await pbv.uploadVideo(YOUR_VIDEO_FILENAME, optionalMetadata);
123
+ const { vid } = await pbv.uploadVideo(YOUR_VIDEO_FILENAME, metadata);
124
+ ```
125
+
126
+ `uploadVideo()` sends the file in chunks and resumes by itself after a dropped
127
+ connection or a temporary error. If the upload still fails, the error it throws
128
+ has the video's ID as its `vid` property. To retry without making a second
129
+ video for the game, pass a `nonce` that identifies the game (see
130
+ `makeVideoId()`): every call with the same nonce uploads to the same video.
131
+
132
+ #### Option 3: Pre-allocate a video ID (record/upload elsewhere)
133
+
134
+ Use `makeVideoId()` to reserve a video ID **without** uploading a file yourself.
135
+ This is handy when the video will be recorded and uploaded by a different
136
+ client. Hand the returned `vid` (and `uid`) to that client so it uploads to
137
+ this video instead of allocating a new one.
138
+
139
+ To have the game recorded with the PB Vision app, use `makeRecordingLink()`
140
+ instead: it reserves the video ID for you and returns a link that opens the
141
+ app's camera to record into it. See
142
+ [Recording with the PB Vision app](#recording-with-the-pb-vision-app).
143
+
144
+ ```javascript
145
+ const { vid, uid, hasCredits } = await pbv.makeVideoId({
146
+ // same metadata fields as the other upload methods (all optional)
147
+ name: 'My Game',
148
+ fileExt: 'mp4', // the extension it will be uploaded as (defaults to "mp4")
149
+ playersForTagging: { server: { name: 'Alice', email: 'alice@example.com' }, receiver: { name: 'Bob' } }
150
+ });
151
+ ```
152
+
153
+ If the account paying for the video can't pay for it, `hasCredits` is `false`
154
+ and there is no `vid`. Passthrough partners always get `hasCredits`.
155
+
156
+ #### Video Metadata
157
+
158
+ `sendVideoUrlToDownload()`, `uploadVideo()`, `makeVideoId()`, and
159
+ `makeRecordingLink()` accept an optional metadata object. You can omit it
160
+ entirely, or provide some or all of these fields:
161
+
162
+ | Field | Type | Description |
163
+ |-------|------|-------------|
164
+ | `userEmails` | `string[]` | Up to 4 email addresses of players in the game. These users will have the video added to their PB Vision library, become editors on the video, and be notified when processing is complete. |
165
+ | `name` | `string` | Title of the game. Defaults to the game time, or the upload time if no game time is provided. |
166
+ | `desc` | `string` | A longer description of the game. |
167
+ | `gameStartEpoch` | `integer` | Unix timestamp (seconds) of when the game was played. Not taken by `makeRecordingLink()`, where the game's time comes from its recording. |
168
+ | `facility` | `string` | Name of the facility where the game was recorded, e.g. `"Cool Club #3 - Barcelona"`. Useful for facility and Court Insight integrations. |
169
+ | `court` | `string` | Court identifier where the game was recorded, e.g. `"11A"`. Useful for facility and Court Insight integrations. |
170
+ | `fid` | `integer` | Folder ID, e.g. from `getOrCreateFolder()` (see [Folders](#folders)). Puts the video in that folder in your partner account's library. A `fid` that isn't one of your folders is refused with a 400 error (`unknown folder`). |
171
+ | `playersForTagging` | `object` | The players to **auto-tag** once processing completes, each by a name, an email, or both (see [Auto-tagging players](#auto-tagging-players)). Honored by all of `uploadVideo()`, `sendVideoUrlToDownload()`, `makeVideoId()`, and `makeRecordingLink()`. |
172
+
173
+ The `facility` and `court` fields are primarily used by facility partners
174
+ running [Court Insight](https://help.pb.vision/en/articles/9341690-court-insight-for-facilities-and-clubs)
175
+ or similar venue-based integrations, where tracking which court a game came from
176
+ is important. League and tournament partners may also find these useful for
177
+ organizing videos by location.
178
+
179
+ #### Auto-tagging players
180
+
181
+ Provide `playersForTagging` to have PB Vision tag the players automatically
182
+ once the video is processed. Identify each player by their **role at the start
183
+ of the first game**, and give each one a `name`, an `email`, or both:
184
+
185
+ ```javascript
186
+ const { vid } = await pbv.uploadVideo(YOUR_VIDEO_FILENAME, {
187
+ playersForTagging: {
188
+ server: { name: 'Alice', email: 'alice@example.com' }, // served the first point
189
+ serverPartner: { name: 'Carol' }, // omit for singles
190
+ receiver: { email: 'bob@example.com' }, // received the first point
191
+ receiverPartner: { name: 'Dave', email: 'dave@example.com' } // omit for singles
192
+ }
193
+ });
194
+ ```
195
+
196
+ To fill in the roles from a match:
197
+
198
+ - The team that serves first supplies the `server` (its first server) and the
199
+ `serverPartner`.
200
+ - The other team's first server is the `receiver`: they start on the right and
201
+ receive the first serve. Their partner is the `receiverPartner`.
202
+ - For singles, omit `serverPartner` and `receiverPartner`.
203
+
204
+ How each player is tagged depends on what you provide:
205
+
206
+ - A player with an `email` is emailed that they were tagged. If the email
207
+ belongs to a PB Vision account, the game is added to that account's library
208
+ and the account's own name is shown. If not, the `name` you gave is shown,
209
+ and the game joins their library when they sign up with that email.
210
+ - A player with only a `name` is labeled with that name.
211
+
212
+ `server` and `receiver` are required when `playersForTagging` is present.
213
+ Names are 1 to 64 characters (after trimming), and each email may be used for
214
+ only one player. In multi-game videos the same players are matched across
215
+ games automatically.
216
+
217
+ ### Folders
218
+
219
+ Folders organize the videos in your partner account's library. To put a new
220
+ video in a folder, pass the folder's ID as the `fid` [metadata](#video-metadata)
221
+ field.
222
+
223
+ `getOrCreateFolder()` returns the folder in your library with exactly the name
224
+ you give (inside `parent`, if you pass one), creating it if there is none.
225
+ Calling it again with the same name returns the same folder, as long as you
226
+ haven't renamed or moved it since, so it is safe to retry. Use a unique name
227
+ for each event, such as one that includes its date; otherwise two events with
228
+ the same name would share one folder. Names are 1 to 200 characters, with no
229
+ leading or trailing whitespace.
230
+
231
+ ```javascript
232
+ // a folder at the top level of your library
233
+ const { fid, created } = await pbv.getOrCreateFolder('Spring Open 2026-04-18');
234
+
235
+ // a folder inside it
236
+ const { fid: day1Fid } = await pbv.getOrCreateFolder('Day 1', { parent: fid });
237
+ ```
238
+
239
+ `created` is `true` if the call made the folder, or `false` if it already
240
+ existed.
241
+
242
+ `updateFolder()` renames a folder, moves it, or makes it public. Fields you
243
+ leave out stay as they are:
244
+
245
+ ```javascript
246
+ await pbv.updateFolder(fid, { name: 'Spring Open 2026' }); // rename it
247
+ await pbv.updateFolder(day1Fid, { parent: null }); // move it to the top level
248
+ await pbv.updateFolder(fid, { public: true }); // let anyone with its link view it
249
+ await pbv.updateFolder(day1Fid, { public: true }); // and include Day 1 when it's viewed
250
+ ```
251
+
252
+ Anyone with a public folder's link can view its videos, signed in to PB Vision
253
+ or not. A folder inside it is only included if it is public too, so make each
254
+ folder you want shared public (the folders PB Vision makes to hold the games of
255
+ a multi-game video follow their parent). `getPublicFolderUrl()` gives you the
256
+ link:
257
+
258
+ ```javascript
259
+ const folderUrl = pbv.getPublicFolderUrl(fid);
260
+ // https://pb.vision/library/public/<uid>/<fid>
261
+ ```
262
+
263
+ ### Recording with the PB Vision app
264
+
265
+ The PB Vision iOS and Android apps can record a game straight into your
266
+ partner account. For each game, `makeRecordingLink()` makes a video with the
267
+ [metadata](#video-metadata) you give it and returns a link. Opening the link on
268
+ a phone takes the PB Vision app straight to its camera, and the recording
269
+ uploads to that video. Anyone can record from the link, whether or not they
270
+ are signed in to PB Vision.
271
+
272
+ A typical event goes like this:
273
+
274
+ 1. **Create a folder for the event** with `getOrCreateFolder()`, using a name
275
+ that is unique to the event (see [Folders](#folders)).
276
+ 2. **Make a recording link for each game** with `makeRecordingLink()`, putting
277
+ the game in the event's folder and naming its players so they are
278
+ [tagged automatically](#auto-tagging-players).
279
+ 3. **Share the folder**: make it public with `updateFolder()` and send out the
280
+ link from `getPublicFolderUrl()`.
281
+
282
+ ```javascript
283
+ import { PBVision } from '@pbvision/partner-sdk';
284
+
285
+ const pbv = new PBVision(YOUR_API_KEY, { useProdServer: true });
286
+
287
+ // 1. one folder per event
288
+ const { fid } = await pbv.getOrCreateFolder('Spring Open 2026-04-18');
289
+
290
+ // 2. one recording link per game
291
+ const { vid, url } = await pbv.makeRecordingLink({
292
+ name: 'Spring Open, Court 3, Round 1',
293
+ fid,
294
+ court: '3',
295
+ playersForTagging: {
296
+ server: { name: 'Alice', email: 'alice@example.com' },
297
+ serverPartner: { name: 'Carol' },
298
+ receiver: { name: 'Bob', email: 'bob@example.com' },
299
+ receiverPartner: { name: 'Dave' }
300
+ }
301
+ });
302
+ // save `vid` with the game in your own records
303
+ // show `url` as a QR code, or send it to whoever is recording the game
304
+
305
+ // 3. share the event
306
+ await pbv.updateFolder(fid, { public: true });
307
+ const eventUrl = pbv.getPublicFolderUrl(fid);
308
+ ```
309
+
310
+ `makeRecordingLink()` returns the new video's `vid`, your `uid`, and the `url`
311
+ to share. It takes the same metadata as the other methods except
312
+ `gameStartEpoch`: the game's time comes from its recording, and a time given
313
+ in advance would override it, so passing one throws an error. It also throws
314
+ if the account paying for the video can't pay for it (`hasCredits` is
315
+ `false`).
316
+
317
+ **One link per game.** If several people may ask for the same game's link (for
318
+ example, any player on the court can tap "Record"), pass a `nonce` that
319
+ identifies the game, such as your own ID for it. Every call with the same nonce
320
+ returns the same video and link, even calls made at the same moment, and the
321
+ first call decides the video's metadata. Until the video is uploaded, each call
322
+ checks that the account paying for it can still pay; a call that can't gets no
323
+ video and throws, and a later call gets the same video once the account can pay.
324
+ `makeVideoId()` takes a `nonce` too (it returns `hasCredits: false` instead of
325
+ throwing).
326
+
327
+ ```javascript
328
+ const { url } = await pbv.makeRecordingLink({ nonce: `${eventId}-${gameId}`, fid, playersForTagging });
117
329
  ```
118
330
 
119
- ### Video Editors
331
+ **Camera settings.** By default the link records at 1080p and 30 FPS, and the
332
+ person recording can't change either. Ask for 4K with `resolution: '4k'` or
333
+ 60 FPS with `fps: 60`, and let the person recording choose with
334
+ `mayChangeResolution: true` and `mayChangeFPS: true`. A phone that can't record
335
+ what the link asks for steps down to the best quality it supports. The link's
336
+ settings apply regardless of the recorder's own PB Vision subscription, but 4K
337
+ requires your partner account to have 4K enabled; otherwise PB Vision rejects
338
+ the upload.
339
+
340
+ ```javascript
341
+ const { url } = await pbv.makeRecordingLink({
342
+ fid,
343
+ resolution: '4k', // '1080p' (default) or '4k'
344
+ fps: 60, // 30 (default) or 60
345
+ mayChangeResolution: true, // default false
346
+ mayChangeFPS: false // default false
347
+ });
348
+ ```
120
349
 
121
- Editors can tag themselves and friends in the video. They also have access to
122
- the video even if the video is private (to make videos uploaded by your partner
123
- account private by default, please let us know via email).
350
+ **One recording per link.** Only the first upload to a link's video counts, so
351
+ make a new link for each game.
124
352
 
125
- You can get the current list of editors on a video by using the `getVideoEditors()`
126
- method. Similarly, the `setVideoEditors()` method can be used to change the
127
- email addresses which have editor access on your video.
353
+ **Opening the link.** The PB Vision app opens only when the phone itself opens
354
+ the link: when someone taps it (in a text message or an email, for example) or
355
+ scans it as a QR code with the camera, or when your own app hands it to the
356
+ operating system (for example with `UIApplication.shared.open()` on iOS or an
357
+ `ACTION_VIEW` intent on Android). Don't load the link inside a webview or an
358
+ in-app browser, where the app may not open. On a phone without the PB Vision
359
+ app, the link shows a page for installing it.
360
+
361
+ **Testing.** With the test server (`useProdServer: false`), links start with
362
+ `pbvision://record` instead of `https://pb.vision/record`. Any installed PB
363
+ Vision app opens them, so test on a phone with a development build of the app.
364
+ The App Store or Google Play app would upload the recording to production,
365
+ where the test video doesn't exist, and the recording would be lost.
366
+
367
+ ### Video Editors and Viewers
368
+
369
+ Editors can tag themselves and friends in the video. Viewers have read-only
370
+ access. Both editors and viewers can access the video even if it is private (to
371
+ make videos uploaded by your partner account private by default, please let us
372
+ know via email).
373
+
374
+ You can get the current list of editors on a video using `getVideoEditors()`.
375
+ Use `setVideoEditors()` to set both editor and viewer access (up to 8 emails
376
+ each). Note that `setVideoEditors()` _replaces_ the existing editors and
377
+ viewers lists entirely.
378
+
379
+ ```javascript
380
+ // Get current editors
381
+ const editors = await pbv.getVideoEditors(vid);
382
+
383
+ // Set editors and viewers (replaces any previous lists)
384
+ await pbv.setVideoEditors(
385
+ vid,
386
+ ['editor1@example.com', 'editor2@example.com'], // editors
387
+ ['viewer1@example.com'] // viewers
388
+ );
389
+ ```
128
390
 
129
391
  ## After Video Processing is Done
130
392
 
@@ -170,10 +432,95 @@ data is enabled for your API key, only some of these fields may be present):
170
432
  - only included if `insights` is included:
171
433
  - `vid` - the unique ID of the video in our system
172
434
  - `aiEngineVersion` - the version number of our AI used to process the video
173
- - Using these two values, you can retrieve the player thumbnail images extracted from the video like: `https://storage.googleapis.com/pbv-pro/${vid}/${aiEngineVersion}/player${playerIndex}-${imageIndex}.jpg` where `playerIndex` is in the range [0, 3] (for doubles games) and `imageIndex` is in the range [0, 7].
435
+
436
+ ### Fetching Insights by Video ID
437
+
438
+ If you need to retrieve the insights data outside of the webhook callback (for
439
+ example, to re-fetch data for a previously processed video), you can use a
440
+ simple HTTP GET request with the video ID:
441
+
442
+ ```bash
443
+ curl https://api-2o2klzx4pa-uc.a.run.app/video/VIDEO_ID/insights.json
444
+ ```
445
+
446
+ Webhook callbacks with `include.insights` receive the raw insights JSON from
447
+ storage. This endpoint is a separate convenience endpoint; by default, it
448
+ returns a compact representation of the same session data. Add
449
+ `?format=augmented` for readable field names:
450
+
451
+ ```bash
452
+ curl 'https://api-2o2klzx4pa-uc.a.run.app/video/VIDEO_ID/insights.json?format=augmented'
453
+ ```
454
+
455
+ For multi-game videos, pass the 1-indexed session number:
456
+
457
+ ```bash
458
+ curl 'https://api-2o2klzx4pa-uc.a.run.app/video/VIDEO_ID/insights.json?sessionNum=2&format=augmented'
459
+ ```
460
+
461
+ You can call this endpoint as many times as needed.
462
+
463
+ ### Player Identification
464
+
465
+ PB Vision assigns each detected player an index based on visual detection.
466
+ Players are indexed 0-3 for doubles (0-1 on one team, 2-3 on the other) and
467
+ 0 and 2 for singles. These indices are consistent _within_ a single video but
468
+ do not carry over between videos, so "Player 0" in one video is not necessarily
469
+ the same person as "Player 0" in another.
470
+
471
+ **Player thumbnails** are available after processing. Using the `vid` and
472
+ `aiEngineVersion` from the webhook callback, you can fetch thumbnail images for
473
+ each player:
474
+
475
+ ```
476
+ https://storage.googleapis.com/pbv-pro/{vid}/{aiEngineVersion}/player{playerIndex}-{imageIndex}.jpg
477
+ ```
478
+
479
+ - `playerIndex`: 0-3 for doubles, 0 and 2 for singles
480
+ - `imageIndex`: 0-7 (up to 8 thumbnails per player, captured at different
481
+ points in the game)
482
+
483
+ For example, to get thumbnails for all four players in a doubles game:
484
+
485
+ ```
486
+ https://storage.googleapis.com/pbv-pro/83gyqyc10y8f/7/player0-0.jpg
487
+ https://storage.googleapis.com/pbv-pro/83gyqyc10y8f/7/player1-0.jpg
488
+ https://storage.googleapis.com/pbv-pro/83gyqyc10y8f/7/player2-0.jpg
489
+ https://storage.googleapis.com/pbv-pro/83gyqyc10y8f/7/player3-0.jpg
490
+ ```
491
+
492
+ **Mapping players to your system:** If your integration needs to know which
493
+ player is which (e.g. linking game stats to player profiles in your app), you
494
+ have a few options:
495
+
496
+ 1. **Pass player emails at upload time** via the `userEmails` metadata field.
497
+ If you know which players are in the match, their emails will be associated
498
+ with the video as editors, and you can match them against your records.
499
+ Note that providing an email associates the video with that PB Vision
500
+ account, but it does not automatically identify which of the detected
501
+ players (`avatar_id`) belongs to that email. That mapping happens on
502
+ your side using the thumbnails or webpage link below.
503
+
504
+ 2. **Use thumbnails for manual or automated matching.** After processing, fetch
505
+ the player thumbnails and either surface them in an admin UI for manual
506
+ confirmation, or compare them against known player photos programmatically.
507
+ If thumbnails alone aren't enough, show the PB Vision `webpage` link next
508
+ to the avatar choices (or embed it in an iframe) so the user can reference
509
+ the video before selecting themselves.
510
+
511
+ 3. **Store your own metadata alongside the video ID.** When you upload a video,
512
+ record the returned `vid` alongside whatever context you have (match ID,
513
+ player roster, season, week, etc.) in your own system. When the webhook
514
+ fires, use the `vid` to look up that context and route the results
515
+ accordingly.
516
+
517
+ The insights JSON also includes an `avatar_id` for each player, which
518
+ corresponds to the `playerIndex` used in the thumbnail URLs above.
174
519
 
175
520
  ## Video Guidelines
176
521
 
522
+ Note: We are now able to split videos into individual games if you'd like to upload more than one game per video. Let us know if this applies to your use case.
523
+
177
524
  Requirements:
178
525
 
179
526
  - Comply with [PB Vision's framing guidelines](https://help.pb.vision/en/help/articles/1108176-video-recording-and-framing-tips)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pbvision/partner-sdk",
3
3
  "description": "SDK for interacting with the PB Vision pickleball AI engine.",
4
- "version": "0.1.13",
4
+ "version": "0.1.15",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {
package/src/index.js CHANGED
@@ -3,9 +3,38 @@ import fs from 'node:fs'
3
3
 
4
4
  import fetch from 'node-fetch'
5
5
 
6
+ // sent with each new video so PB Vision knows which version of this SDK made
7
+ // it. Read lazily and forgivingly: an app bundled with a tool like esbuild may
8
+ // not ship this package's package.json, or may ship its own in its place.
9
+ let platform
10
+ function getPlatform () {
11
+ if (!platform) {
12
+ let version = 'unknown'
13
+ try {
14
+ const pkg = JSON.parse(
15
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
16
+ if (pkg.name === '@pbvision/partner-sdk' && pkg.version) {
17
+ version = pkg.version
18
+ }
19
+ } catch {
20
+ // keep 'unknown'
21
+ }
22
+ platform = { name: 'api', version }
23
+ }
24
+ return platform
25
+ }
26
+
27
+ // the camera settings a recording link may ask the PB Vision app for
28
+ const RECORDING_RESOLUTIONS = ['1080p', '4k']
29
+ const RECORDING_FPS = [30, 60]
30
+
6
31
  const ENVIRONMENTS = {
7
32
  test: {
8
33
  apiServer: 'https://api-ko3kowqi6a-uc.a.run.app',
34
+ webApp: 'https://pbv-dev.web.app',
35
+ // development builds of the PB Vision app can't open https links, so test
36
+ // links use the app's own scheme, which store builds open too
37
+ recordLink: 'pbvision://record',
9
38
  firebaseConfig: {
10
39
  apiKey: 'AIzaSyCV1uh4fM7IFopuZOJ306oVWLV3cKLijFc',
11
40
  projectId: 'pbv-dev',
@@ -14,6 +43,8 @@ const ENVIRONMENTS = {
14
43
  },
15
44
  prod: {
16
45
  apiServer: 'https://api-2o2klzx4pa-uc.a.run.app',
46
+ webApp: 'https://pb.vision',
47
+ recordLink: 'https://pb.vision/record',
17
48
  firebaseConfig: {
18
49
  apiKey: 'AIzaSyCzC8mfo38HtkOR-_Y6xb7Pevp72LkrYfc',
19
50
  projectId: 'pbv-prod',
@@ -32,6 +63,8 @@ export class PBVision {
32
63
  this.uid = apiKey.substring(0, underscoreIndex)
33
64
  const config = useProdServer ? ENVIRONMENTS.prod : ENVIRONMENTS.test
34
65
  this.server = config.apiServer
66
+ this.webAppUrl = config.webApp
67
+ this.recordUrl = config.recordLink
35
68
  this.isDev = config === ENVIRONMENTS.test
36
69
  }
37
70
 
@@ -86,13 +119,13 @@ export class PBVision {
86
119
  * @param {VideoMetadata} [metadata]
87
120
  * @returns {VideoUrlToDownloadResponse}
88
121
  */
89
- async sendVideoUrlToDownload (videoUrl, { userEmails = [], name, desc, gameStartEpoch, facility, court, fid } = {}) {
122
+ async sendVideoUrlToDownload (videoUrl, { userEmails = [], name, desc, gameStartEpoch, facility, court, fid, playersForTagging } = {}) {
90
123
  assert(typeof videoUrl === 'string' && videoUrl.startsWith('http'),
91
124
  'URL must be a string beginning with http')
92
125
  assert(videoUrl.split('?')[0].endsWith('.mp4'), 'video URL must have the .mp4 extension')
93
126
  const resp = await this.__callAPI(
94
127
  'add_video_by_url',
95
- { url: videoUrl, userEmails, name, desc, gameStartEpoch, facility, court, fid })
128
+ { url: videoUrl, userEmails, name, desc, gameStartEpoch, facility, court, fid, playersForTagging })
96
129
  return JSON.parse(resp)
97
130
  }
98
131
 
@@ -126,94 +159,455 @@ export class PBVision {
126
159
  * @property {integer} [gameStartEpoch] the epoch at which the game started
127
160
  * @property {string} [facility] the facility where the game was recorded (e.g., "Cool Club #3 - Barcelona")
128
161
  * @property {string} [court] the court where the game was recorded (e.g., "11A")
129
- * @property {integer} [fid] the ID of the folder in which this video should be added
162
+ * @property {integer} [fid] the ID of the folder in your partner account's
163
+ * library to add this video to (see getOrCreateFolder()). A fid that is
164
+ * not one of your folders is refused.
165
+ * @property {PlayersForTagging} [playersForTagging] the players to tag
166
+ * automatically once processing completes, each by a name, an email, or
167
+ * both. Identify players by their role at the start of the first game.
168
+ * Honored by uploadVideo(), sendVideoUrlToDownload(), makeVideoId(), and
169
+ * makeRecordingLink().
170
+ */
171
+
172
+ /**
173
+ * The players to tag automatically once the video is processed, identified
174
+ * by their role at the start of the first game. The team that serves first
175
+ * supplies `server` (its first server) and `serverPartner`. The other team's
176
+ * first server is `receiver`: they start on the right and receive the first
177
+ * serve. Their partner is `receiverPartner`. Omit both partners for singles.
178
+ * Each email may be given for only one player.
179
+ * @typedef {Object} PlayersForTagging
180
+ * @property {PlayerForTagging} server the player who served the first point
181
+ * @property {PlayerForTagging} receiver the player who received the first point
182
+ * @property {PlayerForTagging} [serverPartner] the server's partner (omit for singles)
183
+ * @property {PlayerForTagging} [receiverPartner] the receiver's partner (omit for singles)
184
+ */
185
+
186
+ /**
187
+ * A player to tag, identified by a name, an email, or both. At least one of
188
+ * the two is required.
189
+ * @typedef {Object} PlayerForTagging
190
+ * @property {string} [name] what to call the player: 1 to 64 characters
191
+ * after trimming. Shown unless the email belongs to a PB Vision account,
192
+ * in which case that account's own name is shown.
193
+ * @property {string} [email] the player's email address. The player is
194
+ * emailed that they were tagged. If the address belongs to a PB Vision
195
+ * account, the game is added to that account's library; if not, the game
196
+ * joins their library when they sign up with this address.
197
+ */
198
+
199
+ /**
200
+ * Allocate a new video ID *without* uploading a file yourself. Useful when the
201
+ * video will be recorded and uploaded by another client. Pass the returned
202
+ * `vid` (and `uid`) to that client so it uploads to this video instead of
203
+ * allocating a new one. To have the game recorded with the PB Vision app,
204
+ * use makeRecordingLink() instead.
205
+ *
206
+ * @param {VideoMetadata} [metadata] the video's metadata, plus fileExt
207
+ * @param {string} [metadata.fileExt="mp4"] the extension the video will be
208
+ * uploaded with
209
+ * @param {string} [metadata.nonce] 1 to 200 characters you choose, such as
210
+ * your own ID for the game. Every call with the same nonce gets the same
211
+ * video, even calls made at the same moment; the first call decides its
212
+ * metadata. Until the video is uploaded, each call checks that the account
213
+ * paying for it can still pay; if not, that call gets no video (`hasCredits`
214
+ * false), and a later call gets the same video once it can. A call that
215
+ * gets no video doesn't use the nonce up.
216
+ * @returns {{vid: (string|undefined), uid: string, hasCredits: (boolean|undefined)}}
217
+ * the new video id and the uid it belongs to. `hasCredits` is false, with
218
+ * no `vid`, when the account paying for the video can't pay for it; for
219
+ * passthrough partners it is always present.
220
+ */
221
+ async makeVideoId ({ fileExt = 'mp4', userEmails = [], name, desc, gameStartEpoch, facility, court, fid, playersForTagging, nonce } = {}) {
222
+ const resp = await this.__callAPI('make_video_id',
223
+ { platform: getPlatform(), fileExt, userEmails, name, desc, gameStartEpoch, facility, court, fid, playersForTagging, nonce })
224
+ const { hasCredits, vid } = JSON.parse(resp)
225
+ const ret = { vid, uid: this.uid }
226
+ if (hasCredits !== undefined) {
227
+ ret.hasCredits = hasCredits
228
+ }
229
+ return ret
230
+ }
231
+
232
+ /**
233
+ * The video's details and camera settings for makeRecordingLink(). The
234
+ * details are the same as in VideoMetadata, except that there is no
235
+ * gameStartEpoch: the game's time comes from its recording.
236
+ * @typedef {Object} RecordingLinkOptions
237
+ * @property {Array<string>} [userEmails] the email addresses of up to 4
238
+ * players in the game (see VideoMetadata)
239
+ * @property {string} [name] the title of the game
240
+ * @property {string} [desc] a longer description of the game
241
+ * @property {string} [facility] the facility where the game is played
242
+ * @property {string} [court] the court where the game is played
243
+ * @property {integer} [fid] the ID of the folder in your partner account's
244
+ * library to add the video to (see getOrCreateFolder())
245
+ * @property {PlayersForTagging} [playersForTagging] the players to tag
246
+ * automatically once processing completes
247
+ * @property {string} [nonce] identifies the game, e.g. your own ID for it:
248
+ * every call with the same nonce returns the same video and link (see
249
+ * makeVideoId())
250
+ * @property {string} [resolution='1080p'] the resolution to record at:
251
+ * '1080p' or '4k'. 4K requires your partner account to have 4K enabled;
252
+ * otherwise PB Vision rejects the upload.
253
+ * @property {integer} [fps=30] the frame rate to record at: 30 or 60
254
+ * @property {boolean} [mayChangeResolution=false] whether the person
255
+ * recording may choose a different resolution in the app
256
+ * @property {boolean} [mayChangeFPS=false] whether the person recording may
257
+ * choose a different frame rate in the app
258
+ */
259
+
260
+ /**
261
+ * @typedef {Object} RecordingLink
262
+ * @property {string} vid the ID of the video the recording will upload to
263
+ * @property {string} uid the ID of your partner account, which owns the video
264
+ * @property {string} url the link that opens the PB Vision app to record the
265
+ * game
130
266
  */
131
267
 
268
+ /**
269
+ * Makes a video for a game and a link that opens the PB Vision app on a
270
+ * phone straight to its camera, to record the game into that video. Anyone
271
+ * can record from the link, signed in to PB Vision or not. On a phone
272
+ * without the app, the link shows a page for installing it.
273
+ *
274
+ * By default the app records at 1080p and 30 FPS, and the person recording
275
+ * can't change either. A phone that can't record what the link asks for
276
+ * steps down to the best quality it supports. The link's settings apply
277
+ * regardless of the recorder's own PB Vision subscription, but 4K requires
278
+ * your partner account to have 4K enabled; otherwise PB Vision rejects the
279
+ * upload.
280
+ *
281
+ * Each link is for one recording: only the first upload to its video
282
+ * counts, so make a new link for each game. If several people may ask for
283
+ * the same game's link, pass the same `nonce` (e.g. your own ID for the
284
+ * game) and they all get one video and one link.
285
+ *
286
+ * Open the link with a real tap (e.g. in a text message, an email, or a
287
+ * scanned QR code) or by handing it to the phone's operating system, not
288
+ * inside a webview, so that the phone can open the PB Vision app.
289
+ *
290
+ * With the test server (`useProdServer: false`), the link starts with
291
+ * `pbvision://record`. Any installed PB Vision app opens it, so test with a
292
+ * development build of the app: the App Store or Google Play app would
293
+ * upload the recording to production, where the test video doesn't exist,
294
+ * and the recording would be lost.
295
+ *
296
+ * @param {RecordingLinkOptions} [options]
297
+ * @returns {RecordingLink}
298
+ * @throws {Error} if gameStartEpoch is given, a camera setting is invalid,
299
+ * or PB Vision doesn't make the video (e.g. because the account paying for
300
+ * it can't pay for it)
301
+ */
302
+ async makeRecordingLink ({
303
+ resolution = '1080p', fps = 30, mayChangeResolution = false, mayChangeFPS = false,
304
+ gameStartEpoch, userEmails, name, desc, facility, court, fid, playersForTagging, nonce
305
+ } = {}) {
306
+ assert(gameStartEpoch === undefined,
307
+ 'makeRecordingLink() does not take gameStartEpoch: the game\'s time comes from its recording, and a time set now would override it')
308
+ assert(RECORDING_RESOLUTIONS.includes(resolution),
309
+ `resolution must be '1080p' or '4k', not ${JSON.stringify(resolution)}`)
310
+ assert(RECORDING_FPS.includes(fps), `fps must be 30 or 60, not ${JSON.stringify(fps)}`)
311
+ assert(typeof mayChangeResolution === 'boolean', 'mayChangeResolution must be a boolean')
312
+ assert(typeof mayChangeFPS === 'boolean', 'mayChangeFPS must be a boolean')
313
+
314
+ const { vid, uid, hasCredits } = await this.makeVideoId({
315
+ fileExt: 'mp4', userEmails, name, desc, facility, court, fid, playersForTagging, nonce })
316
+ if (hasCredits === false) {
317
+ throw new Error('PB Vision did not make a video for this recording link: the account paying for it has no credits available (hasCredits is false)')
318
+ }
319
+ if (!vid) {
320
+ throw new Error('PB Vision did not return a video ID for this recording link')
321
+ }
322
+ // encodeURIComponent() rather than URLSearchParams, which writes a space
323
+ // as "+" and not every app reads that back as a space
324
+ const query = Object.entries({ vid, uid, resolution, fps, mayChangeResolution, mayChangeFPS })
325
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
326
+ .join('&')
327
+ return { vid, uid, url: `${this.recordUrl}?${query}` }
328
+ }
329
+
132
330
  /**
133
331
  * Upload a video for processing by the AI.
134
332
  *
135
- * For passthrough partners, the video is only uploaded if the paying user
136
- * has credit(s) available with which the video can be analyzed.
333
+ * The video is only uploaded if the account paying for it has credit(s)
334
+ * available with which the video can be analyzed. If not, nothing is
335
+ * uploaded and this returns `{ hasCredits: false }` with no `vid`.
336
+ *
337
+ * The upload resumes by itself after a dropped connection or a temporary
338
+ * error from the storage service. If it still fails, the error thrown has
339
+ * the video's ID as its `vid` property. To retry without making a second
340
+ * video for the game, pass a `nonce` (see makeVideoId()): calling this again
341
+ * with the same nonce uploads to the same video.
137
342
  *
138
343
  * @param {string} mp4Filename
139
344
  * @param {VideoMetadata} [metadata]
140
345
  * @returns {VideoUrlToDownloadResponse}
346
+ * @throws {Error} if the upload fails, with the video's ID as `vid`. A
347
+ * missing or empty file is refused before any video is made.
141
348
  */
142
- async uploadVideo (mp4Filename, { userEmails = [], name, desc, gameStartEpoch, facility, court, fid } = {}) {
349
+ async uploadVideo (mp4Filename, metadata = {}) {
350
+ // checked before making the video, so a file that can't be uploaded
351
+ // doesn't leave a video behind that never gets one
352
+ const stats = await fs.promises.stat(mp4Filename)
353
+ assert(stats.isFile(), `cannot upload ${mp4Filename}: it is not a file`)
354
+ assert(stats.size > 0, `cannot upload ${mp4Filename}: the file is empty`)
143
355
  const pieces = mp4Filename.split('.')
144
356
  const ext = pieces[pieces.length - 1]
145
- const platform = { name: 'api', version: '0.1.12' }
146
- const makeVIDResp = await this.__callAPI('make_video_id', { platform, userEmails, name, desc, gameStartEpoch, facility, court, fid, fileExt: ext })
147
- const { hasCredits, vid } = JSON.parse(makeVIDResp)
357
+ const { hasCredits, vid } = await this.makeVideoId({ ...metadata, fileExt: ext })
148
358
  if (hasCredits === false) {
149
359
  return { hasCredits }
150
360
  }
151
361
  const bucket = `pbv-uploads${this.isDev ? '-dev' : ''}`
152
362
  const objName = `${this.uid}/${vid}.${ext}`
153
- await uploadToGCS(bucket, objName, mp4Filename)
363
+ try {
364
+ await uploadToGCS(bucket, objName, mp4Filename)
365
+ } catch (e) {
366
+ const err = new Error(`PB Vision Upload of video ${vid} failed: ${e.message}`, { cause: e })
367
+ err.vid = vid
368
+ throw err
369
+ }
154
370
  const ret = { vid }
155
371
  if (hasCredits !== undefined) {
156
372
  ret.hasCredits = hasCredits
157
373
  }
158
374
  return ret
159
375
  }
376
+
377
+ /**
378
+ * @typedef {Object} GetOrCreateFolderResponse
379
+ * @property {integer} fid the folder's ID; pass it as the `fid` metadata
380
+ * field to put a new video in this folder
381
+ * @property {boolean} created true if this call created the folder, false if
382
+ * it already existed
383
+ */
384
+
385
+ /**
386
+ * Gets the folder in your partner account's library with exactly this name,
387
+ * creating it if there is none. Calling it again with the same name returns
388
+ * the same folder, as long as it hasn't been renamed or moved since, so it
389
+ * is safe to retry.
390
+ *
391
+ * Use a unique name for each event, such as one that includes its date;
392
+ * otherwise two events with the same name would share one folder.
393
+ *
394
+ * @param {string} name the folder's name: 1 to 200 characters, with no
395
+ * leading or trailing whitespace
396
+ * @param {Object} [options]
397
+ * @param {integer} [options.parent] the ID of the folder to look in, and to
398
+ * create the folder in; omit for the top level of your library
399
+ * @returns {GetOrCreateFolderResponse}
400
+ */
401
+ async getOrCreateFolder (name, { parent } = {}) {
402
+ const resp = await this.__callAPI('folder/get_or_create', { name, parent })
403
+ const { fid, created } = JSON.parse(resp)
404
+ return { fid, created }
405
+ }
406
+
407
+ /**
408
+ * Renames a folder in your partner account's library, moves it, or changes
409
+ * whether it is public. Fields you omit are left as they are.
410
+ *
411
+ * @param {integer} fid the ID of the folder to change
412
+ * @param {Object} [changes]
413
+ * @param {string} [changes.name] the folder's new name: 1 to 200 characters,
414
+ * with no leading or trailing whitespace
415
+ * @param {?integer} [changes.parent] the ID of the folder to move it into, or
416
+ * null to move it to the top level of your library
417
+ * @param {boolean} [changes.public] whether anyone with the folder's link may
418
+ * view it, signed in or not (see getPublicFolderUrl()). A folder inside a
419
+ * public folder is only shown with it if it is public too.
420
+ */
421
+ async updateFolder (fid, { name, parent, public: isPublic } = {}) {
422
+ await this.__callAPI('folder/update', { fid, name, parent, public: isPublic })
423
+ }
424
+
425
+ /**
426
+ * The link to a folder's page on PB Vision, where anyone can view its videos
427
+ * once the folder is public (see updateFolder()).
428
+ *
429
+ * @param {integer} fid the folder's ID
430
+ * @returns {string}
431
+ */
432
+ getPublicFolderUrl (fid) {
433
+ assert(Number.isInteger(fid) && fid > 0, `invalid folder ID: ${fid}`)
434
+ return `${this.webAppUrl}/library/public/${encodeURIComponent(this.uid)}/${fid}`
435
+ }
160
436
  }
161
437
 
438
+ // How uploadToGCS() reads and sends the file: one chunk in memory at a time.
439
+ // GCS needs every chunk but the last to be a multiple of 256 KiB; larger
440
+ // chunks tend to upload faster but take more memory.
441
+ const UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024
442
+
443
+ // How uploadToGCS() retries a request that fails with a network error or a
444
+ // temporary server error: up to UPLOAD_MAX_RETRIES times in a row without the
445
+ // upload making progress, waiting about twice as long before each retry, up to
446
+ // UPLOAD_MAX_RETRY_DELAY_MS (plus up to a second at random).
447
+ const UPLOAD_MAX_RETRIES = 6
448
+ const UPLOAD_FIRST_RETRY_DELAY_MS = 1000
449
+ const UPLOAD_MAX_RETRY_DELAY_MS = 32000
450
+
451
+ // Uploads a file to GCS with a resumable upload, following GCS's protocol: a
452
+ // 308 response says how much GCS has stored so far (its Range header), and the
453
+ // upload is only done once GCS answers 200 or 201. After a network error or a
454
+ // temporary server error, it waits, asks GCS how much it has stored, and goes
455
+ // on from there. Throws an Error saying why if the upload fails.
162
456
  async function uploadToGCS (bucket, objName, filename) {
163
- // request to start a new upload
457
+ const file = await fs.promises.open(filename, 'r')
458
+ try {
459
+ const { size: numBytesTotal } = await file.stat()
460
+ if (numBytesTotal === 0) {
461
+ throw new Error('the file is empty') // it was emptied after it was checked
462
+ }
463
+ const sessionURI = await startResumableUpload(bucket, objName, numBytesTotal)
464
+
465
+ let offset = 0 // how many bytes GCS has stored
466
+ let failures = 0 // tries in a row which made no progress
467
+ let mustAskOffset = false // whether to ask GCS for the offset before sending
468
+ for (;;) {
469
+ let request, action
470
+ if (mustAskOffset) {
471
+ action = 'checking the upload\'s progress'
472
+ request = {
473
+ method: 'PUT',
474
+ headers: { 'Content-Length': '0', 'Content-Range': `bytes */${numBytesTotal}` }
475
+ }
476
+ } else {
477
+ const endIdx = Math.min(offset + UPLOAD_CHUNK_SIZE, numBytesTotal) - 1
478
+ action = `uploading bytes ${offset}-${endIdx}`
479
+ // a file that can't be read is not worth retrying, so this throws
480
+ const chunk = await readChunk(file, offset, endIdx - offset + 1, numBytesTotal)
481
+ request = {
482
+ method: 'PUT',
483
+ headers: {
484
+ 'Content-Length': String(chunk.length),
485
+ 'Content-Range': `bytes ${offset}-${endIdx}/${numBytesTotal}`
486
+ },
487
+ body: chunk
488
+ }
489
+ }
490
+
491
+ let resp, respBody, problem
492
+ try {
493
+ resp = await fetch(sessionURI, request)
494
+ respBody = await resp.text()
495
+ } catch (e) {
496
+ // even a 200 whose body was cut off is confirmed by asking GCS
497
+ resp = undefined
498
+ problem = `network error while ${action}: ${e.message}`
499
+ }
500
+
501
+ if (resp && (resp.status === 200 || resp.status === 201)) {
502
+ return
503
+ }
504
+ if (resp?.status === 308) {
505
+ const stored = parseStoredRange(resp.headers.get('Range'), numBytesTotal)
506
+ if (stored === numBytesTotal) {
507
+ throw new Error(`GCS stored all ${numBytesTotal} bytes but did not finish the upload (308)`)
508
+ }
509
+ const madeProgress = stored > offset
510
+ const wasAsking = mustAskOffset
511
+ problem = `GCS stored nothing more while ${action} (308, offset ${stored})`
512
+ offset = stored
513
+ mustAskOffset = false
514
+ if (madeProgress) {
515
+ failures = 0
516
+ continue
517
+ }
518
+ if (wasAsking) {
519
+ continue // the failure that made us ask was already counted
520
+ }
521
+ // GCS kept none of the chunk: resend it, after a wait
522
+ } else if (resp && !isTemporaryStatus(resp.status)) {
523
+ if (mustAskOffset && (resp.status === 404 || resp.status === 410)) {
524
+ throw new Error(`the upload session expired (${resp.status}): ${respBody}`)
525
+ }
526
+ throw new Error(`GCS answered ${resp.status} while ${action}: ${respBody}`)
527
+ } else {
528
+ problem ??= `GCS answered ${resp.status} while ${action}: ${respBody}`
529
+ mustAskOffset = true
530
+ }
531
+
532
+ failures++
533
+ if (failures > UPLOAD_MAX_RETRIES) {
534
+ throw new Error(`gave up after ${UPLOAD_MAX_RETRIES} retries; last error: ${problem}`)
535
+ }
536
+ await waitBeforeRetry(failures)
537
+ }
538
+ } finally {
539
+ await file.close()
540
+ }
541
+ }
542
+
543
+ // Starts a resumable upload and returns its session URI.
544
+ async function startResumableUpload (bucket, objName, numBytesTotal) {
164
545
  const url = `https://storage.googleapis.com/upload/storage/v1/b/${bucket}/o?uploadType=resumable&name=${objName}`
165
- const numBytesTotal = fs.statSync(filename).size
166
- let headers = { 'X-Upload-Content-Length': numBytesTotal }
167
- let resp = await fetch(url, { method: 'POST', headers })
168
- if (!resp.ok) {
169
- throw new Error(`PB Vision Upload failed to initialize (${resp.status}): ${await resp.text()}`)
170
- }
171
- const sessionURI = resp.headers.get('Location')
172
-
173
- // determine how much data to read from the file at once; larger tends to
174
- // result in faster uploads but also has a bigger memory footprint
175
- const minChunkSz = 256 * 1024 // this is the *minimum* size
176
- const targetChunkSzMB = 8
177
- const chunkSize = Math.max(minChunkSz, targetChunkSzMB * 1024 * 1024)
178
-
179
- // upload one chunk at a time until it is done successfully
180
- let startIdx = 0
181
- while (startIdx < numBytesTotal) {
182
- let endIdx = startIdx + chunkSize - 1
183
- endIdx = Math.min(endIdx, numBytesTotal - 1)
184
- const thisChunkSize = endIdx - startIdx + 1
185
-
186
- // read just the chunk we need from the file
187
- const streamPromise = new Promise((resolve, reject) => {
188
- const chunk = Buffer.alloc(thisChunkSize)
189
- let chunkBytesRead = 0
190
- const stream = fs.createReadStream(
191
- filename, { start: startIdx, end: endIdx })
192
- stream.on('data', x => {
193
- x.copy(chunk, chunkBytesRead)
194
- chunkBytesRead += x.length
195
- })
196
- stream.on('end', () => resolve(chunk))
197
- stream.on('error', e => reject(e))
198
- })
199
- let chunk
546
+ for (let failures = 0; ; failures++) {
547
+ let resp, respBody, problem
200
548
  try {
201
- chunk = await streamPromise
549
+ resp = await fetch(url, {
550
+ method: 'POST',
551
+ headers: { 'X-Upload-Content-Length': String(numBytesTotal) }
552
+ })
553
+ respBody = await resp.text()
202
554
  } catch (e) {
203
- throw new Error(`PB Vision Upload failed to read from file ${e.toString()}`)
555
+ problem = `network error while initializing: ${e.message}`
204
556
  }
205
-
206
- headers = {
207
- 'Content-Length': chunk.length,
208
- 'Content-Range': `bytes ${startIdx}-${endIdx}/${numBytesTotal}`
557
+ if (resp?.ok) {
558
+ const sessionURI = resp.headers.get('Location')
559
+ if (!sessionURI) {
560
+ throw new Error('GCS started the upload but gave no session URI')
561
+ }
562
+ return sessionURI
563
+ }
564
+ if (resp && !isTemporaryStatus(resp.status)) {
565
+ throw new Error(`failed to initialize (${resp.status}): ${respBody}`)
566
+ }
567
+ problem ??= `GCS answered ${resp.status} while initializing: ${respBody}`
568
+ if (failures >= UPLOAD_MAX_RETRIES) {
569
+ throw new Error(`gave up after ${UPLOAD_MAX_RETRIES} retries; last error: ${problem}`)
209
570
  }
210
- assert(chunk.length <= numBytesTotal)
211
- assert(chunk.length === endIdx - startIdx + 1)
212
- resp = await fetch(sessionURI, { method: 'PUT', headers, body: chunk })
213
- if (!resp.status >= 400) {
214
- throw new Error(`PB Vision Upload failed to upload chunk ${startIdx} (${resp.status}): ${await resp.text()} ${JSON.stringify(resp.headers.raw())}`)
571
+ await waitBeforeRetry(failures + 1)
572
+ }
573
+ }
574
+
575
+ // GCS asks clients to retry these, with exponential backoff
576
+ function isTemporaryStatus (status) {
577
+ return status >= 500 || status === 408 || status === 429
578
+ }
579
+
580
+ async function waitBeforeRetry (retryNumber) {
581
+ const delayMs = Math.min(
582
+ UPLOAD_FIRST_RETRY_DELAY_MS * 2 ** (retryNumber - 1), UPLOAD_MAX_RETRY_DELAY_MS)
583
+ await new Promise(resolve => setTimeout(resolve, delayMs + Math.random() * 1000))
584
+ }
585
+
586
+ // Returns how many bytes a 308's Range header (e.g. "bytes=0-1048575") says
587
+ // GCS has stored; no Range header means none.
588
+ function parseStoredRange (range, numBytesTotal) {
589
+ if (range === null) {
590
+ return 0
591
+ }
592
+ const match = /^bytes=0-(\d+)$/.exec(range)
593
+ const stored = match ? Number(match[1]) + 1 : NaN
594
+ if (!(stored <= numBytesTotal)) {
595
+ throw new Error(`GCS sent an unexpected Range header: ${range}`)
596
+ }
597
+ return stored
598
+ }
599
+
600
+ // Reads length bytes of the file, starting at position.
601
+ async function readChunk (file, position, length, numBytesTotal) {
602
+ const chunk = Buffer.alloc(length)
603
+ let chunkBytesRead = 0
604
+ while (chunkBytesRead < length) {
605
+ const { bytesRead } = await file.read(
606
+ chunk, chunkBytesRead, length - chunkBytesRead, position + chunkBytesRead)
607
+ if (bytesRead === 0) {
608
+ throw new Error(`the file ended at byte ${position + chunkBytesRead}, before the ${numBytesTotal} bytes it had when the upload started`)
215
609
  }
216
- startIdx = endIdx + 1
610
+ chunkBytesRead += bytesRead
217
611
  }
218
- return true
612
+ return chunk
219
613
  }