@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 +24 -2
- package/package.json +1 -1
- package/src/index.js +127 -10
- package/src/video-object-name.js +65 -0
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
|
|
57
|
-
await pbv.sendVideoUrlToDownload(YOUR_VIDEO_URL,
|
|
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
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,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
|
+
}
|