@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.
- package/README.md +253 -6
- package/package.json +1 -1
- 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
|
-
|
|
125
|
-
object. You can omit it
|
|
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.
|
|
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
|
-
|
|
223
|
-
|
|
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
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
|
|
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
|
-
*
|
|
136
|
-
*
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
562
|
+
problem = `network error while initializing: ${e.message}`
|
|
204
563
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
617
|
+
chunkBytesRead += bytesRead
|
|
217
618
|
}
|
|
218
|
-
return
|
|
619
|
+
return chunk
|
|
219
620
|
}
|