@capturly/capture-sdk 2.10.2 → 2.11.0
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 +2 -0
- package/LICENSE +0 -0
- package/README.md +53 -18
- package/dist/CaptureClient.d.ts +7 -1
- package/dist/index.js +274 -82
- package/dist/transport.d.ts +12 -1
- package/dist/types.d.ts +22 -3
- package/package.json +12 -13
package/CHANGELOG.md
CHANGED
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
|
package/dist/index.js
CHANGED
|
@@ -151,6 +151,11 @@ var CloudFileSinkHandle = class {
|
|
|
151
151
|
spilling = false;
|
|
152
152
|
readSource;
|
|
153
153
|
spillRetryTimer = null;
|
|
154
|
+
// Resolved when the sink reaches a terminal state. close() waits on this
|
|
155
|
+
// rather than on one worker pass: a pass that arms a retry and returns has
|
|
156
|
+
// not finished flushing, and treating it as finished is how a truncated
|
|
157
|
+
// cloud copy gets reported as a healthy one.
|
|
158
|
+
settleWaiters = [];
|
|
154
159
|
constructor(config) {
|
|
155
160
|
this.path = `cloud:${config.recordingId}/${config.fileId}`;
|
|
156
161
|
this.initUrl = config.initUrl;
|
|
@@ -209,6 +214,9 @@ var CloudFileSinkHandle = class {
|
|
|
209
214
|
this.cancelFlushTimer();
|
|
210
215
|
this.cancelSpillRetry();
|
|
211
216
|
this.kickWorker();
|
|
217
|
+
const settled = new Promise((resolve) => {
|
|
218
|
+
this.settleWaiters.push(resolve);
|
|
219
|
+
});
|
|
212
220
|
const timerHolder = { current: null };
|
|
213
221
|
const timeout = new Promise((resolve) => {
|
|
214
222
|
timerHolder.current = this.scheduler.schedule(() => {
|
|
@@ -216,7 +224,7 @@ var CloudFileSinkHandle = class {
|
|
|
216
224
|
resolve();
|
|
217
225
|
}, CLOSE_TIMEOUT_MS);
|
|
218
226
|
});
|
|
219
|
-
await Promise.race([
|
|
227
|
+
await Promise.race([settled, timeout]);
|
|
220
228
|
timerHolder.current?.cancel();
|
|
221
229
|
}
|
|
222
230
|
async abort() {
|
|
@@ -249,11 +257,20 @@ var CloudFileSinkHandle = class {
|
|
|
249
257
|
if (this.isTerminal())
|
|
250
258
|
return;
|
|
251
259
|
this.state = next;
|
|
260
|
+
if (this.isTerminal())
|
|
261
|
+
this.settle();
|
|
252
262
|
try {
|
|
253
263
|
this.callbacks.onState?.(next, info);
|
|
254
264
|
} catch {
|
|
255
265
|
}
|
|
256
266
|
}
|
|
267
|
+
/** Release anyone waiting on close(); terminal is terminal, so once only. */
|
|
268
|
+
settle() {
|
|
269
|
+
const waiters = this.settleWaiters;
|
|
270
|
+
this.settleWaiters = [];
|
|
271
|
+
for (const resolve of waiters)
|
|
272
|
+
resolve();
|
|
273
|
+
}
|
|
257
274
|
markFailed(reason) {
|
|
258
275
|
if (this.isTerminal())
|
|
259
276
|
return;
|
|
@@ -263,6 +280,7 @@ var CloudFileSinkHandle = class {
|
|
|
263
280
|
this.cancelFlushTimer();
|
|
264
281
|
this.cancelSpillRetry();
|
|
265
282
|
this.inflightAbort?.abort();
|
|
283
|
+
this.settle();
|
|
266
284
|
try {
|
|
267
285
|
this.callbacks.onState?.("failed", { reason });
|
|
268
286
|
} catch {
|
|
@@ -751,6 +769,7 @@ var PADDING = 20;
|
|
|
751
769
|
var OPACITY = 0.8;
|
|
752
770
|
var FALLBACK_TEXT = "Powered by Capturly";
|
|
753
771
|
var DRAW_WATCHDOG_MS = 1e3;
|
|
772
|
+
var DEGRADED_AFTER_FAILURES = 30;
|
|
754
773
|
function watermarkSupported() {
|
|
755
774
|
return typeof document !== "undefined" && typeof HTMLCanvasElement !== "undefined" && typeof HTMLCanvasElement.prototype.captureStream === "function" && typeof HTMLVideoElement !== "undefined" && "requestVideoFrameCallback" in HTMLVideoElement.prototype;
|
|
756
775
|
}
|
|
@@ -765,6 +784,8 @@ var WatermarkCompositor = class {
|
|
|
765
784
|
watchdog = null;
|
|
766
785
|
lastDrawMs = 0;
|
|
767
786
|
drawFailureLogged = false;
|
|
787
|
+
consecutiveFailures = 0;
|
|
788
|
+
degradedReported = false;
|
|
768
789
|
constructor(source, config) {
|
|
769
790
|
this.source = source;
|
|
770
791
|
this.config = config;
|
|
@@ -790,25 +811,19 @@ var WatermarkCompositor = class {
|
|
|
790
811
|
const drawFrame = () => {
|
|
791
812
|
if (this.stopped)
|
|
792
813
|
return;
|
|
793
|
-
|
|
794
|
-
|
|
814
|
+
try {
|
|
815
|
+
this.safeDraw();
|
|
816
|
+
} finally {
|
|
817
|
+
this.video.requestVideoFrameCallback(drawFrame);
|
|
818
|
+
}
|
|
795
819
|
};
|
|
796
|
-
this.
|
|
820
|
+
this.safeDraw();
|
|
797
821
|
this.video.requestVideoFrameCallback(drawFrame);
|
|
798
822
|
this.watchdog = setInterval(() => {
|
|
799
823
|
if (this.stopped)
|
|
800
824
|
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
|
-
}
|
|
825
|
+
if (Date.now() - this.lastDrawMs >= DRAW_WATCHDOG_MS)
|
|
826
|
+
this.safeDraw();
|
|
812
827
|
}, DRAW_WATCHDOG_MS);
|
|
813
828
|
const canvasStream = this.canvas.captureStream();
|
|
814
829
|
this.output = new MediaStream([
|
|
@@ -817,6 +832,37 @@ var WatermarkCompositor = class {
|
|
|
817
832
|
]);
|
|
818
833
|
return this.output;
|
|
819
834
|
}
|
|
835
|
+
/** True once the mark stopped painting reliably. The take is still recording
|
|
836
|
+
* — the canvas keeps emitting frames — so this is the only signal that what
|
|
837
|
+
* it captured is not what was asked for. */
|
|
838
|
+
get degraded() {
|
|
839
|
+
return this.degradedReported;
|
|
840
|
+
}
|
|
841
|
+
/** Every draw goes through here so one throw can never end the loop, and a
|
|
842
|
+
* run of them is escalated rather than latched away after the first. */
|
|
843
|
+
safeDraw() {
|
|
844
|
+
try {
|
|
845
|
+
this.draw();
|
|
846
|
+
this.consecutiveFailures = 0;
|
|
847
|
+
} catch (err) {
|
|
848
|
+
this.consecutiveFailures++;
|
|
849
|
+
if (!this.drawFailureLogged) {
|
|
850
|
+
this.drawFailureLogged = true;
|
|
851
|
+
const log = this.config.logError ?? ((component, message, error) => console.error(`${component}: ${message}`, error));
|
|
852
|
+
log("watermark", "draw failed; loop continues", err);
|
|
853
|
+
}
|
|
854
|
+
if (this.consecutiveFailures >= DEGRADED_AFTER_FAILURES && !this.degradedReported) {
|
|
855
|
+
this.degradedReported = true;
|
|
856
|
+
const log = this.config.logError ?? ((component, message, error) => console.error(`${component}: ${message}`, error));
|
|
857
|
+
log("watermark", `mark has not painted for ${String(this.consecutiveFailures)} consecutive frames`, err);
|
|
858
|
+
try {
|
|
859
|
+
this.config.onDegraded?.(this.consecutiveFailures);
|
|
860
|
+
} catch (handlerErr) {
|
|
861
|
+
log("watermark", "onDegraded handler threw", handlerErr);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
}
|
|
820
866
|
draw() {
|
|
821
867
|
this.lastDrawMs = Date.now();
|
|
822
868
|
const { canvas, ctx, video } = this;
|
|
@@ -3648,11 +3694,10 @@ var TeeFileSinkHandle = class {
|
|
|
3648
3694
|
}
|
|
3649
3695
|
}
|
|
3650
3696
|
async close() {
|
|
3651
|
-
|
|
3652
|
-
const secondaryClose = this.secondary.close().catch((err) => {
|
|
3697
|
+
await this.secondary.close().catch((err) => {
|
|
3653
3698
|
this.handleSecondaryError("close", err);
|
|
3654
3699
|
});
|
|
3655
|
-
await
|
|
3700
|
+
await this.primary.close();
|
|
3656
3701
|
}
|
|
3657
3702
|
async abort() {
|
|
3658
3703
|
const primaryAbort = this.primary.abort();
|
|
@@ -3756,7 +3801,15 @@ var CaptureSession = class {
|
|
|
3756
3801
|
try {
|
|
3757
3802
|
compositor = new WatermarkCompositor(stream, {
|
|
3758
3803
|
...options.watermark,
|
|
3759
|
-
...this.deps.logError ? { logError: this.deps.logError } : {}
|
|
3804
|
+
...this.deps.logError ? { logError: this.deps.logError } : {},
|
|
3805
|
+
// The canvas keeps emitting frames whatever the mark does, so
|
|
3806
|
+
// the stall watchdog cannot see this. Surfaced while the take is
|
|
3807
|
+
// still short enough that restarting costs little.
|
|
3808
|
+
onDegraded: (failures) => {
|
|
3809
|
+
this.emit("error", {
|
|
3810
|
+
message: `Watermark stopped painting after ${String(failures)} failed frames; the recording is continuing without a reliable mark.`
|
|
3811
|
+
});
|
|
3812
|
+
}
|
|
3760
3813
|
});
|
|
3761
3814
|
recordStream = await compositor.start();
|
|
3762
3815
|
} catch (err) {
|
|
@@ -3890,8 +3943,10 @@ var CaptureSession = class {
|
|
|
3890
3943
|
audioSampleRate: audioSettings?.sampleRate ?? resources.options.audioConstraints?.sampleRate ?? 0,
|
|
3891
3944
|
surface: this.deps.surface,
|
|
3892
3945
|
// The compositor, not options.watermark: a requested mark that failed
|
|
3893
|
-
// to start leaves this null, and the manifest must say so.
|
|
3894
|
-
|
|
3946
|
+
// to start leaves this null, and the manifest must say so. A compositor
|
|
3947
|
+
// that started and then stopped painting is the same claim to avoid —
|
|
3948
|
+
// most of those frames carry no mark.
|
|
3949
|
+
watermarked: resources.compositor !== null && !resources.compositor.degraded,
|
|
3895
3950
|
...muxResult.framesDropped !== void 0 ? { framesDropped: muxResult.framesDropped } : {}
|
|
3896
3951
|
};
|
|
3897
3952
|
if (muxResult.framesDropped && muxResult.framesDropped > 0) {
|
|
@@ -4198,35 +4253,64 @@ var CaptureError = class extends Error {
|
|
|
4198
4253
|
};
|
|
4199
4254
|
|
|
4200
4255
|
// src/transport.ts
|
|
4201
|
-
|
|
4202
|
-
"
|
|
4203
|
-
"
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4256
|
+
function isStringMap(value) {
|
|
4257
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
4258
|
+
return Object.values(value).every((v) => typeof v === "string");
|
|
4259
|
+
}
|
|
4260
|
+
var CLOSED_ERROR = "ended";
|
|
4261
|
+
var RATE_LIMITED_ERROR = "rate_limited";
|
|
4262
|
+
var SUPERSEDED_ERROR = "superseded";
|
|
4208
4263
|
function accessBody(access) {
|
|
4209
4264
|
return access.mode === "contact" ? { mode: "contact", contactId: access.contactId, key: access.key } : { mode: "open", campaignId: access.campaignId, key: access.key };
|
|
4210
4265
|
}
|
|
4211
4266
|
function codeForResponse(status, error) {
|
|
4212
|
-
if (
|
|
4213
|
-
if (status ===
|
|
4214
|
-
if (
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
if (status
|
|
4267
|
+
if (error === RATE_LIMITED_ERROR) return "rate_limited";
|
|
4268
|
+
if (error === SUPERSEDED_ERROR || status === 409) return "superseded";
|
|
4269
|
+
if (error === CLOSED_ERROR || status === 410) return "request_closed";
|
|
4270
|
+
if (status === 401 || status === 403 || status === 404) return "link_expired";
|
|
4271
|
+
if (status === 413 || status === 429) return "quota_exceeded";
|
|
4272
|
+
if (status >= 400 && status < 500) return "invalid_request";
|
|
4218
4273
|
return "upload_failed";
|
|
4219
4274
|
}
|
|
4220
|
-
|
|
4275
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
4276
|
+
var UPLOAD_STALL_MS = 6e4;
|
|
4277
|
+
function linkedAbort(signal, timeoutMs) {
|
|
4278
|
+
const controller = new AbortController();
|
|
4279
|
+
let timedOut = false;
|
|
4280
|
+
const timer = setTimeout(() => {
|
|
4281
|
+
timedOut = true;
|
|
4282
|
+
controller.abort();
|
|
4283
|
+
}, timeoutMs);
|
|
4284
|
+
const onAbort = () => controller.abort();
|
|
4285
|
+
if (signal?.aborted) controller.abort();
|
|
4286
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
4287
|
+
return {
|
|
4288
|
+
signal: controller.signal,
|
|
4289
|
+
timedOut: () => timedOut,
|
|
4290
|
+
dispose: () => {
|
|
4291
|
+
clearTimeout(timer);
|
|
4292
|
+
signal?.removeEventListener("abort", onAbort);
|
|
4293
|
+
}
|
|
4294
|
+
};
|
|
4295
|
+
}
|
|
4296
|
+
async function postJson(baseUrl, path, body, signal) {
|
|
4297
|
+
const link = linkedAbort(signal, REQUEST_TIMEOUT_MS);
|
|
4221
4298
|
let res;
|
|
4222
4299
|
try {
|
|
4223
4300
|
res = await fetch(`${baseUrl}${path}`, {
|
|
4224
4301
|
method: "POST",
|
|
4225
4302
|
headers: { "content-type": "application/json" },
|
|
4226
|
-
body: JSON.stringify(body)
|
|
4303
|
+
body: JSON.stringify(body),
|
|
4304
|
+
signal: link.signal
|
|
4227
4305
|
});
|
|
4228
4306
|
} catch (err) {
|
|
4307
|
+
if (signal?.aborted) throw new CaptureError("aborted", "The request was cancelled.", err);
|
|
4308
|
+
if (link.timedOut()) {
|
|
4309
|
+
throw new CaptureError("network", "Capturly did not respond in time.", err);
|
|
4310
|
+
}
|
|
4229
4311
|
throw new CaptureError("network", "Could not reach Capturly.", err);
|
|
4312
|
+
} finally {
|
|
4313
|
+
link.dispose();
|
|
4230
4314
|
}
|
|
4231
4315
|
const json = await res.json().catch(() => null);
|
|
4232
4316
|
if (!res.ok) {
|
|
@@ -4239,35 +4323,72 @@ async function postJson(baseUrl, path, body) {
|
|
|
4239
4323
|
return json ?? {};
|
|
4240
4324
|
}
|
|
4241
4325
|
async function requestUpload(args) {
|
|
4242
|
-
const json = await postJson(
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4326
|
+
const json = await postJson(
|
|
4327
|
+
args.baseUrl,
|
|
4328
|
+
"/api/campaigns/respond/upload-url",
|
|
4329
|
+
{
|
|
4330
|
+
...accessBody(args.access),
|
|
4331
|
+
contentType: "video/mp4",
|
|
4332
|
+
sizeBytes: args.sizeBytes,
|
|
4333
|
+
durationSec: args.durationSec,
|
|
4334
|
+
...args.displayName ? { displayName: args.displayName } : {},
|
|
4335
|
+
releaseAccepted: args.releaseAccepted
|
|
4336
|
+
},
|
|
4337
|
+
args.signal
|
|
4338
|
+
);
|
|
4247
4339
|
const objectName = typeof json.objectName === "string" ? json.objectName : null;
|
|
4248
|
-
|
|
4249
|
-
if (!objectName || !url2) {
|
|
4340
|
+
if (!objectName) {
|
|
4250
4341
|
throw new CaptureError("upload_failed", "Capturly did not return an upload target.");
|
|
4251
4342
|
}
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4343
|
+
const url2 = json.url;
|
|
4344
|
+
if (!isSecureTransportUrl(url2)) {
|
|
4345
|
+
throw new CaptureError("upload_failed", "Capturly returned an unusable upload URL.");
|
|
4346
|
+
}
|
|
4347
|
+
if (json.method !== "PUT") {
|
|
4348
|
+
throw new CaptureError("upload_failed", "Capturly returned an unsupported upload method.");
|
|
4349
|
+
}
|
|
4350
|
+
const headers = json.headers;
|
|
4351
|
+
if (!isStringMap(headers)) {
|
|
4352
|
+
throw new CaptureError("upload_failed", "Capturly returned unusable upload headers.");
|
|
4353
|
+
}
|
|
4354
|
+
return { objectName, url: url2, method: "PUT", headers };
|
|
4258
4355
|
}
|
|
4259
4356
|
function uploadBlob(args) {
|
|
4260
4357
|
return new Promise((resolve, reject) => {
|
|
4358
|
+
if (args.signal?.aborted) {
|
|
4359
|
+
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
4360
|
+
return;
|
|
4361
|
+
}
|
|
4261
4362
|
const xhr = new XMLHttpRequest();
|
|
4363
|
+
let stalled = false;
|
|
4364
|
+
let cancelled = false;
|
|
4365
|
+
let stallTimer;
|
|
4366
|
+
const onCallerAbort = () => {
|
|
4367
|
+
cancelled = true;
|
|
4368
|
+
xhr.abort();
|
|
4369
|
+
};
|
|
4370
|
+
const armStall = () => {
|
|
4371
|
+
clearTimeout(stallTimer);
|
|
4372
|
+
stallTimer = setTimeout(() => {
|
|
4373
|
+
stalled = true;
|
|
4374
|
+
xhr.abort();
|
|
4375
|
+
}, UPLOAD_STALL_MS);
|
|
4376
|
+
};
|
|
4377
|
+
const cleanup = () => {
|
|
4378
|
+
clearTimeout(stallTimer);
|
|
4379
|
+
args.signal?.removeEventListener("abort", onCallerAbort);
|
|
4380
|
+
};
|
|
4262
4381
|
xhr.open(args.target.method, args.target.url, true);
|
|
4263
4382
|
for (const [name, value] of Object.entries(args.target.headers)) {
|
|
4264
4383
|
xhr.setRequestHeader(name, value);
|
|
4265
4384
|
}
|
|
4266
4385
|
xhr.upload.onprogress = (event) => {
|
|
4386
|
+
armStall();
|
|
4267
4387
|
if (!event.lengthComputable || !args.onProgress) return;
|
|
4268
4388
|
args.onProgress(Math.min(1, event.loaded / event.total));
|
|
4269
4389
|
};
|
|
4270
4390
|
xhr.onload = () => {
|
|
4391
|
+
cleanup();
|
|
4271
4392
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
4272
4393
|
args.onProgress?.(1);
|
|
4273
4394
|
resolve();
|
|
@@ -4275,19 +4396,38 @@ function uploadBlob(args) {
|
|
|
4275
4396
|
}
|
|
4276
4397
|
reject(new CaptureError("upload_failed", `Storage rejected the upload (${xhr.status}).`));
|
|
4277
4398
|
};
|
|
4278
|
-
xhr.onerror = () =>
|
|
4279
|
-
|
|
4399
|
+
xhr.onerror = () => {
|
|
4400
|
+
cleanup();
|
|
4401
|
+
reject(new CaptureError("network", "The upload could not reach storage."));
|
|
4402
|
+
};
|
|
4403
|
+
xhr.onabort = () => {
|
|
4404
|
+
cleanup();
|
|
4405
|
+
if (cancelled) {
|
|
4406
|
+
reject(new CaptureError("aborted", "The upload was cancelled."));
|
|
4407
|
+
} else if (stalled) {
|
|
4408
|
+
reject(new CaptureError("network", "The upload stopped making progress."));
|
|
4409
|
+
} else {
|
|
4410
|
+
reject(new CaptureError("upload_failed", "The upload was aborted."));
|
|
4411
|
+
}
|
|
4412
|
+
};
|
|
4413
|
+
args.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
4414
|
+
armStall();
|
|
4280
4415
|
xhr.send(args.blob);
|
|
4281
4416
|
});
|
|
4282
4417
|
}
|
|
4283
4418
|
async function finalizeSubmission(args) {
|
|
4284
|
-
const json = await postJson(
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4419
|
+
const json = await postJson(
|
|
4420
|
+
args.baseUrl,
|
|
4421
|
+
"/api/campaigns/respond/finalize",
|
|
4422
|
+
{
|
|
4423
|
+
...accessBody(args.access),
|
|
4424
|
+
objectName: args.objectName,
|
|
4425
|
+
durationSec: args.durationSec,
|
|
4426
|
+
...args.displayName ? { displayName: args.displayName } : {},
|
|
4427
|
+
releaseAccepted: args.releaseAccepted
|
|
4428
|
+
},
|
|
4429
|
+
args.signal
|
|
4430
|
+
);
|
|
4291
4431
|
const submissionId = typeof json.submissionId === "string" ? json.submissionId : null;
|
|
4292
4432
|
if (!submissionId) {
|
|
4293
4433
|
throw new CaptureError(
|
|
@@ -4304,6 +4444,7 @@ async function finalizeSubmission(args) {
|
|
|
4304
4444
|
|
|
4305
4445
|
// src/CaptureClient.ts
|
|
4306
4446
|
var DEFAULT_BASE_URL = "https://capturly.app";
|
|
4447
|
+
var SLASH = 47;
|
|
4307
4448
|
var CaptureClient = class _CaptureClient {
|
|
4308
4449
|
#options;
|
|
4309
4450
|
#sink = null;
|
|
@@ -4316,7 +4457,16 @@ var CaptureClient = class _CaptureClient {
|
|
|
4316
4457
|
#destroyed = false;
|
|
4317
4458
|
#stateListeners = /* @__PURE__ */ new Set();
|
|
4318
4459
|
constructor(options) {
|
|
4319
|
-
const
|
|
4460
|
+
const configured = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
4461
|
+
let end = configured.length;
|
|
4462
|
+
while (end > 0 && configured.charCodeAt(end - 1) === SLASH) end--;
|
|
4463
|
+
const baseUrl = configured.slice(0, end);
|
|
4464
|
+
if (!isSecureTransportUrl(baseUrl)) {
|
|
4465
|
+
throw new CaptureError(
|
|
4466
|
+
"invalid_request",
|
|
4467
|
+
`baseUrl must be an https:// URL (got ${baseUrl || "an empty string"}).`
|
|
4468
|
+
);
|
|
4469
|
+
}
|
|
4320
4470
|
this.#options = {
|
|
4321
4471
|
access: options.access,
|
|
4322
4472
|
baseUrl,
|
|
@@ -4367,7 +4517,7 @@ var CaptureClient = class _CaptureClient {
|
|
|
4367
4517
|
if (!_CaptureClient.isSupported()) {
|
|
4368
4518
|
throw new CaptureError(
|
|
4369
4519
|
"unsupported_browser",
|
|
4370
|
-
"This browser cannot record. Chrome, Edge,
|
|
4520
|
+
"This browser cannot record. Chrome, Edge, or Safari 16.4+ can."
|
|
4371
4521
|
);
|
|
4372
4522
|
}
|
|
4373
4523
|
const constraints = {
|
|
@@ -4378,7 +4528,8 @@ var CaptureClient = class _CaptureClient {
|
|
|
4378
4528
|
try {
|
|
4379
4529
|
stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
4380
4530
|
} catch (err) {
|
|
4381
|
-
|
|
4531
|
+
const failure = this.#gumFailure(err);
|
|
4532
|
+
throw new CaptureError(failure.code, failure.message, err);
|
|
4382
4533
|
}
|
|
4383
4534
|
this.#stopStream();
|
|
4384
4535
|
this.#stream = stream;
|
|
@@ -4392,8 +4543,9 @@ var CaptureClient = class _CaptureClient {
|
|
|
4392
4543
|
throw new CaptureError("not_recording", "Call preview() before start().");
|
|
4393
4544
|
}
|
|
4394
4545
|
if (this.#session) {
|
|
4395
|
-
throw new CaptureError("
|
|
4546
|
+
throw new CaptureError("invalid_request", "Already recording.");
|
|
4396
4547
|
}
|
|
4548
|
+
this.#discardPending();
|
|
4397
4549
|
this.#setState("starting");
|
|
4398
4550
|
this.#sink ??= new OpfsFileSink({ logError: (m, e) => this.#options.onWarning(m, e) });
|
|
4399
4551
|
const session = new CaptureSession({
|
|
@@ -4433,6 +4585,7 @@ var CaptureClient = class _CaptureClient {
|
|
|
4433
4585
|
try {
|
|
4434
4586
|
artifact = await session.stop();
|
|
4435
4587
|
} catch (err) {
|
|
4588
|
+
this.#discardPending();
|
|
4436
4589
|
this.#setState("idle");
|
|
4437
4590
|
throw new CaptureError("unknown", "Recording could not be finished.", err);
|
|
4438
4591
|
}
|
|
@@ -4461,6 +4614,9 @@ var CaptureClient = class _CaptureClient {
|
|
|
4461
4614
|
*/
|
|
4462
4615
|
async submit(options) {
|
|
4463
4616
|
this.#assertAlive();
|
|
4617
|
+
if (this.#state !== "stopped") {
|
|
4618
|
+
throw new CaptureError("nothing_to_submit", "Stop the recording before submitting.");
|
|
4619
|
+
}
|
|
4464
4620
|
const pending = this.#pending;
|
|
4465
4621
|
if (!pending) {
|
|
4466
4622
|
throw new CaptureError("nothing_to_submit", "Record something before submitting.");
|
|
@@ -4478,12 +4634,17 @@ var CaptureClient = class _CaptureClient {
|
|
|
4478
4634
|
const target = await requestUpload({
|
|
4479
4635
|
baseUrl: this.#options.baseUrl,
|
|
4480
4636
|
access: this.#options.access,
|
|
4481
|
-
sizeBytes: blob.size
|
|
4637
|
+
sizeBytes: blob.size,
|
|
4638
|
+
durationSec: pending.durationSec,
|
|
4639
|
+
displayName: options.displayName,
|
|
4640
|
+
releaseAccepted: options.releaseAccepted,
|
|
4641
|
+
signal: options.signal
|
|
4482
4642
|
});
|
|
4483
4643
|
await uploadBlob({
|
|
4484
4644
|
target,
|
|
4485
4645
|
blob,
|
|
4486
|
-
...options.onProgress ? { onProgress: options.onProgress } : {}
|
|
4646
|
+
...options.onProgress ? { onProgress: options.onProgress } : {},
|
|
4647
|
+
signal: options.signal
|
|
4487
4648
|
});
|
|
4488
4649
|
const result = await finalizeSubmission({
|
|
4489
4650
|
baseUrl: this.#options.baseUrl,
|
|
@@ -4491,13 +4652,27 @@ var CaptureClient = class _CaptureClient {
|
|
|
4491
4652
|
objectName: target.objectName,
|
|
4492
4653
|
durationSec: pending.durationSec,
|
|
4493
4654
|
displayName: options.displayName,
|
|
4494
|
-
releaseAccepted: options.releaseAccepted
|
|
4655
|
+
releaseAccepted: options.releaseAccepted,
|
|
4656
|
+
signal: options.signal
|
|
4495
4657
|
});
|
|
4496
4658
|
this.#discardPending();
|
|
4497
4659
|
return result;
|
|
4498
4660
|
}
|
|
4499
|
-
/**
|
|
4661
|
+
/**
|
|
4662
|
+
* Throw the take away without submitting, so someone can record again.
|
|
4663
|
+
*
|
|
4664
|
+
* Stops the recorder first if one is running: setting the state to idle
|
|
4665
|
+
* while the session kept writing left the client claiming to be idle with a
|
|
4666
|
+
* recording still going into OPFS.
|
|
4667
|
+
*/
|
|
4500
4668
|
discard() {
|
|
4669
|
+
const session = this.#session;
|
|
4670
|
+
this.#session = null;
|
|
4671
|
+
if (session) {
|
|
4672
|
+
void session.stop().catch((err) => {
|
|
4673
|
+
this.#options.onWarning("recorder could not be stopped on discard", err);
|
|
4674
|
+
});
|
|
4675
|
+
}
|
|
4501
4676
|
this.#discardPending();
|
|
4502
4677
|
this.#setState("idle");
|
|
4503
4678
|
}
|
|
@@ -4528,28 +4703,45 @@ var CaptureClient = class _CaptureClient {
|
|
|
4528
4703
|
this.#stream?.getTracks().forEach((t) => t.stop());
|
|
4529
4704
|
this.#stream = null;
|
|
4530
4705
|
}
|
|
4706
|
+
/**
|
|
4707
|
+
* Let go of the pending take — the preview URL and the bytes behind it.
|
|
4708
|
+
*
|
|
4709
|
+
* Deleting the file is the part that matters. This SDK records into OPFS
|
|
4710
|
+
* under the EMBEDDING site's origin, one directory per take; revoking the
|
|
4711
|
+
* object URL alone left the video on the visitor's disk forever, with no
|
|
4712
|
+
* surface anywhere for them to clear it. A testimonial widget would
|
|
4713
|
+
* accumulate gigabytes of other people's video in third-party storage.
|
|
4714
|
+
*
|
|
4715
|
+
* Best-effort and fire-and-forget: `discard()` and `destroy()` are
|
|
4716
|
+
* synchronous by contract, and failing to tidy up must never become an
|
|
4717
|
+
* error the integrator has to handle. A failure warns instead — and the
|
|
4718
|
+
* next take still records, because paths are per-take.
|
|
4719
|
+
*/
|
|
4531
4720
|
#discardPending() {
|
|
4532
|
-
|
|
4721
|
+
const pending = this.#pending;
|
|
4533
4722
|
this.#pending = null;
|
|
4723
|
+
if (!pending) return;
|
|
4724
|
+
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
|
|
4725
|
+
const sink = this.#sink;
|
|
4726
|
+
if (!sink) return;
|
|
4727
|
+
void sink.remove(pending.path).catch((err) => {
|
|
4728
|
+
this.#options.onWarning("take could not be removed from local storage", err);
|
|
4729
|
+
});
|
|
4534
4730
|
}
|
|
4535
|
-
/** getUserMedia DOMException names, mapped to
|
|
4536
|
-
|
|
4731
|
+
/** getUserMedia DOMException names, mapped to a code and its copy. Both come
|
|
4732
|
+
* out of one classification: deriving the message from a second call could
|
|
4733
|
+
* report a code and a sentence that disagree. */
|
|
4734
|
+
#gumFailure(err) {
|
|
4537
4735
|
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
|
-
}
|
|
4736
|
+
const code = name === "NotAllowedError" || name === "SecurityError" ? "permission_denied" : name === "NotFoundError" || name === "OverconstrainedError" ? "no_devices" : "unknown";
|
|
4737
|
+
return { code, message: GUM_MESSAGES[code] };
|
|
4551
4738
|
}
|
|
4552
4739
|
};
|
|
4740
|
+
var GUM_MESSAGES = {
|
|
4741
|
+
permission_denied: "Camera and microphone access was denied.",
|
|
4742
|
+
no_devices: "No camera or microphone was available.",
|
|
4743
|
+
unknown: "The camera and microphone could not be opened."
|
|
4744
|
+
};
|
|
4553
4745
|
export {
|
|
4554
4746
|
CaptureClient,
|
|
4555
4747
|
CaptureError
|
package/dist/transport.d.ts
CHANGED
|
@@ -7,11 +7,18 @@ export interface UploadTarget {
|
|
|
7
7
|
headers: Record<string, string>;
|
|
8
8
|
}
|
|
9
9
|
/** Request an upload target. The size is signed in, so it must be the real
|
|
10
|
-
* byte count of what gets uploaded.
|
|
10
|
+
* byte count of what gets uploaded. Everything finalize will be sent goes
|
|
11
|
+
* with it, so the server can finish the submission itself if this browser
|
|
12
|
+
* never gets to call finalize — and so a missing release or name fails here
|
|
13
|
+
* rather than after the whole take has crossed the wire. */
|
|
11
14
|
export declare function requestUpload(args: {
|
|
12
15
|
baseUrl: string;
|
|
13
16
|
access: CaptureAccess;
|
|
14
17
|
sizeBytes: number;
|
|
18
|
+
durationSec: number;
|
|
19
|
+
displayName?: string | undefined;
|
|
20
|
+
releaseAccepted: boolean;
|
|
21
|
+
signal?: AbortSignal | undefined;
|
|
15
22
|
}): Promise<UploadTarget>;
|
|
16
23
|
/**
|
|
17
24
|
* Send the bytes. XHR rather than fetch only for `upload.onprogress`; fetch
|
|
@@ -21,6 +28,7 @@ export declare function uploadBlob(args: {
|
|
|
21
28
|
target: UploadTarget;
|
|
22
29
|
blob: Blob;
|
|
23
30
|
onProgress?: (fraction: number) => void;
|
|
31
|
+
signal?: AbortSignal | undefined;
|
|
24
32
|
}): Promise<void>;
|
|
25
33
|
/**
|
|
26
34
|
* Confirm the upload and record the submission. Idempotent on the object
|
|
@@ -33,6 +41,9 @@ export declare function finalizeSubmission(args: {
|
|
|
33
41
|
durationSec: number;
|
|
34
42
|
displayName?: string | undefined;
|
|
35
43
|
releaseAccepted: boolean;
|
|
44
|
+
/** Cancelling here is safe but rarely what you want: the bytes have already
|
|
45
|
+
* landed, and Capturly finishes an abandoned upload server-side. */
|
|
46
|
+
signal?: AbortSignal | undefined;
|
|
36
47
|
}): Promise<{
|
|
37
48
|
submissionId: string;
|
|
38
49
|
submittedAt: string;
|
package/dist/types.d.ts
CHANGED
|
@@ -46,11 +46,19 @@ export interface SubmitOptions {
|
|
|
46
46
|
/** Required for an open link. Ignored for a contact link, which already has
|
|
47
47
|
* the roster name. */
|
|
48
48
|
displayName?: string;
|
|
49
|
-
/** Whether the person accepted the request's release text.
|
|
50
|
-
*
|
|
49
|
+
/** Whether the person accepted the request's release text.
|
|
50
|
+
*
|
|
51
|
+
* Read that text from the API and show it before recording. A consent
|
|
52
|
+
* record is written only when the request requires a release AND you pass
|
|
53
|
+
* `true`; passing `true` without showing them anything records agreement
|
|
54
|
+
* that never happened. If the request requires a release and this is
|
|
55
|
+
* `false`, the submission is refused. */
|
|
51
56
|
releaseAccepted: boolean;
|
|
52
57
|
/** 0–1, as the bytes move. */
|
|
53
58
|
onProgress?: (fraction: number) => void;
|
|
59
|
+
/** Cancel the submission. Aborting rejects with code `aborted`; the take
|
|
60
|
+
* stays in local storage, so the same client can submit it again. */
|
|
61
|
+
signal?: AbortSignal;
|
|
54
62
|
}
|
|
55
63
|
export interface SubmitResult {
|
|
56
64
|
submissionId: string;
|
|
@@ -68,7 +76,18 @@ export interface SubmitResult {
|
|
|
68
76
|
* a retry for those loops forever. `network` and `upload_failed` are worth
|
|
69
77
|
* retrying as-is.
|
|
70
78
|
*/
|
|
71
|
-
export type CaptureErrorCode = 'unsupported_browser' | 'permission_denied' | 'no_devices' | 'not_recording' | 'nothing_to_submit' | 'link_expired' | 'request_closed'
|
|
79
|
+
export type CaptureErrorCode = 'unsupported_browser' | 'permission_denied' | 'no_devices' | 'not_recording' | 'nothing_to_submit' | 'link_expired' | 'request_closed'
|
|
80
|
+
/** Another take for this person landed first. Terminal: retrying loses the
|
|
81
|
+
* same race, and their other submission is the live one. */
|
|
82
|
+
| 'superseded' | 'quota_exceeded'
|
|
83
|
+
/** Too many requests. The one refusal here that succeeds if you wait. */
|
|
84
|
+
| 'rate_limited'
|
|
85
|
+
/** Cancelled through the `signal` you passed. The take is untouched. */
|
|
86
|
+
| 'aborted'
|
|
87
|
+
/** The call was wrong, not the connection — a bad option, or a payload the
|
|
88
|
+
* capture request refuses (no release, no name, too long). Retrying the
|
|
89
|
+
* same call cannot succeed; fix the input first. */
|
|
90
|
+
| 'invalid_request' | 'upload_failed' | 'network' | 'unknown';
|
|
72
91
|
export declare class CaptureError extends Error {
|
|
73
92
|
readonly code: CaptureErrorCode;
|
|
74
93
|
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.0",
|
|
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",
|
|
@@ -46,22 +46,21 @@
|
|
|
46
46
|
"publishConfig": {
|
|
47
47
|
"access": "public"
|
|
48
48
|
},
|
|
49
|
+
"dependencies": {},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"esbuild": "^0.25.0",
|
|
52
|
+
"typescript": "^5.4.0",
|
|
53
|
+
"vitest": "^4.1.7",
|
|
54
|
+
"@capturly/eslint-config": "2.11.0",
|
|
55
|
+
"@capturly/tsconfig": "2.11.0",
|
|
56
|
+
"@capturly/recording-engine": "2.11.0"
|
|
57
|
+
},
|
|
49
58
|
"scripts": {
|
|
50
59
|
"build": "node build.mjs && tsc -p tsconfig.build.json --emitDeclarationOnly",
|
|
51
60
|
"test": "vitest run",
|
|
52
61
|
"test:watch": "vitest",
|
|
53
62
|
"lint": "eslint src --ext .ts",
|
|
54
63
|
"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"
|
|
64
|
+
"clean": "rm -rf dist"
|
|
66
65
|
}
|
|
67
|
-
}
|
|
66
|
+
}
|