@pbvision/partner-sdk 0.0.1 → 0.0.2

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
@@ -8,6 +8,8 @@ standard length game, so we'll notify your servers when the results are ready.
8
8
  - [Step 1: Get API Access](#step-1-get-api-access)
9
9
  - [Step 2: Webhook Setup](#step-2-webhook-setup)
10
10
  - [Step 3: Send Videos](#step-3-send-videos)
11
+ - [Option 1: PBV Downloads your video from a URL](#option-1-pbv-downloads-your-video-from-a-url)
12
+ - [Option 2: Upload your video](#option-2-upload-your-video)
11
13
  - [Step 4: Video Processing Done](#step-4-video-processing-done)
12
14
  - [SDK Setup](#sdk-setup)
13
15
  - [Video Guidelines](#video-guidelines)
@@ -44,6 +46,8 @@ curl -X POST \
44
46
 
45
47
  ### Step 3: Send Videos
46
48
 
49
+ #### Option 1: PBV Downloads your video from a URL
50
+
47
51
  First, upload a video to a _publicly_ accessible URL on your server. For best
48
52
  results, videos should follow our [guidelines](#video-guidelines).
49
53
 
@@ -53,8 +57,8 @@ Next, tell us to download and work on the video. You can do this using our SDK:
53
57
  import { PBVision } from '@pbvision/partner-sdk'
54
58
 
55
59
  const pbv = new PBVision(YOUR_API_KEY)
56
- const playerEmails = [] // optional!
57
- await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL, playerEmails)
60
+ const userEmails = [] // optional!
61
+ await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL, userEmails)
58
62
  ```
59
63
 
60
64
  Alternatively, you can just use `curl`:
@@ -67,6 +71,24 @@ curl -X POST \
67
71
  https://api-ko3kowqi6a-uc.a.run.app/partner/add_video_by_url
68
72
  ```
69
73
 
74
+ #### Option 2: Upload your video
75
+
76
+ You can directly upload your video from a file using the SDK too:
77
+
78
+ ```javascript
79
+ import { PBVision } from '@pbvision/partner-sdk'
80
+
81
+ const pbv = new PBVision(YOUR_API_KEY)
82
+ // you can omit this metadata, or provide it-- whatever you'd like!
83
+ const optionalMetadata = {
84
+ userEmails: [],
85
+ name: 'Dink Championship 2024',
86
+ desc: 'A longer description, if you want',
87
+ gameStartEpoch: 1711393200 // when the game was played
88
+ }
89
+ await pbv.uploadVideo(YOUR_VIDEO_FILENAME, optionalMetadata)
90
+ ```
91
+
70
92
  ### Step 4: Video Processing Done
71
93
 
72
94
  When our AI is done processing your video, we will email the players in the
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.2",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "engines": {
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,65 @@
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
+ * @public
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
+ */
41
+ function getHash (uploaderUID, signingKey, uploadId, fileExt, bucket) {
42
+ const nameWithEmptyHash = makeObjectNameGivenHash(
43
+ uploaderUID, uploadId, fileExt, '')
44
+ return crypto.createHash('sha256')
45
+ .update(signingKey + bucket + nameWithEmptyHash)
46
+ .digest('hex')
47
+ }
48
+
49
+ /**
50
+ * Returns the full GCS object name (including hash) for the given parameters.
51
+ */
52
+ function makeObjectName (uploaderUID, signingKey, uploadId, fileExt, bucket) {
53
+ const hash = getHash(uploaderUID, signingKey, uploadId, fileExt, bucket)
54
+ const nameWithHash = makeObjectNameGivenHash(
55
+ uploaderUID, uploadId, fileExt, hash)
56
+ return nameWithHash
57
+ }
58
+
59
+ /**
60
+ * Returns the full GCS object name using the specified hash string.
61
+ */
62
+ function makeObjectNameGivenHash (uploaderUID, uploadId, fileExt, hash) {
63
+ assert(uploaderUID.indexOf('/') === -1)
64
+ return `${uploaderUID}/${uploadId}-${hash}.${fileExt}`
65
+ }