@pbvision/partner-sdk 0.1.14 → 0.1.16

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 +253 -6
  2. package/package.json +1 -1
  3. package/src/index.js +460 -59
package/README.md CHANGED
@@ -14,7 +14,11 @@ standard length game, so we'll notify your servers when the results are ready.
14
14
  - [Send Videos](#send-videos)
15
15
  - [Option 1: PBV downloads your video from a URL](#option-1-pbv-downloads-your-video-from-a-url)
16
16
  - [Option 2: Upload your video](#option-2-upload-your-video)
17
+ - [Option 3: Pre-allocate a video ID (record/upload elsewhere)](#option-3-pre-allocate-a-video-id-recordupload-elsewhere)
17
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)
18
22
  - [Video Editors and Viewers](#video-editors-and-viewers)
19
23
  - [After Video Processing is Done](#after-video-processing-is-done)
20
24
  - [Callback Data](#callback-data)
@@ -119,20 +123,52 @@ const metadata = {
119
123
  const { vid } = await pbv.uploadVideo(YOUR_VIDEO_FILENAME, metadata);
120
124
  ```
121
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
+
122
156
  #### Video Metadata
123
157
 
124
- Both `sendVideoUrlToDownload()` and `uploadVideo()` accept an optional metadata
125
- object. You can omit it entirely, or provide some or all of these fields:
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:
126
161
 
127
162
  | Field | Type | Description |
128
163
  |-------|------|-------------|
129
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. |
130
165
  | `name` | `string` | Title of the game. Defaults to the game time, or the upload time if no game time is provided. |
131
166
  | `desc` | `string` | A longer description of the game. |
132
- | `gameStartEpoch` | `integer` | Unix timestamp (seconds) of when the game was played. |
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. |
133
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. |
134
169
  | `court` | `string` | Court identifier where the game was recorded, e.g. `"11A"`. Useful for facility and Court Insight integrations. |
135
- | `fid` | `integer` | Folder ID. Organizes the video into a specific folder in the uploader's PB Vision library. |
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()`. |
136
172
 
137
173
  The `facility` and `court` fields are primarily used by facility partners
138
174
  running [Court Insight](https://help.pb.vision/en/articles/9341690-court-insight-for-facilities-and-clubs)
@@ -140,6 +176,196 @@ or similar venue-based integrations, where tracking which court a game came from
140
176
  is important. League and tournament partners may also find these useful for
141
177
  organizing videos by location.
142
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 });
329
+ ```
330
+
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
+ ```
349
+
350
+ **One recording per link.** Only the first upload to a link's video counts, so
351
+ make a new link for each game.
352
+
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` and end with
363
+ `env=test`. Any installed PB Vision app opens them, so test on a phone with a
364
+ development build of the app. Current App Store and Google Play versions check
365
+ the link before recording and say it needs the development build. Older
366
+ versions would record the game and upload it to production, where the test
367
+ video doesn't exist, so the recording would be lost.
368
+
143
369
  ### Video Editors and Viewers
144
370
 
145
371
  Editors can tag themselves and friends in the video. Viewers have read-only
@@ -219,8 +445,22 @@ simple HTTP GET request with the video ID:
219
445
  curl https://api-2o2klzx4pa-uc.a.run.app/video/VIDEO_ID/insights.json
220
446
  ```
221
447
 
222
- This returns the same insights JSON that would be delivered via the webhook
223
- callback. You can call this endpoint as many times as needed.
448
+ Webhook callbacks with `include.insights` receive the raw insights JSON from
449
+ storage. This endpoint is a separate convenience endpoint; by default, it
450
+ returns a compact representation of the same session data. Add
451
+ `?format=augmented` for readable field names:
452
+
453
+ ```bash
454
+ curl 'https://api-2o2klzx4pa-uc.a.run.app/video/VIDEO_ID/insights.json?format=augmented'
455
+ ```
456
+
457
+ For multi-game videos, pass the 1-indexed session number:
458
+
459
+ ```bash
460
+ curl 'https://api-2o2klzx4pa-uc.a.run.app/video/VIDEO_ID/insights.json?sessionNum=2&format=augmented'
461
+ ```
462
+
463
+ You can call this endpoint as many times as needed.
224
464
 
225
465
  ### Player Identification
226
466
 
@@ -258,10 +498,17 @@ have a few options:
258
498
  1. **Pass player emails at upload time** via the `userEmails` metadata field.
259
499
  If you know which players are in the match, their emails will be associated
260
500
  with the video as editors, and you can match them against your records.
501
+ Note that providing an email associates the video with that PB Vision
502
+ account, but it does not automatically identify which of the detected
503
+ players (`avatar_id`) belongs to that email. That mapping happens on
504
+ your side using the thumbnails or webpage link below.
261
505
 
262
506
  2. **Use thumbnails for manual or automated matching.** After processing, fetch
263
507
  the player thumbnails and either surface them in an admin UI for manual
264
508
  confirmation, or compare them against known player photos programmatically.
509
+ If thumbnails alone aren't enough, show the PB Vision `webpage` link next
510
+ to the avatar choices (or embed it in an iframe) so the user can reference
511
+ the video before selecting themselves.
265
512
 
266
513
  3. **Store your own metadata alongside the video ID.** When you upload a video,
267
514
  record the returned `vid` alongside whatever context you have (match ID,
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.14",
4
+ "version": "0.1.16",
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,462 @@ 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.
130
197
  */
131
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
266
+ */
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` and ends with `env=test`. Any installed PB Vision app
292
+ * opens it, so test with a development build of the app. Current App Store
293
+ * and Google Play versions check the link before recording and say it needs
294
+ * the development build; older versions would record the game and upload it
295
+ * to production, where the test video doesn't exist, so it would be lost.
296
+ *
297
+ * @param {RecordingLinkOptions} [options]
298
+ * @returns {RecordingLink}
299
+ * @throws {Error} if gameStartEpoch is given, a camera setting is invalid,
300
+ * or PB Vision doesn't make the video (e.g. because the account paying for
301
+ * it can't pay for it)
302
+ */
303
+ async makeRecordingLink ({
304
+ resolution = '1080p', fps = 30, mayChangeResolution = false, mayChangeFPS = false,
305
+ gameStartEpoch, userEmails, name, desc, facility, court, fid, playersForTagging, nonce
306
+ } = {}) {
307
+ assert(gameStartEpoch === undefined,
308
+ 'makeRecordingLink() does not take gameStartEpoch: the game\'s time comes from its recording, and a time set now would override it')
309
+ assert(RECORDING_RESOLUTIONS.includes(resolution),
310
+ `resolution must be '1080p' or '4k', not ${JSON.stringify(resolution)}`)
311
+ assert(RECORDING_FPS.includes(fps), `fps must be 30 or 60, not ${JSON.stringify(fps)}`)
312
+ assert(typeof mayChangeResolution === 'boolean', 'mayChangeResolution must be a boolean')
313
+ assert(typeof mayChangeFPS === 'boolean', 'mayChangeFPS must be a boolean')
314
+
315
+ const { vid, uid, hasCredits } = await this.makeVideoId({
316
+ fileExt: 'mp4', userEmails, name, desc, facility, court, fid, playersForTagging, nonce })
317
+ if (hasCredits === false) {
318
+ 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)')
319
+ }
320
+ if (!vid) {
321
+ throw new Error('PB Vision did not return a video ID for this recording link')
322
+ }
323
+ // env=test lets the app explain a test-server link it can't record,
324
+ // instead of recording a game whose video exists only on the test server
325
+ const params = { vid, uid, resolution, fps, mayChangeResolution, mayChangeFPS }
326
+ if (this.isDev) {
327
+ params.env = 'test'
328
+ }
329
+ // encodeURIComponent() rather than URLSearchParams, which writes a space
330
+ // as "+" and not every app reads that back as a space
331
+ const query = Object.entries(params)
332
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
333
+ .join('&')
334
+ return { vid, uid, url: `${this.recordUrl}?${query}` }
335
+ }
336
+
132
337
  /**
133
338
  * Upload a video for processing by the AI.
134
339
  *
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.
340
+ * The video is only uploaded if the account paying for it has credit(s)
341
+ * available with which the video can be analyzed. If not, nothing is
342
+ * uploaded and this returns `{ hasCredits: false }` with no `vid`.
343
+ *
344
+ * The upload resumes by itself after a dropped connection or a temporary
345
+ * error from the storage service. If it still fails, the error thrown has
346
+ * the video's ID as its `vid` property. To retry without making a second
347
+ * video for the game, pass a `nonce` (see makeVideoId()): calling this again
348
+ * with the same nonce uploads to the same video.
137
349
  *
138
350
  * @param {string} mp4Filename
139
351
  * @param {VideoMetadata} [metadata]
140
352
  * @returns {VideoUrlToDownloadResponse}
353
+ * @throws {Error} if the upload fails, with the video's ID as `vid`. A
354
+ * missing or empty file is refused before any video is made.
141
355
  */
142
- async uploadVideo (mp4Filename, { userEmails = [], name, desc, gameStartEpoch, facility, court, fid } = {}) {
356
+ async uploadVideo (mp4Filename, metadata = {}) {
357
+ // checked before making the video, so a file that can't be uploaded
358
+ // doesn't leave a video behind that never gets one
359
+ const stats = await fs.promises.stat(mp4Filename)
360
+ assert(stats.isFile(), `cannot upload ${mp4Filename}: it is not a file`)
361
+ assert(stats.size > 0, `cannot upload ${mp4Filename}: the file is empty`)
143
362
  const pieces = mp4Filename.split('.')
144
363
  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)
364
+ const { hasCredits, vid } = await this.makeVideoId({ ...metadata, fileExt: ext })
148
365
  if (hasCredits === false) {
149
366
  return { hasCredits }
150
367
  }
151
368
  const bucket = `pbv-uploads${this.isDev ? '-dev' : ''}`
152
369
  const objName = `${this.uid}/${vid}.${ext}`
153
- await uploadToGCS(bucket, objName, mp4Filename)
370
+ try {
371
+ await uploadToGCS(bucket, objName, mp4Filename)
372
+ } catch (e) {
373
+ const err = new Error(`PB Vision Upload of video ${vid} failed: ${e.message}`, { cause: e })
374
+ err.vid = vid
375
+ throw err
376
+ }
154
377
  const ret = { vid }
155
378
  if (hasCredits !== undefined) {
156
379
  ret.hasCredits = hasCredits
157
380
  }
158
381
  return ret
159
382
  }
383
+
384
+ /**
385
+ * @typedef {Object} GetOrCreateFolderResponse
386
+ * @property {integer} fid the folder's ID; pass it as the `fid` metadata
387
+ * field to put a new video in this folder
388
+ * @property {boolean} created true if this call created the folder, false if
389
+ * it already existed
390
+ */
391
+
392
+ /**
393
+ * Gets the folder in your partner account's library with exactly this name,
394
+ * creating it if there is none. Calling it again with the same name returns
395
+ * the same folder, as long as it hasn't been renamed or moved since, so it
396
+ * is safe to retry.
397
+ *
398
+ * Use a unique name for each event, such as one that includes its date;
399
+ * otherwise two events with the same name would share one folder.
400
+ *
401
+ * @param {string} name the folder's name: 1 to 200 characters, with no
402
+ * leading or trailing whitespace
403
+ * @param {Object} [options]
404
+ * @param {integer} [options.parent] the ID of the folder to look in, and to
405
+ * create the folder in; omit for the top level of your library
406
+ * @returns {GetOrCreateFolderResponse}
407
+ */
408
+ async getOrCreateFolder (name, { parent } = {}) {
409
+ const resp = await this.__callAPI('folder/get_or_create', { name, parent })
410
+ const { fid, created } = JSON.parse(resp)
411
+ return { fid, created }
412
+ }
413
+
414
+ /**
415
+ * Renames a folder in your partner account's library, moves it, or changes
416
+ * whether it is public. Fields you omit are left as they are.
417
+ *
418
+ * @param {integer} fid the ID of the folder to change
419
+ * @param {Object} [changes]
420
+ * @param {string} [changes.name] the folder's new name: 1 to 200 characters,
421
+ * with no leading or trailing whitespace
422
+ * @param {?integer} [changes.parent] the ID of the folder to move it into, or
423
+ * null to move it to the top level of your library
424
+ * @param {boolean} [changes.public] whether anyone with the folder's link may
425
+ * view it, signed in or not (see getPublicFolderUrl()). A folder inside a
426
+ * public folder is only shown with it if it is public too.
427
+ */
428
+ async updateFolder (fid, { name, parent, public: isPublic } = {}) {
429
+ await this.__callAPI('folder/update', { fid, name, parent, public: isPublic })
430
+ }
431
+
432
+ /**
433
+ * The link to a folder's page on PB Vision, where anyone can view its videos
434
+ * once the folder is public (see updateFolder()).
435
+ *
436
+ * @param {integer} fid the folder's ID
437
+ * @returns {string}
438
+ */
439
+ getPublicFolderUrl (fid) {
440
+ assert(Number.isInteger(fid) && fid > 0, `invalid folder ID: ${fid}`)
441
+ return `${this.webAppUrl}/library/public/${encodeURIComponent(this.uid)}/${fid}`
442
+ }
160
443
  }
161
444
 
445
+ // How uploadToGCS() reads and sends the file: one chunk in memory at a time.
446
+ // GCS needs every chunk but the last to be a multiple of 256 KiB; larger
447
+ // chunks tend to upload faster but take more memory.
448
+ const UPLOAD_CHUNK_SIZE = 8 * 1024 * 1024
449
+
450
+ // How uploadToGCS() retries a request that fails with a network error or a
451
+ // temporary server error: up to UPLOAD_MAX_RETRIES times in a row without the
452
+ // upload making progress, waiting about twice as long before each retry, up to
453
+ // UPLOAD_MAX_RETRY_DELAY_MS (plus up to a second at random).
454
+ const UPLOAD_MAX_RETRIES = 6
455
+ const UPLOAD_FIRST_RETRY_DELAY_MS = 1000
456
+ const UPLOAD_MAX_RETRY_DELAY_MS = 32000
457
+
458
+ // Uploads a file to GCS with a resumable upload, following GCS's protocol: a
459
+ // 308 response says how much GCS has stored so far (its Range header), and the
460
+ // upload is only done once GCS answers 200 or 201. After a network error or a
461
+ // temporary server error, it waits, asks GCS how much it has stored, and goes
462
+ // on from there. Throws an Error saying why if the upload fails.
162
463
  async function uploadToGCS (bucket, objName, filename) {
163
- // request to start a new upload
464
+ const file = await fs.promises.open(filename, 'r')
465
+ try {
466
+ const { size: numBytesTotal } = await file.stat()
467
+ if (numBytesTotal === 0) {
468
+ throw new Error('the file is empty') // it was emptied after it was checked
469
+ }
470
+ const sessionURI = await startResumableUpload(bucket, objName, numBytesTotal)
471
+
472
+ let offset = 0 // how many bytes GCS has stored
473
+ let failures = 0 // tries in a row which made no progress
474
+ let mustAskOffset = false // whether to ask GCS for the offset before sending
475
+ for (;;) {
476
+ let request, action
477
+ if (mustAskOffset) {
478
+ action = 'checking the upload\'s progress'
479
+ request = {
480
+ method: 'PUT',
481
+ headers: { 'Content-Length': '0', 'Content-Range': `bytes */${numBytesTotal}` }
482
+ }
483
+ } else {
484
+ const endIdx = Math.min(offset + UPLOAD_CHUNK_SIZE, numBytesTotal) - 1
485
+ action = `uploading bytes ${offset}-${endIdx}`
486
+ // a file that can't be read is not worth retrying, so this throws
487
+ const chunk = await readChunk(file, offset, endIdx - offset + 1, numBytesTotal)
488
+ request = {
489
+ method: 'PUT',
490
+ headers: {
491
+ 'Content-Length': String(chunk.length),
492
+ 'Content-Range': `bytes ${offset}-${endIdx}/${numBytesTotal}`
493
+ },
494
+ body: chunk
495
+ }
496
+ }
497
+
498
+ let resp, respBody, problem
499
+ try {
500
+ resp = await fetch(sessionURI, request)
501
+ respBody = await resp.text()
502
+ } catch (e) {
503
+ // even a 200 whose body was cut off is confirmed by asking GCS
504
+ resp = undefined
505
+ problem = `network error while ${action}: ${e.message}`
506
+ }
507
+
508
+ if (resp && (resp.status === 200 || resp.status === 201)) {
509
+ return
510
+ }
511
+ if (resp?.status === 308) {
512
+ const stored = parseStoredRange(resp.headers.get('Range'), numBytesTotal)
513
+ if (stored === numBytesTotal) {
514
+ throw new Error(`GCS stored all ${numBytesTotal} bytes but did not finish the upload (308)`)
515
+ }
516
+ const madeProgress = stored > offset
517
+ const wasAsking = mustAskOffset
518
+ problem = `GCS stored nothing more while ${action} (308, offset ${stored})`
519
+ offset = stored
520
+ mustAskOffset = false
521
+ if (madeProgress) {
522
+ failures = 0
523
+ continue
524
+ }
525
+ if (wasAsking) {
526
+ continue // the failure that made us ask was already counted
527
+ }
528
+ // GCS kept none of the chunk: resend it, after a wait
529
+ } else if (resp && !isTemporaryStatus(resp.status)) {
530
+ if (mustAskOffset && (resp.status === 404 || resp.status === 410)) {
531
+ throw new Error(`the upload session expired (${resp.status}): ${respBody}`)
532
+ }
533
+ throw new Error(`GCS answered ${resp.status} while ${action}: ${respBody}`)
534
+ } else {
535
+ problem ??= `GCS answered ${resp.status} while ${action}: ${respBody}`
536
+ mustAskOffset = true
537
+ }
538
+
539
+ failures++
540
+ if (failures > UPLOAD_MAX_RETRIES) {
541
+ throw new Error(`gave up after ${UPLOAD_MAX_RETRIES} retries; last error: ${problem}`)
542
+ }
543
+ await waitBeforeRetry(failures)
544
+ }
545
+ } finally {
546
+ await file.close()
547
+ }
548
+ }
549
+
550
+ // Starts a resumable upload and returns its session URI.
551
+ async function startResumableUpload (bucket, objName, numBytesTotal) {
164
552
  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
553
+ for (let failures = 0; ; failures++) {
554
+ let resp, respBody, problem
200
555
  try {
201
- chunk = await streamPromise
556
+ resp = await fetch(url, {
557
+ method: 'POST',
558
+ headers: { 'X-Upload-Content-Length': String(numBytesTotal) }
559
+ })
560
+ respBody = await resp.text()
202
561
  } catch (e) {
203
- throw new Error(`PB Vision Upload failed to read from file ${e.toString()}`)
562
+ problem = `network error while initializing: ${e.message}`
204
563
  }
205
-
206
- headers = {
207
- 'Content-Length': chunk.length,
208
- 'Content-Range': `bytes ${startIdx}-${endIdx}/${numBytesTotal}`
564
+ if (resp?.ok) {
565
+ const sessionURI = resp.headers.get('Location')
566
+ if (!sessionURI) {
567
+ throw new Error('GCS started the upload but gave no session URI')
568
+ }
569
+ return sessionURI
570
+ }
571
+ if (resp && !isTemporaryStatus(resp.status)) {
572
+ throw new Error(`failed to initialize (${resp.status}): ${respBody}`)
573
+ }
574
+ problem ??= `GCS answered ${resp.status} while initializing: ${respBody}`
575
+ if (failures >= UPLOAD_MAX_RETRIES) {
576
+ throw new Error(`gave up after ${UPLOAD_MAX_RETRIES} retries; last error: ${problem}`)
209
577
  }
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())}`)
578
+ await waitBeforeRetry(failures + 1)
579
+ }
580
+ }
581
+
582
+ // GCS asks clients to retry these, with exponential backoff
583
+ function isTemporaryStatus (status) {
584
+ return status >= 500 || status === 408 || status === 429
585
+ }
586
+
587
+ async function waitBeforeRetry (retryNumber) {
588
+ const delayMs = Math.min(
589
+ UPLOAD_FIRST_RETRY_DELAY_MS * 2 ** (retryNumber - 1), UPLOAD_MAX_RETRY_DELAY_MS)
590
+ await new Promise(resolve => setTimeout(resolve, delayMs + Math.random() * 1000))
591
+ }
592
+
593
+ // Returns how many bytes a 308's Range header (e.g. "bytes=0-1048575") says
594
+ // GCS has stored; no Range header means none.
595
+ function parseStoredRange (range, numBytesTotal) {
596
+ if (range === null) {
597
+ return 0
598
+ }
599
+ const match = /^bytes=0-(\d+)$/.exec(range)
600
+ const stored = match ? Number(match[1]) + 1 : NaN
601
+ if (!(stored <= numBytesTotal)) {
602
+ throw new Error(`GCS sent an unexpected Range header: ${range}`)
603
+ }
604
+ return stored
605
+ }
606
+
607
+ // Reads length bytes of the file, starting at position.
608
+ async function readChunk (file, position, length, numBytesTotal) {
609
+ const chunk = Buffer.alloc(length)
610
+ let chunkBytesRead = 0
611
+ while (chunkBytesRead < length) {
612
+ const { bytesRead } = await file.read(
613
+ chunk, chunkBytesRead, length - chunkBytesRead, position + chunkBytesRead)
614
+ if (bytesRead === 0) {
615
+ throw new Error(`the file ended at byte ${position + chunkBytesRead}, before the ${numBytesTotal} bytes it had when the upload started`)
215
616
  }
216
- startIdx = endIdx + 1
617
+ chunkBytesRead += bytesRead
217
618
  }
218
- return true
619
+ return chunk
219
620
  }