@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 CHANGED
@@ -1 +1,3 @@
1
1
  # @capturly/capture-sdk
2
+
3
+ ## 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`: roughly Chrome/Edge 111+,
26
- Firefox 113+, Safari 16.4+. Call `CaptureClient.isSupported()` before you show
27
- a record button; a browser that fails it cannot be talked into working, so
28
- offer another browser rather than a retry.
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`. Download the master through
90
- `GET /v1/recordings/{id}`, where it arrives with its capture manifest: codecs,
91
- resolution, frame rate, dropped frames, checksum, and the consent that was
92
- accepted.
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`. The split that matters is
108
- between what your user can fix and what only you can:
109
-
110
- | Code | Who can act |
111
- | --- | --- |
112
- | `unsupported_browser`, `permission_denied`, `no_devices` | your user |
113
- | `link_expired`, `request_closed`, `quota_exceeded` | you |
114
- | `network`, `upload_failed` | retryable |
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
- A retry for the middle row can never succeed.
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
 
@@ -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
- /** Throw the take away without submitting, so someone can record again. */
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([this.worker.catch(() => void 0), timeout]);
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
- this.draw();
794
- this.video.requestVideoFrameCallback(drawFrame);
814
+ try {
815
+ this.safeDraw();
816
+ } finally {
817
+ this.video.requestVideoFrameCallback(drawFrame);
818
+ }
795
819
  };
796
- this.draw();
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
- try {
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
- const primaryClose = this.primary.close();
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 Promise.all([primaryClose, secondaryClose]);
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
- watermarked: resources.compositor !== null,
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
- var TERMINAL_ACCESS_ERRORS = /* @__PURE__ */ new Set([
4202
- "campaign_closed",
4203
- "deadline_passed",
4204
- "link_revoked",
4205
- "not_found",
4206
- "forbidden"
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 (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";
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
- async function postJson(baseUrl, path, body) {
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(args.baseUrl, "/api/campaigns/respond/upload-url", {
4243
- ...accessBody(args.access),
4244
- contentType: "video/mp4",
4245
- sizeBytes: args.sizeBytes
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
- const url2 = typeof json.url === "string" ? json.url : null;
4249
- if (!objectName || !url2) {
4340
+ if (!objectName) {
4250
4341
  throw new CaptureError("upload_failed", "Capturly did not return an upload target.");
4251
4342
  }
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
- };
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 = () => reject(new CaptureError("network", "The upload could not reach storage."));
4279
- xhr.onabort = () => reject(new CaptureError("upload_failed", "The upload was aborted."));
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(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
- });
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 baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
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, Firefox or Safari 16.4+ can."
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
- throw new CaptureError(this.#gumCode(err), this.#gumMessage(err), err);
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("not_recording", "Already recording.");
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
- /** Throw the take away without submitting, so someone can record again. */
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
- if (this.#pending?.previewUrl) URL.revokeObjectURL(this.#pending.previewUrl);
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 codes. */
4536
- #gumCode(err) {
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
- if (name === "NotAllowedError" || name === "SecurityError") return "permission_denied";
4539
- if (name === "NotFoundError" || name === "OverconstrainedError") return "no_devices";
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
@@ -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. Read that text
50
- * from the API and show it — this writes a consent record either way. */
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' | 'quota_exceeded' | 'upload_failed' | 'network' | 'unknown';
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.10.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
+ }