@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 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`: 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
@@ -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 { CaptureError, type CaptureAccess, type CaptureClientOptions, type CaptureDevice, type CaptureErrorCode, type CaptureState, type CaptureTake, type PreviewOptions, type SubmitOptions, type SubmitResult, } from './types.js';
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([this.worker.catch(() => void 0), timeout]);
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
- this.draw();
794
- this.video.requestVideoFrameCallback(drawFrame);
806
+ try {
807
+ this.safeDraw();
808
+ } finally {
809
+ this.video.requestVideoFrameCallback(drawFrame);
810
+ }
795
811
  };
796
- this.draw();
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
- 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
- }
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
- const primaryClose = this.primary.close();
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 Promise.all([primaryClose, secondaryClose]);
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
- watermarked: resources.compositor !== null,
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 baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
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, Firefox or Safari 16.4+ can."
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
- throw new CaptureError(this.#gumCode(err), this.#gumMessage(err), err);
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("not_recording", "Already recording.");
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
- /** Throw the take away without submitting, so someone can record again. */
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
- if (this.#pending?.previewUrl) URL.revokeObjectURL(this.#pending.previewUrl);
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 codes. */
4536
- #gumCode(err) {
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
- 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
- }
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
  };
@@ -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;
@@ -0,0 +1,14 @@
1
+ import {
2
+ CaptureError,
3
+ fetchCaptureView,
4
+ finalizeSubmission,
5
+ requestUpload,
6
+ uploadBlob
7
+ } from "./chunk-CZPDQKMD.js";
8
+ export {
9
+ CaptureError,
10
+ fetchCaptureView,
11
+ finalizeSubmission,
12
+ requestUpload,
13
+ uploadBlob
14
+ };
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. Read that text
50
- * from the API and show it — this writes a consent record either way. */
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' | 'quota_exceeded' | 'upload_failed' | 'network' | 'unknown';
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.10.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
+ }