@pbvision/partner-sdk 0.0.1 → 0.0.3

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 CHANGED
@@ -4,22 +4,43 @@ The easiest way to use the PB Vision AI Engine is to simply share a link to
4
4
  your video file. Our AI usually works on each video for about an hour for a
5
5
  standard length game, so we'll notify your servers when the results are ready.
6
6
 
7
- - [Using the API](#using-the-api)
7
+ - [Installation](#installation)
8
8
  - [Step 1: Get API Access](#step-1-get-api-access)
9
- - [Step 2: Webhook Setup](#step-2-webhook-setup)
10
- - [Step 3: Send Videos](#step-3-send-videos)
11
- - [Step 4: Video Processing Done](#step-4-video-processing-done)
12
- - [SDK Setup](#sdk-setup)
9
+ - [Step 2: SDK Setup](#step-2-sdk-setup)
10
+ - [Using the API](#using-the-api)
11
+ - [Webhook Setup](#webhook-setup)
12
+ - [Send Videos](#send-videos)
13
+ - [Option 1: PBV Downloads your video from a URL](#option-1-pbv-downloads-your-video-from-a-url)
14
+ - [Option 2: Upload your video](#option-2-upload-your-video)
15
+ - [After Video Processing is Done](#after-video-processing-is-done)
13
16
  - [Video Guidelines](#video-guidelines)
17
+ - [Reference Guide](#reference-guide)
14
18
 
15
- ## Using the API
19
+ ## Installation
16
20
 
17
21
  ### Step 1: Get API Access
18
22
 
19
23
  Email **[dev@pb.vision](mailto:dev@pb.vision)** to request API access for your
20
24
  account. We'll send you an API Key.
21
25
 
22
- ### Step 2: Webhook Setup
26
+ ### Step 2: SDK Setup
27
+
28
+ 1. Install [node](https://nodejs.org/en/download)
29
+ 2. Install the SDK: `npm i @pbvision/partner-sdk`
30
+ 3. In your package.json, make sure you're configured to use ESM (modules): `"type": "module"`
31
+ 4. Initialize the SDK:
32
+
33
+ ```javascript
34
+ import { PBVision } from '@pbvision/partner-sdk'
35
+ const pbv = new PBVision(YOUR_API_KEY)
36
+ ```
37
+
38
+ 5. Use its methods as described to [send us video URLs](#send-videos) to
39
+ to process, or [tell us your webhook's URL](#webhook-setup).
40
+
41
+ ## Using the API
42
+
43
+ ### Webhook Setup
23
44
 
24
45
  Each time our AI finishes processing a video, it will notify your servers via
25
46
  an HTTP POST request to a URL ("webhook") of your choice. You can use our SDK
@@ -42,7 +63,9 @@ curl -X POST \
42
63
  https://api-ko3kowqi6a-uc.a.run.app/partner/webhook/set
43
64
  ```
44
65
 
45
- ### Step 3: Send Videos
66
+ ### Send Videos
67
+
68
+ #### Option 1: PBV Downloads your video from a URL
46
69
 
47
70
  First, upload a video to a _publicly_ accessible URL on your server. For best
48
71
  results, videos should follow our [guidelines](#video-guidelines).
@@ -53,8 +76,8 @@ Next, tell us to download and work on the video. You can do this using our SDK:
53
76
  import { PBVision } from '@pbvision/partner-sdk'
54
77
 
55
78
  const pbv = new PBVision(YOUR_API_KEY)
56
- const playerEmails = [] // optional!
57
- await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL, playerEmails)
79
+ const userEmails = [] // optional!
80
+ await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL, userEmails)
58
81
  ```
59
82
 
60
83
  Alternatively, you can just use `curl`:
@@ -67,12 +90,30 @@ curl -X POST \
67
90
  https://api-ko3kowqi6a-uc.a.run.app/partner/add_video_by_url
68
91
  ```
69
92
 
70
- ### Step 4: Video Processing Done
93
+ #### Option 2: Upload your video
94
+
95
+ You can directly upload your video from a file using the SDK too:
96
+
97
+ ```javascript
98
+ import { PBVision } from '@pbvision/partner-sdk'
99
+
100
+ const pbv = new PBVision(YOUR_API_KEY)
101
+ // you can omit this metadata, or provide it-- whatever you'd like!
102
+ const optionalMetadata = {
103
+ userEmails: [],
104
+ name: 'Dink Championship 2024',
105
+ desc: 'A longer description, if you want',
106
+ gameStartEpoch: 1711393200 // when the game was played
107
+ }
108
+ await pbv.uploadVideo(YOUR_VIDEO_FILENAME, optionalMetadata)
109
+ ```
110
+
111
+ ## After Video Processing is Done
71
112
 
72
113
  When our AI is done processing your video, we will email the players in the
73
114
  game (_if_ their email addresses were provided).
74
115
 
75
- If you provided a webhook in [step 2](#step-2-webhook-setup), then we'll send
116
+ If you [provided a webhook](#webhook-setup), then we'll send
76
117
  an HTTP POST request to your server. Your server should acknowledge this
77
118
  callback with a standard 200 (OK) response. If it does not, we will attempt to
78
119
  retry sending this request later (up to some maximum number of attempts).
@@ -91,7 +132,7 @@ The HTTP POST body will contain JSON which looks like this:
91
132
  }
92
133
  ```
93
134
 
94
- - `from_url` is the video url you sent us in [step 3](#step-3-send-videos)
135
+ - `from_url` is the video url you sent us in [step 3](#send-videos)
95
136
  - `webpage` is a link to our web app where the stats can be explored
96
137
  - `error` is only present if your video could not be processed
97
138
  - `events` - describes the pickleball game in detailed, low-level events
@@ -101,21 +142,6 @@ The HTTP POST body will contain JSON which looks like this:
101
142
  - Explore the _insights_ schema at <https://pbv-public.github.io/insights/dev>
102
143
  - Schema changes and diffs are in our [`pbv-public/insights` repo](https://github.com/pbv-public/insights/blob/dev/CHANGELOG.md)
103
144
 
104
- ## SDK Setup
105
-
106
- 1. Install [node](https://nodejs.org/en/download)
107
- 2. Install the SDK: `npm i @pbvision/partner-sdk`
108
- 3. In your package.json, make sure you're configured to use ESM (modules): `"type": "module"`
109
- 4. Initialize the SDK:
110
-
111
- ```javascript
112
- import { PBVision } from '@pbvision/partner-sdk'
113
- const pbv = new PBVision(YOUR_API_KEY)
114
- ```
115
-
116
- 5. Use its methods as described to [send us video URLs](#step-3-send-videos) to
117
- to process, or [tell us your webhook's URL](#step-2-webhook-setup).
118
-
119
145
  ## Video Guidelines
120
146
 
121
147
  Requirements:
@@ -132,3 +158,7 @@ For best results, we also recommend:
132
158
  - Max bitrate: 2 Mbps
133
159
  - Max file size: 1GB
134
160
  - Max duration: 30 minutes
161
+
162
+ ## Reference Guide
163
+
164
+ - [JSDocs](https://pbv-public.github.io/partner-sdk-nodejs/)
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.0.1",
4
+ "version": "0.0.3",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {
@@ -18,5 +18,8 @@
18
18
  "dependencies": {
19
19
  "node-fetch": "^3.3.2"
20
20
  },
21
- "devDependencies": {}
21
+ "devDependencies": {
22
+ "jsdoc": "3.6.4",
23
+ "tui-jsdoc-template": "^1.2.2"
24
+ }
22
25
  }
package/src/index.js CHANGED
@@ -1,14 +1,29 @@
1
1
  import assert from 'node:assert'
2
+ import fs from 'node:fs'
2
3
 
4
+ import { makeSignedGoogleCloudStorageVideoObjectName } from './video-object-name.js'
3
5
  import fetch from 'node-fetch'
4
6
 
7
+ const ENVIRONMENTS = {
8
+ test: {
9
+ apiServer: 'https://api-ko3kowqi6a-uc.a.run.app',
10
+ firebaseConfig: {
11
+ apiKey: 'AIzaSyCV1uh4fM7IFopuZOJ306oVWLV3cKLijFc',
12
+ projectId: 'pbv-dev',
13
+ appId: '1:542837591762:web:06f45c0d7a7e62f25aa70b'
14
+ }
15
+ }
16
+ }
17
+
18
+ /** @public */
5
19
  export class PBVision {
6
- constructor (apiKey, server = 'https://api-ko3kowqi6a-uc.a.run.app') {
20
+ constructor (apiKey, config = ENVIRONMENTS.test) {
7
21
  assert(apiKey && apiKey.length === 31 && apiKey.indexOf('_') === 10,
8
22
  `invalid API key: ${apiKey}`)
9
23
 
10
24
  this.apiKey = apiKey
11
- this.server = server
25
+ this.server = config.apiServer
26
+ this.isDev = config === ENVIRONMENTS.test
12
27
  }
13
28
 
14
29
  /**
@@ -28,16 +43,14 @@ export class PBVision {
28
43
  * processing is complete, your webhook URL will receive a callback.
29
44
  *
30
45
  * @param {string} videoUrl the publicly available URL of the video
31
- * @param {Array<string>} [playerEmails] the email addresses of players in
46
+ * @param {Array<string>} [userEmails] the email addresses of players in
32
47
  * this video; they'll be notified when the video is done processing
33
- * @returns
34
48
  */
35
- async sendVideoUrlToDownload (videoUrl, playerEmails = []) {
49
+ async sendVideoUrlToDownload (videoUrl, userEmails = []) {
36
50
  assert(typeof videoUrl === 'string' && videoUrl.startsWith('http'),
37
51
  'URL must be a string beginning with http')
38
52
  assert(videoUrl.endsWith('.mp4'), 'video URL must have the .mp4 extension')
39
- return this.__callAPI(
40
- 'add_video_by_url', { url: videoUrl, userEmails: playerEmails })
53
+ this.__callAPI('add_video_by_url', { url: videoUrl, userEmails })
41
54
  }
42
55
 
43
56
  async __callAPI (path, body) {
@@ -50,10 +63,114 @@ export class PBVision {
50
63
  compress: true,
51
64
  body: JSON.stringify(body)
52
65
  })
66
+ const respBody = await resp.text()
53
67
  if (resp.ok) {
54
- return true
68
+ return respBody || true
69
+ }
70
+ throw new Error(`PB Vision API ${path} failed (${resp.status}): ${respBody}`)
71
+ }
72
+
73
+ /**
74
+ * Information about the Video that can be set prior to it being uploaded.
75
+ * @typedef {Object} VideoMetadata
76
+ * @property {Array<string>} userEmails a list of email addresses of up to 4
77
+ * players who were playing in the game; they will also be notified when
78
+ * the video processing is complete (unless they have these notifications
79
+ * disabled)
80
+ * @property {string} [name] the title of the game (if omitted, we'll use the
81
+ * time of the game, or if that isn't provided then the time of the upload)
82
+ * @property {string} [desc] a longer description of the game
83
+ * @property {integer} [gameStartEpoch] the epoch at which the game started
84
+ */
85
+
86
+ /**
87
+ * Upload a video for processing by the AI.
88
+ * @param {string} mp4Filename
89
+ * @param {VideoMetadata} [metadata]
90
+ * @returns {string} the full Google Cloud Storage filename which this was
91
+ * uploaded to
92
+ */
93
+ async uploadVideo (mp4Filename, { userEmails = [], name, desc, gameStartEpoch } = {}) {
94
+ // get the upload signing key if we didn't get it earlier
95
+ if (!this.signingKey) {
96
+ this.signingKey = await this.__callAPI('get_signing_key')
97
+ }
98
+
99
+ // create a unique, signed upload name
100
+ const uid = this.apiKey.split('_')[0]
101
+ const bucket = `pbv-uploads${this.isDev ? '-dev' : ''}`
102
+ const objName = makeSignedGoogleCloudStorageVideoObjectName({
103
+ uploaderUID: uid,
104
+ signingKey: this.signingKey,
105
+ bucket
106
+ })
107
+
108
+ // if given extra information about this video, upload that first
109
+ if (userEmails.length || name || desc || gameStartEpoch) {
110
+ const body = { objName, userEmails, name, desc, gameStartEpoch }
111
+ await this.__callAPI('set_pre_upload_metadata', body)
112
+ }
113
+
114
+ await uploadToGCS(bucket, objName, mp4Filename)
115
+ return objName
116
+ }
117
+ }
118
+
119
+ async function uploadToGCS (bucket, objName, filename) {
120
+ // request to start a new upload
121
+ const url = `https://storage.googleapis.com/upload/storage/v1/b/${bucket}/o?uploadType=resumable&name=${objName}`
122
+ const numBytesTotal = fs.statSync(filename).size
123
+ let headers = { 'X-Upload-Content-Length': numBytesTotal }
124
+ let resp = await fetch(url, { method: 'POST', headers })
125
+ if (!resp.ok) {
126
+ throw new Error(`PB Vision Upload failed to initialize (${resp.status}): ${await resp.text()}`)
127
+ }
128
+ const sessionURI = resp.headers.get('Location')
129
+
130
+ // determine how much data to read from the file at once; larger tends to
131
+ // result in faster uploads but also has a bigger memory footprint
132
+ const minChunkSz = 256 * 1024 // this is the *minimum* size
133
+ const targetChunkSzMB = 8
134
+ const chunkSize = Math.max(minChunkSz, targetChunkSzMB * 1024 * 1024)
135
+
136
+ // upload one chunk at a time until it is done successfully
137
+ let startIdx = 0
138
+ while (startIdx < numBytesTotal) {
139
+ let endIdx = startIdx + chunkSize - 1
140
+ endIdx = Math.min(endIdx, numBytesTotal - 1)
141
+ const thisChunkSize = endIdx - startIdx + 1
142
+
143
+ // read just the chunk we need from the file
144
+ const streamPromise = new Promise((resolve, reject) => {
145
+ const chunk = Buffer.alloc(thisChunkSize)
146
+ let chunkBytesRead = 0
147
+ const stream = fs.createReadStream(
148
+ filename, { start: startIdx, end: endIdx })
149
+ stream.on('data', x => {
150
+ x.copy(chunk, chunkBytesRead)
151
+ chunkBytesRead += x.length
152
+ })
153
+ stream.on('end', () => resolve(chunk))
154
+ stream.on('error', e => reject(e))
155
+ })
156
+ let chunk
157
+ try {
158
+ chunk = await streamPromise
159
+ } catch (e) {
160
+ throw new Error(`PB Vision Upload failed to read from file ${e.toString()}`)
161
+ }
162
+
163
+ headers = {
164
+ 'Content-Length': chunk.length,
165
+ 'Content-Range': `bytes ${startIdx}-${endIdx}/${numBytesTotal}`
166
+ }
167
+ assert(chunk.length <= numBytesTotal)
168
+ assert(chunk.length === endIdx - startIdx + 1)
169
+ resp = await fetch(sessionURI, { method: 'PUT', headers, body: chunk })
170
+ if (!resp.status >= 400) {
171
+ throw new Error(`PB Vision Upload failed to upload chunk ${startIdx} (${resp.status}): ${await resp.text()} ${JSON.stringify(resp.headers.raw())}`)
55
172
  }
56
- const errBody = await resp.text()
57
- throw new Error(`PB Vision API ${path} failed (${resp.status}): ${errBody}`)
173
+ startIdx = endIdx + 1
58
174
  }
175
+ return true
59
176
  }
@@ -0,0 +1,68 @@
1
+ import assert from 'node:assert'
2
+ import crypto from 'node:crypto'
3
+
4
+ /**
5
+ * Creates the name for a video file to upload to our uploads bucket.
6
+ *
7
+ * The name is signed with the uploader's API Key to prove that the upload is
8
+ * indeed from them.
9
+ *
10
+ * The object name is formatted like:
11
+ * <User ID>/<Video ID>-<Security Hash>.<File Extension>
12
+ *
13
+ * @param {Object} opts information about the video upload
14
+ * @param {string} opts.uploaderUID the video uploader's user ID
15
+ * @param {string} opts.signingKey the video uploader's API Key
16
+ * @param {string} [opts.fileExt] the video's file extension
17
+ * @param {string} [opts.bucket] the GCS bucket to upload to
18
+ * @returns {string} the signed GCS object filename
19
+ * @protected
20
+ */
21
+ export function makeSignedGoogleCloudStorageVideoObjectName ({
22
+ uploaderUID,
23
+ signingKey,
24
+ fileExt = 'mp4',
25
+ bucket = 'pbv-uploads',
26
+ uploadId = undefined
27
+ }) {
28
+ assert(typeof uploaderUID === 'string' && uploaderUID.length)
29
+ assert(typeof signingKey === 'string' && signingKey.length)
30
+ uploadId = uploadId ?? crypto.randomUUID().replace(/-/g, '')
31
+ assert(typeof uploadId === 'string' && uploadId.length === 32)
32
+ return makeObjectName(uploaderUID, signingKey, uploadId, fileExt, bucket)
33
+ }
34
+
35
+ /**
36
+ * Returns the hash from the parameters used to construct a GCS object name.
37
+ *
38
+ * The hash includes all elements of the file's unique path (including the
39
+ * bucket) except for the hash itself.
40
+ * @private
41
+ */
42
+ function getHash (uploaderUID, signingKey, uploadId, fileExt, bucket) {
43
+ const nameWithEmptyHash = makeObjectNameGivenHash(
44
+ uploaderUID, uploadId, fileExt, '')
45
+ return crypto.createHash('sha256')
46
+ .update(signingKey + bucket + nameWithEmptyHash)
47
+ .digest('hex')
48
+ }
49
+
50
+ /**
51
+ * Returns the full GCS object name (including hash) for the given parameters.
52
+ * @private
53
+ */
54
+ function makeObjectName (uploaderUID, signingKey, uploadId, fileExt, bucket) {
55
+ const hash = getHash(uploaderUID, signingKey, uploadId, fileExt, bucket)
56
+ const nameWithHash = makeObjectNameGivenHash(
57
+ uploaderUID, uploadId, fileExt, hash)
58
+ return nameWithHash
59
+ }
60
+
61
+ /**
62
+ * Returns the full GCS object name using the specified hash string.
63
+ * @private
64
+ */
65
+ function makeObjectNameGivenHash (uploaderUID, uploadId, fileExt, hash) {
66
+ assert(uploaderUID.indexOf('/') === -1)
67
+ return `${uploaderUID}/${uploadId}-${hash}.${fileExt}`
68
+ }