@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 +58 -28
- package/package.json +5 -2
- package/src/index.js +127 -10
- package/src/video-object-name.js +68 -0
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
|
-
- [
|
|
7
|
+
- [Installation](#installation)
|
|
8
8
|
- [Step 1: Get API Access](#step-1-get-api-access)
|
|
9
|
-
- [Step 2:
|
|
10
|
-
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
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
|
-
##
|
|
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:
|
|
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
|
-
###
|
|
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
|
|
57
|
-
await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL,
|
|
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
|
-
|
|
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
|
|
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](#
|
|
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.
|
|
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,
|
|
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 =
|
|
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>} [
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|