@capturly/capture-sdk 2.10.2 → 2.11.1
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/CHANGELOG.md +41 -0
- package/LICENSE +0 -0
- package/README.md +53 -18
- package/dist/CaptureClient.d.ts +7 -1
- package/dist/chunk-CZPDQKMD.js +278 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +163 -180
- package/dist/transport.d.ts +27 -2
- package/dist/transport.js +14 -0
- package/dist/types.d.ts +58 -3
- package/package.json +17 -14
package/CHANGELOG.md
CHANGED
|
@@ -1 +1,42 @@
|
|
|
1
1
|
# @capturly/capture-sdk
|
|
2
|
+
|
|
3
|
+
## 2.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#464](https://github.com/Corey-Alan-Consulting/capturly.app/pull/464) [`47c00ea`](https://github.com/Corey-Alan-Consulting/capturly.app/commit/47c00ea9b1943850a4427a78004f85cf165adaf8) Thanks [@NX211](https://github.com/NX211)! - Add a `@capturly/capture-sdk/transport` subpath exposing the bare respondent
|
|
8
|
+
transport (`requestUpload`, `uploadBlob`, `finalizeSubmission`) for hosts that
|
|
9
|
+
bring their own recorder, and a new `fetchCaptureView` call (both entries)
|
|
10
|
+
that returns what the person is being asked — title, prompt, branding,
|
|
11
|
+
release text, limits, and any existing submission — via the new
|
|
12
|
+
`/api/campaigns/respond/view` endpoint. Existing APIs are unchanged.
|
|
13
|
+
|
|
14
|
+
- [#464](https://github.com/Corey-Alan-Consulting/capturly.app/pull/464) [`47c00ea`](https://github.com/Corey-Alan-Consulting/capturly.app/commit/47c00ea9b1943850a4427a78004f85cf165adaf8) Thanks [@NX211](https://github.com/NX211)! - Harden the capture-request respond and collect surfaces, and the recording
|
|
15
|
+
storage lifecycle, per the branch's second review round:
|
|
16
|
+
- OPFS garbage collection now fails closed: an unreadable or corrupt
|
|
17
|
+
recovery marker aborts the sweep instead of reading as "nothing
|
|
18
|
+
referenced" and reclaiming crash-recovery takes.
|
|
19
|
+
- The mobile respondent flow can always be exited (persistent Close on
|
|
20
|
+
every non-recording phase), its API calls time out instead of spinning
|
|
21
|
+
forever, and a storage-full refusal is no longer misreported as
|
|
22
|
+
"take too large" with retry disabled.
|
|
23
|
+
- Respondent links are parsed origin-strictly, malformed escapes are
|
|
24
|
+
refused instead of thrown, and email unsubscribe links stay out of the
|
|
25
|
+
app on iOS (AASA exclude) while Android completes them natively.
|
|
26
|
+
- The web respondent page no longer blanks in Safari private mode, the
|
|
27
|
+
submission-received owner email can no longer be dropped or hang
|
|
28
|
+
finalize (bounded Mailgun timeout, awaited send), and view decoders
|
|
29
|
+
refuse a missing recording time limit instead of defaulting to zero.
|
|
30
|
+
- The desktop Collect screen drops stale detail responses (preventing a
|
|
31
|
+
personal link rendering under the wrong request), shows mapped error
|
|
32
|
+
copy instead of raw API codes, and its badge only polls while signed in.
|
|
33
|
+
- Take Delete / Share controls are reachable by screen readers, clock
|
|
34
|
+
labels can no longer render ":60", and saved filenames defuse Windows
|
|
35
|
+
reserved device names.
|
|
36
|
+
|
|
37
|
+
GET /api/capture-requests is now cursor-paginated (`cursor`/`limit`,
|
|
38
|
+
`nextCursor` in the response; default 50, max 100). List rows and detail
|
|
39
|
+
views group the owning studio as one nullable `studio { id, name }` object
|
|
40
|
+
in place of the previous studioId/studioName pair.
|
|
41
|
+
|
|
42
|
+
## 2.11.0
|
package/LICENSE
CHANGED
|
File without changes
|
package/README.md
CHANGED
|
@@ -21,11 +21,16 @@ uses is bundled in.
|
|
|
21
21
|
|
|
22
22
|
## Environment
|
|
23
23
|
|
|
24
|
-
Browsers only. It needs `MediaRecorder` or WebCodecs, OPFS
|
|
25
|
-
(`navigator.storage`), and `getUserMedia`:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
Browsers only. It needs MP4 `MediaRecorder` or WebCodecs, OPFS
|
|
25
|
+
(`navigator.storage`), and `getUserMedia`: Chrome/Edge 124+ and Safari 16.4+.
|
|
26
|
+
|
|
27
|
+
**Firefox is not supported.** It has neither MP4 `MediaRecorder` nor
|
|
28
|
+
`MediaStreamTrackProcessor`, so there is no way to write the file — this is a
|
|
29
|
+
missing capability, not a version floor.
|
|
30
|
+
|
|
31
|
+
Call `CaptureClient.isSupported()` before you show a record button; a browser
|
|
32
|
+
that fails it cannot be talked into working, so offer another browser rather
|
|
33
|
+
than a retry.
|
|
29
34
|
|
|
30
35
|
`getUserMedia` also requires a secure context, so this will not work on a page
|
|
31
36
|
served over plain HTTP (localhost excepted).
|
|
@@ -86,10 +91,13 @@ client.destroy();
|
|
|
86
91
|
|
|
87
92
|
**4. Your backend learns it landed**, from the
|
|
88
93
|
`capture_request.submission_received` webhook, or by polling
|
|
89
|
-
`GET /v1/capture-requests/{id}/submissions
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
`GET /v1/capture-requests/{id}/submissions`, which carries the consent the
|
|
95
|
+
respondent accepted.
|
|
96
|
+
|
|
97
|
+
`GET /v1/recordings/{id}` returns the recording's metadata and its files.
|
|
98
|
+
Download a file with
|
|
99
|
+
`GET /v1/recordings/{id}/files/{fileId}/download-url`, which mints a signed
|
|
100
|
+
URL — the API never proxies the bytes.
|
|
93
101
|
|
|
94
102
|
The recording id is not returned to the browser. Trust your backend for it.
|
|
95
103
|
|
|
@@ -104,16 +112,43 @@ anything produces a record that says something untrue.
|
|
|
104
112
|
|
|
105
113
|
## Errors
|
|
106
114
|
|
|
107
|
-
Every failure is a `CaptureError` with a `code`.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
| Code |
|
|
111
|
-
| --- | --- |
|
|
112
|
-
| `unsupported_browser
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
+
Every failure is a `CaptureError` with a `code`. What matters is whether
|
|
116
|
+
retrying can work, and who has to act:
|
|
117
|
+
|
|
118
|
+
| Code | Meaning | Retry? |
|
|
119
|
+
| --- | --- | --- |
|
|
120
|
+
| `unsupported_browser` | No MP4 recording in this browser | No — offer another browser |
|
|
121
|
+
| `permission_denied` | Camera or microphone refused | No — your user must allow it |
|
|
122
|
+
| `no_devices` | No camera or microphone found | No — your user must connect one |
|
|
123
|
+
| `not_recording`, `nothing_to_submit` | Called out of order | No — fix the call sequence |
|
|
124
|
+
| `invalid_request` | The call or its payload was rejected — missing release, missing name, take too long | No — fix the input |
|
|
125
|
+
| `link_expired` | Unknown link, or a key that no longer matches | No |
|
|
126
|
+
| `request_closed` | The capture request is closed, past its deadline, revoked, or the workspace has lapsed | No |
|
|
127
|
+
| `superseded` | Another take for this person landed first | No — their other submission is the live one |
|
|
128
|
+
| `quota_exceeded` | No room right now: take too large, or the request is at its cap | No — the workspace owner must act |
|
|
129
|
+
| `rate_limited` | Too many requests | **Yes**, after a pause |
|
|
130
|
+
| `upload_failed` | Storage or the API failed server-side | **Yes** |
|
|
131
|
+
| `network` | The request never reached Capturly | **Yes** |
|
|
132
|
+
|
|
133
|
+
Only the last three are worth retrying as-is. Retrying anything above them
|
|
134
|
+
re-uploads the whole take to fail the same way.
|
|
135
|
+
|
|
136
|
+
### Cancelling
|
|
137
|
+
|
|
138
|
+
`submit` takes an `AbortSignal`. Aborting rejects with `aborted` and leaves the
|
|
139
|
+
take in local storage, so the same client can submit it again.
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
cancelButton.onclick = () => controller.abort();
|
|
144
|
+
|
|
145
|
+
await client.submit({ releaseAccepted: true, signal: controller.signal });
|
|
146
|
+
```
|
|
115
147
|
|
|
116
|
-
|
|
148
|
+
You do not need a timeout of your own. JSON calls give up after 30 seconds, and
|
|
149
|
+
an upload that stops making progress for 60 seconds is abandoned with a
|
|
150
|
+
retryable `network` error — an idle limit rather than a total one, so a long
|
|
151
|
+
take on a slow connection is never cut off mid-upload.
|
|
117
152
|
|
|
118
153
|
## What this does not do
|
|
119
154
|
|
package/dist/CaptureClient.d.ts
CHANGED
|
@@ -36,7 +36,13 @@ export declare class CaptureClient {
|
|
|
36
36
|
* resolves to the same submission.
|
|
37
37
|
*/
|
|
38
38
|
submit(options: SubmitOptions): Promise<SubmitResult>;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Throw the take away without submitting, so someone can record again.
|
|
41
|
+
*
|
|
42
|
+
* Stops the recorder first if one is running: setting the state to idle
|
|
43
|
+
* while the session kept writing left the client claiming to be idle with a
|
|
44
|
+
* recording still going into OPFS.
|
|
45
|
+
*/
|
|
40
46
|
discard(): void;
|
|
41
47
|
/**
|
|
42
48
|
* Release the camera, microphone and recorder. Call it on unmount, or the
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
2
|
+
function isSecureTransportUrl(value) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
return false;
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(value);
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
if (url.protocol === "https:")
|
|
12
|
+
return true;
|
|
13
|
+
if (url.protocol === "http:")
|
|
14
|
+
return LOOPBACK_HOSTS.has(url.hostname);
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/types.ts
|
|
19
|
+
var CaptureError = class extends Error {
|
|
20
|
+
code;
|
|
21
|
+
cause;
|
|
22
|
+
constructor(code, message, cause) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "CaptureError";
|
|
25
|
+
this.code = code;
|
|
26
|
+
if (cause !== void 0) this.cause = cause;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/transport.ts
|
|
31
|
+
function isStringMap(value) {
|
|
32
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
33
|
+
return Object.values(value).every((v) => typeof v === "string");
|
|
34
|
+
}
|
|
35
|
+
var CLOSED_ERROR = "ended";
|
|
36
|
+
var RATE_LIMITED_ERROR = "rate_limited";
|
|
37
|
+
var SUPERSEDED_ERROR = "superseded";
|
|
38
|
+
function accessBody(access) {
|
|
39
|
+
return access.mode === "contact" ? { mode: "contact", contactId: access.contactId, key: access.key } : { mode: "open", campaignId: access.campaignId, key: access.key };
|
|
40
|
+
}
|
|
41
|
+
function codeForResponse(status, error) {
|
|
42
|
+
if (error === RATE_LIMITED_ERROR) return "rate_limited";
|
|
43
|
+
if (error === SUPERSEDED_ERROR || status === 409) return "superseded";
|
|
44
|
+
if (error === CLOSED_ERROR || status === 410) return "request_closed";
|
|
45
|
+
if (status === 401 || status === 403 || status === 404) return "link_expired";
|
|
46
|
+
if (status === 413 || status === 429) return "quota_exceeded";
|
|
47
|
+
if (status >= 400 && status < 500) return "invalid_request";
|
|
48
|
+
return "upload_failed";
|
|
49
|
+
}
|
|
50
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
51
|
+
var UPLOAD_STALL_MS = 6e4;
|
|
52
|
+
function linkedAbort(signal, timeoutMs) {
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
let timedOut = false;
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
timedOut = true;
|
|
57
|
+
controller.abort();
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
const onAbort = () => controller.abort();
|
|
60
|
+
if (signal?.aborted) controller.abort();
|
|
61
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
62
|
+
return {
|
|
63
|
+
signal: controller.signal,
|
|
64
|
+
timedOut: () => timedOut,
|
|
65
|
+
dispose: () => {
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
signal?.removeEventListener("abort", onAbort);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function postJson(baseUrl, path, body, signal) {
|
|
72
|
+
const link = linkedAbort(signal, REQUEST_TIMEOUT_MS);
|
|
73
|
+
let res;
|
|
74
|
+
try {
|
|
75
|
+
res = await fetch(`${baseUrl}${path}`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/json" },
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
signal: link.signal
|
|
80
|
+
});
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (signal?.aborted) throw new CaptureError("aborted", "The request was cancelled.", err);
|
|
83
|
+
if (link.timedOut()) {
|
|
84
|
+
throw new CaptureError("network", "Capturly did not respond in time.", err);
|
|
85
|
+
}
|
|
86
|
+
throw new CaptureError("network", "Could not reach Capturly.", err);
|
|
87
|
+
} finally {
|
|
88
|
+
link.dispose();
|
|
89
|
+
}
|
|
90
|
+
const json = await res.json().catch(() => null);
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
const error = typeof json?.error === "string" ? json.error : null;
|
|
93
|
+
throw new CaptureError(
|
|
94
|
+
codeForResponse(res.status, error),
|
|
95
|
+
error ?? `Capturly returned ${res.status}.`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return json ?? {};
|
|
99
|
+
}
|
|
100
|
+
function isRecord(value) {
|
|
101
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
102
|
+
}
|
|
103
|
+
var stringOrNull = (v) => typeof v === "string" ? v : null;
|
|
104
|
+
async function fetchCaptureView(args) {
|
|
105
|
+
const json = await postJson(
|
|
106
|
+
args.baseUrl,
|
|
107
|
+
"/api/campaigns/respond/view",
|
|
108
|
+
accessBody(args.access),
|
|
109
|
+
args.signal
|
|
110
|
+
);
|
|
111
|
+
const view = json.view;
|
|
112
|
+
if (!isRecord(view) || typeof view.title !== "string" || typeof view.prompt !== "string") {
|
|
113
|
+
throw new CaptureError("unknown", "Capturly did not return the capture request.");
|
|
114
|
+
}
|
|
115
|
+
const branding = isRecord(view.branding) ? {
|
|
116
|
+
studioName: typeof view.branding.studioName === "string" ? view.branding.studioName : "",
|
|
117
|
+
brandColor: stringOrNull(view.branding.brandColor),
|
|
118
|
+
brandSecondaryColor: stringOrNull(view.branding.brandSecondaryColor),
|
|
119
|
+
brandAccentColor: stringOrNull(view.branding.brandAccentColor),
|
|
120
|
+
coverPath: stringOrNull(view.branding.coverPath)
|
|
121
|
+
} : {
|
|
122
|
+
studioName: "",
|
|
123
|
+
brandColor: null,
|
|
124
|
+
brandSecondaryColor: null,
|
|
125
|
+
brandAccentColor: null,
|
|
126
|
+
coverPath: null
|
|
127
|
+
};
|
|
128
|
+
if (typeof view.maxDurationSec !== "number" || view.maxDurationSec <= 0) {
|
|
129
|
+
throw new CaptureError("unknown", "Capturly returned no recording time limit.");
|
|
130
|
+
}
|
|
131
|
+
const existing = isRecord(view.existing) && typeof view.existing.submissionId === "string" && typeof view.existing.submittedAt === "string" ? {
|
|
132
|
+
submissionId: view.existing.submissionId,
|
|
133
|
+
submittedAt: view.existing.submittedAt,
|
|
134
|
+
durationSec: typeof view.existing.durationSec === "number" ? view.existing.durationSec : 0
|
|
135
|
+
} : null;
|
|
136
|
+
return {
|
|
137
|
+
title: view.title,
|
|
138
|
+
prompt: view.prompt,
|
|
139
|
+
deadline: stringOrNull(view.deadline),
|
|
140
|
+
timezone: typeof view.timezone === "string" ? view.timezone : "UTC",
|
|
141
|
+
maxDurationSec: view.maxDurationSec,
|
|
142
|
+
releaseRequired: view.releaseRequired === true,
|
|
143
|
+
releaseText: typeof view.releaseText === "string" ? view.releaseText : "",
|
|
144
|
+
respondentName: stringOrNull(view.respondentName),
|
|
145
|
+
branding,
|
|
146
|
+
promptVideoUrl: stringOrNull(view.promptVideoUrl),
|
|
147
|
+
targetHeight: typeof view.targetHeight === "number" ? view.targetHeight : 1080,
|
|
148
|
+
existing
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function requestUpload(args) {
|
|
152
|
+
const json = await postJson(
|
|
153
|
+
args.baseUrl,
|
|
154
|
+
"/api/campaigns/respond/upload-url",
|
|
155
|
+
{
|
|
156
|
+
...accessBody(args.access),
|
|
157
|
+
contentType: "video/mp4",
|
|
158
|
+
sizeBytes: args.sizeBytes,
|
|
159
|
+
durationSec: args.durationSec,
|
|
160
|
+
...args.displayName ? { displayName: args.displayName } : {},
|
|
161
|
+
releaseAccepted: args.releaseAccepted
|
|
162
|
+
},
|
|
163
|
+
args.signal
|
|
164
|
+
);
|
|
165
|
+
const objectName = typeof json.objectName === "string" ? json.objectName : null;
|
|
166
|
+
if (!objectName) {
|
|
167
|
+
throw new CaptureError("upload_failed", "Capturly did not return an upload target.");
|
|
168
|
+
}
|
|
169
|
+
const url = json.url;
|
|
170
|
+
if (!isSecureTransportUrl(url)) {
|
|
171
|
+
throw new CaptureError("upload_failed", "Capturly returned an unusable upload URL.");
|
|
172
|
+
}
|
|
173
|
+
if (json.method !== "PUT") {
|
|
174
|
+
throw new CaptureError("upload_failed", "Capturly returned an unsupported upload method.");
|
|
175
|
+
}
|
|
176
|
+
const headers = json.headers;
|
|
177
|
+
if (!isStringMap(headers)) {
|
|
178
|
+
throw new CaptureError("upload_failed", "Capturly returned unusable upload headers.");
|
|
179
|
+
}
|
|
180
|
+
return { objectName, url, method: "PUT", headers };
|
|
181
|
+
}
|
|
182
|
+
function uploadBlob(args) {
|
|
183
|
+
return new Promise((resolve, reject) => {
|
|
184
|
+
if (args.signal?.aborted) {
|
|
185
|
+
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const xhr = new XMLHttpRequest();
|
|
189
|
+
let stalled = false;
|
|
190
|
+
let cancelled = false;
|
|
191
|
+
let stallTimer;
|
|
192
|
+
const onCallerAbort = () => {
|
|
193
|
+
cancelled = true;
|
|
194
|
+
xhr.abort();
|
|
195
|
+
};
|
|
196
|
+
const armStall = () => {
|
|
197
|
+
clearTimeout(stallTimer);
|
|
198
|
+
stallTimer = setTimeout(() => {
|
|
199
|
+
stalled = true;
|
|
200
|
+
xhr.abort();
|
|
201
|
+
}, UPLOAD_STALL_MS);
|
|
202
|
+
};
|
|
203
|
+
const cleanup = () => {
|
|
204
|
+
clearTimeout(stallTimer);
|
|
205
|
+
args.signal?.removeEventListener("abort", onCallerAbort);
|
|
206
|
+
};
|
|
207
|
+
xhr.open(args.target.method, args.target.url, true);
|
|
208
|
+
for (const [name, value] of Object.entries(args.target.headers)) {
|
|
209
|
+
xhr.setRequestHeader(name, value);
|
|
210
|
+
}
|
|
211
|
+
xhr.upload.onprogress = (event) => {
|
|
212
|
+
armStall();
|
|
213
|
+
if (!event.lengthComputable || !args.onProgress) return;
|
|
214
|
+
args.onProgress(Math.min(1, event.loaded / event.total));
|
|
215
|
+
};
|
|
216
|
+
xhr.onload = () => {
|
|
217
|
+
cleanup();
|
|
218
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
219
|
+
args.onProgress?.(1);
|
|
220
|
+
resolve();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
reject(new CaptureError("upload_failed", `Storage rejected the upload (${xhr.status}).`));
|
|
224
|
+
};
|
|
225
|
+
xhr.onerror = () => {
|
|
226
|
+
cleanup();
|
|
227
|
+
reject(new CaptureError("network", "The upload could not reach storage."));
|
|
228
|
+
};
|
|
229
|
+
xhr.onabort = () => {
|
|
230
|
+
cleanup();
|
|
231
|
+
if (cancelled) {
|
|
232
|
+
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
233
|
+
} else if (stalled) {
|
|
234
|
+
reject(new CaptureError("network", "The upload stopped making progress."));
|
|
235
|
+
} else {
|
|
236
|
+
reject(new CaptureError("upload_failed", "The upload was aborted."));
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
args.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
240
|
+
armStall();
|
|
241
|
+
xhr.send(args.blob);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
async function finalizeSubmission(args) {
|
|
245
|
+
const json = await postJson(
|
|
246
|
+
args.baseUrl,
|
|
247
|
+
"/api/campaigns/respond/finalize",
|
|
248
|
+
{
|
|
249
|
+
...accessBody(args.access),
|
|
250
|
+
objectName: args.objectName,
|
|
251
|
+
durationSec: args.durationSec,
|
|
252
|
+
...args.displayName ? { displayName: args.displayName } : {},
|
|
253
|
+
releaseAccepted: args.releaseAccepted
|
|
254
|
+
},
|
|
255
|
+
args.signal
|
|
256
|
+
);
|
|
257
|
+
const submissionId = typeof json.submissionId === "string" ? json.submissionId : null;
|
|
258
|
+
if (!submissionId) {
|
|
259
|
+
throw new CaptureError(
|
|
260
|
+
"unknown",
|
|
261
|
+
"The recording was submitted but Capturly did not return its id."
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return {
|
|
265
|
+
submissionId,
|
|
266
|
+
submittedAt: typeof json.submittedAt === "string" ? json.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
267
|
+
replaced: json.replaced === true
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export {
|
|
272
|
+
isSecureTransportUrl,
|
|
273
|
+
CaptureError,
|
|
274
|
+
fetchCaptureView,
|
|
275
|
+
requestUpload,
|
|
276
|
+
uploadBlob,
|
|
277
|
+
finalizeSubmission
|
|
278
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { CaptureClient } from './CaptureClient.js';
|
|
2
|
-
export {
|
|
2
|
+
export { fetchCaptureView } from './transport.js';
|
|
3
|
+
export { CaptureError, type CaptureAccess, type CaptureClientOptions, type CaptureDevice, type CaptureErrorCode, type CaptureRequestBranding, type CaptureRequestView, type CaptureState, type CaptureTake, type PreviewOptions, type SubmitOptions, type SubmitResult, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CaptureError,
|
|
3
|
+
fetchCaptureView,
|
|
4
|
+
finalizeSubmission,
|
|
5
|
+
isSecureTransportUrl,
|
|
6
|
+
requestUpload,
|
|
7
|
+
uploadBlob
|
|
8
|
+
} from "./chunk-CZPDQKMD.js";
|
|
9
|
+
|
|
1
10
|
var MP4_MIME = "video/mp4;codecs=avc1.42E01E,mp4a.40.2";
|
|
2
11
|
function pickMuxStrategy() {
|
|
3
12
|
if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(MP4_MIME)) {
|
|
@@ -62,23 +71,6 @@ var RecordingEngineError = class _RecordingEngineError extends Error {
|
|
|
62
71
|
}
|
|
63
72
|
};
|
|
64
73
|
|
|
65
|
-
var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
66
|
-
function isSecureTransportUrl(value) {
|
|
67
|
-
if (typeof value !== "string")
|
|
68
|
-
return false;
|
|
69
|
-
let url2;
|
|
70
|
-
try {
|
|
71
|
-
url2 = new URL(value);
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
if (url2.protocol === "https:")
|
|
76
|
-
return true;
|
|
77
|
-
if (url2.protocol === "http:")
|
|
78
|
-
return LOOPBACK_HOSTS.has(url2.hostname);
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
74
|
var CHUNK_ALIGNMENT = 256 * 1024;
|
|
83
75
|
var RESUMABLE_CHUNK_BYTES = 32 * CHUNK_ALIGNMENT;
|
|
84
76
|
var CHUNK_TIMEOUT_MS = 4 * 60 * 1e3;
|
|
@@ -151,6 +143,11 @@ var CloudFileSinkHandle = class {
|
|
|
151
143
|
spilling = false;
|
|
152
144
|
readSource;
|
|
153
145
|
spillRetryTimer = null;
|
|
146
|
+
// Resolved when the sink reaches a terminal state. close() waits on this
|
|
147
|
+
// rather than on one worker pass: a pass that arms a retry and returns has
|
|
148
|
+
// not finished flushing, and treating it as finished is how a truncated
|
|
149
|
+
// cloud copy gets reported as a healthy one.
|
|
150
|
+
settleWaiters = [];
|
|
154
151
|
constructor(config) {
|
|
155
152
|
this.path = `cloud:${config.recordingId}/${config.fileId}`;
|
|
156
153
|
this.initUrl = config.initUrl;
|
|
@@ -209,6 +206,9 @@ var CloudFileSinkHandle = class {
|
|
|
209
206
|
this.cancelFlushTimer();
|
|
210
207
|
this.cancelSpillRetry();
|
|
211
208
|
this.kickWorker();
|
|
209
|
+
const settled = new Promise((resolve) => {
|
|
210
|
+
this.settleWaiters.push(resolve);
|
|
211
|
+
});
|
|
212
212
|
const timerHolder = { current: null };
|
|
213
213
|
const timeout = new Promise((resolve) => {
|
|
214
214
|
timerHolder.current = this.scheduler.schedule(() => {
|
|
@@ -216,7 +216,7 @@ var CloudFileSinkHandle = class {
|
|
|
216
216
|
resolve();
|
|
217
217
|
}, CLOSE_TIMEOUT_MS);
|
|
218
218
|
});
|
|
219
|
-
await Promise.race([
|
|
219
|
+
await Promise.race([settled, timeout]);
|
|
220
220
|
timerHolder.current?.cancel();
|
|
221
221
|
}
|
|
222
222
|
async abort() {
|
|
@@ -249,11 +249,20 @@ var CloudFileSinkHandle = class {
|
|
|
249
249
|
if (this.isTerminal())
|
|
250
250
|
return;
|
|
251
251
|
this.state = next;
|
|
252
|
+
if (this.isTerminal())
|
|
253
|
+
this.settle();
|
|
252
254
|
try {
|
|
253
255
|
this.callbacks.onState?.(next, info);
|
|
254
256
|
} catch {
|
|
255
257
|
}
|
|
256
258
|
}
|
|
259
|
+
/** Release anyone waiting on close(); terminal is terminal, so once only. */
|
|
260
|
+
settle() {
|
|
261
|
+
const waiters = this.settleWaiters;
|
|
262
|
+
this.settleWaiters = [];
|
|
263
|
+
for (const resolve of waiters)
|
|
264
|
+
resolve();
|
|
265
|
+
}
|
|
257
266
|
markFailed(reason) {
|
|
258
267
|
if (this.isTerminal())
|
|
259
268
|
return;
|
|
@@ -263,6 +272,7 @@ var CloudFileSinkHandle = class {
|
|
|
263
272
|
this.cancelFlushTimer();
|
|
264
273
|
this.cancelSpillRetry();
|
|
265
274
|
this.inflightAbort?.abort();
|
|
275
|
+
this.settle();
|
|
266
276
|
try {
|
|
267
277
|
this.callbacks.onState?.("failed", { reason });
|
|
268
278
|
} catch {
|
|
@@ -751,6 +761,7 @@ var PADDING = 20;
|
|
|
751
761
|
var OPACITY = 0.8;
|
|
752
762
|
var FALLBACK_TEXT = "Powered by Capturly";
|
|
753
763
|
var DRAW_WATCHDOG_MS = 1e3;
|
|
764
|
+
var DEGRADED_AFTER_FAILURES = 30;
|
|
754
765
|
function watermarkSupported() {
|
|
755
766
|
return typeof document !== "undefined" && typeof HTMLCanvasElement !== "undefined" && typeof HTMLCanvasElement.prototype.captureStream === "function" && typeof HTMLVideoElement !== "undefined" && "requestVideoFrameCallback" in HTMLVideoElement.prototype;
|
|
756
767
|
}
|
|
@@ -765,6 +776,8 @@ var WatermarkCompositor = class {
|
|
|
765
776
|
watchdog = null;
|
|
766
777
|
lastDrawMs = 0;
|
|
767
778
|
drawFailureLogged = false;
|
|
779
|
+
consecutiveFailures = 0;
|
|
780
|
+
degradedReported = false;
|
|
768
781
|
constructor(source, config) {
|
|
769
782
|
this.source = source;
|
|
770
783
|
this.config = config;
|
|
@@ -790,25 +803,19 @@ var WatermarkCompositor = class {
|
|
|
790
803
|
const drawFrame = () => {
|
|
791
804
|
if (this.stopped)
|
|
792
805
|
return;
|
|
793
|
-
|
|
794
|
-
|
|
806
|
+
try {
|
|
807
|
+
this.safeDraw();
|
|
808
|
+
} finally {
|
|
809
|
+
this.video.requestVideoFrameCallback(drawFrame);
|
|
810
|
+
}
|
|
795
811
|
};
|
|
796
|
-
this.
|
|
812
|
+
this.safeDraw();
|
|
797
813
|
this.video.requestVideoFrameCallback(drawFrame);
|
|
798
814
|
this.watchdog = setInterval(() => {
|
|
799
815
|
if (this.stopped)
|
|
800
816
|
return;
|
|
801
|
-
if (Date.now() - this.lastDrawMs >= DRAW_WATCHDOG_MS)
|
|
802
|
-
|
|
803
|
-
this.draw();
|
|
804
|
-
} catch (err) {
|
|
805
|
-
if (!this.drawFailureLogged) {
|
|
806
|
-
this.drawFailureLogged = true;
|
|
807
|
-
const log = this.config.logError ?? ((component, message, error) => console.error(`${component}: ${message}`, error));
|
|
808
|
-
log("watermark", "draw failed; watchdog keeps ticking", err);
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
817
|
+
if (Date.now() - this.lastDrawMs >= DRAW_WATCHDOG_MS)
|
|
818
|
+
this.safeDraw();
|
|
812
819
|
}, DRAW_WATCHDOG_MS);
|
|
813
820
|
const canvasStream = this.canvas.captureStream();
|
|
814
821
|
this.output = new MediaStream([
|
|
@@ -817,6 +824,37 @@ var WatermarkCompositor = class {
|
|
|
817
824
|
]);
|
|
818
825
|
return this.output;
|
|
819
826
|
}
|
|
827
|
+
/** True once the mark stopped painting reliably. The take is still recording
|
|
828
|
+
* — the canvas keeps emitting frames — so this is the only signal that what
|
|
829
|
+
* it captured is not what was asked for. */
|
|
830
|
+
get degraded() {
|
|
831
|
+
return this.degradedReported;
|
|
832
|
+
}
|
|
833
|
+
/** Every draw goes through here so one throw can never end the loop, and a
|
|
834
|
+
* run of them is escalated rather than latched away after the first. */
|
|
835
|
+
safeDraw() {
|
|
836
|
+
try {
|
|
837
|
+
this.draw();
|
|
838
|
+
this.consecutiveFailures = 0;
|
|
839
|
+
} catch (err) {
|
|
840
|
+
this.consecutiveFailures++;
|
|
841
|
+
if (!this.drawFailureLogged) {
|
|
842
|
+
this.drawFailureLogged = true;
|
|
843
|
+
const log = this.config.logError ?? ((component, message, error) => console.error(`${component}: ${message}`, error));
|
|
844
|
+
log("watermark", "draw failed; loop continues", err);
|
|
845
|
+
}
|
|
846
|
+
if (this.consecutiveFailures >= DEGRADED_AFTER_FAILURES && !this.degradedReported) {
|
|
847
|
+
this.degradedReported = true;
|
|
848
|
+
const log = this.config.logError ?? ((component, message, error) => console.error(`${component}: ${message}`, error));
|
|
849
|
+
log("watermark", `mark has not painted for ${String(this.consecutiveFailures)} consecutive frames`, err);
|
|
850
|
+
try {
|
|
851
|
+
this.config.onDegraded?.(this.consecutiveFailures);
|
|
852
|
+
} catch (handlerErr) {
|
|
853
|
+
log("watermark", "onDegraded handler threw", handlerErr);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
820
858
|
draw() {
|
|
821
859
|
this.lastDrawMs = Date.now();
|
|
822
860
|
const { canvas, ctx, video } = this;
|
|
@@ -3648,11 +3686,10 @@ var TeeFileSinkHandle = class {
|
|
|
3648
3686
|
}
|
|
3649
3687
|
}
|
|
3650
3688
|
async close() {
|
|
3651
|
-
|
|
3652
|
-
const secondaryClose = this.secondary.close().catch((err) => {
|
|
3689
|
+
await this.secondary.close().catch((err) => {
|
|
3653
3690
|
this.handleSecondaryError("close", err);
|
|
3654
3691
|
});
|
|
3655
|
-
await
|
|
3692
|
+
await this.primary.close();
|
|
3656
3693
|
}
|
|
3657
3694
|
async abort() {
|
|
3658
3695
|
const primaryAbort = this.primary.abort();
|
|
@@ -3756,7 +3793,15 @@ var CaptureSession = class {
|
|
|
3756
3793
|
try {
|
|
3757
3794
|
compositor = new WatermarkCompositor(stream, {
|
|
3758
3795
|
...options.watermark,
|
|
3759
|
-
...this.deps.logError ? { logError: this.deps.logError } : {}
|
|
3796
|
+
...this.deps.logError ? { logError: this.deps.logError } : {},
|
|
3797
|
+
// The canvas keeps emitting frames whatever the mark does, so
|
|
3798
|
+
// the stall watchdog cannot see this. Surfaced while the take is
|
|
3799
|
+
// still short enough that restarting costs little.
|
|
3800
|
+
onDegraded: (failures) => {
|
|
3801
|
+
this.emit("error", {
|
|
3802
|
+
message: `Watermark stopped painting after ${String(failures)} failed frames; the recording is continuing without a reliable mark.`
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3760
3805
|
});
|
|
3761
3806
|
recordStream = await compositor.start();
|
|
3762
3807
|
} catch (err) {
|
|
@@ -3890,8 +3935,10 @@ var CaptureSession = class {
|
|
|
3890
3935
|
audioSampleRate: audioSettings?.sampleRate ?? resources.options.audioConstraints?.sampleRate ?? 0,
|
|
3891
3936
|
surface: this.deps.surface,
|
|
3892
3937
|
// The compositor, not options.watermark: a requested mark that failed
|
|
3893
|
-
// to start leaves this null, and the manifest must say so.
|
|
3894
|
-
|
|
3938
|
+
// to start leaves this null, and the manifest must say so. A compositor
|
|
3939
|
+
// that started and then stopped painting is the same claim to avoid —
|
|
3940
|
+
// most of those frames carry no mark.
|
|
3941
|
+
watermarked: resources.compositor !== null && !resources.compositor.degraded,
|
|
3895
3942
|
...muxResult.framesDropped !== void 0 ? { framesDropped: muxResult.framesDropped } : {}
|
|
3896
3943
|
};
|
|
3897
3944
|
if (muxResult.framesDropped && muxResult.framesDropped > 0) {
|
|
@@ -4185,125 +4232,9 @@ function commitResources(r, logError) {
|
|
|
4185
4232
|
})();
|
|
4186
4233
|
}
|
|
4187
4234
|
|
|
4188
|
-
// src/types.ts
|
|
4189
|
-
var CaptureError = class extends Error {
|
|
4190
|
-
code;
|
|
4191
|
-
cause;
|
|
4192
|
-
constructor(code, message, cause) {
|
|
4193
|
-
super(message);
|
|
4194
|
-
this.name = "CaptureError";
|
|
4195
|
-
this.code = code;
|
|
4196
|
-
if (cause !== void 0) this.cause = cause;
|
|
4197
|
-
}
|
|
4198
|
-
};
|
|
4199
|
-
|
|
4200
|
-
// src/transport.ts
|
|
4201
|
-
var TERMINAL_ACCESS_ERRORS = /* @__PURE__ */ new Set([
|
|
4202
|
-
"campaign_closed",
|
|
4203
|
-
"deadline_passed",
|
|
4204
|
-
"link_revoked",
|
|
4205
|
-
"not_found",
|
|
4206
|
-
"forbidden"
|
|
4207
|
-
]);
|
|
4208
|
-
function accessBody(access) {
|
|
4209
|
-
return access.mode === "contact" ? { mode: "contact", contactId: access.contactId, key: access.key } : { mode: "open", campaignId: access.campaignId, key: access.key };
|
|
4210
|
-
}
|
|
4211
|
-
function codeForResponse(status, error) {
|
|
4212
|
-
if (status === 413) return "quota_exceeded";
|
|
4213
|
-
if (status === 429) return "quota_exceeded";
|
|
4214
|
-
if (status === 410 || error !== null && TERMINAL_ACCESS_ERRORS.has(error)) {
|
|
4215
|
-
return error === "campaign_closed" ? "request_closed" : "link_expired";
|
|
4216
|
-
}
|
|
4217
|
-
if (status === 401 || status === 403) return "link_expired";
|
|
4218
|
-
return "upload_failed";
|
|
4219
|
-
}
|
|
4220
|
-
async function postJson(baseUrl, path, body) {
|
|
4221
|
-
let res;
|
|
4222
|
-
try {
|
|
4223
|
-
res = await fetch(`${baseUrl}${path}`, {
|
|
4224
|
-
method: "POST",
|
|
4225
|
-
headers: { "content-type": "application/json" },
|
|
4226
|
-
body: JSON.stringify(body)
|
|
4227
|
-
});
|
|
4228
|
-
} catch (err) {
|
|
4229
|
-
throw new CaptureError("network", "Could not reach Capturly.", err);
|
|
4230
|
-
}
|
|
4231
|
-
const json = await res.json().catch(() => null);
|
|
4232
|
-
if (!res.ok) {
|
|
4233
|
-
const error = typeof json?.error === "string" ? json.error : null;
|
|
4234
|
-
throw new CaptureError(
|
|
4235
|
-
codeForResponse(res.status, error),
|
|
4236
|
-
error ?? `Capturly returned ${res.status}.`
|
|
4237
|
-
);
|
|
4238
|
-
}
|
|
4239
|
-
return json ?? {};
|
|
4240
|
-
}
|
|
4241
|
-
async function requestUpload(args) {
|
|
4242
|
-
const json = await postJson(args.baseUrl, "/api/campaigns/respond/upload-url", {
|
|
4243
|
-
...accessBody(args.access),
|
|
4244
|
-
contentType: "video/mp4",
|
|
4245
|
-
sizeBytes: args.sizeBytes
|
|
4246
|
-
});
|
|
4247
|
-
const objectName = typeof json.objectName === "string" ? json.objectName : null;
|
|
4248
|
-
const url2 = typeof json.url === "string" ? json.url : null;
|
|
4249
|
-
if (!objectName || !url2) {
|
|
4250
|
-
throw new CaptureError("upload_failed", "Capturly did not return an upload target.");
|
|
4251
|
-
}
|
|
4252
|
-
return {
|
|
4253
|
-
objectName,
|
|
4254
|
-
url: url2,
|
|
4255
|
-
method: typeof json.method === "string" ? json.method : "PUT",
|
|
4256
|
-
headers: json.headers !== null && typeof json.headers === "object" ? json.headers : {}
|
|
4257
|
-
};
|
|
4258
|
-
}
|
|
4259
|
-
function uploadBlob(args) {
|
|
4260
|
-
return new Promise((resolve, reject) => {
|
|
4261
|
-
const xhr = new XMLHttpRequest();
|
|
4262
|
-
xhr.open(args.target.method, args.target.url, true);
|
|
4263
|
-
for (const [name, value] of Object.entries(args.target.headers)) {
|
|
4264
|
-
xhr.setRequestHeader(name, value);
|
|
4265
|
-
}
|
|
4266
|
-
xhr.upload.onprogress = (event) => {
|
|
4267
|
-
if (!event.lengthComputable || !args.onProgress) return;
|
|
4268
|
-
args.onProgress(Math.min(1, event.loaded / event.total));
|
|
4269
|
-
};
|
|
4270
|
-
xhr.onload = () => {
|
|
4271
|
-
if (xhr.status >= 200 && xhr.status < 300) {
|
|
4272
|
-
args.onProgress?.(1);
|
|
4273
|
-
resolve();
|
|
4274
|
-
return;
|
|
4275
|
-
}
|
|
4276
|
-
reject(new CaptureError("upload_failed", `Storage rejected the upload (${xhr.status}).`));
|
|
4277
|
-
};
|
|
4278
|
-
xhr.onerror = () => reject(new CaptureError("network", "The upload could not reach storage."));
|
|
4279
|
-
xhr.onabort = () => reject(new CaptureError("upload_failed", "The upload was aborted."));
|
|
4280
|
-
xhr.send(args.blob);
|
|
4281
|
-
});
|
|
4282
|
-
}
|
|
4283
|
-
async function finalizeSubmission(args) {
|
|
4284
|
-
const json = await postJson(args.baseUrl, "/api/campaigns/respond/finalize", {
|
|
4285
|
-
...accessBody(args.access),
|
|
4286
|
-
objectName: args.objectName,
|
|
4287
|
-
durationSec: args.durationSec,
|
|
4288
|
-
...args.displayName ? { displayName: args.displayName } : {},
|
|
4289
|
-
releaseAccepted: args.releaseAccepted
|
|
4290
|
-
});
|
|
4291
|
-
const submissionId = typeof json.submissionId === "string" ? json.submissionId : null;
|
|
4292
|
-
if (!submissionId) {
|
|
4293
|
-
throw new CaptureError(
|
|
4294
|
-
"unknown",
|
|
4295
|
-
"The recording was submitted but Capturly did not return its id."
|
|
4296
|
-
);
|
|
4297
|
-
}
|
|
4298
|
-
return {
|
|
4299
|
-
submissionId,
|
|
4300
|
-
submittedAt: typeof json.submittedAt === "string" ? json.submittedAt : (/* @__PURE__ */ new Date()).toISOString(),
|
|
4301
|
-
replaced: json.replaced === true
|
|
4302
|
-
};
|
|
4303
|
-
}
|
|
4304
|
-
|
|
4305
4235
|
// src/CaptureClient.ts
|
|
4306
4236
|
var DEFAULT_BASE_URL = "https://capturly.app";
|
|
4237
|
+
var SLASH = 47;
|
|
4307
4238
|
var CaptureClient = class _CaptureClient {
|
|
4308
4239
|
#options;
|
|
4309
4240
|
#sink = null;
|
|
@@ -4316,7 +4247,16 @@ var CaptureClient = class _CaptureClient {
|
|
|
4316
4247
|
#destroyed = false;
|
|
4317
4248
|
#stateListeners = /* @__PURE__ */ new Set();
|
|
4318
4249
|
constructor(options) {
|
|
4319
|
-
const
|
|
4250
|
+
const configured = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
4251
|
+
let end = configured.length;
|
|
4252
|
+
while (end > 0 && configured.charCodeAt(end - 1) === SLASH) end--;
|
|
4253
|
+
const baseUrl = configured.slice(0, end);
|
|
4254
|
+
if (!isSecureTransportUrl(baseUrl)) {
|
|
4255
|
+
throw new CaptureError(
|
|
4256
|
+
"invalid_request",
|
|
4257
|
+
`baseUrl must be an https:// URL (got ${baseUrl || "an empty string"}).`
|
|
4258
|
+
);
|
|
4259
|
+
}
|
|
4320
4260
|
this.#options = {
|
|
4321
4261
|
access: options.access,
|
|
4322
4262
|
baseUrl,
|
|
@@ -4367,7 +4307,7 @@ var CaptureClient = class _CaptureClient {
|
|
|
4367
4307
|
if (!_CaptureClient.isSupported()) {
|
|
4368
4308
|
throw new CaptureError(
|
|
4369
4309
|
"unsupported_browser",
|
|
4370
|
-
"This browser cannot record. Chrome, Edge,
|
|
4310
|
+
"This browser cannot record. Chrome, Edge, or Safari 16.4+ can."
|
|
4371
4311
|
);
|
|
4372
4312
|
}
|
|
4373
4313
|
const constraints = {
|
|
@@ -4378,7 +4318,8 @@ var CaptureClient = class _CaptureClient {
|
|
|
4378
4318
|
try {
|
|
4379
4319
|
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
4380
4320
|
} catch (err) {
|
|
4381
|
-
|
|
4321
|
+
const failure = this.#gumFailure(err);
|
|
4322
|
+
throw new CaptureError(failure.code, failure.message, err);
|
|
4382
4323
|
}
|
|
4383
4324
|
this.#stopStream();
|
|
4384
4325
|
this.#stream = stream;
|
|
@@ -4392,8 +4333,9 @@ var CaptureClient = class _CaptureClient {
|
|
|
4392
4333
|
throw new CaptureError("not_recording", "Call preview() before start().");
|
|
4393
4334
|
}
|
|
4394
4335
|
if (this.#session) {
|
|
4395
|
-
throw new CaptureError("
|
|
4336
|
+
throw new CaptureError("invalid_request", "Already recording.");
|
|
4396
4337
|
}
|
|
4338
|
+
this.#discardPending();
|
|
4397
4339
|
this.#setState("starting");
|
|
4398
4340
|
this.#sink ??= new OpfsFileSink({ logError: (m, e) => this.#options.onWarning(m, e) });
|
|
4399
4341
|
const session = new CaptureSession({
|
|
@@ -4433,6 +4375,7 @@ var CaptureClient = class _CaptureClient {
|
|
|
4433
4375
|
try {
|
|
4434
4376
|
artifact = await session.stop();
|
|
4435
4377
|
} catch (err) {
|
|
4378
|
+
this.#discardPending();
|
|
4436
4379
|
this.#setState("idle");
|
|
4437
4380
|
throw new CaptureError("unknown", "Recording could not be finished.", err);
|
|
4438
4381
|
}
|
|
@@ -4461,6 +4404,9 @@ var CaptureClient = class _CaptureClient {
|
|
|
4461
4404
|
*/
|
|
4462
4405
|
async submit(options) {
|
|
4463
4406
|
this.#assertAlive();
|
|
4407
|
+
if (this.#state !== "stopped") {
|
|
4408
|
+
throw new CaptureError("nothing_to_submit", "Stop the recording before submitting.");
|
|
4409
|
+
}
|
|
4464
4410
|
const pending = this.#pending;
|
|
4465
4411
|
if (!pending) {
|
|
4466
4412
|
throw new CaptureError("nothing_to_submit", "Record something before submitting.");
|
|
@@ -4478,12 +4424,17 @@ var CaptureClient = class _CaptureClient {
|
|
|
4478
4424
|
const target = await requestUpload({
|
|
4479
4425
|
baseUrl: this.#options.baseUrl,
|
|
4480
4426
|
access: this.#options.access,
|
|
4481
|
-
sizeBytes: blob.size
|
|
4427
|
+
sizeBytes: blob.size,
|
|
4428
|
+
durationSec: pending.durationSec,
|
|
4429
|
+
displayName: options.displayName,
|
|
4430
|
+
releaseAccepted: options.releaseAccepted,
|
|
4431
|
+
signal: options.signal
|
|
4482
4432
|
});
|
|
4483
4433
|
await uploadBlob({
|
|
4484
4434
|
target,
|
|
4485
4435
|
blob,
|
|
4486
|
-
...options.onProgress ? { onProgress: options.onProgress } : {}
|
|
4436
|
+
...options.onProgress ? { onProgress: options.onProgress } : {},
|
|
4437
|
+
signal: options.signal
|
|
4487
4438
|
});
|
|
4488
4439
|
const result = await finalizeSubmission({
|
|
4489
4440
|
baseUrl: this.#options.baseUrl,
|
|
@@ -4491,13 +4442,27 @@ var CaptureClient = class _CaptureClient {
|
|
|
4491
4442
|
objectName: target.objectName,
|
|
4492
4443
|
durationSec: pending.durationSec,
|
|
4493
4444
|
displayName: options.displayName,
|
|
4494
|
-
releaseAccepted: options.releaseAccepted
|
|
4445
|
+
releaseAccepted: options.releaseAccepted,
|
|
4446
|
+
signal: options.signal
|
|
4495
4447
|
});
|
|
4496
4448
|
this.#discardPending();
|
|
4497
4449
|
return result;
|
|
4498
4450
|
}
|
|
4499
|
-
/**
|
|
4451
|
+
/**
|
|
4452
|
+
* Throw the take away without submitting, so someone can record again.
|
|
4453
|
+
*
|
|
4454
|
+
* Stops the recorder first if one is running: setting the state to idle
|
|
4455
|
+
* while the session kept writing left the client claiming to be idle with a
|
|
4456
|
+
* recording still going into OPFS.
|
|
4457
|
+
*/
|
|
4500
4458
|
discard() {
|
|
4459
|
+
const session = this.#session;
|
|
4460
|
+
this.#session = null;
|
|
4461
|
+
if (session) {
|
|
4462
|
+
void session.stop().catch((err) => {
|
|
4463
|
+
this.#options.onWarning("recorder could not be stopped on discard", err);
|
|
4464
|
+
});
|
|
4465
|
+
}
|
|
4501
4466
|
this.#discardPending();
|
|
4502
4467
|
this.#setState("idle");
|
|
4503
4468
|
}
|
|
@@ -4528,29 +4493,47 @@ var CaptureClient = class _CaptureClient {
|
|
|
4528
4493
|
this.#stream?.getTracks().forEach((t) => t.stop());
|
|
4529
4494
|
this.#stream = null;
|
|
4530
4495
|
}
|
|
4496
|
+
/**
|
|
4497
|
+
* Let go of the pending take — the preview URL and the bytes behind it.
|
|
4498
|
+
*
|
|
4499
|
+
* Deleting the file is the part that matters. This SDK records into OPFS
|
|
4500
|
+
* under the EMBEDDING site's origin, one directory per take; revoking the
|
|
4501
|
+
* object URL alone left the video on the visitor's disk forever, with no
|
|
4502
|
+
* surface anywhere for them to clear it. A testimonial widget would
|
|
4503
|
+
* accumulate gigabytes of other people's video in third-party storage.
|
|
4504
|
+
*
|
|
4505
|
+
* Best-effort and fire-and-forget: `discard()` and `destroy()` are
|
|
4506
|
+
* synchronous by contract, and failing to tidy up must never become an
|
|
4507
|
+
* error the integrator has to handle. A failure warns instead — and the
|
|
4508
|
+
* next take still records, because paths are per-take.
|
|
4509
|
+
*/
|
|
4531
4510
|
#discardPending() {
|
|
4532
|
-
|
|
4511
|
+
const pending = this.#pending;
|
|
4533
4512
|
this.#pending = null;
|
|
4513
|
+
if (!pending) return;
|
|
4514
|
+
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
|
|
4515
|
+
const sink = this.#sink;
|
|
4516
|
+
if (!sink) return;
|
|
4517
|
+
void sink.remove(pending.path).catch((err) => {
|
|
4518
|
+
this.#options.onWarning("take could not be removed from local storage", err);
|
|
4519
|
+
});
|
|
4534
4520
|
}
|
|
4535
|
-
/** getUserMedia DOMException names, mapped to
|
|
4536
|
-
|
|
4521
|
+
/** getUserMedia DOMException names, mapped to a code and its copy. Both come
|
|
4522
|
+
* out of one classification: deriving the message from a second call could
|
|
4523
|
+
* report a code and a sentence that disagree. */
|
|
4524
|
+
#gumFailure(err) {
|
|
4537
4525
|
const name = err?.name;
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
return "unknown";
|
|
4541
|
-
}
|
|
4542
|
-
#gumMessage(err) {
|
|
4543
|
-
switch (this.#gumCode(err)) {
|
|
4544
|
-
case "permission_denied":
|
|
4545
|
-
return "Camera and microphone access was denied.";
|
|
4546
|
-
case "no_devices":
|
|
4547
|
-
return "No camera or microphone was available.";
|
|
4548
|
-
default:
|
|
4549
|
-
return "The camera and microphone could not be opened.";
|
|
4550
|
-
}
|
|
4526
|
+
const code = name === "NotAllowedError" || name === "SecurityError" ? "permission_denied" : name === "NotFoundError" || name === "OverconstrainedError" ? "no_devices" : "unknown";
|
|
4527
|
+
return { code, message: GUM_MESSAGES[code] };
|
|
4551
4528
|
}
|
|
4552
4529
|
};
|
|
4530
|
+
var GUM_MESSAGES = {
|
|
4531
|
+
permission_denied: "Camera and microphone access was denied.",
|
|
4532
|
+
no_devices: "No camera or microphone was available.",
|
|
4533
|
+
unknown: "The camera and microphone could not be opened."
|
|
4534
|
+
};
|
|
4553
4535
|
export {
|
|
4554
4536
|
CaptureClient,
|
|
4555
|
-
CaptureError
|
|
4537
|
+
CaptureError,
|
|
4538
|
+
fetchCaptureView
|
|
4556
4539
|
};
|
package/dist/transport.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
import { type CaptureAccess } from './types.js';
|
|
1
|
+
import { CaptureError, type CaptureAccess, type CaptureErrorCode, type CaptureRequestBranding, type CaptureRequestView } from './types.js';
|
|
2
|
+
export { CaptureError };
|
|
3
|
+
export type { CaptureAccess, CaptureErrorCode, CaptureRequestBranding, CaptureRequestView };
|
|
4
|
+
/**
|
|
5
|
+
* Fetch what the person is being asked — title, prompt, branding, release
|
|
6
|
+
* text, limits, and any submission they already made. Same access, same
|
|
7
|
+
* refusal codes as the other calls, so a dead link fails here rather than
|
|
8
|
+
* after a recording exists. Contact links are marked opened by this call,
|
|
9
|
+
* exactly as opening the hosted page would.
|
|
10
|
+
*/
|
|
11
|
+
export declare function fetchCaptureView(args: {
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
access: CaptureAccess;
|
|
14
|
+
signal?: AbortSignal | undefined;
|
|
15
|
+
}): Promise<CaptureRequestView>;
|
|
2
16
|
export interface UploadTarget {
|
|
3
17
|
objectName: string;
|
|
4
18
|
url: string;
|
|
@@ -7,11 +21,18 @@ export interface UploadTarget {
|
|
|
7
21
|
headers: Record<string, string>;
|
|
8
22
|
}
|
|
9
23
|
/** Request an upload target. The size is signed in, so it must be the real
|
|
10
|
-
* byte count of what gets uploaded.
|
|
24
|
+
* byte count of what gets uploaded. Everything finalize will be sent goes
|
|
25
|
+
* with it, so the server can finish the submission itself if this browser
|
|
26
|
+
* never gets to call finalize — and so a missing release or name fails here
|
|
27
|
+
* rather than after the whole take has crossed the wire. */
|
|
11
28
|
export declare function requestUpload(args: {
|
|
12
29
|
baseUrl: string;
|
|
13
30
|
access: CaptureAccess;
|
|
14
31
|
sizeBytes: number;
|
|
32
|
+
durationSec: number;
|
|
33
|
+
displayName?: string | undefined;
|
|
34
|
+
releaseAccepted: boolean;
|
|
35
|
+
signal?: AbortSignal | undefined;
|
|
15
36
|
}): Promise<UploadTarget>;
|
|
16
37
|
/**
|
|
17
38
|
* Send the bytes. XHR rather than fetch only for `upload.onprogress`; fetch
|
|
@@ -21,6 +42,7 @@ export declare function uploadBlob(args: {
|
|
|
21
42
|
target: UploadTarget;
|
|
22
43
|
blob: Blob;
|
|
23
44
|
onProgress?: (fraction: number) => void;
|
|
45
|
+
signal?: AbortSignal | undefined;
|
|
24
46
|
}): Promise<void>;
|
|
25
47
|
/**
|
|
26
48
|
* Confirm the upload and record the submission. Idempotent on the object
|
|
@@ -33,6 +55,9 @@ export declare function finalizeSubmission(args: {
|
|
|
33
55
|
durationSec: number;
|
|
34
56
|
displayName?: string | undefined;
|
|
35
57
|
releaseAccepted: boolean;
|
|
58
|
+
/** Cancelling here is safe but rarely what you want: the bytes have already
|
|
59
|
+
* landed, and Capturly finishes an abandoned upload server-side. */
|
|
60
|
+
signal?: AbortSignal | undefined;
|
|
36
61
|
}): Promise<{
|
|
37
62
|
submissionId: string;
|
|
38
63
|
submittedAt: string;
|
package/dist/types.d.ts
CHANGED
|
@@ -24,6 +24,42 @@ export interface CaptureClientOptions {
|
|
|
24
24
|
onWarning?: (message: string, cause?: unknown) => void;
|
|
25
25
|
}
|
|
26
26
|
export type CaptureState = 'idle' | 'starting' | 'recording' | 'stopping' | 'stopped';
|
|
27
|
+
export interface CaptureRequestBranding {
|
|
28
|
+
studioName: string;
|
|
29
|
+
brandColor: string | null;
|
|
30
|
+
brandSecondaryColor: string | null;
|
|
31
|
+
brandAccentColor: string | null;
|
|
32
|
+
/** Cover-art path under the Capturly origin, or null when unset. */
|
|
33
|
+
coverPath: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What the person is being asked, fetched with the same access the recording
|
|
37
|
+
* uses. Show `releaseText` before recording whenever `releaseRequired` — a
|
|
38
|
+
* consent record is only honest if the person actually saw the text.
|
|
39
|
+
*/
|
|
40
|
+
export interface CaptureRequestView {
|
|
41
|
+
title: string;
|
|
42
|
+
prompt: string;
|
|
43
|
+
deadline: string | null;
|
|
44
|
+
timezone: string;
|
|
45
|
+
maxDurationSec: number;
|
|
46
|
+
releaseRequired: boolean;
|
|
47
|
+
/** Resolved release copy — never empty when `releaseRequired`. */
|
|
48
|
+
releaseText: string;
|
|
49
|
+
/** The person's name for a contact link; null for an open link. */
|
|
50
|
+
respondentName: string | null;
|
|
51
|
+
branding: CaptureRequestBranding;
|
|
52
|
+
/** Short-lived signed URL of the video prompt, when the request has one. */
|
|
53
|
+
promptVideoUrl: string | null;
|
|
54
|
+
/** Frame-height ceiling the owner's plan records at (e.g. 1080). */
|
|
55
|
+
targetHeight: number;
|
|
56
|
+
/** This person's existing submission, when they already answered. */
|
|
57
|
+
existing: {
|
|
58
|
+
submissionId: string;
|
|
59
|
+
submittedAt: string;
|
|
60
|
+
durationSec: number;
|
|
61
|
+
} | null;
|
|
62
|
+
}
|
|
27
63
|
export interface CaptureDevice {
|
|
28
64
|
deviceId: string;
|
|
29
65
|
label: string;
|
|
@@ -46,11 +82,19 @@ export interface SubmitOptions {
|
|
|
46
82
|
/** Required for an open link. Ignored for a contact link, which already has
|
|
47
83
|
* the roster name. */
|
|
48
84
|
displayName?: string;
|
|
49
|
-
/** Whether the person accepted the request's release text.
|
|
50
|
-
*
|
|
85
|
+
/** Whether the person accepted the request's release text.
|
|
86
|
+
*
|
|
87
|
+
* Read that text from the API and show it before recording. A consent
|
|
88
|
+
* record is written only when the request requires a release AND you pass
|
|
89
|
+
* `true`; passing `true` without showing them anything records agreement
|
|
90
|
+
* that never happened. If the request requires a release and this is
|
|
91
|
+
* `false`, the submission is refused. */
|
|
51
92
|
releaseAccepted: boolean;
|
|
52
93
|
/** 0–1, as the bytes move. */
|
|
53
94
|
onProgress?: (fraction: number) => void;
|
|
95
|
+
/** Cancel the submission. Aborting rejects with code `aborted`; the take
|
|
96
|
+
* stays in local storage, so the same client can submit it again. */
|
|
97
|
+
signal?: AbortSignal;
|
|
54
98
|
}
|
|
55
99
|
export interface SubmitResult {
|
|
56
100
|
submissionId: string;
|
|
@@ -68,7 +112,18 @@ export interface SubmitResult {
|
|
|
68
112
|
* a retry for those loops forever. `network` and `upload_failed` are worth
|
|
69
113
|
* retrying as-is.
|
|
70
114
|
*/
|
|
71
|
-
export type CaptureErrorCode = 'unsupported_browser' | 'permission_denied' | 'no_devices' | 'not_recording' | 'nothing_to_submit' | 'link_expired' | 'request_closed'
|
|
115
|
+
export type CaptureErrorCode = 'unsupported_browser' | 'permission_denied' | 'no_devices' | 'not_recording' | 'nothing_to_submit' | 'link_expired' | 'request_closed'
|
|
116
|
+
/** Another take for this person landed first. Terminal: retrying loses the
|
|
117
|
+
* same race, and their other submission is the live one. */
|
|
118
|
+
| 'superseded' | 'quota_exceeded'
|
|
119
|
+
/** Too many requests. The one refusal here that succeeds if you wait. */
|
|
120
|
+
| 'rate_limited'
|
|
121
|
+
/** Cancelled through the `signal` you passed. The take is untouched. */
|
|
122
|
+
| 'aborted'
|
|
123
|
+
/** The call was wrong, not the connection — a bad option, or a payload the
|
|
124
|
+
* capture request refuses (no release, no name, too long). Retrying the
|
|
125
|
+
* same call cannot succeed; fix the input first. */
|
|
126
|
+
| 'invalid_request' | 'upload_failed' | 'network' | 'unknown';
|
|
72
127
|
export declare class CaptureError extends Error {
|
|
73
128
|
readonly code: CaptureErrorCode;
|
|
74
129
|
readonly cause?: unknown;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capturly/capture-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"description": "Record full-quality audio and video on your user's own device, from your own product.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"recording",
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"types": "./dist/index.d.ts",
|
|
36
36
|
"default": "./dist/index.js"
|
|
37
37
|
},
|
|
38
|
+
"./transport": {
|
|
39
|
+
"types": "./dist/transport.d.ts",
|
|
40
|
+
"default": "./dist/transport.js"
|
|
41
|
+
},
|
|
38
42
|
"./package.json": "./package.json"
|
|
39
43
|
},
|
|
40
44
|
"files": [
|
|
@@ -46,22 +50,21 @@
|
|
|
46
50
|
"publishConfig": {
|
|
47
51
|
"access": "public"
|
|
48
52
|
},
|
|
53
|
+
"dependencies": {},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"esbuild": "^0.25.0",
|
|
56
|
+
"typescript": "^5.4.0",
|
|
57
|
+
"vitest": "^4.1.7",
|
|
58
|
+
"@capturly/eslint-config": "2.11.1",
|
|
59
|
+
"@capturly/recording-engine": "2.11.1",
|
|
60
|
+
"@capturly/tsconfig": "2.11.1"
|
|
61
|
+
},
|
|
49
62
|
"scripts": {
|
|
50
|
-
"build": "node build.mjs && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
63
|
+
"build": "pnpm clean && node build.mjs && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
51
64
|
"test": "vitest run",
|
|
52
65
|
"test:watch": "vitest",
|
|
53
66
|
"lint": "eslint src --ext .ts",
|
|
54
67
|
"type-check": "tsc --noEmit",
|
|
55
|
-
"clean": "rm -rf dist"
|
|
56
|
-
"prepublishOnly": "pnpm clean && pnpm type-check && pnpm test && pnpm build"
|
|
57
|
-
},
|
|
58
|
-
"dependencies": {},
|
|
59
|
-
"devDependencies": {
|
|
60
|
-
"@capturly/eslint-config": "workspace:*",
|
|
61
|
-
"@capturly/recording-engine": "workspace:*",
|
|
62
|
-
"@capturly/tsconfig": "workspace:*",
|
|
63
|
-
"esbuild": "^0.25.0",
|
|
64
|
-
"typescript": "^5.4.0",
|
|
65
|
-
"vitest": "^4.1.7"
|
|
68
|
+
"clean": "rm -rf dist"
|
|
66
69
|
}
|
|
67
|
-
}
|
|
70
|
+
}
|